Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion skills/b2c/skills/b2c-hooks/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: b2c-hooks
description: Implement hooks with HookMgr, hooks.json registration, and system extension points for order, basket, and API lifecycle events. Use this skill whenever the user needs to register a hook implementation, extend OCAPI/SCAPI behavior with before/after hooks, customize order calculation or payment authorization, or create custom extension points. Also use when debugging hook registration or Status return values -- even if they just say 'run code when an order is placed' or 'intercept the basket API'.
description: Register and implement B2C Commerce platform hooks -- scripts registered in a cartridge's hooks.json and invoked by HookMgr during OCAPI/SCAPI requests or system events. This skill applies ONLY when the user's question involves writing or debugging code that is registered in hooks.json and called by the platform's HookMgr dispatcher. Key identifiers that signal this skill: hooks.json, HookMgr.callHook(), dw.system.Status returns, dw.ocapi.shop.* extension points (beforePOST/afterPOST/modifyResponse), dw.order.calculate, app.payment.processor.*. Covers: hook script authoring, registration, Status OK/ERROR/rollback semantics, request.custom inter-hook data passing, custom extension points. Hard exclusions -- do NOT trigger for these even if the query mentions 'hook' or 'order': outbound webhook/notification endpoints on external Node.js/Express services (not hooks.json-registered); SFRA controller routes, middleware, or prepend/append chains; scheduled job step modules (execute/beforeStep/afterStep in steptypes.json, not hooks.json); Git or CI hooks (husky, pre-commit).
---

# B2C Commerce Hooks
Expand Down Expand Up @@ -338,6 +338,57 @@ Register it like any order hook:

> The `app.payment.processor.<id>` Authorize hooks are themselves custom hooks (one per payment processor, function `Authorize`). By SFRA convention they return a plain object whose `error` flag signals the outcome — `{ authorized: true }` on success, `{ error: true }` on decline — which is why the example treats a missing result or `result.error` as a failure. This mirrors the SFRA `handlePayments` checkout helper. For order-status semantics (`placeOrder`/`failOrder`, reopen-basket behavior, status transitions) see [b2c-ordering](../b2c-ordering/SKILL.md).

## Order Hook Lifecycle and Rollback Semantics

The order POST hooks execute in a defined sequence with different transaction semantics at each phase:

```
beforePOST(basket) ← Validation; Status.ERROR rejects before order creation
[Order created: CREATED] ← Platform creates order from basket
afterPOST(order) ← Inside platform transaction; owns CREATED→NEW/FAILED
[Transaction commits]
modifyPOSTResponse(order, response) ← After commit; response-shaping only
```

### Rollback Semantics

| Phase | Transaction context | `Status.ERROR` effect |
|-------|--------------------|-----------------------|
| `afterPOST` | Inside transaction | **Rolls back** — no order record survives |
| `modifyPOSTResponse` | After commit | **No rollback** — order already persisted; only sets HTTP response to 400 |

This means `afterPOST` gives you EITHER a persisted failed order (return `Status.OK` after `OrderMgr.failOrder`) OR an HTTP error (return `Status.ERROR`), **not both**.

### Two-Hook Pattern: Persist Failed Order AND Return HTTP Error

When you need a queryable FAILED order (for metrics/triage) AND an HTTP error to the storefront (so the UI shows a decline):

1. **`afterPOST`**: Call `OrderMgr.failOrder(order, false)`, stash decline details on `request.custom`, return `Status.OK` so the transaction commits.
2. **`modifyPOSTResponse`**: Read `request.custom`, return `new Status(Status.ERROR, code, message)` — this sets the HTTP response to 400 without rolling back the persisted order.

See [Order Hook Lifecycle reference](references/ORDER-HOOK-LIFECYCLE.md) for the full code example and verified test results.

### `request.custom` for Inter-Hook Data Passing

`request.custom` (`dw.system.Request`) is the idiomatic channel to pass data between hooks within the same request. It persists for the request's lifetime and works across all hook phases (before → after → modifyResponse).

```javascript
// In afterPOST
request.custom.declineInfo = { code: 'PAYMENT_DECLINED', reason: 'Insufficient funds' };

// In modifyPOSTResponse (same request, after commit)
var info = request.custom.declineInfo;
if (info) {
return new Status(Status.ERROR, info.code, info.reason);
}
```

