@blamejs/blamejs-shop 0.1.25 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.1.x
10
10
 
11
+ - v0.1.27 (2026-05-25) — **Loyalty rewards go live — points earned on purchase, viewed in the account, and redeemed at checkout.** The loyalty points ledger now has a customer-facing surface. A new /account/loyalty page shows the signed-in customer's balance, tier, and earn/redeem activity, explains how points are earned, and lists the reward catalog with a one-click redeem. Paid orders award points automatically: the order primitive fans its paid transition into the earn rules (per-dollar-spent + per-order), fire-and-forget and deduplicated so a re-delivered payment webhook never double-credits. At checkout, a signed-in customer can spend points for a storefront credit against the order total — capped at the order's value and the balance, recorded once, with the same atomic double-spend guard the gift-card credit uses. **Added:** *Customer rewards page at /account/loyalty* — A login-gated page showing the points balance, current tier, lifetime points, and the cash value of the balance; how points are earned (the active earn rules in plain language); the reward catalog with a Redeem control per reward (disabled when the balance is short); the customer's past redemptions; and the earn/redeem ledger, paginated newest-first. Linked from the account dashboard as "Rewards". A redeem that the customer can't complete (not enough points, reward unavailable, per-customer limit reached) re-renders the page with the reason rather than erroring; a malformed history cursor degrades to the first page. · *Points earned automatically on paid orders* — When an order reaches paid, the configured earn rules award points to the customer's balance — per dollar of order subtotal and a flat per-order bonus, whichever rules the operator has defined. The award rides the order's paid transition (so every paid path — card, PayPal, gift-card-covered, admin — earns), runs fire-and-forget off the request path, and is keyed to the order so a re-delivered payment webhook collapses onto a single award instead of double-crediting. Guest orders (no customer account) earn nothing, by design. · *Redeem points at checkout as a credit* — A signed-in customer with a balance sees a "Redeem loyalty points" field on the checkout form. The points are valued via the ledger's redemption ratio (100 points = $1 by default), capped at the order total so the credit never exceeds what the order is worth, and the credit reduces the amount due. The order still records the full grand total it owed; the points are debited once, after the order row exists, behind the same balance-guarded SQL decrement that prevents a double-spend. Points worth more than the order spend only what the granted credit is worth — the surplus stays in the balance. A request for more points than the balance, or from a guest cart, is refused with a clean re-prompt. A points credit stacks with a gift-card credit; together they can pay an order in full with no card charge.
12
+
13
+ - v0.1.26 (2026-05-25) — **Fix the container build gate that rejected a manifest absent from the image.** The asset-integrity drift gate added last release checked both the container and edge copies of the manifest. The container image excludes the edge worker directory from its build context, so when the gate runs inside the image build it can't see the worker copy and read it as empty — failing the build. The gate now skips a manifest that isn't present in the current build context; its drift is still caught wherever the file exists (the full-tree CI run checks both). **Fixed:** *Asset-manifest drift gate no longer fails the container build* — `generate-asset-manifest.js --check` compared both manifest copies (`lib/` for the container, `worker/` for the edge) against a fresh rebuild. The container image's build context excludes the `worker/` tree, so inside the image the worker copy is absent and the gate read it as empty — reporting false drift and failing `node test/smoke.js`, which is a build gate, so the container image never built. The check now treats a manifest that isn't present in the current context as not-applicable and skips it (requiring at least one copy to be present), so the same drift detection still runs in CI and at the edge build where both files exist, without breaking the container image build.
14
+
11
15
  - v0.1.25 (2026-05-25) — **Asset integrity and the cache-buster are correct in production now.** The renderers stamped a Subresource Integrity digest and a `?v=` cache-buster onto the theme stylesheet and island scripts by hashing the files at render time — which silently produced nothing in production, where the container image doesn't ship the theme files and the edge has no filesystem to read them. So the integrity attribute was absent live and edge-rendered pages pinned a `0.0.0` version that never changed between releases (a stylesheet update would render against a browser-cached copy). Both now come from a manifest built at release time and read directly by both runtimes. **Fixed:** *Subresource Integrity now applied on server-rendered pages* — The `integrity="sha384-…"` attribute on the default theme's stylesheet and island scripts was computed by reading the asset off disk during the render. That read fails in the deployed container (its image carries the code, not the `themes/` tree) and at the edge (no filesystem), so the attribute was silently dropped — the tamper-evidence it's meant to provide wasn't active. The digests are now computed once at release time into `lib/asset-manifest.json` (bundled for both the container and the edge worker) and read from there, so the browser actually verifies the served bytes against the pinned hash. · *Edge-rendered pages no longer pin a 0.0.0 cache-buster* — Pages rendered at the edge (home, product, search, the empty cart, blog) appended `?v=0.0.0` to the stylesheet URL because the worker had no release version to stamp — so the cache-buster never changed across deploys and a browser could keep serving an old stylesheet after a CSS update (markup against stale styling). The version now comes from the release manifest, so each release invalidates the cached asset as intended. The drift between the manifest and the on-disk assets is caught by a smoke gate (`generate-asset-manifest.js --check`).
