@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 +4 -0
- package/README.md +4 -0
- package/SECURITY.md +14 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/checkout.js +125 -12
- package/lib/order.js +42 -0
- package/lib/storefront.js +373 -6
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/api-snapshot.json +55 -2
- package/lib/vendor/blamejs/lib/backup/manifest.js +1 -1
- package/lib/vendor/blamejs/lib/cloud-events.js +455 -0
- package/lib/vendor/blamejs/lib/csp.js +3 -2
- package/lib/vendor/blamejs/lib/jtd.js +4 -15
- package/lib/vendor/blamejs/lib/rfc3339.js +37 -0
- package/lib/vendor/blamejs/lib/safe-buffer.js +8 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.63.json +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cloud-events.test.js +129 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/csp-builder.test.js +26 -0
- package/package.json +1 -1
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.
|
package/lib/asset-manifest.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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 —
|
|
312
|
-
// PaymentIntent (Stripe refuses a zero-amount intent);
|
|
313
|
-
// the order, burn the
|
|
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
|
|
378
|
-
// after the order row exists so a failed order never
|
|
379
|
-
//
|
|
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
|
|