This technique applies generally — not just to orders. Any pair of hooks in the same request can communicate via `request.custom`.

## System Hooks

### Calculate Hooks
Expand Down Expand Up @@ -482,3 +533,4 @@ Hooks must complete within the SCAPI timeout (HTTP 504 on timeout).

- [OCAPI/SCAPI Hooks](references/OCAPI-SCAPI-HOOKS.md) - API hook patterns and available hooks
- [System Hooks](references/SYSTEM-HOOKS.md) - Calculate, payment, and order hooks
- [Order Hook Lifecycle](references/ORDER-HOOK-LIFECYCLE.md) - Lifecycle phases, rollback semantics, and the two-hook pattern
2 changes: 2 additions & 0 deletions skills/b2c/skills/b2c-hooks/evals/trigger-evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
{"query": "I'm implementing custom payment authorization using the dw.order.payment.authorize hook. My hooks.json is in the cartridge but the hook never fires. I verified the cartridge is on the path. What else could be wrong?", "should_trigger": true},
{"query": "We want to create our own extension point so other cartridges can plug into our loyalty module. Specifically, when loyalty points are redeemed, other cartridges should be able to react. How do I define and call a custom hook, and how do consuming cartridges register for it?", "should_trigger": true},
{"query": "I need to modify the OCAPI response for product searches to add a custom field. I know there's a modifyGETResponse hook for shop resources. How do I access the original response, add my field, and pass data from a beforeGET hook using request.custom?", "should_trigger": true},
{"query": "My SCAPI order afterPOST hook returns Status.ERROR when payment is declined but the order disappears completely from order_search. I need the failed order to persist for reporting AND still return an error to the storefront. How do I achieve both?", "should_trigger": true},
{"query": "What's the difference between afterPOST and modifyPOSTResponse for the order hook? I heard one rolls back and the other doesn't. How do I pass data between them using request.custom?", "should_trigger": true},
{"query": "How do I set up Git pre-commit hooks to run our linter before every commit? I want to use husky or a similar tool in the project.", "should_trigger": false},
{"query": "I need to set up a webhook endpoint in our external Node.js service that receives order placement notifications from Commerce Cloud. How do I configure the outbound webhook?", "should_trigger": false},
{"query": "How do I add a new controller route that runs before the checkout page loads to check inventory availability? I want it as middleware in the SFRA controller chain.", "should_trigger": false},
Expand Down
153 changes: 153 additions & 0 deletions skills/b2c/skills/b2c-hooks/references/ORDER-HOOK-LIFECYCLE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Order Hook Lifecycle (SCAPI/OCAPI)

## Lifecycle Phases

The order creation request flows through these phases in order:

```
Request in
|
v
[1] beforePOST(basket)
- Validation phase
- Returning Status.ERROR rejects the request before any order is created
|
v
[2] Platform creates order in CREATED status (from the basket)
|
v
[3] afterPOST(order) <-- INSIDE platform transaction
- Owns the CREATED -> NEW or CREATED -> FAILED transition
- Status.ERROR here rolls back the entire order creation
|
v
(transaction commits)
|
v
[4] modifyPOSTResponse(order, orderResponse) <-- AFTER transaction commits
- Response-shaping phase only
- Order is already persisted; cannot be rolled back from here
|
v
Response out
```

| Phase | Hook | Runs relative to transaction | Purpose |
|-------|------|------------------------------|---------|
| 1 | `beforePOST(basket)` | Before order creation | Validate the incoming basket; reject with `Status.ERROR` to prevent order creation |
| 2 | (platform) | — | Platform creates order in `CREATED` status |
| 3 | `afterPOST(order)` | Inside platform transaction | Transition `CREATED` -> `NEW` (place) or `CREATED` -> `FAILED` (fail) |
| 4 | `modifyPOSTResponse(order, orderResponse)` | After commit | Shape the HTTP response; cannot roll back |

Key point: a non-null `Status` return ends `modifyPOSTResponse` hook execution (same behavior as other `modifyResponse` hooks).

## Rollback Semantics