12
16
 
13
17
  - v0.1.23 (2026-05-25) — **The admin console renders styled again, and both surfaces now self-host their fonts.** The admin console shipped its design system as an inline <style> block, inline style="" attributes, and a cross-origin Google Fonts link — all three of which the strict `style-src 'self'` / `font-src 'self'` CSP that governs container-served routes refuses, so the console rendered as an unstyled page in production. Its styling now lives in an external stylesheet served from /assets (like the storefront's), and the typeface is self-hosted, so the console renders as designed under the same CSP that protects it. Both the storefront and the console now serve Inter from /assets rather than naming it and hoping the visitor has it installed. **Changed:** *Inter is self-hosted on both the storefront and the console* — The default theme named Inter / Inter Tight first in its font stack but shipped no font files, so the intended typography only rendered for visitors who happened to have Inter installed locally — everyone else fell back to a system sans-serif. The latin subset of Inter (400/500/600) and Inter Tight (600/700) now ships under `themes/default/assets/fonts` and is referenced via `@font-face`, so the typeface renders for every visitor and loads same-origin under `font-src 'self'` — no cross-origin font CDN on any page. The fonts are SIL OFL 1.1 (license alongside the files); `font-display:swap` paints text immediately in the system fallback and swaps when the file arrives. · *Admin console visual refresh* — With the stylesheet external it also got a pass: a proper centered sign-in card (replacing the bare form that was the first thing an operator saw), softer surfaces and shadows on cards and panels, a sticky console nav, and consistent spacing. The brand palette (ink + the storefront's accent) and every screen's structure are unchanged — this is presentation only. **Fixed:** *Admin console renders unstyled in production* — Every `/admin` screen built its design system three ways the strict CSP refuses: an inline `<style>` block, inline `style=""` attributes, and a `<link>` to fonts.googleapis.com. Container-served routes run under `style-src 'self'` / `font-src 'self'`, which drops all three — so the console arrived as unstyled HTML (a plain page) once deployed, even though it looked correct in isolation. The styling now ships as `/assets/themes/default/css/admin.css`, an external file `'self'` allows, linked with a `sha384` Subresource Integrity digest so the browser rejects a tampered or stale object; the inline attributes are gone, folded into that stylesheet. A render-shell test asserts every admin screen links the external stylesheet, carries no inline styles, and pulls no third-party font host, so the regression can't return. Operators redeploy to pick this up — `npm run deploy` uploads the new stylesheet (and the fonts below) to R2 as part of the asset sync.
package/README.md CHANGED
@@ -68,6 +68,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
68
68
  | **`lib/save-for-later.js`** | Per-customer cart holding list. Each cart line gets a login-gated "Save for later" control (`POST /cart/lines/:id/save` → `moveFromCart`); `/account/saved` lists items with Move-to-cart / Remove. `moveToCart` reprices to the current catalog price and stock-gates (out-of-stock + non-backorderable is refused). Composes `catalog.inventory` + `catalog.prices` + `catalog.variants`. |
69
69
  | **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
70
70
  | **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
71
+ | **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
71
72
  | **`lib/recently-viewed.js`** | Signed-in customer browse history. A product-page visit records the view server-side against the customer's account (drop-silent — never blocks the page); `/account/recently-viewed` lists them newest-first as a grid with a Clear-history control. De-duped + capped per customer, archived products drop out, login-gated. Guest/session history is opt-in (a client beacon) and not shipped — the lib's `forSession` + `merge` support it. |
