@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 +18 -5
- package/dist/balance.d.ts +18 -8
- package/dist/balance.d.ts.map +1 -1
- package/dist/balance.js +37 -10
- package/dist/balance.js.map +1 -1
- package/dist/checkout.d.ts.map +1 -1
- package/dist/checkout.js +3 -1
- package/dist/checkout.js.map +1 -1
- package/dist/client.d.ts +3 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +3 -2
- package/dist/client.js.map +1 -1
- package/dist/credits.d.ts +15 -3
- package/dist/credits.d.ts.map +1 -1
- package/dist/credits.js +42 -10
- package/dist/credits.js.map +1 -1
- package/dist/customers.d.ts +4 -4
- package/dist/customers.d.ts.map +1 -1
- package/dist/customers.js +15 -5
- package/dist/customers.js.map +1 -1
- package/dist/keys.d.ts +6 -6
- package/dist/keys.js +6 -6
- package/dist/subscription.d.ts.map +1 -1
- package/dist/subscription.js +3 -1
- package/dist/subscription.js.map +1 -1
- package/migrations/0002_billing_status_order.sql +10 -0
- package/package.json +6 -6
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
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
3
|
-
*
|
|
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
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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;
|
package/dist/balance.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
|
3
|
-
*
|
|
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
|
|
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 {
|
|
21
|
+
return {
|
|
22
|
+
amount: Math.max(0, -customer.balance),
|
|
23
|
+
currency: customer.currency ?? "eur",
|
|
24
|
+
};
|
|
20
25
|
}
|
|
21
|
-
/** Debit the wallet by `amountCents
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
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
|
package/dist/balance.js.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/checkout.d.ts.map
CHANGED
|
@@ -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,
|
|
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,
|
package/dist/checkout.js.map
CHANGED
|
@@ -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;
|
|
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};
|
|
8
|
-
* {@link stripeFor} with a mode and gets one
|
|
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";
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA
|
|
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};
|
|
8
|
-
* {@link stripeFor} with a mode and gets one
|
|
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";
|
package/dist/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA
|
|
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
|
|
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 {
|
package/dist/credits.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"credits.d.ts","sourceRoot":"","sources":["../src/credits.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAG5D,OAAO,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAEzE;oDACoD;AACpD,MAAM,WAAW,SAAS;IAIzB,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,OAAO,EAAE,GAChB,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,EAAE,CAAA;KAAE,CAAC,CAAC;CAC1B;AAED;2DAC2D;AAC3D,MAAM,WAAW,WAAW;IAC3B,kDAAkD;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,WAAW,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC7B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,UAAU,CAAA;CAAE,CAAC;
|
|
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
|
|
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
|
-
|
|
119
|
-
|
|
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()
|
|
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),
|
package/dist/credits.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"credits.js","sourceRoot":"","sources":["../src/credits.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,YAAY,EAAmB,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,4BAA4B,EAAE,MAAM,aAAa,CAAC;AAE3D,OAAO,EAAE,YAAY,EAAmB,WAAW,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"credits.js","sourceRoot":"","sources":["../src/credits.ts"],"names":[],"mappings":"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"}
|
package/dist/customers.d.ts
CHANGED
|
@@ -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 (`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
* "
|
|
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;
|
package/dist/customers.d.ts.map
CHANGED
|
@@ -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;
|
|
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 (`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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, {
|
package/dist/customers.js.map
CHANGED
|
@@ -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;
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 (
|
|
16
|
-
*
|
|
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
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 (
|
|
16
|
-
*
|
|
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
|
|
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,
|
|
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"}
|
package/dist/subscription.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/subscription.js.map
CHANGED
|
@@ -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,
|
|
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.
|
|
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.
|
|
37
|
-
"zod": "^4.
|
|
36
|
+
"stripe": "^22.3.0",
|
|
37
|
+
"zod": "^4.4.3"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@ingram-tech/nk-dev": "
|
|
41
|
-
"@types/node": "^
|
|
40
|
+
"@ingram-tech/nk-dev": "0.2.5",
|
|
41
|
+
"@types/node": "^26.0.1",
|
|
42
42
|
"typescript": "^6.0.3",
|
|
43
|
-
"vitest": "^4.1.
|
|
43
|
+
"vitest": "^4.1.9"
|
|
44
44
|
}
|
|
45
45
|
}
|