@ingram-tech/nk-billing 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -79,14 +79,27 @@ export async function POST(request: Request) {
79
79
 
80
80
  The ledger takes the DB connection by **injection** and leaves isolation to you —
81
81
  wrap each call in `withTenant(orgId, db => spendCredits(db, …))` under RLS, or a
82
- plain transaction under app-layer filtering. It owns two tables; apply
83
- `migrations/0001_billing.sql` (a Drizzle-composable fragment) and add your own
84
- RLS policy if your stack uses one. See [`src/credits.ts`](./src/credits.ts).
82
+ plain transaction under app-layer filtering. It owns two tables; apply the
83
+ `migrations/*.sql` files in order (Drizzle-composable fragments) and add your
84
+ own RLS policy if your stack uses one. See [`src/credits.ts`](./src/credits.ts).
85
+
86
+ Two semantics to know before wiring it up:
87
+
88
+ - **Entitlement gates the balance.** `spendCredits` requires an active
89
+ subscription or a live trial *before* it looks at credits: a one-time credit
90
+ pack bought by a tenant with no subscription and an expired trial is not
91
+ spendable. If your site sells packs standalone, gate pack checkout on
92
+ entitlement — or pass `activeStatuses` semantics that match your model.
93
+ - **Webhook ordering.** Pass `event.created` as `eventCreated` to
94
+ `recordSubscriptionStatus` (and apply `0002_billing_status_order.sql`) so a
95
+ delayed, older `customer.subscription.updated` can't overwrite the status a
96
+ later `deleted` event already recorded.
85
97
 
86
98
  ## Full surface
87
99
 
88
- See the [nk-billing guide](../../../nk-billing.md) for the exhaustive,
89
- per-export reference and migration recipes for each consuming app.
100
+ The per-export reference is the JSDoc on each export — every entry point in
101
+ [`src/index.ts`](./src/index.ts) and [`src/credits.ts`](./src/credits.ts) is
102
+ documented at the definition.
90
103
 
91
104
  ## License
92
105
 
package/dist/balance.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Customer-balance wallet — Stripe's own credit ledger, for pay-as-you-go sites
3
- * (domains.ingram.tech). Distinct from the in-app credit ledger in
2
+ * Customer-balance wallet — Stripe's own credit ledger, for pay-as-you-go
3
+ * sites. Distinct from the in-app credit ledger in
4
4
  * `@ingram-tech/nk-billing/credits`: this stores the balance *in Stripe* as a
5
5
  * signed integer (negative = credit available to the customer), so there is no
6
6
  * local table to keep in sync. Pick this when the unit you sell is money;
@@ -16,13 +16,23 @@ export interface Balance {
16
16
  currency: string;
17
17
  }
18
18
  /** Read a customer's available wallet balance (Stripe's negative credit balance,
19
- * sign-flipped to positive). A deleted customer reads as zero. */
19
+ * sign-flipped to positive and clamped at zero a positive Stripe balance is
20
+ * an amount owed, not available credit). A deleted customer reads as zero. */
20
21
  export declare function readBalance(customerId: string, stripe?: Stripe): Promise<Balance>;