72
73
  | **`lib/recommendations.js`** | Product-recommendation engine. Operator-curated override pins first (`setOverride` — "when viewing A, show B", kind-scoped + weight-ordered), then a signal-based fallback: co-purchase (products bought in the same orders), category-popular, and in-stock-random filler. `recommendForProduct` / `recommendForCart` / `recommendForCustomer` / `recommendForCategory` each return renderable picks (active + in-stock, source product excluded). The order confirmation page (`/orders/:id`) renders a "Customers also bought" rail from it — best-effort, anchored on the order's items, excluding what was just bought. |
73
74
  | **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
@@ -102,6 +103,9 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
102
103
  - `migrations-d1/0043_collections.sql` — manual + smart product collections (members + rules + sort strategy)
103
104
  - `migrations-d1/0050_recently_viewed.sql` — per-customer / per-session product browse history (dedup + per-subject cap)
104
105
  - `migrations-d1/0105_recommendations.sql` — operator-curated recommendation overrides (kind-scoped, weight-ordered)
106
+ - `migrations-d1/0022_loyalty.sql` — loyalty accounts (balance + lifetime + tier) + audited points transactions
107
+ - `migrations-d1/0085_loyalty_redemptions.sql` — reward catalog + per-redemption records (FSM: active → consumed / expired / cancelled)
108
+ - `migrations-d1/0163_loyalty_earn_rules.sql` — per-event earn rules + dedup-keyed award log
105
109
 
106
110
  ### Demo seed
107
111
 
package/SECURITY.md CHANGED
@@ -160,3 +160,17 @@ node -e "
160
160
  Redemption decrements with an atomic `balance >= amount` SQL guard
161
161
  keyed on the order id, so concurrent or replayed checkouts can never
162
162
  overdraw a card or apply more than the remaining balance.
