@blamejs/blamejs-shop 0.4.43 → 0.4.45
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/lib/asset-manifest.json +1 -1
- package/lib/checkout.js +13 -8
- package/lib/loyalty-redemption.js +15 -16
- package/lib/loyalty.js +36 -0
- package/lib/promo-bundles.js +45 -13
- package/lib/referrals.js +70 -27
- 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.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.45 (2026-06-13) — **Sales tax is computed on the discounted price, so a discounted order is no longer over-taxed.** Checkout calculated sales tax on the full pre-discount subtotal even though the tax primitive's documented contract is to receive the post-discount subtotal (lib/tax.js: "Tax is computed against subtotal_minor (post-discount, pre-shipping)"). So every order carrying an automatic discount or a coupon was taxed on merchandise value the customer didn't pay for, over-collecting tax and overstating the order total. The tax base is now the subtotal minus the discount, computed by resolving the discount before the tax call. Shipping rates still gate on the pre-discount merchandise value — a free-shipping threshold is earned on what's in the cart, not the post-discount total — and an order with no discount is unaffected. No migration to apply. **Fixed:** *A discounted order is taxed on the price actually paid* — Sales tax now applies to the post-discount subtotal: the automatic-discount and coupon reduction is resolved first and subtracted from the taxable base before tax is calculated. Previously tax was computed on the full pre-discount subtotal, so a discounted or couponed order was over-taxed and its total overstated by the tax on the discounted-away amount. An order with no discount sees no change, and free-shipping thresholds continue to be evaluated against the pre-discount merchandise value.
|
|
12
|
+
|
|
13
|
+
- v0.4.44 (2026-06-13) — **Close three integrity gaps: loyalty tier ratcheting, an unenforced per-customer promo cap, and a double-counted referral funnel.** Three confirmed correctness fixes. Cancelling a loyalty redemption refunded the points to the customer's lifetime total as well as their spendable balance — but a redemption only ever debited the spendable balance, so repeatedly redeeming and cancelling ratcheted the lifetime total, which drives tier placement, upward without limit; the refund now credits the spendable balance only and never the lifetime total. A promo bundle's per-customer redemption cap was stored and editable but never consulted at redemption, so a capped bundle was redeemable an unlimited number of times by one customer; the cap is now enforced with an atomic count-and-insert that also holds when a customer's checkouts race. And recording a referred customer's first purchase read the funnel-completion flag and then wrote it in separate steps, so two concurrent recordings — a double-submit or a re-delivered purchase webhook — could both complete the funnel and credit the referrer twice; the completion is now claimed atomically and counts exactly once. No migration to apply. **Fixed:** *Loyalty tier can no longer be ratcheted by redeem-then-cancel* — Cancelling a redemption now refunds the debited points to the spendable balance only, through a new balance-only credit that never touches the lifetime total. Previously the refund went through the general adjustment path, which also credits the lifetime total and recomputes the tier — but a redemption never reduced the lifetime total in the first place, so each redeem-then-cancel cycle inflated it and a repeated loop could climb the tier ladder without ever earning the points. · *A promo bundle's per-customer cap is now enforced* — A bundle's per-customer redemption cap is now checked when a redemption is recorded: the ledger insert is conditional on the customer's existing redemption count for that bundle being below the cap, in a single atomic statement, so two concurrent checkouts for the same customer can't both slip past a nearly-full cap. The cap was previously stored and editable but never consulted, leaving a per-customer-capped bundle redeemable any number of times by one customer. A bundle with no per-customer cap is unchanged. · *A referral funnel is counted once under concurrency* — Recording a referred customer's first qualifying purchase now claims the funnel-completion transition atomically — the referrer's count is incremented only by the call that wins the claim. Previously the flag was read and then written in separate steps, so two concurrent recordings (a double-submit, or a purchase webhook re-delivered) could both pass the not-yet-completed check and credit the referrer twice. Signup attribution is likewise claimed atomically so two different customers can't both be pinned to the same pending invitation.
|
|
14
|
+
|
|
11
15
|
- v0.4.43 (2026-06-13) — **Harden input handling: linear-time pattern matching on caller-influenced strings, complete escaping, and redacted edge-bridge error responses.** A set of input-handling hardening fixes. Several internal pattern matches that run on caller-influenced strings used regular expressions whose shape could be driven into super-linear (polynomial) backtracking by a long run of a single character — a denial-of-service shape. These are rewritten to linear-time equivalents that accept and reject exactly the same inputs: the email-shape guard in the analytics and clickstream PII filters, a whitespace trim in the captcha gate, and the trailing-slash trim on the D1 and R2 bridge URLs. A JSON-escape helper in the catalog draft path now escapes backslashes as well as quotes so a crafted value can't smuggle an escape. And the edge worker's database- and storage-bridge error responses no longer return internal error detail to the caller — the detail is logged at the edge and the client receives only a generic failure code. No migration to apply. **Security:** *Pattern matching on caller-influenced input is now linear-time* — The email-shape detector used by the analytics and clickstream PII guards, the whitespace trim in the captcha gate, and the trailing-slash trim applied to the D1 and R2 bridge URLs were regular expressions whose backtracking could grow polynomially with the input length — a long run of one character could pin a request thread. Each is replaced with a linear-time form (a bounded, unambiguous regex; a native trim; a small character loop) proven to accept and reject the identical set of inputs, so the same values still pass and fail while a crafted long input can no longer cause super-linear work. · *Edge bridge errors no longer leak internal detail* — The Cloudflare Worker's database-bridge and storage-bridge endpoints returned the underlying error message in their 500 responses. They now return only a generic failure code to the caller and log the (redacted) detail at the edge, so an internal error string or stack fragment is never exposed in a response body. · *Complete escaping in the catalog draft lookup* — The catalog draft path builds a JSON-fragment match string from a SKU; its escape step now escapes backslashes before quotes, so the escaping is complete and a value containing a backslash cannot break out of the quoted fragment.
|
|
12
16
|
|
|
13
17
|
- v0.4.42 (2026-06-13) — **A return refund is now recorded against the order, so it can't be paid out a second time from the order console.** Issuing a provider refund for a return moved the money but never recorded it against the order, so order.refundedTotalMinor didn't count it. The order console's refund caps each refund at the order's remaining un-refunded balance — but with the return refund invisible to that balance, an operator could refund the full order total a second time from the order screen, paying the customer back twice. A return's provider refund now writes to the order's refund ledger, stamped with the provider refund id so the entry collapses with the payment provider's own refund webhook — whether the console or the webhook records it first, the same refund is counted exactly once. The order's move to a fully-refunded state and the gift-card / loyalty reversals remain driven by the refund webhook, as before. No migration to apply. **Fixed:** *A return refund now counts against the order's refunded total* — When the returns console issues a provider refund, the amount is now recorded against the order through the same deduplicating ledger path a partial console refund uses, stamped with the provider refund id. Previously the money moved but the order's refunded total never saw it, so the order console's over-refund cap — which limits a refund to the order's remaining balance — could be cleared again and pay the customer back a second time. Keying the entry on the provider refund id makes it idempotent against the provider's refund webhook: whichever path records first, a refund mirrored by both is counted exactly once. The order's terminal refunded state and the gift-card / loyalty reversals stay driven by the refund webhook.
|
package/lib/asset-manifest.json
CHANGED
package/lib/checkout.js
CHANGED
|
@@ -1139,10 +1139,22 @@ function create(deps) {
|
|
|
1139
1139
|
}
|
|
1140
1140
|
|
|
1141
1141
|
var sub = pricing.subtotal(lines, { currency: c.currency });
|
|
1142
|
+
|
|
1143
|
+
// Resolve the automatic discount BEFORE tax: sales tax is owed on the
|
|
1144
|
+
// DISCOUNTED price the customer actually pays, not the pre-discount
|
|
1145
|
+
// subtotal, so the discount has to be known to compute the tax base. The
|
|
1146
|
+
// resolver clamps to [0, subtotal] and falls back to 0 on any failure, so
|
|
1147
|
+
// the math is identical to the un-wired flow whenever no rule applies.
|
|
1148
|
+
// (Pure computation here — the redemption CLAIM happens later, in confirm.)
|
|
1149
|
+
var autoDisc = await _resolveAutoDiscount(lines, sub.amount_minor, c.customer_id || null, input.codes);
|
|
1150
|
+
var taxableMinor = Math.max(0, sub.amount_minor - autoDisc.discount_minor);
|
|
1151
|
+
|
|
1142
1152
|
var taxRow = await tax.calculate({
|
|
1143
1153
|
shipTo: input.ship_to,
|
|
1144
|
-
subtotal_minor:
|
|
1154
|
+
subtotal_minor: taxableMinor,
|
|
1145
1155
|
});
|
|
1156
|
+
// Shipping rates still gate on the PRE-discount subtotal — a free-shipping
|
|
1157
|
+
// threshold is earned on merchandise value, not the post-discount total.
|
|
1146
1158
|
var ratesRow = await shipping.rates({
|
|
1147
1159
|
shipTo: input.ship_to,
|
|
1148
1160
|
lines: enrichedLines,
|
|
@@ -1182,13 +1194,6 @@ function create(deps) {
|
|
|
1182
1194
|
|
|
1183
1195
|
var shippingMinor = selected ? selected.amount_minor : 0;
|
|
1184
1196
|
|
|
1185
|
-
// Automatic discounts: consult the operator-authored rule engine
|
|
1186
|
-
// (when wired) for the subtotal reduction this cart earns. The
|
|
1187
|
-
// resolver clamps the result to [0, subtotal] and falls back to 0
|
|
1188
|
-
// on any failure, so the total math below is identical to the
|
|
1189
|
-
// un-wired flow whenever no rule applies.
|
|
1190
|
-
var autoDisc = await _resolveAutoDiscount(lines, sub.amount_minor, c.customer_id || null, input.codes);
|
|
1191
|
-
|
|
1192
1197
|
var totals = pricing.totals(c, lines, {
|
|
1193
1198
|
tax_minor: taxRow.tax_minor,
|
|
1194
1199
|
shipping_minor: shippingMinor,
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* checkout. This primitive composes:
|
|
11
11
|
*
|
|
12
12
|
* - `loyalty` (required) — points-ledger writes. The redemption
|
|
13
|
-
* debits points via `loyalty.redeem` and refunds them
|
|
14
|
-
* `loyalty.
|
|
13
|
+
* debits points via `loyalty.redeem` and refunds them to the
|
|
14
|
+
* spendable balance via `loyalty.creditBalance` on cancellation
|
|
15
|
+
* (balance only — lifetime stays where redeem left it).
|
|
15
16
|
* - `coupons` (optional) — when injected, redemption mints a
|
|
16
17
|
* single-use coupon code via `coupons.issueSingleUseFromReward`
|
|
17
18
|
* (or whichever handle the operator wires). When absent, the
|
|
@@ -45,10 +46,11 @@
|
|
|
45
46
|
*
|
|
46
47
|
* - `cancelRedemption({ redemption_id, reason })`
|
|
47
48
|
* FSM transition active → cancelled. Refunds the debited points
|
|
48
|
-
* via `loyalty.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
49
|
+
* to the SPENDABLE balance via `loyalty.creditBalance` — never to
|
|
50
|
+
* the lifetime total, so a redeem→cancel loop can't ratchet the
|
|
51
|
+
* tier. A consumed/expired/cancelled redemption is refused —
|
|
52
|
+
* operators issue a manual `loyalty.adjust` row if the points
|
|
53
|
+
* need to come back after consumption.
|
|
52
54
|
*
|
|
53
55
|
* - `getRedemption(redemption_id)` / `redemptionsForCustomer(id,
|
|
54
56
|
* { limit?, cursor? })` — read paths.
|
|
@@ -614,7 +616,6 @@ function create(opts) {
|
|
|
614
616
|
throw bad;
|
|
615
617
|
}
|
|
616
618
|
|
|
617
|
-
var ts = _now();
|
|
618
619
|
var r = await query(
|
|
619
620
|
"UPDATE loyalty_redemptions SET status = 'cancelled', cancel_reason = ?1 " +
|
|
620
621
|
"WHERE id = ?2 AND status = 'active'",
|
|
@@ -627,20 +628,18 @@ function create(opts) {
|
|
|
627
628
|
throw raceErr;
|
|
628
629
|
}
|
|
629
630
|
|
|
630
|
-
// Refund the debited points
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
await loyalty.adjust({
|
|
631
|
+
// Refund the debited points to the SPENDABLE balance only —
|
|
632
|
+
// `loyalty.redeem` debited balance and left lifetime untouched, so
|
|
633
|
+
// the refund mirrors that. Using `loyalty.adjust(+points)` here
|
|
634
|
+
// would credit lifetime, inflating the tier-driving total above
|
|
635
|
+
// what was earned; a redeem→cancel loop would then escalate the
|
|
636
|
+
// tier without limit. `creditBalance` never moves lifetime.
|
|
637
|
+
await loyalty.creditBalance({
|
|
638
638
|
customer_id: current.customer_id,
|
|
639
639
|
points: current.points_debited,
|
|
640
640
|
source: "redemption-refund",
|
|
641
641
|
notes: "redemption:" + redemptionId + " " + reason,
|
|
642
642
|
});
|
|
643
|
-
void ts;
|
|
644
643
|
|
|
645
644
|
return await getRedemption(redemptionId);
|
|
646
645
|
}
|
package/lib/loyalty.js
CHANGED
|
@@ -545,6 +545,42 @@ function create(opts) {
|
|
|
545
545
|
};
|
|
546
546
|
},
|
|
547
547
|
|
|
548
|
+
// Credit the SPENDABLE balance only, never lifetime. The
|
|
549
|
+
// refund-the-burn counterpart to `redeem`: redeem debits balance
|
|
550
|
+
// and leaves lifetime alone, so giving the points back must mirror
|
|
551
|
+
// that — touching lifetime here would inflate the tier-driving
|
|
552
|
+
// total above what was actually earned, and a redeem→cancel loop
|
|
553
|
+
// would escalate the tier without limit. Unlike `adjust(+points)`,
|
|
554
|
+
// which credits lifetime on a positive delta, this never moves
|
|
555
|
+
// lifetime and never recomputes tier. Relative-atomic so a
|
|
556
|
+
// concurrent earn/redeem can't clobber the credit. Writes an
|
|
557
|
+
// `adjust`-type ledger row so the audit trail records the refund.
|
|
558
|
+
creditBalance: async function (input) {
|
|
559
|
+
if (!input || typeof input !== "object") {
|
|
560
|
+
throw new TypeError("loyalty.creditBalance: input object required");
|
|
561
|
+
}
|
|
562
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
563
|
+
var points = _positiveInt(input.points, "points");
|
|
564
|
+
var source = _source(input.source);
|
|
565
|
+
var notes = _notes(input.notes);
|
|
566
|
+
|
|
567
|
+
var ts = _now();
|
|
568
|
+
await _ensureAccountRow(customerId, ts);
|
|
569
|
+
await query(
|
|
570
|
+
"UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
|
|
571
|
+
"updated_at = ?2 WHERE customer_id = ?3",
|
|
572
|
+
[points, ts, customerId],
|
|
573
|
+
);
|
|
574
|
+
await _writeTx(customerId, "adjust", points, source, null, notes, ts);
|
|
575
|
+
|
|
576
|
+
var after = await _readAccount(customerId);
|
|
577
|
+
return {
|
|
578
|
+
balance: after.balance_points,
|
|
579
|
+
lifetime: after.lifetime_points,
|
|
580
|
+
tier: after.tier,
|
|
581
|
+
};
|
|
582
|
+
},
|
|
583
|
+
|
|
548
584
|
expire: async function (input) {
|
|
549
585
|
if (!input || typeof input !== "object") {
|
|
550
586
|
throw new TypeError("loyalty.expire: input object required");
|
package/lib/promo-bundles.js
CHANGED
|
@@ -62,7 +62,13 @@
|
|
|
62
62
|
* `max_redemptions_total` (the cap is checked under the same
|
|
63
63
|
* statement that increments the counter via a guarded UPDATE so
|
|
64
64
|
* two simultaneous checkouts cannot both succeed on the final
|
|
65
|
-
* slot).
|
|
65
|
+
* slot). Also refuses once a customer reaches `max_per_customer`
|
|
66
|
+
* — that cap is enforced on the ledger INSERT itself (the row
|
|
67
|
+
* materializes only while the customer's existing redemption
|
|
68
|
+
* count for the slug is below the cap), so concurrent checkouts
|
|
69
|
+
* for one customer cannot both clear an N-1 count. A null cap or
|
|
70
|
+
* an anonymous (no customer_id) redemption is unlimited.
|
|
71
|
+
* UNIQUE(slug, order_id) gives idempotency — replaying a
|
|
66
72
|
* redemption for the same order is a no-op that returns the
|
|
67
73
|
* existing row.
|
|
68
74
|
*
|
|
@@ -643,13 +649,43 @@ function create(opts) {
|
|
|
643
649
|
)).rows[0];
|
|
644
650
|
if (existing) return _hydrateRedemption(existing);
|
|
645
651
|
|
|
646
|
-
// Cap check + increment in one guarded UPDATE so two concurrent
|
|
647
|
-
// checkouts cannot both claim the last slot. SQLite's statement
|
|
648
|
-
// atomicity guarantees the row update is all-or-nothing; the
|
|
649
|
-
// WHERE filters out a slot-exhausted race losing-side.
|
|
650
652
|
var ts = _now();
|
|
651
653
|
var redemptionId = b.uuid.v7({ now: ts });
|
|
652
654
|
|
|
655
|
+
// Per-customer cap: enforce it on the ledger INSERT itself so the
|
|
656
|
+
// count-and-insert is one atomic statement. The INSERT...SELECT
|
|
657
|
+
// materializes a row only while the customer's existing redemption
|
|
658
|
+
// count for this slug is still below the cap; two concurrent
|
|
659
|
+
// checkouts for the same customer cannot both observe N-1 and both
|
|
660
|
+
// win, because SQLite serializes the writes and the second sees the
|
|
661
|
+
// first's committed row. A null cap or anonymous (customer_id null)
|
|
662
|
+
// redemption is unlimited, so it takes the plain unconditional
|
|
663
|
+
// INSERT. The ledger row is written before the total-cap counter
|
|
664
|
+
// UPDATE so a per-customer refusal never ticks redemptions_used.
|
|
665
|
+
if (bundle.max_per_customer != null && customerId != null) {
|
|
666
|
+
var perCustRes = await query(
|
|
667
|
+
"INSERT INTO promo_bundle_redemptions (id, slug, order_id, customer_id, savings_minor, occurred_at) " +
|
|
668
|
+
"SELECT ?1, ?2, ?3, ?4, ?5, ?6 WHERE " +
|
|
669
|
+
"(SELECT COUNT(*) FROM promo_bundle_redemptions WHERE slug = ?2 AND customer_id = ?4) < ?7",
|
|
670
|
+
[redemptionId, slug, orderId, customerId, savingsMinor, ts, bundle.max_per_customer],
|
|
671
|
+
);
|
|
672
|
+
if (Number(perCustRes.rowCount || 0) === 0) {
|
|
673
|
+
var perCustErr = new Error("promoBundles.recordRedemption: customer has hit max_per_customer for bundle " + JSON.stringify(slug));
|
|
674
|
+
perCustErr.code = "BUNDLE_PER_CUSTOMER_CAP_REACHED";
|
|
675
|
+
throw perCustErr;
|
|
676
|
+
}
|
|
677
|
+
} else {
|
|
678
|
+
await query(
|
|
679
|
+
"INSERT INTO promo_bundle_redemptions (id, slug, order_id, customer_id, savings_minor, occurred_at) " +
|
|
680
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
681
|
+
[redemptionId, slug, orderId, customerId, savingsMinor, ts],
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Total-cap check + increment in one guarded UPDATE so two concurrent
|
|
686
|
+
// checkouts cannot both claim the last slot. SQLite's statement
|
|
687
|
+
// atomicity guarantees the row update is all-or-nothing; the
|
|
688
|
+
// WHERE filters out a slot-exhausted race losing-side.
|
|
653
689
|
var updateSql, updateParams;
|
|
654
690
|
if (bundle.max_redemptions_total == null) {
|
|
655
691
|
updateSql = "UPDATE promo_bundles SET redemptions_used = redemptions_used + 1, updated_at = ?1 WHERE slug = ?2";
|
|
@@ -661,8 +697,10 @@ function create(opts) {
|
|
|
661
697
|
}
|
|
662
698
|
var updateRes = await query(updateSql, updateParams);
|
|
663
699
|
if (Number(updateRes.rowCount || 0) === 0) {
|
|
664
|
-
// Lost the race for the last slot —
|
|
665
|
-
//
|
|
700
|
+
// Lost the race for the last slot — the ledger row is already
|
|
701
|
+
// written, so roll it back to keep redemptions_used and the audit
|
|
702
|
+
// rows in step before surfacing the cap error.
|
|
703
|
+
await query("DELETE FROM promo_bundle_redemptions WHERE id = ?1", [redemptionId]);
|
|
666
704
|
var after = await getBundle(slug);
|
|
667
705
|
if (after && after.max_redemptions_total != null && after.redemptions_used >= after.max_redemptions_total) {
|
|
668
706
|
var capErr = new Error("promoBundles.recordRedemption: bundle " + JSON.stringify(slug) + " has hit max_redemptions_total");
|
|
@@ -674,12 +712,6 @@ function create(opts) {
|
|
|
674
712
|
throw new Error("promoBundles.recordRedemption: failed to claim a redemption slot for " + JSON.stringify(slug));
|
|
675
713
|
}
|
|
676
714
|
|
|
677
|
-
await query(
|
|
678
|
-
"INSERT INTO promo_bundle_redemptions (id, slug, order_id, customer_id, savings_minor, occurred_at) " +
|
|
679
|
-
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
680
|
-
[redemptionId, slug, orderId, customerId, savingsMinor, ts],
|
|
681
|
-
);
|
|
682
|
-
|
|
683
715
|
return {
|
|
684
716
|
id: redemptionId,
|
|
685
717
|
slug: slug,
|
package/lib/referrals.js
CHANGED
|
@@ -361,36 +361,54 @@ function create(opts) {
|
|
|
361
361
|
throw miss;
|
|
362
362
|
}
|
|
363
363
|
var ts = _now();
|
|
364
|
+
// Idempotent fast-path: if this customer already signed up under
|
|
365
|
+
// this code, this is a no-op — re-tracking must NOT pin the same
|
|
366
|
+
// friend to a SECOND pending invitation (which would over-count the
|
|
367
|
+
// funnel). Returns null, the same "nothing new recorded" signal the
|
|
368
|
+
// caller already treats as already-attributed.
|
|
369
|
+
var existing = await query(
|
|
370
|
+
"SELECT id FROM referral_invitations " +
|
|
371
|
+
"WHERE referral_code_id = ?1 AND signed_up_customer_id = ?2 LIMIT 1",
|
|
372
|
+
[row.id, customerId],
|
|
373
|
+
);
|
|
374
|
+
if (existing.rows.length) return null;
|
|
364
375
|
// Pin the signup to the oldest pending invitation under this
|
|
365
376
|
// code that doesn't have a customer attached yet — the same
|
|
366
377
|
// friend can be invited by multiple referrers, but only one
|
|
367
378
|
// (the code they actually landed through) gets the funnel
|
|
368
|
-
// attribution.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
379
|
+
// attribution. The claim is atomic (`signed_up_customer_id IS
|
|
380
|
+
// NULL` in the WHERE) so two different customers signing up
|
|
381
|
+
// concurrently can't both pin the same oldest invitation; the
|
|
382
|
+
// loser re-reads the next pending candidate. Bounded by the
|
|
383
|
+
// number of pending rows so a fully-claimed code returns null.
|
|
384
|
+
for (;;) {
|
|
385
|
+
var candidate = await query(
|
|
386
|
+
"SELECT id FROM referral_invitations " +
|
|
387
|
+
"WHERE referral_code_id = ?1 AND signed_up_customer_id IS NULL " +
|
|
388
|
+
"ORDER BY invited_at ASC LIMIT 1",
|
|
389
|
+
[row.id],
|
|
390
|
+
);
|
|
391
|
+
if (!candidate.rows.length) {
|
|
392
|
+
// No pending invitation — record nothing. The caller can
|
|
393
|
+
// surface this if they want to gate signup-bonuses to known
|
|
394
|
+
// referrals.
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
var invitationId = candidate.rows[0].id;
|
|
398
|
+
var claim = await query(
|
|
399
|
+
"UPDATE referral_invitations " +
|
|
400
|
+
"SET signed_up_at = ?1, signed_up_customer_id = ?2 " +
|
|
401
|
+
"WHERE id = ?3 AND signed_up_customer_id IS NULL",
|
|
402
|
+
[ts, customerId, invitationId],
|
|
403
|
+
);
|
|
404
|
+
if (Number(claim.rowCount || 0) === 0) continue; // lost the claim — try the next pending row
|
|
405
|
+
var updated = await query(
|
|
406
|
+
"SELECT id, referral_code_id, signed_up_customer_id, signed_up_at, reward_status " +
|
|
407
|
+
"FROM referral_invitations WHERE id = ?1",
|
|
408
|
+
[invitationId],
|
|
409
|
+
);
|
|
410
|
+
return updated.rows[0] || null;
|
|
380
411
|
}
|
|
381
|
-
var invitationId = candidate.rows[0].id;
|
|
382
|
-
await query(
|
|
383
|
-
"UPDATE referral_invitations " +
|
|
384
|
-
"SET signed_up_at = ?1, signed_up_customer_id = ?2 " +
|
|
385
|
-
"WHERE id = ?3",
|
|
386
|
-
[ts, customerId, invitationId],
|
|
387
|
-
);
|
|
388
|
-
var updated = await query(
|
|
389
|
-
"SELECT id, referral_code_id, signed_up_customer_id, signed_up_at, reward_status " +
|
|
390
|
-
"FROM referral_invitations WHERE id = ?1",
|
|
391
|
-
[invitationId],
|
|
392
|
-
);
|
|
393
|
-
return updated.rows[0] || null;
|
|
394
412
|
},
|
|
395
413
|
|
|
396
414
|
// Recorded when a referred customer makes their first qualifying
|
|
@@ -426,13 +444,38 @@ function create(opts) {
|
|
|
426
444
|
};
|
|
427
445
|
}
|
|
428
446
|
var ts = _now();
|
|
429
|
-
|
|
447
|
+
// Claim the funnel completion atomically — the
|
|
448
|
+
// `first_purchase_at IS NULL` predicate is the serialization
|
|
449
|
+
// point so two concurrent calls (a double-submit or a
|
|
450
|
+
// re-delivered purchase webhook) can't both pass the null check
|
|
451
|
+
// above and both bump the leaderboard. Only the call that wins
|
|
452
|
+
// the claim (rowCount === 1) increments referrals_count; a loser
|
|
453
|
+
// returns the already-recorded shape.
|
|
454
|
+
var claim = await query(
|
|
430
455
|
"UPDATE referral_invitations " +
|
|
431
456
|
"SET first_purchase_at = ?1, first_order_id = ?2, " +
|
|
432
457
|
"reward_status = 'both-rewarded' " +
|
|
433
|
-
"WHERE id = ?3",
|
|
458
|
+
"WHERE id = ?3 AND first_purchase_at IS NULL",
|
|
434
459
|
[ts, orderId, inv.id],
|
|
435
460
|
);
|
|
461
|
+
if (Number(claim.rowCount || 0) === 0) {
|
|
462
|
+
// Lost the claim — another concurrent call already recorded the
|
|
463
|
+
// first qualifying purchase. Re-read so the loser reports the
|
|
464
|
+
// winner's pinned values, not a stale snapshot.
|
|
465
|
+
var lost = await query(
|
|
466
|
+
"SELECT id, reward_status, first_purchase_at " +
|
|
467
|
+
"FROM referral_invitations WHERE id = ?1",
|
|
468
|
+
[inv.id],
|
|
469
|
+
);
|
|
470
|
+
var lostRow = lost.rows[0] || null;
|
|
471
|
+
if (!lostRow) return null;
|
|
472
|
+
return {
|
|
473
|
+
id: lostRow.id,
|
|
474
|
+
reward_status: lostRow.reward_status,
|
|
475
|
+
first_purchase_at: lostRow.first_purchase_at,
|
|
476
|
+
status: "already-recorded",
|
|
477
|
+
};
|
|
478
|
+
}
|
|
436
479
|
// Bump the referrer's running count. `referrals_count` is the
|
|
437
480
|
// leaderboard key; it counts completed funnels (a friend who
|
|
438
481
|
// signed up but never purchased doesn't count).
|
package/package.json
CHANGED