21
- /** Debit the wallet by `amountCents`. Stripe rejects a debit that would push the
22
- * balance positive (the customer would owe us), giving a hard over-spend
23
- * guarantee with no pre-flight check. Idempotency-keyed by `idempotencyTag` so
24
- * retries can't double-debit tie the tag to the action that triggered the
25
- * spend (e.g. the operation's own idempotency key). */
22
+ /** Debit the wallet by `amountCents`, throwing {@link BillingError}
23
+ * (`insufficient_credits`) when the customer's credit doesn't cover it.
24
+ *
25
+ * Stripe does NOT reject a debit that pushes the balance positive (a positive
26
+ * balance is simply owed on the next invoice which a PAYG site never
27
+ * issues), so the guarantee is enforced here: after the debit we check the
28
+ * transaction's `ending_balance`, and if the customer would owe money we
29
+ * reverse the debit and throw. Two concurrent overspends both apply, then
30
+ * both observe the positive balance and reverse — the wallet converges to its
31
+ * pre-race state minus at most the covered amount.
32
+ *
33
+ * Idempotency-keyed by `idempotencyTag` so retries can't double-debit — tie
34
+ * the tag to the action that triggered the spend (e.g. the operation's own
35
+ * idempotency key). */
26
36
  export declare function debitBalance(opts: {
27
37
  customerId: string;
28
38
  amountCents: number;
@@ -1 +1 @@
1
- {"version":3,"file":"balance.d.ts","sourceRoot":"","sources":["../src/balance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAGjC,MAAM,WAAW,OAAO;IACvB,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED;mEACmE;AACnE,wBAAsB,WAAW,CAChC,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,OAAO,CAAC,CAIlB;AAED;;;;wDAIwD;AACxD,wBAAsB,YAAY,CACjC,IAAI,EAAE;IACL,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC,EACD,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,IAAI,CAAC,CAWf"}
1
+ {"version":3,"file":"balance.d.ts","sourceRoot":"","sources":["../src/balance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAIjC,MAAM,WAAW,OAAO;IACvB,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED;;+EAE+E;AAC/E,wBAAsB,WAAW,CAChC,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED;;;;;;;;;;;;;wBAawB;AACxB,wBAAsB,YAAY,CACjC,IAAI,EAAE;IACL,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC,EACD,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,IAAI,CAAC,CAiCf"}
package/dist/balance.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Customer-balance wallet — Stripe's own credit ledger, for pay-as-you-go sites
3
- * (domains.ingram.tech). Distinct from the in-app credit ledger in
2
+ * Customer-balance wallet — Stripe's own credit ledger, for pay-as-you-go
3
+ * sites. Distinct from the in-app credit ledger in
4
4
  * `@ingram-tech/nk-billing/credits`: this stores the balance *in Stripe* as a
5
5
  * signed integer (negative = credit available to the customer), so there is no
6
6
  * local table to keep in sync. Pick this when the unit you sell is money;
@@ -9,26 +9,53 @@
9
9
  * We normalise Stripe's sign convention to "amount available" (positive cents)
10
10
  * so callers never have to remember that a credit is negative.
11
11
  */
12
+ import { BillingError } from "./errors.js";
12
13
  import { getStripe } from "./client.js";
13
14
  /** Read a customer's available wallet balance (Stripe's negative credit balance,
14
- * sign-flipped to positive). A deleted customer reads as zero. */
15
+ * sign-flipped to positive and clamped at zero a positive Stripe balance is
16
+ * an amount owed, not available credit). A deleted customer reads as zero. */
15
17
  export async function readBalance(customerId, stripe = getStripe()) {
16
18
  const customer = await stripe.customers.retrieve(customerId);
17
19
  if (customer.deleted)
18
20
  return { amount: 0, currency: "eur" };
19
- return { amount: -customer.balance, currency: customer.currency ?? "eur" };
21
+ return {
22
+ amount: Math.max(0, -customer.balance),
23
+ currency: customer.currency ?? "eur",
24
+ };
20
25
  }
21
- /** Debit the wallet by `amountCents`. Stripe rejects a debit that would push the
22
- * balance positive (the customer would owe us), giving a hard over-spend
23
- * guarantee with no pre-flight check. Idempotency-keyed by `idempotencyTag` so
24
- * retries can't double-debit tie the tag to the action that triggered the
25
- * spend (e.g. the operation's own idempotency key). */
26
+ /** Debit the wallet by `amountCents`, throwing {@link BillingError}
27
+ * (`insufficient_credits`) when the customer's credit doesn't cover it.
28
+ *
29
+ * Stripe does NOT reject a debit that pushes the balance positive (a positive
30
+ * balance is simply owed on the next invoice which a PAYG site never
31
+ * issues), so the guarantee is enforced here: after the debit we check the
32
+ * transaction's `ending_balance`, and if the customer would owe money we
33
+ * reverse the debit and throw. Two concurrent overspends both apply, then
34
+ * both observe the positive balance and reverse — the wallet converges to its
35
+ * pre-race state minus at most the covered amount.
36
+ *
37
+ * Idempotency-keyed by `idempotencyTag` so retries can't double-debit — tie
38
+ * the tag to the action that triggered the spend (e.g. the operation's own
39
+ * idempotency key). */
26
40
  export async function debitBalance(opts, stripe = getStripe()) {
27
- await stripe.customers.createBalanceTransaction(opts.customerId, {
41
+ if (!Number.isSafeInteger(opts.amountCents) || opts.amountCents <= 0) {
42
+ // A negative amount would silently *credit* the wallet.
43
+ throw new RangeError(`debitBalance amountCents must be a positive integer, got ${opts.amountCents}`);
44
+ }
45
+ const txn = await stripe.customers.createBalanceTransaction(opts.customerId, {
28
46
  amount: opts.amountCents,
29
47
  currency: opts.currency,
30
48
  description: opts.description,
31
49
  metadata: opts.metadata ?? {},
32
50
  }, { idempotencyKey: `bal_debit_${opts.idempotencyTag}` });
51
+ if (txn.ending_balance > 0) {
52
+ await stripe.customers.createBalanceTransaction(opts.customerId, {
53
+ amount: -opts.amountCents,
54
+ currency: opts.currency,
55
+ description: `Reversal (insufficient balance): ${opts.description}`,
56
+ metadata: opts.metadata ?? {},
57
+ }, { idempotencyKey: `bal_debit_reversal_${opts.idempotencyTag}` });
58
+ throw new BillingError("insufficient_credits", "Insufficient balance for this debit.");
59
+ }
33
60
  }
34
61
  //# sourceMappingURL=balance.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"balance.js","sourceRoot":"","sources":["../src/balance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAQxC;mEACmE;AACnE,MAAM,CAAC,KAAK,UAAU,WAAW,CAChC,UAAkB,EAClB,SAAiB,SAAS,EAAE;IAE5B,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;IAC7D,IAAI,QAAQ,CAAC,OAAO;QAAE,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;IAC5D,OAAO,EAAE,MAAM,EAAE,CAAC,QAAQ,CAAC,OAAO,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,IAAI,KAAK,EAAE,CAAC;AAC5E,CAAC;AAED;;;;wDAIwD;AACxD,MAAM,CAAC,KAAK,UAAU,YAAY,CACjC,IAOC,EACD,SAAiB,SAAS,EAAE;IAE5B,MAAM,MAAM,CAAC,SAAS,CAAC,wBAAwB,CAC9C,IAAI,CAAC,UAAU,EACf;QACC,MAAM,EAAE,IAAI,CAAC,WAAW;QACxB,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,WAAW,EAAE,IAAI,CAAC,WAAW;QAC7B,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,EAAE;KAC7B,EACD,EAAE,cAAc,EAAE,aAAa,IAAI,CAAC,cAAc,EAAE,EAAE,CACtD,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"balance.js","sourceRoot":"","sources":["../src/balance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAQxC;;+EAE+E;AAC/E,MAAM,CAAC,KAAK,UAAU,WAAW,CAChC,UAAkB,EAClB,SAAiB,SAAS,EAAE;IAE5B,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;IAC7D,IAAI,QAAQ,CAAC,OAAO;QAAE,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;IAC5D,OAAO;QACN,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,OAAO,CAAC;QACtC,QAAQ,EAAE,QAAQ,CAAC,QAAQ,IAAI,KAAK;KACpC,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;wBAawB;AACxB,MAAM,CAAC,KAAK,UAAU,YAAY,CACjC,IAOC,EACD,SAAiB,SAAS,EAAE;IAE5B,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,WAAW,CAAC,IAAI,IAAI,CAAC,WAAW,IAAI,CAAC,EAAE,CAAC;QACtE,wDAAwD;QACxD,MAAM,IAAI,UAAU,CACnB,4DAA4D,IAAI,CAAC,WAAW,EAAE,CAC9E,CAAC;IACH,CAAC;IACD,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,wBAAwB,CAC1D,IAAI,CAAC,UAAU,EACf;QACC,MAAM,EAAE,IAAI,CAAC,WAAW;QACxB,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,WAAW,EAAE,IAAI,CAAC,WAAW;QAC7B,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,EAAE;KAC7B,EACD,EAAE,cAAc,EAAE,aAAa,IAAI,CAAC,cAAc,EAAE,EAAE,CACtD,CAAC;IACF,IAAI,GAAG,CAAC,cAAc,GAAG,CAAC,EAAE,CAAC;QAC5B,MAAM,MAAM,CAAC,SAAS,CAAC,wBAAwB,CAC9C,IAAI,CAAC,UAAU,EACf;YACC,MAAM,EAAE,CAAC,IAAI,CAAC,WAAW;YACzB,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,WAAW,EAAE,oCAAoC,IAAI,CAAC,WAAW,EAAE;YACnE,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,EAAE;SAC7B,EACD,EAAE,cAAc,EAAE,sBAAsB,IAAI,CAAC,cAAc,EAAE,EAAE,CAC/D,CAAC;QACF,MAAM,IAAI,YAAY,CACrB,sBAAsB,EACtB,sCAAsC,CACtC,CAAC;IACH,CAAC;AACF,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"checkout.d.ts","sourceRoot":"","sources":["../src/checkout.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAEjC,OAAO,EACN,KAAK,eAAe,EACpB,KAAK,WAAW,EAEhB,MAAM,gBAAgB,CAAC;AAGxB,MAAM,WAAW,eAAe;IAC/B,gEAAgE;IAChE,QAAQ,EAAE,WAAW,CAAC;IACtB,mEAAmE;IACnE,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;IAClB,8EAA8E;IAC9E,IAAI,EAAE,cAAc,GAAG,SAAS,CAAC;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;uDACmD;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B;oFACgF;IAChF,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,wEAAwE;IACxE,MAAM,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC,mBAAmB,CAAC,QAAQ,CAAC,CAAC;IACvD;4DACwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED;;;;gFAIgF;AAChF,wBAAsB,qBAAqB,CAC1C,IAAI,EAAE,eAAe,EACrB,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,MAAM,CAAC,CAqCjB;AAED,6EAA6E;AAC7E,wBAAsB,mBAAmB,CACxC,IAAI,EAAE;IACL,QAAQ,EAAE,WAAW,CAAC;IACtB,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,SAAS,EAAE,MAAM,CAAC;CAClB,EACD,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,MAAM,CAAC,CAWjB"}
1
+ {"version":3,"file":"checkout.d.ts","sourceRoot":"","sources":["../src/checkout.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAEjC,OAAO,EACN,KAAK,eAAe,EACpB,KAAK,WAAW,EAEhB,MAAM,gBAAgB,CAAC;AAGxB,MAAM,WAAW,eAAe;IAC/B,gEAAgE;IAChE,QAAQ,EAAE,WAAW,CAAC;IACtB,mEAAmE;IACnE,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;IAClB,8EAA8E;IAC9E,IAAI,EAAE,cAAc,GAAG,SAAS,CAAC;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;uDACmD;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B;oFACgF;IAChF,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,wEAAwE;IACxE,MAAM,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC,mBAAmB,CAAC,QAAQ,CAAC,CAAC;IACvD;4DACwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED;;;;gFAIgF;AAChF,wBAAsB,qBAAqB,CAC1C,IAAI,EAAE,eAAe,EACrB,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,MAAM,CAAC,CAuCjB;AAED,6EAA6E;AAC7E,wBAAsB,mBAAmB,CACxC,IAAI,EAAE;IACL,QAAQ,EAAE,WAAW,CAAC;IACtB,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,SAAS,EAAE,MAAM,CAAC;CAClB,EACD,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,MAAM,CAAC,CAWjB"}
package/dist/checkout.js CHANGED
@@ -21,9 +21,11 @@ export async function createCheckoutSession(opts, stripe = getStripe()) {
21
21
  const currency = opts.currency && supportedCurrencies(price).has(opts.currency)
22
22
  ? opts.currency
23
23
  : undefined;
24
+ // The ref tag is spread last: the webhook side resolves the tenant from it,
25
+ // so caller metadata must never be able to overwrite it.
24
26
  const metadata = {
25
- [opts.customer.metadataKey]: opts.customer.id,
26
27
  ...opts.metadata,
28
+ [opts.customer.metadataKey]: opts.customer.id,
27
29
  };
28
30
  const session = await stripe.checkout.sessions.create({
29
31
  mode: opts.mode,
@@ -1 +1 @@
1
- {"version":3,"file":"checkout.js","sourceRoot":"","sources":["../src/checkout.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAGN,oBAAoB,GACpB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AA8BpE;;;;gFAIgF;AAChF,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAC1C,IAAqB,EACrB,SAAiB,SAAS,EAAE;IAE5B,MAAM,CAAC,QAAQ,EAAE,KAAK,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;QAC3C,oBAAoB,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,CAAC;QACjE,gBAAgB,CAAC,IAAI,CAAC,SAAS,EAAE,MAAM,CAAC;KACxC,CAAC,CAAC;IAEH,MAAM,QAAQ,GACb,IAAI,CAAC,QAAQ,IAAI,mBAAmB,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC;QAC7D,CAAC,CAAC,IAAI,CAAC,QAAQ;QACf,CAAC,CAAC,SAAS,CAAC;IAEd,MAAM,QAAQ,GAAG;QAChB,CAAC,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,EAAE;QAC7C,GAAG,IAAI,CAAC,QAAQ;KAChB,CAAC;IAEF,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC;QACrD,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,QAAQ,EAAE,QAAQ,CAAC,EAAE;QACrB,UAAU,EAAE,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,CAAC,EAAE,CAAC;QAC/D,qBAAqB,EAAE,IAAI,CAAC,mBAAmB,IAAI,IAAI;QACvD,MAAM,EAAE,IAAI,CAAC,MAAM,IAAI,MAAM;QAC7B,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACjC,GAAG,CAAC,IAAI,CAAC,YAAY;YACpB,CAAC,CAAC;gBACA,iBAAiB,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE;gBACpC,eAAe,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE;aACjC;YACF,CAAC,CAAC,EAAE,CAAC;QACN,WAAW,EAAE,IAAI,CAAC,UAAU;QAC5B,UAAU,EAAE,IAAI,CAAC,SAAS;QAC1B,QAAQ;QACR,GAAG,CAAC,IAAI,CAAC,IAAI,KAAK,cAAc,CAAC,CAAC,CAAC,EAAE,iBAAiB,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC5E,CAAC,CAAC;IAEH,IAAI,CAAC,OAAO,CAAC,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC;IAC1E,OAAO,OAAO,CAAC,GAAG,CAAC;AACpB,CAAC;AAED,6EAA6E;AAC7E,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACxC,IAIC,EACD,SAAiB,SAAS,EAAE;IAE5B,MAAM,QAAQ,GAAG,MAAM,oBAAoB,CAC1C,IAAI,CAAC,QAAQ,EACb,IAAI,CAAC,eAAe,EACpB,MAAM,CACN,CAAC;IACF,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,QAAQ,CAAC,MAAM,CAAC;QAC1D,QAAQ,EAAE,QAAQ,CAAC,EAAE;QACrB,UAAU,EAAE,IAAI,CAAC,SAAS;KAC1B,CAAC,CAAC;IACH,OAAO,OAAO,CAAC,GAAG,CAAC;AACpB,CAAC"}
1
+ {"version":3,"file":"checkout.js","sourceRoot":"","sources":["../src/checkout.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAGN,oBAAoB,GACpB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AA8BpE;;;;gFAIgF;AAChF,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAC1C,IAAqB,EACrB,SAAiB,SAAS,EAAE;IAE5B,MAAM,CAAC,QAAQ,EAAE,KAAK,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;QAC3C,oBAAoB,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,CAAC;QACjE,gBAAgB,CAAC,IAAI,CAAC,SAAS,EAAE,MAAM,CAAC;KACxC,CAAC,CAAC;IAEH,MAAM,QAAQ,GACb,IAAI,CAAC,QAAQ,IAAI,mBAAmB,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC;QAC7D,CAAC,CAAC,IAAI,CAAC,QAAQ;QACf,CAAC,CAAC,SAAS,CAAC;IAEd,4EAA4E;IAC5E,yDAAyD;IACzD,MAAM,QAAQ,GAAG;QAChB,GAAG,IAAI,CAAC,QAAQ;QAChB,CAAC,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,EAAE;KAC7C,CAAC;IAEF,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC;QACrD,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,QAAQ,EAAE,QAAQ,CAAC,EAAE;QACrB,UAAU,EAAE,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,CAAC,EAAE,CAAC;QAC/D,qBAAqB,EAAE,IAAI,CAAC,mBAAmB,IAAI,IAAI;QACvD,MAAM,EAAE,IAAI,CAAC,MAAM,IAAI,MAAM;QAC7B,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACjC,GAAG,CAAC,IAAI,CAAC,YAAY;YACpB,CAAC,CAAC;gBACA,iBAAiB,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE;gBACpC,eAAe,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE;aACjC;YACF,CAAC,CAAC,EAAE,CAAC;QACN,WAAW,EAAE,IAAI,CAAC,UAAU;QAC5B,UAAU,EAAE,IAAI,CAAC,SAAS;QAC1B,QAAQ;QACR,GAAG,CAAC,IAAI,CAAC,IAAI,KAAK,cAAc,CAAC,CAAC,CAAC,EAAE,iBAAiB,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC5E,CAAC,CAAC;IAEH,IAAI,CAAC,OAAO,CAAC,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC;IAC1E,OAAO,OAAO,CAAC,GAAG,CAAC;AACpB,CAAC;AAED,6EAA6E;AAC7E,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACxC,IAIC,EACD,SAAiB,SAAS,EAAE;IAE5B,MAAM,QAAQ,GAAG,MAAM,oBAAoB,CAC1C,IAAI,CAAC,QAAQ,EACb,IAAI,CAAC,eAAe,EACpB,MAAM,CACN,CAAC;IACF,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,QAAQ,CAAC,MAAM,CAAC;QAC1D,QAAQ,EAAE,QAAQ,CAAC,EAAE;QACrB,UAAU,EAAE,IAAI,CAAC,SAAS;KAC1B,CAAC,CAAC;IACH,OAAO,OAAO,CAAC,GAAG,CAAC;AACpB,CAAC"}
package/dist/client.d.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * This is the one pattern every Ingram site had hand-rolled identically (the
6
6
  * `getStripe()` singleton); it lives here once now. Single-mode sites call
7
- * {@link getStripe}; the dual-mode site (cloud.ingram.tech) calls
8
- * {@link stripeFor} with a mode and gets one memoised client per mode.
7
+ * {@link getStripe}; a dual-mode site (its own merchant of record, running the
8
+ * Stripe sandbox beside live) calls {@link stripeFor} with a mode and gets one
9
+ * memoised client per mode.
9
10
  */
10
11
  import Stripe from "stripe";
11
12
  import { type StripeMode } from "./keys.js";
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,WAAW,CAAC;AAexD;;mCAEmC;AACnC,wBAAgB,SAAS,IAAI,MAAM,CAOlC;AAED;0CAC0C;AAC1C,wBAAgB,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAelD;AAED,0EAA0E;AAC1E,wBAAgB,kBAAkB,IAAI,IAAI,CAIzC"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,WAAW,CAAC;AAexD;;mCAEmC;AACnC,wBAAgB,SAAS,IAAI,MAAM,CAOlC;AAED;0CAC0C;AAC1C,wBAAgB,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAelD;AAED,0EAA0E;AAC1E,wBAAgB,kBAAkB,IAAI,IAAI,CAIzC"}
package/dist/client.js CHANGED
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * This is the one pattern every Ingram site had hand-rolled identically (the
6
6
  * `getStripe()` singleton); it lives here once now. Single-mode sites call
7
- * {@link getStripe}; the dual-mode site (cloud.ingram.tech) calls
8
- * {@link stripeFor} with a mode and gets one memoised client per mode.
7
+ * {@link getStripe}; a dual-mode site (its own merchant of record, running the
8
+ * Stripe sandbox beside live) calls {@link stripeFor} with a mode and gets one
9
+ * memoised client per mode.
9
10
  */
10
11
  import Stripe from "stripe";
11
12
  import { billingEnv } from "./keys.js";
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,UAAU,EAAmB,MAAM,WAAW,CAAC;AAExD,IAAI,MAAM,GAAkB,IAAI,CAAC;AACjC,MAAM,MAAM,GAAwC,EAAE,CAAC;AAEvD,SAAS,SAAS,CAAC,GAAW;IAC7B,MAAM,EAAE,UAAU,EAAE,GAAG,UAAU,EAAE,CAAC;IACpC,6EAA6E;IAC7E,yEAAyE;IACzE,MAAM,MAAM,GAAG,UAAU;QACxB,CAAC,CAAE,EAAE,UAAU,EAA8C;QAC7D,CAAC,CAAC,SAAS,CAAC;IACb,OAAO,IAAI,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;AAChC,CAAC;AAED;;mCAEmC;AACnC,MAAM,UAAU,SAAS;IACxB,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC;QAC1C,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;QAC1D,MAAM,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;IACD,OAAO,MAAM,CAAC;AACf,CAAC;AAED;0CAC0C;AAC1C,MAAM,UAAU,SAAS,CAAC,IAAgB;IACzC,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC;IAC5B,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC;IAC1B,MAAM,GAAG,GACR,IAAI,KAAK,MAAM;QACd,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,sBAAsB;QACpC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC;IACvC,IAAI,CAAC,GAAG,EAAE,CAAC;QACV,MAAM,IAAI,KAAK,CACd,qBAAqB,IAAI,CAAC,WAAW,EAAE,iCAAiC,CACxE,CAAC;IACH,CAAC;IACD,MAAM,MAAM,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;IAC9B,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC;IACtB,OAAO,MAAM,CAAC;AACf,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,kBAAkB;IACjC,MAAM,GAAG,IAAI,CAAC;IACd,OAAO,MAAM,CAAC,IAAI,CAAC;IACnB,OAAO,MAAM,CAAC,IAAI,CAAC;AACpB,CAAC"}
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,UAAU,EAAmB,MAAM,WAAW,CAAC;AAExD,IAAI,MAAM,GAAkB,IAAI,CAAC;AACjC,MAAM,MAAM,GAAwC,EAAE,CAAC;AAEvD,SAAS,SAAS,CAAC,GAAW;IAC7B,MAAM,EAAE,UAAU,EAAE,GAAG,UAAU,EAAE,CAAC;IACpC,6EAA6E;IAC7E,yEAAyE;IACzE,MAAM,MAAM,GAAG,UAAU;QACxB,CAAC,CAAE,EAAE,UAAU,EAA8C;QAC7D,CAAC,CAAC,SAAS,CAAC;IACb,OAAO,IAAI,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;AAChC,CAAC;AAED;;mCAEmC;AACnC,MAAM,UAAU,SAAS;IACxB,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC;QAC1C,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;QAC1D,MAAM,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;IACD,OAAO,MAAM,CAAC;AACf,CAAC;AAED;0CAC0C;AAC1C,MAAM,UAAU,SAAS,CAAC,IAAgB;IACzC,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC;IAC5B,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC;IAC1B,MAAM,GAAG,GACR,IAAI,KAAK,MAAM;QACd,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,sBAAsB;QACpC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC;IACvC,IAAI,CAAC,GAAG,EAAE,CAAC;QACV,MAAM,IAAI,KAAK,CACd,qBAAqB,IAAI,CAAC,WAAW,EAAE,iCAAiC,CACxE,CAAC;IACH,CAAC;IACD,MAAM,MAAM,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;IAC9B,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC;IACtB,OAAO,MAAM,CAAC;AACf,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,kBAAkB;IACjC,MAAM,GAAG,IAAI,CAAC;IACd,OAAO,MAAM,CAAC,IAAI,CAAC;IACnB,OAAO,MAAM,CAAC,IAAI,CAAC;AACpB,CAAC"}
package/dist/credits.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The in-app credit ledger — the stateful half of nk-billing, for sites that
3
- * meter usage in abstract "credits" rather than money (integrain). It follows
3
+ * meter usage in abstract "credits" rather than money. It follows
4
4
  * nextkit's Django-app model: this package owns its tables and ships their
5
5
  * migration (`migrations/0001_billing.sql`), and takes the database connection by
6
6
  * **injection** so it never reaches into the consuming site's schema.
@@ -67,10 +67,13 @@ export declare function spendCredits(db: Queryable, opts: SpendOptions): Promise
67
67
  export declare function requireCredits(db: Queryable, opts: SpendOptions): Promise<void>;
68
68
  /** Return previously-spent credits — call when the work a spend paid for did not
69
69
  * run (a dedup hit, a hard failure, an aborted enqueue). No-op for non-positive
70
- * amounts. */
70
+ * amounts. Pass `eventId` (any stable id for the failure being compensated) to
71
+ * make the refund idempotent — a retried failure handler then can't
72
+ * double-refund. Without it, retries are the caller's problem. */
71
73
  export declare function refundCredits(db: Queryable, opts: {
72
74
  tenantId: string;
73
75
  amount: number;
76
+ eventId?: string;
74
77
  }): Promise<void>;
75
78
  /**
76
79
  * Mark a Stripe event processed, idempotently. Returns false if the event was
@@ -92,12 +95,21 @@ export declare function grantCredits(db: Queryable, opts: {
92
95
  stripeCustomerId?: string | null;
93
96
  }): Promise<boolean>;
94
97
  /** Cache a tenant's subscription status from a webhook, idempotently per event.
95
- * Returns false if the event was already processed. */
98
+ * Returns false if the event was already processed.
99
+ *
100
+ * Stripe does not guarantee delivery order, so pass the event's `created`
101
+ * timestamp (Unix seconds — `event.created`): a delayed older event (e.g. an
102
+ * `updated: active` arriving after `deleted: canceled`) is then ignored
103
+ * instead of resurrecting a dead subscription indefinitely. Requires migration
104
+ * `0002_billing_status_order.sql`; without `eventCreated` the last-write-wins
105
+ * legacy behavior is kept. */
96
106
  export declare function recordSubscriptionStatus(db: Queryable, opts: {
97
107
  tenantId: string;
98
108
  eventId: string;
99
109
  eventType: string;
100
110
  status: string;
111
+ /** The Stripe event's `created` (Unix seconds), for out-of-order defense. */
112
+ eventCreated?: number;
101
113
  stripeCustomerId?: string | null;
102
114
  }): Promise<boolean>;
103
115
  export interface BillingSummary {
@@ -1 +1 @@
1
- {"version":3,"file":"credits.d.ts","sourceRoot":"","sources":["../src/credits.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"credits.d.ts","sourceRoot":"","sources":["../src/credits.ts"],"names":[],"mappings":"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"}
package/dist/credits.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The in-app credit ledger — the stateful half of nk-billing, for sites that
3
- * meter usage in abstract "credits" rather than money (integrain). It follows
3
+ * meter usage in abstract "credits" rather than money. It follows
4
4
  * nextkit's Django-app model: this package owns its tables and ships their
5
5
  * migration (`migrations/0001_billing.sql`), and takes the database connection by
6
6
  * **injection** so it never reaches into the consuming site's schema.
@@ -21,6 +21,9 @@
21
21
  import { BillingError } from "./errors.js";
22
22
  import { ACTIVE_SUBSCRIPTION_STATUSES } from "./status.js";
23
23
  export { BillingError, denyMessage } from "./errors.js";
24
+ // `credits_balance` is a Postgres bigint, which `pg` hands back as a string.
25
+ // Coerce before it leaks into arithmetic (string concatenation) or JSON.
26
+ const toBalance = (value) => Number(value);
24
27
  function entitled(row, now, trialWindowMs, activeStatuses) {
25
28
  if (row.subscription_status && activeStatuses.has(row.subscription_status)) {
26
29
  return true;
@@ -44,6 +47,10 @@ function entitled(row, now, trialWindowMs, activeStatuses) {
44
47
  */
45
48
  export async function spendCredits(db, opts) {
46
49
  const activeStatuses = opts.activeStatuses ?? ACTIVE_SUBSCRIPTION_STATUSES;
50
+ if (!Number.isSafeInteger(opts.cost) || opts.cost < 0) {
51
+ // A negative cost would silently credit the tenant; NaN passes `<` checks.
52
+ throw new RangeError(`spendCredits cost must be a non-negative integer, got ${opts.cost}`);
53
+ }
47
54
  // First touch: seed the row (with trial credits + clock when trials are on).
48
55
  // ON CONFLICT DO NOTHING makes the trial a one-time grant.
49
56
  await db.query(`insert into billing_credits (tenant_id, credits_balance, trial_started_at)
@@ -57,14 +64,14 @@ export async function spendCredits(db, opts) {
57
64
  if (!entitled(row, Date.now(), opts.trial?.windowMs, activeStatuses)) {
58
65
  return { ok: false, reason: "not_entitled" };
59
66
  }
60
- if (row.credits_balance < opts.cost) {
67
+ if (toBalance(row.credits_balance) < opts.cost) {
61
68
  return { ok: false, reason: "insufficient_credits" };
62
69
  }
63
70
  const { rows: updated } = await db.query(`update billing_credits
64
71
  set credits_balance = credits_balance - $2, updated_at = now()
65
72
  where tenant_id = $1
66
73
  returning credits_balance`, [opts.tenantId, opts.cost]);
67
- return { ok: true, balance: updated[0]?.credits_balance ?? 0 };
74
+ return { ok: true, balance: toBalance(updated[0]?.credits_balance ?? 0) };
68
75
  }
69
76
  /** Like {@link spendCredits} but throws {@link BillingError} on a denial — use in
70
77
  * API routes that map the error to a 402. */
@@ -75,10 +82,16 @@ export async function requireCredits(db, opts) {
75
82
  }
76
83
  /** Return previously-spent credits — call when the work a spend paid for did not
77
84
  * run (a dedup hit, a hard failure, an aborted enqueue). No-op for non-positive
78
- * amounts. */
85
+ * amounts. Pass `eventId` (any stable id for the failure being compensated) to
86
+ * make the refund idempotent — a retried failure handler then can't
87
+ * double-refund. Without it, retries are the caller's problem. */
79
88
  export async function refundCredits(db, opts) {
80
- if (opts.amount <= 0)
89
+ if (!Number.isSafeInteger(opts.amount) || opts.amount <= 0)
90
+ return;
91
+ if (opts.eventId != null &&
92
+ !(await claimStripeEvent(db, opts.eventId, "credits.refund"))) {
81
93
  return;
94
+ }
82
95
  await db.query(`update billing_credits
83
96
  set credits_balance = credits_balance + $2, updated_at = now()
84
97
  where tenant_id = $1`, [opts.tenantId, opts.amount]);
@@ -111,16 +124,35 @@ export async function grantCredits(db, opts) {
111
124
  return true;
112
125
  }
113
126
  /** Cache a tenant's subscription status from a webhook, idempotently per event.
114
- * Returns false if the event was already processed. */
127
+ * Returns false if the event was already processed.
128
+ *
129
+ * Stripe does not guarantee delivery order, so pass the event's `created`
130
+ * timestamp (Unix seconds — `event.created`): a delayed older event (e.g. an
131
+ * `updated: active` arriving after `deleted: canceled`) is then ignored
132
+ * instead of resurrecting a dead subscription indefinitely. Requires migration
133
+ * `0002_billing_status_order.sql`; without `eventCreated` the last-write-wins
134
+ * legacy behavior is kept. */
115
135
  export async function recordSubscriptionStatus(db, opts) {
116
136
  if (!(await claimStripeEvent(db, opts.eventId, opts.eventType)))
117
137
  return false;
118
- await db.query(`insert into billing_credits (tenant_id, subscription_status, stripe_customer_id)
119
- values ($1, $2, $3)
138
+ if (opts.eventCreated == null) {
139
+ await db.query(`insert into billing_credits (tenant_id, subscription_status, stripe_customer_id)
140
+ values ($1, $2, $3)
141
+ on conflict (tenant_id) do update set
142
+ subscription_status = excluded.subscription_status,
143
+ stripe_customer_id = coalesce(excluded.stripe_customer_id, billing_credits.stripe_customer_id),
144
+ updated_at = now()`, [opts.tenantId, opts.status, opts.stripeCustomerId ?? null]);
145
+ return true;
146
+ }
147
+ await db.query(`insert into billing_credits (tenant_id, subscription_status, subscription_status_at, stripe_customer_id)
148
+ values ($1, $2, to_timestamp($3), $4)
120
149
  on conflict (tenant_id) do update set
121
150
  subscription_status = excluded.subscription_status,
151
+ subscription_status_at = excluded.subscription_status_at,
122
152
  stripe_customer_id = coalesce(excluded.stripe_customer_id, billing_credits.stripe_customer_id),
123
- updated_at = now()`, [opts.tenantId, opts.status, opts.stripeCustomerId ?? null]);
153
+ updated_at = now()
154
+ where billing_credits.subscription_status_at is null
155
+ or excluded.subscription_status_at >= billing_credits.subscription_status_at`, [opts.tenantId, opts.status, opts.eventCreated, opts.stripeCustomerId ?? null]);
124
156
  return true;
125
157
  }
126
158
  /** Read-only entitlement + balance snapshot for the billing UI. Does not create a
@@ -144,7 +176,7 @@ export async function getBillingSummary(db, opts) {
144
176
  ? new Date(row.trial_started_at).getTime() + opts.trialWindowMs
145
177
  : null;
146
178
  return {
147
- creditsBalance: row.credits_balance,
179
+ creditsBalance: toBalance(row.credits_balance),
148
180
  subscriptionStatus: row.subscription_status,
149
181
  stripeCustomerId: row.stripe_customer_id,
150
182
  entitled: entitled(row, Date.now(), opts.trialWindowMs, activeStatuses),
@@ -1 +1 @@
1
- {"version":3,"file":"credits.js","sourceRoot":"","sources":["../src/credits.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"credits.js","sourceRoot":"","sources":["../src/credits.ts"],"names":[],"mappings":"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"}
@@ -4,15 +4,15 @@
4
4
  * then finds it back by metadata search — no local `stripe_customer_id` column is
5
5
  * required just to locate the customer (though sites cache it for speed).
6
6
  *
7
- * The metadata key differs per site (`financica_organization_id`,
8
- * `integrain_org_id`, …), so it is always passed in explicitly via
9
- * {@link CustomerRef} rather than baked in here — that is the framework seam.
7
+ * The metadata key differs per site (`acme_organization_id`, `acme_user_id`,
8
+ * …), so it is always passed in explicitly via {@link CustomerRef} rather than
9
+ * baked in here — that is the framework seam.
10
10
  */
11
11
  import type Stripe from "stripe";
12
12
  /** Identifies a Stripe customer by a metadata tag → local entity id. */
13
13
  export interface CustomerRef {
14
14
  /** The Stripe `metadata` key the local id is stored under, e.g.
15
- * "integrain_org_id". Choose one per site and use it everywhere. */
15
+ * "acme_org_id". Choose one per site and use it everywhere. */
16
16
  metadataKey: string;
17
17
  /** The local entity id (org id, user id, …). */
18
18
  id: string;
@@ -1 +1 @@
1
- {"version":3,"file":"customers.d.ts","sourceRoot":"","sources":["../src/customers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAGjC,wEAAwE;AACxE,MAAM,WAAW,WAAW;IAC3B;yEACqE;IACrE,WAAW,EAAE,MAAM,CAAC;IACpB,gDAAgD;IAChD,EAAE,EAAE,MAAM,CAAC;CACX;AAED,MAAM,WAAW,eAAe;IAC/B,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,OAAO,CAAC,EAAE;QACT,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QACtB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QACrB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC5B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KACxB,GAAG,IAAI,CAAC;CACT;AAED;;gBAEgB;AAChB,wBAAsB,YAAY,CACjC,GAAG,EAAE,WAAW,EAChB,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,MAAM,CAAC,QAAQ,GAAG,IAAI,CAAC,CAMjC;AAED;;eAEe;AACf,wBAAsB,oBAAoB,CACzC,GAAG,EAAE,WAAW,EAChB,OAAO,GAAE,eAAoB,EAC7B,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAiC1B;AAED;gEACgE;AAChE,wBAAsB,yBAAyB,CAC9C,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,OAAO,CAAC,CAIlB;AAED;8CAC8C;AAC9C,wBAAsB,4BAA4B,CACjD,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE;IACL,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,OAAO,EAAE;QACR,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,EAAE,MAAM,CAAC;QACpB,OAAO,EAAE,MAAM,CAAC;KAChB,CAAC;CACF,EACD,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,IAAI,CAAC,CAKf"}
1
+ {"version":3,"file":"customers.d.ts","sourceRoot":"","sources":["../src/customers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAGjC,wEAAwE;AACxE,MAAM,WAAW,WAAW;IAC3B;oEACgE;IAChE,WAAW,EAAE,MAAM,CAAC;IACpB,gDAAgD;IAChD,EAAE,EAAE,MAAM,CAAC;CACX;AAED,MAAM,WAAW,eAAe;IAC/B,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,OAAO,CAAC,EAAE;QACT,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QACtB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QACrB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC5B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KACxB,GAAG,IAAI,CAAC;CACT;AAQD;;gBAEgB;AAChB,wBAAsB,YAAY,CACjC,GAAG,EAAE,WAAW,EAChB,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,MAAM,CAAC,QAAQ,GAAG,IAAI,CAAC,CAMjC;AAED;;eAEe;AACf,wBAAsB,oBAAoB,CACzC,GAAG,EAAE,WAAW,EAChB,OAAO,GAAE,eAAoB,EAC7B,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAuC1B;AAED;gEACgE;AAChE,wBAAsB,yBAAyB,CAC9C,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,OAAO,CAAC,CAIlB;AAED;8CAC8C;AAC9C,wBAAsB,4BAA4B,CACjD,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE;IACL,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,OAAO,EAAE;QACR,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,EAAE,MAAM,CAAC;QACpB,OAAO,EAAE,MAAM,CAAC;KAChB,CAAC;CACF,EACD,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,IAAI,CAAC,CAKf"}
package/dist/customers.js CHANGED
@@ -4,17 +4,21 @@
4
4
  * then finds it back by metadata search — no local `stripe_customer_id` column is
5
5
  * required just to locate the customer (though sites cache it for speed).
6
6
  *
7
- * The metadata key differs per site (`financica_organization_id`,
8
- * `integrain_org_id`, …), so it is always passed in explicitly via
9
- * {@link CustomerRef} rather than baked in here — that is the framework seam.
7
+ * The metadata key differs per site (`acme_organization_id`, `acme_user_id`,
8
+ * …), so it is always passed in explicitly via {@link CustomerRef} rather than
9
+ * baked in here — that is the framework seam.
10
10
  */
11
11
  import { getStripe } from "./client.js";
12
+ // Stripe's search syntax delimits values with single quotes; an unescaped
13
+ // quote in an interpolated value would let a crafted id alter the query (it
14
+ // supports OR) and match another tenant's customer.
15
+ const escapeSearchValue = (value) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
12
16
  /** The customer for `ref`, found by metadata search, or null if none exists yet.
13
17
  * Read paths use this so merely viewing a billing page never mints an empty
14
18
  * customer. */
15
19
  export async function findCustomer(ref, stripe = getStripe()) {
16
20
  const found = await stripe.customers.search({
17
- query: `metadata['${ref.metadataKey}']:'${ref.id}'`,
21
+ query: `metadata['${escapeSearchValue(ref.metadataKey)}']:'${escapeSearchValue(ref.id)}'`,
18
22
  limit: 1,
19
23
  });
20
24
  return found.data[0] ?? null;
@@ -39,7 +43,13 @@ export async function findOrCreateCustomer(ref, details = {}, stripe = getStripe
39
43
  country: details.address.country ?? undefined,
40
44
  };
41
45
  }
42
- const customer = await stripe.customers.create(params);
46
+ // Idempotency-keyed on the ref: `customers.search` is eventually consistent
47
+ // (freshly created customers can be invisible for up to ~a minute), so two
48
+ // concurrent find-then-create calls both miss — the key makes Stripe hand
49
+ // both the same customer instead of minting a duplicate.
50
+ const customer = await stripe.customers.create(params, {
51
+ idempotencyKey: `cust_create_${ref.metadataKey}_${ref.id}`,
52
+ });
43
53
  if (details.vatNumber) {
44
54
  try {
45
55
  await stripe.customers.createTaxId(customer.id, {
@@ -1 +1 @@
1
- {"version":3,"file":"customers.js","sourceRoot":"","sources":["../src/customers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAwBxC;;gBAEgB;AAChB,MAAM,CAAC,KAAK,UAAU,YAAY,CACjC,GAAgB,EAChB,SAAiB,SAAS,EAAE;IAE5B,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC;QAC3C,KAAK,EAAE,aAAa,GAAG,CAAC,WAAW,OAAO,GAAG,CAAC,EAAE,GAAG;QACnD,KAAK,EAAE,CAAC;KACR,CAAC,CAAC;IACH,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;AAC9B,CAAC;AAED;;eAEe;AACf,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACzC,GAAgB,EAChB,UAA2B,EAAE,EAC7B,SAAiB,SAAS,EAAE;IAE5B,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACjD,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAE9B,MAAM,MAAM,GAAgC;QAC3C,IAAI,EAAE,OAAO,CAAC,IAAI,IAAI,SAAS;QAC/B,KAAK,EAAE,OAAO,CAAC,KAAK,IAAI,SAAS;QACjC,QAAQ,EAAE,EAAE,CAAC,GAAG,CAAC,WAAW,CAAC,EAAE,GAAG,CAAC,EAAE,EAAE;KACvC,CAAC;IACF,IAAI,OAAO,CAAC,OAAO,EAAE,KAAK,IAAI,OAAO,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC;QACrD,MAAM,CAAC,OAAO,GAAG;YAChB,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,SAAS;YACzC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,IAAI,IAAI,SAAS;YACvC,WAAW,EAAE,OAAO,CAAC,OAAO,CAAC,WAAW,IAAI,SAAS;YACrD,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,OAAO,IAAI,SAAS;SAC7C,CAAC;IACH,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAEvD,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;QACvB,IAAI,CAAC;YACJ,MAAM,MAAM,CAAC,SAAS,CAAC,WAAW,CAAC,QAAQ,CAAC,EAAE,EAAE;gBAC/C,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,OAAO,CAAC,SAAS;aACxB,CAAC,CAAC;QACJ,CAAC;QAAC,MAAM,CAAC;YACR,wEAAwE;YACxE,+DAA+D;QAChE,CAAC;IACF,CAAC;IAED,OAAO,QAAQ,CAAC;AACjB,CAAC;AAED;gEACgE;AAChE,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC9C,UAAkB,EAClB,SAAiB,SAAS,EAAE;IAE5B,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;IAC7D,IAAI,QAAQ,CAAC,OAAO;QAAE,OAAO,KAAK,CAAC;IACnC,OAAO,OAAO,CAAC,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;AACzC,CAAC;AAED;8CAC8C;AAC9C,MAAM,CAAC,KAAK,UAAU,4BAA4B,CACjD,UAAkB,EAClB,IAQC,EACD,SAAiB,SAAS,EAAE;IAE5B,MAAM,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,UAAU,EAAE;QACzC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzC,OAAO,EAAE,IAAI,CAAC,OAAO;KACrB,CAAC,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"customers.js","sourceRoot":"","sources":["../src/customers.ts"],"names":[],"mappings":"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"}
package/dist/keys.d.ts CHANGED
@@ -8,17 +8,17 @@
8
8
  *
9
9
  * Two deployment shapes are supported, picked per call site, never both at once:
10
10
  *
11
- * 1. Single-mode (the default financica / integrain / thornhill): one
12
- * account, one key. `getStripe()` reads STRIPE_SECRET_KEY and
13
- * `webhookSecret()` reads STRIPE_WEBHOOK_SECRET.
11
+ * 1. Single-mode (the default for most sites): one account, one key.
12
+ * `getStripe()` reads STRIPE_SECRET_KEY and `webhookSecret()` reads
13
+ * STRIPE_WEBHOOK_SECRET.
14
14
  *
15
- * 2. Dual-mode (cloud.ingram.tech, which is its own merchant of record and
16
- * runs the Stripe sandbox beside live): `stripeFor("test"|"live")` reads
15
+ * 2. Dual-mode (a site that is its own merchant of record and runs the Stripe
16
+ * sandbox beside live): `stripeFor("test"|"live")` reads
17
17
  * STRIPE_SECRET_KEY_{TEST,LIVE} and `webhookSecret(mode)` reads
18
18
  * STRIPE_WEBHOOK_SECRET_{TEST,LIVE}.
19
19
  *
20
20
  * Nothing here hardcodes a price ID. Prices are resolved at runtime by their
21
- * stable Stripe `lookup_key` (set on the Price in the infra Pulumi program), so
21
+ * stable Stripe `lookup_key` (set on the Price out of band, e.g. in your IaC), so
22
22
  * the same key resolves in test and live even though their price IDs differ.
23
23
  */
24
24
  /** Which Stripe account a dual-mode site is talking to. */
package/dist/keys.js CHANGED
@@ -8,17 +8,17 @@
8
8
  *
9
9
  * Two deployment shapes are supported, picked per call site, never both at once:
10
10
  *
11
- * 1. Single-mode (the default financica / integrain / thornhill): one
12
- * account, one key. `getStripe()` reads STRIPE_SECRET_KEY and
13
- * `webhookSecret()` reads STRIPE_WEBHOOK_SECRET.
11
+ * 1. Single-mode (the default for most sites): one account, one key.
12
+ * `getStripe()` reads STRIPE_SECRET_KEY and `webhookSecret()` reads
13
+ * STRIPE_WEBHOOK_SECRET.
14
14
  *
15
- * 2. Dual-mode (cloud.ingram.tech, which is its own merchant of record and
16
- * runs the Stripe sandbox beside live): `stripeFor("test"|"live")` reads
15
+ * 2. Dual-mode (a site that is its own merchant of record and runs the Stripe
16
+ * sandbox beside live): `stripeFor("test"|"live")` reads
17
17
  * STRIPE_SECRET_KEY_{TEST,LIVE} and `webhookSecret(mode)` reads
18
18
  * STRIPE_WEBHOOK_SECRET_{TEST,LIVE}.
19
19
  *
20
20
  * Nothing here hardcodes a price ID. Prices are resolved at runtime by their
21
- * stable Stripe `lookup_key` (set on the Price in the infra Pulumi program), so
21
+ * stable Stripe `lookup_key` (set on the Price out of band, e.g. in your IaC), so
22
22
  * the same key resolves in test and live even though their price IDs differ.
23
23
  */
24
24
  import { z } from "zod";
@@ -1 +1 @@
1
- {"version":3,"file":"subscription.d.ts","sourceRoot":"","sources":["../src/subscription.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAIjC,OAAO,EAAE,4BAA4B,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE3E;oDACoD;AACpD,MAAM,WAAW,mBAAmB;IACnC,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,sEAAsE;IACtE,gBAAgB,EAAE,IAAI,GAAG,IAAI,CAAC;IAC9B,iBAAiB,EAAE,OAAO,CAAC;IAC3B,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;CACtB;AAKD,4EAA4E;AAC5E,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,CAAC,YAAY,GAAG,mBAAmB,CAYnF;AAED;;;sDAGsD;AACtD,wBAAsB,4BAA4B,CACjD,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,MAAM,CAAC,YAAY,GAAG,IAAI,CAAC,CAQrC;AAED,uFAAuF;AACvF,wBAAsB,wBAAwB,CAC7C,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAGrC"}
1
+ {"version":3,"file":"subscription.d.ts","sourceRoot":"","sources":["../src/subscription.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAIjC,OAAO,EAAE,4BAA4B,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE3E;oDACoD;AACpD,MAAM,WAAW,mBAAmB;IACnC,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,sEAAsE;IACtE,gBAAgB,EAAE,IAAI,GAAG,IAAI,CAAC;IAC9B,iBAAiB,EAAE,OAAO,CAAC;IAC3B,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;CACtB;AAKD,4EAA4E;AAC5E,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,CAAC,YAAY,GAAG,mBAAmB,CAYnF;AAED;;;sDAGsD;AACtD,wBAAsB,4BAA4B,CACjD,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,MAAM,CAAC,YAAY,GAAG,IAAI,CAAC,CAUrC;AAED,uFAAuF;AACvF,wBAAsB,wBAAwB,CAC7C,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAGrC"}
@@ -34,7 +34,9 @@ export async function fetchSubscriptionForCustomer(customerId, stripe = getStrip
34
34
  const subs = await stripe.subscriptions.list({
35
35
  customer: customerId,
36
36
  status: "all",
37
- limit: 10,
37
+ // Stripe's max: an active subscription older than the N most recent ones
38
+ // must still be found ahead of the canceled ones.
39
+ limit: 100,
38
40
  expand: ["data.items.data.price"],
39
41
  });
40
42
  return subs.data.find((s) => isActiveStatus(s.status)) ?? subs.data[0] ?? null;
@@ -1 +1 @@
1
- {"version":3,"file":"subscription.js","sourceRoot":"","sources":["../src/subscription.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7C,OAAO,EAAE,4BAA4B,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAgB3E,MAAM,MAAM,GAAG,CAAC,OAAkC,EAAe,EAAE,CAClE,OAAO,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;AAEnD,4EAA4E;AAC5E,MAAM,UAAU,qBAAqB,CAAC,GAAwB;IAC7D,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC/B,OAAO;QACN,cAAc,EAAE,GAAG,CAAC,EAAE;QACtB,UAAU,EAAE,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE;QAC7E,MAAM,EAAE,GAAG,CAAC,MAAM;QAClB,MAAM,EAAE,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC;QAClC,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,IAAI,IAAI;QAC/B,gBAAgB,EAAE,MAAM,CAAC,IAAI,EAAE,kBAAkB,CAAC;QAClD,iBAAiB,EAAE,GAAG,CAAC,oBAAoB;QAC3C,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC;KAC/B,CAAC;AACH,CAAC;AAED;;;sDAGsD;AACtD,MAAM,CAAC,KAAK,UAAU,4BAA4B,CACjD,UAAkB,EAClB,SAAiB,SAAS,EAAE;IAE5B,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC;QAC5C,QAAQ,EAAE,UAAU;QACpB,MAAM,EAAE,KAAK;QACb,KAAK,EAAE,EAAE;QACT,MAAM,EAAE,CAAC,uBAAuB,CAAC;KACjC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,cAAc,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;AAChF,CAAC;AAED,uFAAuF;AACvF,MAAM,CAAC,KAAK,UAAU,wBAAwB,CAC7C,UAAkB,EAClB,SAAiB,SAAS,EAAE;IAE5B,MAAM,GAAG,GAAG,MAAM,4BAA4B,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;IACnE,OAAO,GAAG,CAAC,CAAC,CAAC,qBAAqB,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAChD,CAAC"}
1
+ {"version":3,"file":"subscription.js","sourceRoot":"","sources":["../src/subscription.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7C,OAAO,EAAE,4BAA4B,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAgB3E,MAAM,MAAM,GAAG,CAAC,OAAkC,EAAe,EAAE,CAClE,OAAO,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;AAEnD,4EAA4E;AAC5E,MAAM,UAAU,qBAAqB,CAAC,GAAwB;IAC7D,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC/B,OAAO;QACN,cAAc,EAAE,GAAG,CAAC,EAAE;QACtB,UAAU,EAAE,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE;QAC7E,MAAM,EAAE,GAAG,CAAC,MAAM;QAClB,MAAM,EAAE,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC;QAClC,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,IAAI,IAAI;QAC/B,gBAAgB,EAAE,MAAM,CAAC,IAAI,EAAE,kBAAkB,CAAC;QAClD,iBAAiB,EAAE,GAAG,CAAC,oBAAoB;QAC3C,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC;KAC/B,CAAC;AACH,CAAC;AAED;;;sDAGsD;AACtD,MAAM,CAAC,KAAK,UAAU,4BAA4B,CACjD,UAAkB,EAClB,SAAiB,SAAS,EAAE;IAE5B,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC;QAC5C,QAAQ,EAAE,UAAU;QACpB,MAAM,EAAE,KAAK;QACb,yEAAyE;QACzE,kDAAkD;QAClD,KAAK,EAAE,GAAG;QACV,MAAM,EAAE,CAAC,uBAAuB,CAAC;KACjC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,cAAc,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;AAChF,CAAC;AAED,uFAAuF;AACvF,MAAM,CAAC,KAAK,UAAU,wBAAwB,CAC7C,UAAkB,EAClB,SAAiB,SAAS,EAAE;IAE5B,MAAM,GAAG,GAAG,MAAM,4BAA4B,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;IACnE,OAAO,GAAG,CAAC,CAAC,CAAC,qBAAqB,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAChD,CAAC"}
@@ -0,0 +1,10 @@
1
+ -- @ingram-tech/nk-billing — out-of-order webhook defense.
2
+ --
3
+ -- Stripe does not guarantee event delivery order. `recordSubscriptionStatus`
4
+ -- stamps the Stripe event's `created` timestamp here (when the caller passes
5
+ -- `eventCreated`) and refuses to overwrite the cached status with an older
6
+ -- event, so a delayed `customer.subscription.updated` can't resurrect a
7
+ -- subscription that a later `customer.subscription.deleted` already canceled.
8
+
9
+ alter table billing_credits
10
+ add column if not exists subscription_status_at timestamptz;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-tech/nk-billing",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The Ingram billing foundation: composable Stripe primitives (client, customers, prices, currency, checkout, subscriptions, webhooks) plus an injection-based Postgres credit ledger and event-dedup store.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,13 +33,13 @@
33
33
  "test": "vitest run"
34
34
  },
35
35
  "dependencies": {
36
- "stripe": "^22.0.0",
37
- "zod": "^4.0.0"
36
+ "stripe": "^22.3.0",
37
+ "zod": "^4.4.3"
38
38
  },
39
39
  "devDependencies": {
40
- "@ingram-tech/nk-dev": "workspace:*",
41
- "@types/node": "^25.0.0",
40
+ "@ingram-tech/nk-dev": "0.2.5",
41
+ "@types/node": "^26.0.1",
42
42
  "typescript": "^6.0.3",
43
- "vitest": "^4.1.6"
43
+ "vitest": "^4.1.9"
44
44
  }
45
45
  }