163
+ - **Loyalty points are account-scoped money — earned and spent under
164
+ the same double-spend discipline.** The `/account/loyalty` page and
165
+ the redeem actions are login-gated and read the customer id from the
166
+ sealed session cookie, never from request input, so a customer only
167
+ ever sees and spends their own balance. Points earned on a paid order
168
+ are awarded fire-and-forget off the request path and deduped on the
169
+ order id (`loyalty_earn_log`'s `(rule, customer, event-ref)` UNIQUE),
170
+ so a re-delivered payment webhook can't double-credit. Spending points
171
+ — redeeming a reward or applying a checkout credit — debits through
172
+ the same atomic `balance >= points` SQL guard, so concurrent or
173
+ replayed requests can't overdraw. A checkout credit is capped at both
174
+ the order total and the live balance (a request for more than either
175
+ is refused, not silently clamped to a windfall), and a guest cart
176
+ with no account can't redeem at all.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.25",
2
+ "version": "0.1.27",
3
3
  "assets": {
4
4
  "css/admin.css": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
5
5
  "css/main.css": "sha384-qBY97FWZDGOploZRppwrDi0A/+R4R3iCFtyS1dfrAlqHNEPluPmY0R3HB9cJntyV",
package/lib/checkout.js CHANGED
@@ -112,6 +112,15 @@ function create(deps) {
112
112
  // either is absent — the rest of the flow is unchanged.
113
113
  var giftcards = deps.giftcards || null;
114
114
  var giftCardLedger = deps.giftCardLedger || null;
115
+ // Optional loyalty points credit. When wired, a signed-in customer
116
+ // can spend points at checkout for a storefront credit against the
117
+ // grand total. `loyalty.balance` reads the spendable balance;
118
+ // `loyalty.redeem` is the atomic debit (its `balance >= ?` SQL guard
119
+ // is the authoritative double-spend defense). The redemption value is
120
+ // derived from the ledger's own `REDEMPTION_POINTS_PER_USD` ratio so
121
+ // checkout never re-derives the conversion constant. Disabled when
122
+ // the handle is absent — the rest of the flow is unchanged.
123
+ var loyalty = deps.loyalty || null;
115
124
 
116
125
  // Validate a gift-card code against a priced quote: the card exists,
117
126
  // is active, not expired, and matches the order currency. Returns
@@ -194,6 +203,82 @@ function create(deps) {
194
203
  return redemption;
195
204
  }
196
205
 
206
+ // Resolve an optional loyalty points credit against a priced quote.
207
+ // `points` is the number of points the customer asked to spend;
208
+ // `customerId` is the signed-in customer's UUID (a guest cart has
209
+ // none — loyalty redemption needs an account, so a points request on
210
+ // a guest order is refused). Returns the credit to apply (the points
211
+ // converted to minor units via the ledger's redemption ratio, then
212
+ // capped at the grand total so a customer can't redeem more value
213
+ // than the order is worth) plus the integer points that credit
214
+ // actually consumes. Throws a structured (non-TypeError) error on a
215
+ // request that can't be honored so callers surface a clean re-prompt;
216
+ // returns null when no points were requested or loyalty isn't wired.
217
+ //
218
+ // Capping in points-space (not minor-units) matters: redeeming 250
219
+ // points worth $2.50 against a $1.00 order should debit only the 100
220
+ // points the $1.00 credit consumes, leaving the remaining 150 points
221
+ // in the balance — never silently burning the surplus.
222
+ async function _resolveLoyaltyCredit(points, customerId, quote) {
223
+ if (points == null) return null;
224
+ if (!Number.isInteger(points) || points <= 0) {
225
+ var bad = new Error("checkout: loyalty_redeem_points must be a positive integer");
226
+ bad.code = "LOYALTY_REDEEM_INVALID";
227
+ throw bad;
228
+ }
229
+ if (!loyalty) {
230
+ var unwired = new Error("checkout: loyalty redemption is not configured");
231
+ unwired.code = "LOYALTY_NOT_CONFIGURED";
232
+ throw unwired;
233
+ }
234
+ if (!customerId) {
235
+ var guest = new Error("checkout: sign in to redeem loyalty points");
236
+ guest.code = "LOYALTY_REQUIRES_ACCOUNT";
237
+ throw guest;
238
+ }
239
+ var perUsd = loyalty.REDEMPTION_POINTS_PER_USD;
240
+ var bal = await loyalty.balance(customerId);
241
+ if (bal.balance < points) {
242
+ var insuff = new Error("checkout: loyalty balance is " + bal.balance + ", cannot redeem " + points);
243
+ insuff.code = "LOYALTY_INSUFFICIENT_BALANCE";
244
+ throw insuff;
245
+ }
246
+ // Value the requested points in minor units, then cap at the grand
247
+ // total. The applied minor-units are floored to a whole point's
248
+ // worth so the points actually debited map exactly to the credit
249
+ // granted (no fractional-point credit).
250
+ var grand = quote.totals.grand_total_minor;
251
+ var requestedMinor = Math.floor((points * 100) / perUsd);
252
+ var appliedMinor = requestedMinor < grand ? requestedMinor : grand;
253
+ if (appliedMinor <= 0) {
254
+ var tooFew = new Error("checkout: " + points + " points is worth less than the minimum redeemable credit");
255
+ tooFew.code = "LOYALTY_REDEEM_TOO_SMALL";
256
+ throw tooFew;
257
+ }
258
+ // Points the applied credit actually consumes — re-derived from the
259
+ // capped minor-units so a partial cap only spends the points the
260
+ // granted credit is worth.
261
+ var spentPoints = Math.ceil((appliedMinor * perUsd) / 100);
262
+ if (spentPoints > points) spentPoints = points; // floor/ceil guard — never spend more than asked
263
+ return { points: spentPoints, applied_minor: appliedMinor };
264
+ }
265
+
266
+ // Burn the resolved loyalty credit against the created order. The
267
+ // `loyalty.redeem` decrement is the authoritative double-spend guard
268
+ // (its `balance_points >= ?` SQL predicate refuses an overdraw), and
269
+ // the `order_id` link records the redemption against the order in the
270
+ // loyalty audit trail. Called once per order, after the order row
271
+ // exists, so points are never spent for an order that failed to
272
+ // create.
273
+ async function _redeemLoyalty(resolved, customerId, orderId) {
274
+ await loyalty.redeem({
275
+ customer_id: customerId,
276
+ points: resolved.points,
277
+ order_id: orderId,
278
+ notes: "checkout-credit:" + orderId,
279
+ });
280
+ }
281
+
197
282
  // Compose a quote from a cart + ship-to + (optional) selected
198
283
  // shipping service. Pure read — no DB writes.
199
284
  async function _buildQuote(input) {
@@ -292,7 +377,27 @@ function create(deps) {
292
377
  // credit reduces the amount due; the order still records the
293
378
  // full grand total it owed.
294
379
  var gc = await _resolveGiftCard(input.gift_card_code, quote);
295
- var amountDue = quote.totals.grand_total_minor - (gc ? gc.applied_minor : 0);
380
+ // Loyalty points credit stacks on top of any gift-card credit —
381
+ // both reduce the same amount due. The loyalty credit is capped
382
+ // at the grand total inside the resolver, but since the gift card
383
+ // may already cover part of the order, cap the loyalty credit
384
+ // again at what's still due so the two credits together never
385
+ // drive the amount due below zero.
386
+ var cartRowForCustomer = await cart.get(quote.cart_id);
387
+ var loyaltyCustomerId = cartRowForCustomer ? (cartRowForCustomer.customer_id || null) : null;
388
+ var loy = await _resolveLoyaltyCredit(input.loyalty_redeem_points, loyaltyCustomerId, quote);
389
+ var afterGiftCard = quote.totals.grand_total_minor - (gc ? gc.applied_minor : 0);
390
+ if (loy && loy.applied_minor > afterGiftCard) {
391
+ // The gift card already covers more of the order than the
392
+ // points were valued against — re-cap the points credit to the
393
+ // residual so the two never overlap. Re-derive the spent points
394
+ // from the smaller credit so surplus points stay in the balance.
395
+ loy.applied_minor = afterGiftCard < 0 ? 0 : afterGiftCard;
396
+ var perUsd = loyalty.REDEMPTION_POINTS_PER_USD;
397
+ loy.points = Math.ceil((loy.applied_minor * perUsd) / 100);
398
+ if (loy.applied_minor <= 0) loy = null;
399
+ }
400
+ var amountDue = afterGiftCard - (loy ? loy.applied_minor : 0);
296
401
 
297
402
  var orderLines = quote.lines.map(function (l) {
298
403
  return {
@@ -303,15 +408,17 @@ function create(deps) {
303
408
  unit_currency: l.unit_currency,
304
409
  };
305
410
  });
306
- var cartRow = await cart.get(quote.cart_id);
411
+ // Reuse the cart row already fetched for the loyalty
412
+ // customer-id lookup above — one read, not two.
413
+ var cartRow = cartRowForCustomer;
307
414
  // Hash of the buyer email (same key as the customers table) so a
308
415
  // later verified-email sign-in can claim this guest order.
309
416
  var emailHash = customers ? customers.hashEmail(email) : null;
310
417
 
311
- // Zero amount due — the gift card fully covers the order. No
312
- // PaymentIntent (Stripe refuses a zero-amount intent); create
313
- // the order, burn the card against it, and advance it straight
314
- // to paid via the FSM.
418
+ // Zero amount due — gift-card + loyalty credit fully cover the
419
+ // order. No PaymentIntent (Stripe refuses a zero-amount intent);
420
+ // create the order, burn the credits against it, and advance it
421
+ // straight to paid via the FSM.
315
422
  if (amountDue === 0) {
316
423
  var paidOrder = await order.createFromCart({
317
424
  cart_id: quote.cart_id,
@@ -328,15 +435,17 @@ function create(deps) {
328
435
  customer_email_hash: emailHash,
329
436
  lines: orderLines,
330
437
  });
331
- await _redeemGiftCard(gc, paidOrder.id);
438
+ if (gc) await _redeemGiftCard(gc, paidOrder.id);
439
+ if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, paidOrder.id);
332
440
  await cart.setStatus(quote.cart_id, "converted");
333
441
  var settled = await order.transition(paidOrder.id, "mark_paid", {
334
- reason: "gift_card:full",
442
+ reason: loy && !gc ? "loyalty:full" : "gift_card:full",
335
443
  });
336
444
  return {
337
445
  order: settled,
338
446
  payment_intent: null,
339
- gift_card: { applied_minor: gc.applied_minor, amount_due_minor: 0 },
447
+ gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: 0 } : null,
448
+ loyalty: loy ? { points: loy.points, applied_minor: loy.applied_minor, amount_due_minor: 0 } : null,
340
449
  };
341
450
  }
342
451
 
@@ -354,6 +463,7 @@ function create(deps) {
354
463
  tax_minor: String(quote.totals.tax_minor),
355
464
  shipping_minor: String(quote.totals.shipping_minor),
356
465
  gift_card_applied_minor: String(gc ? gc.applied_minor : 0),
466
+ loyalty_applied_minor: String(loy ? loy.applied_minor : 0),
357
467
  },
358
468
  }, idempotencyKey);
359
469
 
@@ -374,10 +484,12 @@ function create(deps) {
374
484
  lines: orderLines,
375
485
  });
376
486
 
377
- // Burn the gift-card credit against the created order. Runs
378
- // after the order row exists so a failed order never burns the
379
- // card; the redeem decrement is the double-spend guard.
487
+ // Burn the gift-card + loyalty credits against the created order.
488
+ // Runs after the order row exists so a failed order never spends
489
+ // either credit; each redeem decrement is its own double-spend
490
+ // guard.
380
491
  if (gc) await _redeemGiftCard(gc, createdOrder.id);
492
+ if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, createdOrder.id);
381
493
 
382
494
  // Mark the cart converted so a refresh of the storefront
383
495
  // doesn't accidentally re-quote the same cart.
@@ -391,6 +503,7 @@ function create(deps) {
391
503
  status: pi.status,
392
504
  },
393
505
  gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null,
506
+ loyalty: loy ? { points: loy.points, applied_minor: loy.applied_minor, amount_due_minor: amountDue } : null,
394
507
  };
