@blamejs/blamejs-shop 0.1.26 → 0.1.28
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 +7 -0
- package/SECURITY.md +29 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/checkout.js +125 -12
- package/lib/order.js +72 -0
- package/lib/referrals.js +44 -0
- package/lib/storefront.js +769 -6
- 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.28 (2026-05-25) — **Refer-a-friend goes live — a shareable link, attributed signups, and conversions recorded when a referred friend's first order is paid.** The referrals ledger now has a customer-facing surface. A new /account/referrals page gives the signed-in customer a personal referral code, an absolute shareable link, the list of friends they've referred with each one's funnel stage, and an in-account top-referrer leaderboard. A new /r/<code> landing captures an inbound referral: it sets a short-lived sealed first-party cookie naming the code and sends the visitor home. When that visitor creates an account — by passkey, Google, or Apple — the new customer is attributed to the referrer, first-touch, with a self-referral guard so a code can never refer its own owner. When a referred customer's first order reaches paid, the order primitive marks that referral converted and bumps the referrer's count (the leaderboard key) — automatically, off the request path, and recorded once per order so a re-delivered payment webhook can't double-count. Issuing the actual reward (a gift card, store credit, or loyalty points — operator's choice) stays an explicit operator action through the referrals reward API, so the payout instrument and any fraud review are the operator's to control. **Added:** *Customer referrals page at /account/referrals* — A login-gated page showing the customer's referral code and an absolute shareable link (derived from the configured shop origin, falling back to the request host), the count of friends invited / joined / converted, the list of referred friends with each one's funnel stage (invited → visited → joined → converted) and dates, and an in-account top-referrer leaderboard. The friend list carries no personal data — referred emails are stored hash-only — and the leaderboard shows rank plus initials only, never names, emails, or account ids, with the signed-in customer's own row marked "You". Linked from the account dashboard as "Refer a friend". A customer with no code yet sees a one-click create control; a customer with one already gets the same page (creating is idempotent). · *Referral attribution landing at /r/<code>* — A public landing that captures an inbound referral: it sets a short-lived (30-day) sealed, first-party cookie naming the code, records the visit, and redirects the visitor to the home page. Attribution is first-touch — the first referral link a visitor follows wins, and a later link doesn't overwrite it. An unknown, malformed, or disabled code is silently ignored: the visitor still lands on the home page with no error and no cookie, so the route is never a code-existence oracle. · *Signups attributed to the referrer* — When a visitor who arrived through a referral link creates an account — passkey, Sign in with Google, or Sign in with Apple — the new customer is attributed to the referrer named in the referral cookie. A self-referral (the code belongs to the new account) is refused, a customer is attributed only once, and an existing customer signing in through Google or Apple is never attributed (only a genuinely new account is). Attribution is best-effort and never blocks account creation; the referral cookie is cleared after one signup so a stale link can't attribute a later one. · *Referral conversion recorded when a referred friend's first order is paid* — When a referred customer's first order reaches paid, the order primitive marks that referral converted and bumps the referrer's referral count (the leaderboard key). The update rides every paid path (card, wallet, PayPal, gift-card-covered), runs fire-and-forget off the request path, and is recorded once per order so a re-delivered payment webhook collapses onto a single conversion. Only orders carrying a customer account qualify; a guest order records nothing, and a customer's second and later orders don't re-trigger it. Issuing the payout itself — a gift card, store credit, or loyalty points — is a separate operator action through the referrals reward API, so the operator controls the reward instrument and can review before paying out.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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.
|
|
12
16
|
|
|
13
17
|
- 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`).
|
package/README.md
CHANGED
|
@@ -68,6 +68,8 @@ 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. |
|
|
72
|
+
| **`lib/referrals.js`** + **`lib/referral-leaderboard.js`** | Refer-a-friend with two-sided rewards. `referrals` issues each customer an 8-character code (32-glyph confusion-resistant alphabet via `b.crypto.generateBytes`) and tracks each referred friend through the funnel (invited → visited → joined → converted); referred emails are stored hash-only (`b.crypto.namespaceHash`), never plaintext. `referral-leaderboard` sits on top for top-referrer rankings + operator-tunable tier bonuses. Customers see all of it at `/account/referrals` — their code, an absolute shareable link, the friends they've referred (funnel stage + dates, no personal data), and an in-account leaderboard (rank + initials only). A public `/r/<code>` landing captures an inbound referral into a short-lived sealed first-party cookie (first-touch), which attributes the new customer to the referrer when they sign up by passkey / Google / Apple — self-referral and double-attribution guarded. When a referred customer's first order reaches paid, the order FSM's paid transition marks that referral converted and bumps the referrer's count fire-and-forget, deduped on the order id so a re-delivered webhook never double-counts; guest orders and a customer's later orders don't qualify. Issuing the payout itself (gift card / store credit / loyalty points) is an explicit operator action via the referrals reward API, so the reward instrument and any fraud review stay the operator's. |
|
|
71
73
|
| **`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
74
|
| **`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
75
|
| **`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 +104,11 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
102
104
|
- `migrations-d1/0043_collections.sql` — manual + smart product collections (members + rules + sort strategy)
|
|
103
105
|
- `migrations-d1/0050_recently_viewed.sql` — per-customer / per-session product browse history (dedup + per-subject cap)
|
|
104
106
|
- `migrations-d1/0105_recommendations.sql` — operator-curated recommendation overrides (kind-scoped, weight-ordered)
|
|
107
|
+
- `migrations-d1/0022_loyalty.sql` — loyalty accounts (balance + lifetime + tier) + audited points transactions
|
|
108
|
+
- `migrations-d1/0085_loyalty_redemptions.sql` — reward catalog + per-redemption records (FSM: active → consumed / expired / cancelled)
|
|
109
|
+
- `migrations-d1/0163_loyalty_earn_rules.sql` — per-event earn rules + dedup-keyed award log
|
|
110
|
+
- `migrations-d1/0025_referrals.sql` — referral codes (one active per customer) + invitation funnel (hash-only referred email)
|
|
111
|
+
- `migrations-d1/0182_referral_leaderboard.sql` — referral tier thresholds (singleton config) + tiered-bonus payout log
|
|
105
112
|
|
|
106
113
|
### Demo seed
|
|
107
114
|
|
package/SECURITY.md
CHANGED
|
@@ -160,3 +160,32 @@ 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.
|
|
177
|
+
- **Referral rewards are fraud-guarded — no self-referral, no
|
|
178
|
+
double-attribution, no double-reward.** The `/r/<code>` landing only
|
|
179
|
+
sets the attribution cookie for a live, active code and never reveals
|
|
180
|
+
whether a guessed code exists (unknown / malformed / disabled all
|
|
181
|
+
redirect home identically). A new account is attributed to the
|
|
182
|
+
referrer named in the sealed cookie, with a self-referral guard so a
|
|
183
|
+
code can never refer its own owner, and each customer is attributed
|
|
184
|
+
only once (first-touch); an existing customer signing back in is never
|
|
185
|
+
re-attributed. The reward fires only when a referred customer's first
|
|
186
|
+
order reaches paid, rides the order's paid transition fire-and-forget,
|
|
187
|
+
and is recorded once per order so a re-delivered payment webhook can't
|
|
188
|
+
double-reward — guest orders and later orders don't qualify. Referred
|
|
189
|
+
emails are stored hash-only (`b.crypto.namespaceHash`), and the
|
|
190
|
+
account leaderboard exposes rank plus initials only, never an email or
|
|
191
|
+
account id.
|
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,27 @@ 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;
|
|
153
|
+
// Optional referrals dispatcher — when present, an order reaching
|
|
154
|
+
// `paid` records the buyer's first qualifying purchase against any
|
|
155
|
+
// referral invitation pinned to their account, which completes the
|
|
156
|
+
// referrer's funnel (bumps referrals_count → the leaderboard). Opt-in
|
|
157
|
+
// like `webhooks` / `loyaltyEarnRules` so callers and the test suite
|
|
158
|
+
// run without standing up a referrals primitive. `trackPurchase` is
|
|
159
|
+
// idempotent (first_purchase_at is set only once) and a no-op when the
|
|
160
|
+
// buyer was never referred, so a re-delivered mark_paid can't double-
|
|
161
|
+
// reward and an organic purchase is silently skipped. The award runs
|
|
162
|
+
// fire-and-forget on the same detached-promise discipline as the
|
|
163
|
+
// loyalty fan-out: the transition has already persisted.
|
|
164
|
+
var referrals = opts.referrals || null;
|
|
144
165
|
// Pagination cursors for listForCustomer are HMAC-tagged via
|
|
145
166
|
// b.pagination so an operator can't hand-craft one to skip past a
|
|
146
167
|
// hidden order or replay across deployments. The secret defaults
|
|
@@ -306,6 +327,57 @@ function create(opts) {
|
|
|
306
327
|
return webhooks.send("order." + event, _whPayload);
|
|
307
328
|
}).catch(function () { /* drop-silent — delivery rows hold the failure */ });
|
|
308
329
|
}
|
|
330
|
+
// Loyalty earn-on-purchase fan-out — fire-and-forget, same
|
|
331
|
+
// discipline as the webhook block. Only on the paid transition
|
|
332
|
+
// and only for an order that carries a customer_id (guest orders
|
|
333
|
+
// have no loyalty account to credit). The per-event dedup on
|
|
334
|
+
// `loyalty_earn_log` (rule_slug, customer_id, trigger_event_ref)
|
|
335
|
+
// collapses a re-delivered mark_paid (Stripe + PayPal webhook
|
|
336
|
+
// backstops both fire it) onto one award, so a retried transition
|
|
337
|
+
// never double-credits. dollars_spent is derived from the order
|
|
338
|
+
// subtotal (the revenue the customer actually spent on goods,
|
|
339
|
+
// excluding tax + shipping); per_purchase / first_purchase rules
|
|
340
|
+
// ignore it. The award is detached so a loyalty failure lives in
|
|
341
|
+
// the loyalty ledger's own audit trail, never as an
|
|
342
|
+
// unhandledRejection and never on the transition's latency.
|
|
343
|
+
if (loyaltyEarnRules && typeof loyaltyEarnRules.awardForEvent === "function"
|
|
344
|
+
&& result.to === "paid" && refreshed && refreshed.customer_id) {
|
|
345
|
+
var _loyCustomer = refreshed.customer_id;
|
|
346
|
+
var _loyOrderId = refreshed.id;
|
|
347
|
+
var _loyDollars = Math.floor((Number(refreshed.subtotal_minor) || 0) / 100);
|
|
348
|
+
Promise.resolve().then(function () {
|
|
349
|
+
return loyaltyEarnRules.awardForEvent({
|
|
350
|
+
trigger: "per_dollar_spent",
|
|
351
|
+
customer_id: _loyCustomer,
|
|
352
|
+
dollars_spent: _loyDollars,
|
|
353
|
+
trigger_event_ref: "order:" + _loyOrderId,
|
|
354
|
+
});
|
|
355
|
+
}).then(function () {
|
|
356
|
+
return loyaltyEarnRules.awardForEvent({
|
|
357
|
+
trigger: "per_purchase",
|
|
358
|
+
customer_id: _loyCustomer,
|
|
359
|
+
trigger_event_ref: "order:" + _loyOrderId,
|
|
360
|
+
});
|
|
361
|
+
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
362
|
+
}
|
|
363
|
+
// Referral reward-on-first-order fan-out — fire-and-forget, same
|
|
364
|
+
// discipline as the loyalty block. Only on the paid transition and
|
|
365
|
+
// only for an order carrying a customer_id (a guest order has no
|
|
366
|
+
// account to attribute). trackPurchase pins the buyer's FIRST
|
|
367
|
+
// qualifying purchase to the invitation that referred them and
|
|
368
|
+
// completes the referrer's funnel; it is idempotent (first_purchase_at
|
|
369
|
+
// is written once) so a re-delivered mark_paid never double-rewards,
|
|
370
|
+
// and it is a no-op for a buyer who was never referred. The promise
|
|
371
|
+
// is detached so a referrals failure lives in its own tables, never
|
|
372
|
+
// as an unhandledRejection and never on the transition's latency.
|
|
373
|
+
if (referrals && typeof referrals.trackPurchase === "function"
|
|
374
|
+
&& result.to === "paid" && refreshed && refreshed.customer_id) {
|
|
375
|
+
var _refCustomer = refreshed.customer_id;
|
|
376
|
+
var _refOrderId = refreshed.id;
|
|
377
|
+
Promise.resolve().then(function () {
|
|
378
|
+
return referrals.trackPurchase({ customer_id: _refCustomer, order_id: _refOrderId });
|
|
379
|
+
}).catch(function () { /* drop-silent — referral funnel holds its own audit trail */ });
|
|
380
|
+
}
|
|
309
381
|
return refreshed;
|
|
310
382
|
},
|
|
311
383
|
|
package/lib/referrals.js
CHANGED
|
@@ -602,6 +602,50 @@ function create(opts) {
|
|
|
602
602
|
};
|
|
603
603
|
},
|
|
604
604
|
|
|
605
|
+
// Per-friend funnel rows for one referrer, across every code they
|
|
606
|
+
// hold, newest invitation first. Powers the account page's "friends
|
|
607
|
+
// you've referred" list. No PII crosses this boundary — the referred
|
|
608
|
+
// email is stored hash-only, so a row carries only the funnel
|
|
609
|
+
// timestamps + the reward_status, never an address. `signed_up_at`
|
|
610
|
+
// / `first_purchase_at` being non-null is what the surface renders
|
|
611
|
+
// as "joined" / "converted"; `pending` is invited-but-not-converted.
|
|
612
|
+
invitationsForReferrer: async function (customerId) {
|
|
613
|
+
var id = _uuid(customerId, "customer_id");
|
|
614
|
+
var r = await query(
|
|
615
|
+
"SELECT i.id AS id, i.referral_code_id AS referral_code_id, " +
|
|
616
|
+
"i.invited_at AS invited_at, i.visited_at AS visited_at, " +
|
|
617
|
+
"i.signed_up_at AS signed_up_at, i.first_purchase_at AS first_purchase_at, " +
|
|
618
|
+
"i.reward_status AS reward_status " +
|
|
619
|
+
"FROM referral_invitations i " +
|
|
620
|
+
"JOIN referral_codes c ON c.id = i.referral_code_id " +
|
|
621
|
+
"WHERE c.referrer_customer_id = ?1 " +
|
|
622
|
+
"ORDER BY i.invited_at DESC, i.id DESC",
|
|
623
|
+
[id],
|
|
624
|
+
);
|
|
625
|
+
return r.rows.map(function (row) {
|
|
626
|
+
var visited = row.visited_at != null;
|
|
627
|
+
var signedUp = row.signed_up_at != null;
|
|
628
|
+
var converted = row.first_purchase_at != null;
|
|
629
|
+
// Coarse funnel stage the surface renders. Converted wins over
|
|
630
|
+
// signed-up wins over visited wins over invited — the furthest
|
|
631
|
+
// the friend reached.
|
|
632
|
+
var stage = converted ? "converted"
|
|
633
|
+
: signedUp ? "signed-up"
|
|
634
|
+
: visited ? "visited"
|
|
635
|
+
: "pending";
|
|
636
|
+
return {
|
|
637
|
+
id: row.id,
|
|
638
|
+
referral_code_id: row.referral_code_id,
|
|
639
|
+
invited_at: Number(row.invited_at || 0),
|
|
640
|
+
visited_at: row.visited_at != null ? Number(row.visited_at) : null,
|
|
641
|
+
signed_up_at: row.signed_up_at != null ? Number(row.signed_up_at) : null,
|
|
642
|
+
first_purchase_at: row.first_purchase_at != null ? Number(row.first_purchase_at) : null,
|
|
643
|
+
reward_status: row.reward_status,
|
|
644
|
+
stage: stage,
|
|
645
|
+
};
|
|
646
|
+
});
|
|
647
|
+
},
|
|
648
|
+
|
|
605
649
|
// Top-N referrers by completed referrals (referrals_count is the
|
|
606
650
|
// funnel-complete counter; signups without a first purchase don't
|
|
607
651
|
// count). Defaults to 10; capped at 100 so an operator can't
|