| Hook | Transaction context | `Status.ERROR` behavior |
|------|---------------------|-------------------------|
| `afterPOST` | Inside platform transaction | Rolls back entire order creation — NO order record survives |
| `modifyPOSTResponse` | After commit | Does NOT roll back — order already persisted; only affects HTTP response |

This was empirically verified on sandbox zzpq-019 (2026-06-24). Order 00000502 returned HTTP 400 from `afterPOST` `Status.ERROR` and left 0 matches in `order_search`.

Implication: `afterPOST` alone gives you EITHER a persisted failed order (by calling `failOrder` then returning `Status.OK`) OR an HTTP error (by returning `Status.ERROR`) — not both.

## The Two-Hook Pattern: Persist Failed Order AND Return HTTP Error

Sometimes you need BOTH:
- A persisted `FAILED` order (for metrics, triage, queryability)
- An HTTP error response to the storefront (so the UI shows a decline, not a false confirmation)

A single `afterPOST` cannot do both — its transaction semantics force a choice. The solution is to split the responsibilities across two hook phases.

Solution verified on zzpq-019:

### Step 1: `afterPOST` — persist the FAILED order

```javascript
var OrderMgr = require('dw/order/OrderMgr');
var Status = require('dw/system/Status');

exports.afterPOST = function (order) {
var authResult = authorizePayment(order);

if (authResult.declined) {
// Fail the order so it persists in FAILED status (queryable for metrics)
OrderMgr.failOrder(order, false);

// Stash decline details for modifyPOSTResponse via request.custom
request.custom.paymentDecline = {
code: authResult.code,
message: authResult.message
};

// Return OK so the transaction COMMITS (FAILED order persists)
return new Status(Status.OK);
}

// Success path: place the order
OrderMgr.placeOrder(order);
order.setConfirmationStatus(dw.order.Order.CONFIRMATION_STATUS_CONFIRMED);
order.setExportStatus(dw.order.Order.EXPORT_STATUS_READY);
};
```

### Step 2: `modifyPOSTResponse` — return HTTP error without rollback

```javascript
exports.modifyPOSTResponse = function (order, orderResponse) {
// Check if afterPOST stashed a decline
var decline = request.custom.paymentDecline;
if (decline) {
// Return ERROR here — order is already committed, no rollback
return new Status(Status.ERROR, decline.code, decline.message);
}
// No decline — let the normal 200 response through
};
```

### `hooks.json` registration

```json
{
"hooks": [
{ "name": "dw.ocapi.shop.order.afterPOST", "script": "./hooks/order.js" },
{ "name": "dw.ocapi.shop.order.modifyPOSTResponse", "script": "./hooks/order.js" }
]
}
```

### Verified behavior (zzpq-019, 2026-06-24)

- **Tainted order 00000503** -> HTTP 400 with `extensionPoint: dw.ocapi.shop.order.modifyPOSTResponse`, `statusCode`/`statusMessage` from the `Status` AND persisted `status=FAILED` (queryable)
- **Clean order 00000504** -> HTTP 200, `status=NEW`

## Request-Scoped Data Passing: `request.custom`

`request.custom` is the idiomatic channel for passing data between hooks that fire within the same SCAPI/OCAPI request.

- `request` is `dw.system.Request` — a global object whose `.custom` attribute bag persists for the lifetime of the request
- Works between any hook phases in the same request: `beforePOST` -> `afterPOST` -> `modifyPOSTResponse`
- Type: `CustomAttributes` (key-value pairs; values can be any serializable type)

Example pattern:

```javascript
// In afterPOST — stash data
request.custom.myKey = { someData: 'value' };

// In modifyPOSTResponse — read it
var data = request.custom.myKey;
```

Use cases beyond order decline:

- Passing external service call results to response enrichment
- Flagging validation warnings (non-fatal) for response annotation
- Timing/instrumentation across hook phases

## Cross-References

- [Order afterPOST canonical example](../SKILL.md#order-afterpost-headless-order-placement) — the standard `afterPOST` pattern (without `modifyPOSTResponse`)
- [b2c-ordering](../../b2c-ordering/SKILL.md) — `OrderMgr.placeOrder`/`failOrder`, status transitions, reopen-basket behavior
- [OCAPI/SCAPI Hooks reference](./OCAPI-SCAPI-HOOKS.md) — complete hook signature list
Loading