395
508
  },
396
509
 
package/lib/order.js CHANGED
@@ -141,6 +141,15 @@ function create(opts) {
141
141
  // dependency is opt-in so existing callers (and the test suite)
142
142
  // can run without standing up a webhooks primitive.
143
143
  var webhooks = opts.webhooks || null;
144
+ // Optional loyalty earn dispatcher — when present, an order reaching
145
+ // `paid` fans out to the customer's loyalty balance via the earn
146
+ // rules (per_dollar_spent / per_purchase / first_purchase). Opt-in
147
+ // like `webhooks` so callers and the test suite can run without
148
+ // standing up a loyalty primitive. The award runs fire-and-forget on
149
+ // the same detached-promise discipline as the webhook fan-out: the
150
+ // transition has already persisted, so a loyalty read/write must not
151
+ // add latency to (or fail) the order transition.
152
+ var loyaltyEarnRules = opts.loyaltyEarnRules || null;
144
153
  // Pagination cursors for listForCustomer are HMAC-tagged via
145
154
  // b.pagination so an operator can't hand-craft one to skip past a
146
155
  // hidden order or replay across deployments. The secret defaults
@@ -306,6 +315,39 @@ function create(opts) {
306
315
  return webhooks.send("order." + event, _whPayload);
307
316
  }).catch(function () { /* drop-silent — delivery rows hold the failure */ });
308
317
  }
318
+ // Loyalty earn-on-purchase fan-out — fire-and-forget, same
319
+ // discipline as the webhook block. Only on the paid transition
320
+ // and only for an order that carries a customer_id (guest orders
321
+ // have no loyalty account to credit). The per-event dedup on
322
+ // `loyalty_earn_log` (rule_slug, customer_id, trigger_event_ref)
323
+ // collapses a re-delivered mark_paid (Stripe + PayPal webhook
324
+ // backstops both fire it) onto one award, so a retried transition
325
+ // never double-credits. dollars_spent is derived from the order
326
+ // subtotal (the revenue the customer actually spent on goods,
327
+ // excluding tax + shipping); per_purchase / first_purchase rules
328
+ // ignore it. The award is detached so a loyalty failure lives in
329
+ // the loyalty ledger's own audit trail, never as an
330
+ // unhandledRejection and never on the transition's latency.
331
+ if (loyaltyEarnRules && typeof loyaltyEarnRules.awardForEvent === "function"
332
+ && result.to === "paid" && refreshed && refreshed.customer_id) {
333
+ var _loyCustomer = refreshed.customer_id;
334
+ var _loyOrderId = refreshed.id;
335
+ var _loyDollars = Math.floor((Number(refreshed.subtotal_minor) || 0) / 100);
336
+ Promise.resolve().then(function () {
337
+ return loyaltyEarnRules.awardForEvent({
338
+ trigger: "per_dollar_spent",
339
+ customer_id: _loyCustomer,
340
+ dollars_spent: _loyDollars,
341
+ trigger_event_ref: "order:" + _loyOrderId,
342
+ });
343
+ }).then(function () {
344
+ return loyaltyEarnRules.awardForEvent({
345
+ trigger: "per_purchase",
346
+ customer_id: _loyCustomer,
347
+ trigger_event_ref: "order:" + _loyOrderId,
348
+ });
349
+ }).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
350
+ }
309
351
  return refreshed;
310
352
  },
311
353