@ingram-tech/nk-billing 0.1.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 +93 -0
- package/dist/balance.d.ts +34 -0
- package/dist/balance.d.ts.map +1 -0
- package/dist/balance.js +34 -0
- package/dist/balance.js.map +1 -0
- package/dist/checkout.d.ts +49 -0
- package/dist/checkout.d.ts.map +1 -0
- package/dist/checkout.js +59 -0
- package/dist/checkout.js.map +1 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +57 -0
- package/dist/client.js.map +1 -0
- package/dist/credits.d.ts +118 -0
- package/dist/credits.d.ts.map +1 -0
- package/dist/credits.js +154 -0
- package/dist/credits.js.map +1 -0
- package/dist/currency.d.ts +30 -0
- package/dist/currency.d.ts.map +1 -0
- package/dist/currency.js +78 -0
- package/dist/currency.js.map +1 -0
- package/dist/customers.d.ts +54 -0
- package/dist/customers.d.ts.map +1 -0
- package/dist/customers.js +73 -0
- package/dist/customers.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +24 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +44 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +64 -0
- package/dist/keys.js.map +1 -0
- package/dist/prices.d.ts +23 -0
- package/dist/prices.d.ts.map +1 -0
- package/dist/prices.js +45 -0
- package/dist/prices.js.map +1 -0
- package/dist/status.d.ts +12 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +18 -0
- package/dist/status.js.map +1 -0
- package/dist/subscription.d.ts +35 -0
- package/dist/subscription.d.ts.map +1 -0
- package/dist/subscription.js +47 -0
- package/dist/subscription.js.map +1 -0
- package/dist/webhooks.d.ts +34 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +40 -0
- package/dist/webhooks.js.map +1 -0
- package/migrations/0001_billing.sql +42 -0
- package/package.json +45 -0
package/dist/prices.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prices, resolved by stable `lookup_key` rather than hardcoded IDs. The catalog
|
|
3
|
+
* (products/prices) is owned by the infra Pulumi program; this only *consumes*
|
|
4
|
+
* it at runtime. Because a lookup_key resolves to different price IDs in test and
|
|
5
|
+
* live, the same code path works in both modes with nothing mode-specific in env.
|
|
6
|
+
*/
|
|
7
|
+
import type Stripe from "stripe";
|
|
8
|
+
/** Resolve an active Price by its stable lookup_key. `currency_options` is
|
|
9
|
+
* expanded so callers can see (and localise to) the presentment currencies the
|
|
10
|
+
* price supports. Throws if no active price carries the key — usually means the
|
|
11
|
+
* catalog hasn't been applied to this Stripe mode yet. */
|
|
12
|
+
export declare function priceByLookupKey(key: string, stripe?: Stripe): Promise<Stripe.Price>;
|
|
13
|
+
/** The presentment currencies a price supports: its base plus currency_options. */
|
|
14
|
+
export declare function supportedCurrencies(price: Stripe.Price): Set<string>;
|
|
15
|
+
/** The amount (in minor units) a price charges in `currency`, or null if the
|
|
16
|
+
* price offers neither that currency nor a base amount. Falls back to the base
|
|
17
|
+
* currency when the requested one isn't carried. Used to display localised
|
|
18
|
+
* prices without a hardcoded amount. */
|
|
19
|
+
export declare function displayAmount(key: string, currency: string, stripe?: Stripe): Promise<{
|
|
20
|
+
amount: number;
|
|
21
|
+
currency: string;
|
|
22
|
+
} | null>;
|
|
23
|
+
//# sourceMappingURL=prices.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prices.d.ts","sourceRoot":"","sources":["../src/prices.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAGjC;;;2DAG2D;AAC3D,wBAAsB,gBAAgB,CACrC,GAAG,EAAE,MAAM,EACX,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAcvB;AAED,mFAAmF;AACnF,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,CAAC,KAAK,GAAG,GAAG,CAAC,MAAM,CAAC,CAEpE;AAED;;;yCAGyC;AACzC,wBAAsB,aAAa,CAClC,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAStD"}
|
package/dist/prices.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prices, resolved by stable `lookup_key` rather than hardcoded IDs. The catalog
|
|
3
|
+
* (products/prices) is owned by the infra Pulumi program; this only *consumes*
|
|
4
|
+
* it at runtime. Because a lookup_key resolves to different price IDs in test and
|
|
5
|
+
* live, the same code path works in both modes with nothing mode-specific in env.
|
|
6
|
+
*/
|
|
7
|
+
import { getStripe } from "./client.js";
|
|
8
|
+
/** Resolve an active Price by its stable lookup_key. `currency_options` is
|
|
9
|
+
* expanded so callers can see (and localise to) the presentment currencies the
|
|
10
|
+
* price supports. Throws if no active price carries the key — usually means the
|
|
11
|
+
* catalog hasn't been applied to this Stripe mode yet. */
|
|
12
|
+
export async function priceByLookupKey(key, stripe = getStripe()) {
|
|
13
|
+
const prices = await stripe.prices.list({
|
|
14
|
+
lookup_keys: [key],
|
|
15
|
+
active: true,
|
|
16
|
+
limit: 1,
|
|
17
|
+
expand: ["data.currency_options"],
|
|
18
|
+
});
|
|
19
|
+
const price = prices.data[0];
|
|
20
|
+
if (!price) {
|
|
21
|
+
throw new Error(`No active Stripe price for lookup_key '${key}' — has the catalog been applied to this mode?`);
|
|
22
|
+
}
|
|
23
|
+
return price;
|
|
24
|
+
}
|
|
25
|
+
/** The presentment currencies a price supports: its base plus currency_options. */
|
|
26
|
+
export function supportedCurrencies(price) {
|
|
27
|
+
return new Set([price.currency, ...Object.keys(price.currency_options ?? {})]);
|
|
28
|
+
}
|
|
29
|
+
/** The amount (in minor units) a price charges in `currency`, or null if the
|
|
30
|
+
* price offers neither that currency nor a base amount. Falls back to the base
|
|
31
|
+
* currency when the requested one isn't carried. Used to display localised
|
|
32
|
+
* prices without a hardcoded amount. */
|
|
33
|
+
export async function displayAmount(key, currency, stripe = getStripe()) {
|
|
34
|
+
const price = await priceByLookupKey(key, stripe);
|
|
35
|
+
if (currency !== price.currency) {
|
|
36
|
+
const opt = price.currency_options?.[currency]?.unit_amount;
|
|
37
|
+
if (opt != null)
|
|
38
|
+
return { amount: opt, currency };
|
|
39
|
+
// Price doesn't carry that currency — fall through to its base.
|
|
40
|
+
}
|
|
41
|
+
if (price.unit_amount == null)
|
|
42
|
+
return null;
|
|
43
|
+
return { amount: price.unit_amount, currency: price.currency };
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=prices.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prices.js","sourceRoot":"","sources":["../src/prices.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC;;;2DAG2D;AAC3D,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACrC,GAAW,EACX,SAAiB,SAAS,EAAE;IAE5B,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;QACvC,WAAW,EAAE,CAAC,GAAG,CAAC;QAClB,MAAM,EAAE,IAAI;QACZ,KAAK,EAAE,CAAC;QACR,MAAM,EAAE,CAAC,uBAAuB,CAAC;KACjC,CAAC,CAAC;IACH,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC7B,IAAI,CAAC,KAAK,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CACd,0CAA0C,GAAG,gDAAgD,CAC7F,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACd,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,mBAAmB,CAAC,KAAmB;IACtD,OAAO,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,gBAAgB,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;AAChF,CAAC;AAED;;;yCAGyC;AACzC,MAAM,CAAC,KAAK,UAAU,aAAa,CAClC,GAAW,EACX,QAAgB,EAChB,SAAiB,SAAS,EAAE;IAE5B,MAAM,KAAK,GAAG,MAAM,gBAAgB,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IAClD,IAAI,QAAQ,KAAK,KAAK,CAAC,QAAQ,EAAE,CAAC;QACjC,MAAM,GAAG,GAAG,KAAK,CAAC,gBAAgB,EAAE,CAAC,QAAQ,CAAC,EAAE,WAAW,CAAC;QAC5D,IAAI,GAAG,IAAI,IAAI;YAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,CAAC;QAClD,gEAAgE;IACjE,CAAC;IACD,IAAI,KAAK,CAAC,WAAW,IAAI,IAAI;QAAE,OAAO,IAAI,CAAC;IAC3C,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,WAAW,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC;AAChE,CAAC"}
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription-status predicates with **zero dependencies** — no Stripe SDK, no
|
|
3
|
+
* database. Lives apart from `subscription.ts` (which needs the Stripe client) so
|
|
4
|
+
* the Postgres credit ledger in `./credits` can import the entitlement set
|
|
5
|
+
* without dragging Stripe into that bundle.
|
|
6
|
+
*/
|
|
7
|
+
/** Stripe statuses that count as "has access" for gating. `past_due` is included
|
|
8
|
+
* deliberately — the customer keeps access during Stripe's dunning retries. */
|
|
9
|
+
export declare const ACTIVE_SUBSCRIPTION_STATUSES: ReadonlySet<string>;
|
|
10
|
+
/** Whether a raw Stripe status grants access. */
|
|
11
|
+
export declare function isActiveStatus(status: string | null | undefined): boolean;
|
|
12
|
+
//# sourceMappingURL=status.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;gFACgF;AAChF,eAAO,MAAM,4BAA4B,EAAE,WAAW,CAAC,MAAM,CAI3D,CAAC;AAEH,iDAAiD;AACjD,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAEzE"}
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription-status predicates with **zero dependencies** — no Stripe SDK, no
|
|
3
|
+
* database. Lives apart from `subscription.ts` (which needs the Stripe client) so
|
|
4
|
+
* the Postgres credit ledger in `./credits` can import the entitlement set
|
|
5
|
+
* without dragging Stripe into that bundle.
|
|
6
|
+
*/
|
|
7
|
+
/** Stripe statuses that count as "has access" for gating. `past_due` is included
|
|
8
|
+
* deliberately — the customer keeps access during Stripe's dunning retries. */
|
|
9
|
+
export const ACTIVE_SUBSCRIPTION_STATUSES = new Set([
|
|
10
|
+
"active",
|
|
11
|
+
"trialing",
|
|
12
|
+
"past_due",
|
|
13
|
+
]);
|
|
14
|
+
/** Whether a raw Stripe status grants access. */
|
|
15
|
+
export function isActiveStatus(status) {
|
|
16
|
+
return status != null && ACTIVE_SUBSCRIPTION_STATUSES.has(status);
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=status.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status.js","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;gFACgF;AAChF,MAAM,CAAC,MAAM,4BAA4B,GAAwB,IAAI,GAAG,CAAC;IACxE,QAAQ;IACR,UAAU;IACV,UAAU;CACV,CAAC,CAAC;AAEH,iDAAiD;AACjD,MAAM,UAAU,cAAc,CAAC,MAAiC;IAC/D,OAAO,MAAM,IAAI,IAAI,IAAI,4BAA4B,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;AACnE,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription status — the normalisation every site repeats, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Two things are awkward about Stripe subscriptions and handled here: (1) which
|
|
5
|
+
* raw statuses count as "entitled" (you stay entitled through `past_due` while
|
|
6
|
+
* Stripe retries the card), and (2) the billing period now lives on the
|
|
7
|
+
* subscription *item* (`current_period_end`), not the subscription, in current
|
|
8
|
+
* API versions. {@link summarizeSubscription} flattens a raw subscription into
|
|
9
|
+
* the handful of fields a site actually persists/displays.
|
|
10
|
+
*/
|
|
11
|
+
import type Stripe from "stripe";
|
|
12
|
+
export { ACTIVE_SUBSCRIPTION_STATUSES, isActiveStatus } from "./status.js";
|
|
13
|
+
/** The flattened view of a subscription a site persists or displays. Epoch fields
|
|
14
|
+
* are JS `Date`s (Stripe returns Unix seconds). */
|
|
15
|
+
export interface SubscriptionSummary {
|
|
16
|
+
subscriptionId: string;
|
|
17
|
+
customerId: string;
|
|
18
|
+
status: string;
|
|
19
|
+
active: boolean;
|
|
20
|
+
priceId: string | null;
|
|
21
|
+
/** End of the current billing period (from the subscription item). */
|
|
22
|
+
currentPeriodEnd: Date | null;
|
|
23
|
+
cancelAtPeriodEnd: boolean;
|
|
24
|
+
trialEnd: Date | null;
|
|
25
|
+
}
|
|
26
|
+
/** Flatten a raw Stripe subscription into a {@link SubscriptionSummary}. */
|
|
27
|
+
export declare function summarizeSubscription(sub: Stripe.Subscription): SubscriptionSummary;
|
|
28
|
+
/** Read a customer's most relevant subscription live from Stripe — the first
|
|
29
|
+
* active one if any, else the most recent. Returns null when the customer has
|
|
30
|
+
* none. Use to back the "webhook lag self-heal" pattern: when your cache says
|
|
31
|
+
* not-active, re-check here and backfill the row. */
|
|
32
|
+
export declare function fetchSubscriptionForCustomer(customerId: string, stripe?: Stripe): Promise<Stripe.Subscription | null>;
|
|
33
|
+
/** {@link fetchSubscriptionForCustomer} flattened to a {@link SubscriptionSummary}. */
|
|
34
|
+
export declare function fetchSubscriptionSummary(customerId: string, stripe?: Stripe): Promise<SubscriptionSummary | null>;
|
|
35
|
+
//# sourceMappingURL=subscription.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription status — the normalisation every site repeats, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Two things are awkward about Stripe subscriptions and handled here: (1) which
|
|
5
|
+
* raw statuses count as "entitled" (you stay entitled through `past_due` while
|
|
6
|
+
* Stripe retries the card), and (2) the billing period now lives on the
|
|
7
|
+
* subscription *item* (`current_period_end`), not the subscription, in current
|
|
8
|
+
* API versions. {@link summarizeSubscription} flattens a raw subscription into
|
|
9
|
+
* the handful of fields a site actually persists/displays.
|
|
10
|
+
*/
|
|
11
|
+
import { getStripe } from "./client.js";
|
|
12
|
+
import { isActiveStatus } from "./status.js";
|
|
13
|
+
export { ACTIVE_SUBSCRIPTION_STATUSES, isActiveStatus } from "./status.js";
|
|
14
|
+
const toDate = (seconds) => seconds == null ? null : new Date(seconds * 1000);
|
|
15
|
+
/** Flatten a raw Stripe subscription into a {@link SubscriptionSummary}. */
|
|
16
|
+
export function summarizeSubscription(sub) {
|
|
17
|
+
const item = sub.items.data[0];
|
|
18
|
+
return {
|
|
19
|
+
subscriptionId: sub.id,
|
|
20
|
+
customerId: typeof sub.customer === "string" ? sub.customer : sub.customer.id,
|
|
21
|
+
status: sub.status,
|
|
22
|
+
active: isActiveStatus(sub.status),
|
|
23
|
+
priceId: item?.price.id ?? null,
|
|
24
|
+
currentPeriodEnd: toDate(item?.current_period_end),
|
|
25
|
+
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
|
26
|
+
trialEnd: toDate(sub.trial_end),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Read a customer's most relevant subscription live from Stripe — the first
|
|
30
|
+
* active one if any, else the most recent. Returns null when the customer has
|
|
31
|
+
* none. Use to back the "webhook lag self-heal" pattern: when your cache says
|
|
32
|
+
* not-active, re-check here and backfill the row. */
|
|
33
|
+
export async function fetchSubscriptionForCustomer(customerId, stripe = getStripe()) {
|
|
34
|
+
const subs = await stripe.subscriptions.list({
|
|
35
|
+
customer: customerId,
|
|
36
|
+
status: "all",
|
|
37
|
+
limit: 10,
|
|
38
|
+
expand: ["data.items.data.price"],
|
|
39
|
+
});
|
|
40
|
+
return subs.data.find((s) => isActiveStatus(s.status)) ?? subs.data[0] ?? null;
|
|
41
|
+
}
|
|
42
|
+
/** {@link fetchSubscriptionForCustomer} flattened to a {@link SubscriptionSummary}. */
|
|
43
|
+
export async function fetchSubscriptionSummary(customerId, stripe = getStripe()) {
|
|
44
|
+
const sub = await fetchSubscriptionForCustomer(customerId, stripe);
|
|
45
|
+
return sub ? summarizeSubscription(sub) : null;
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=subscription.js.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook verification — the raw-body-read + signature-verify dance every Stripe
|
|
3
|
+
* route repeats. The stateful half (idempotent dedup of an event before applying
|
|
4
|
+
* it) lives in `@ingram-tech/nk-billing/credits` (`claimStripeEvent`), so this
|
|
5
|
+
* module stays free of any database concept.
|
|
6
|
+
*/
|
|
7
|
+
import type Stripe from "stripe";
|
|
8
|
+
/** Result of reading a webhook request: the verified event, or a reason + the
|
|
9
|
+
* HTTP status to return. 400 = bad/missing signature (don't retry); 503 = the
|
|
10
|
+
* endpoint isn't configured (secret unset). */
|
|
11
|
+
export type WebhookResult = {
|
|
12
|
+
ok: true;
|
|
13
|
+
event: Stripe.Event;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
status: 400 | 503;
|
|
17
|
+
message: string;
|
|
18
|
+
};
|
|
19
|
+
/** Verify a raw body + signature against the signing secret. Thin wrapper over
|
|
20
|
+
* `stripe.webhooks.constructEvent` that returns a typed result instead of
|
|
21
|
+
* throwing on a bad signature. */
|
|
22
|
+
export declare function verifyStripeWebhook(body: string, signature: string | null, secret: string, stripe?: Stripe): WebhookResult;
|
|
23
|
+
/** Read and verify a Stripe webhook from a Fetch `Request` — reads the raw text
|
|
24
|
+
* body (required before any JSON parsing), pulls the `stripe-signature` header,
|
|
25
|
+
* and verifies. Pass the secret for the relevant mode. Returns a 503 result when
|
|
26
|
+
* `secret` is empty so an unconfigured endpoint fails closed without throwing.
|
|
27
|
+
*
|
|
28
|
+
* Typical route:
|
|
29
|
+
* const res = await readStripeWebhook(request, process.env.STRIPE_WEBHOOK_SECRET ?? "");
|
|
30
|
+
* if (!res.ok) return new Response(res.message, { status: res.status });
|
|
31
|
+
* // ... dedupe with claimStripeEvent, then handle res.event ...
|
|
32
|
+
*/
|
|
33
|
+
export declare function readStripeWebhook(request: Request, secret: string, stripe?: Stripe): Promise<WebhookResult>;
|
|
34
|
+
//# sourceMappingURL=webhooks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhooks.d.ts","sourceRoot":"","sources":["../src/webhooks.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAGjC;;gDAEgD;AAChD,MAAM,MAAM,aAAa,GACtB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,KAAK,CAAA;CAAE,GACjC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,GAAG,GAAG,GAAG,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAErD;;mCAEmC;AACnC,wBAAgB,mBAAmB,CAClC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,MAAM,EAAE,MAAM,EACd,MAAM,GAAE,MAAoB,GAC1B,aAAa,CAQf;AAED;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CACtC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,EACd,MAAM,GAAE,MAAoB,GAC1B,OAAO,CAAC,aAAa,CAAC,CAOxB"}
|
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook verification — the raw-body-read + signature-verify dance every Stripe
|
|
3
|
+
* route repeats. The stateful half (idempotent dedup of an event before applying
|
|
4
|
+
* it) lives in `@ingram-tech/nk-billing/credits` (`claimStripeEvent`), so this
|
|
5
|
+
* module stays free of any database concept.
|
|
6
|
+
*/
|
|
7
|
+
import { getStripe } from "./client.js";
|
|
8
|
+
/** Verify a raw body + signature against the signing secret. Thin wrapper over
|
|
9
|
+
* `stripe.webhooks.constructEvent` that returns a typed result instead of
|
|
10
|
+
* throwing on a bad signature. */
|
|
11
|
+
export function verifyStripeWebhook(body, signature, secret, stripe = getStripe()) {
|
|
12
|
+
if (!signature)
|
|
13
|
+
return { ok: false, status: 400, message: "Missing signature" };
|
|
14
|
+
try {
|
|
15
|
+
const event = stripe.webhooks.constructEvent(body, signature, secret);
|
|
16
|
+
return { ok: true, event };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { ok: false, status: 400, message: "Invalid signature" };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Read and verify a Stripe webhook from a Fetch `Request` — reads the raw text
|
|
23
|
+
* body (required before any JSON parsing), pulls the `stripe-signature` header,
|
|
24
|
+
* and verifies. Pass the secret for the relevant mode. Returns a 503 result when
|
|
25
|
+
* `secret` is empty so an unconfigured endpoint fails closed without throwing.
|
|
26
|
+
*
|
|
27
|
+
* Typical route:
|
|
28
|
+
* const res = await readStripeWebhook(request, process.env.STRIPE_WEBHOOK_SECRET ?? "");
|
|
29
|
+
* if (!res.ok) return new Response(res.message, { status: res.status });
|
|
30
|
+
* // ... dedupe with claimStripeEvent, then handle res.event ...
|
|
31
|
+
*/
|
|
32
|
+
export async function readStripeWebhook(request, secret, stripe = getStripe()) {
|
|
33
|
+
if (!secret) {
|
|
34
|
+
return { ok: false, status: 503, message: "Webhook not configured" };
|
|
35
|
+
}
|
|
36
|
+
const body = await request.text();
|
|
37
|
+
const signature = request.headers.get("stripe-signature");
|
|
38
|
+
return verifyStripeWebhook(body, signature, secret, stripe);
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=webhooks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhooks.js","sourceRoot":"","sources":["../src/webhooks.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AASxC;;mCAEmC;AACnC,MAAM,UAAU,mBAAmB,CAClC,IAAY,EACZ,SAAwB,EACxB,MAAc,EACd,SAAiB,SAAS,EAAE;IAE5B,IAAI,CAAC,SAAS;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,mBAAmB,EAAE,CAAC;IAChF,IAAI,CAAC;QACJ,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;QACtE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,mBAAmB,EAAE,CAAC;IACjE,CAAC;AACF,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACtC,OAAgB,EAChB,MAAc,EACd,SAAiB,SAAS,EAAE;IAE5B,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,wBAAwB,EAAE,CAAC;IACtE,CAAC;IACD,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,IAAI,EAAE,CAAC;IAClC,MAAM,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;IAC1D,OAAO,mBAAmB,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;AAC7D,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
-- @ingram-tech/nk-billing — credit ledger + Stripe event-dedup store.
|
|
2
|
+
--
|
|
3
|
+
-- The stateful slice of nk-billing, only for sites that use the credit ledger
|
|
4
|
+
-- (`@ingram-tech/nk-billing/credits`). Sites that only do subscriptions, or use
|
|
5
|
+
-- the Stripe-side wallet (`./balance`), do NOT need this migration.
|
|
6
|
+
--
|
|
7
|
+
-- Tenancy is the consuming site's concern (see credits.ts): these tables are
|
|
8
|
+
-- keyed by an opaque `tenant_id` and carry NO row-level security here. Apply
|
|
9
|
+
-- your own isolation to match your stack:
|
|
10
|
+
-- * RLS sites: add a `tenant_isolation` policy keyed on tenant_id and reach
|
|
11
|
+
-- the rows through nk-db's `withRlsTransaction` / `withTenant`.
|
|
12
|
+
-- * App-layer-filtering sites: every query already passes tenant_id; nothing
|
|
13
|
+
-- extra is required.
|
|
14
|
+
--
|
|
15
|
+
-- Ship this as a Drizzle schema fragment + generated SQL so it composes with the
|
|
16
|
+
-- consuming site's drizzle-kit pipeline (see docs/db-package.md). Adjust the
|
|
17
|
+
-- table names only if they collide; the ledger code hardcodes them.
|
|
18
|
+
|
|
19
|
+
create table if not exists billing_credits (
|
|
20
|
+
tenant_id text primary key,
|
|
21
|
+
-- Abstract credits, not money. Bigint so high-volume metering can't overflow.
|
|
22
|
+
credits_balance bigint not null default 0,
|
|
23
|
+
-- Cached Stripe subscription status (active / trialing / past_due / …), kept
|
|
24
|
+
-- fresh by the customer.subscription.* webhook via recordSubscriptionStatus().
|
|
25
|
+
subscription_status text,
|
|
26
|
+
-- Cached Stripe customer id, backfilled from webhooks. A read-side convenience;
|
|
27
|
+
-- the authoritative link is the customer's metadata tag (see customers.ts).
|
|
28
|
+
stripe_customer_id text,
|
|
29
|
+
-- When the lazy trial clock started (set on first spend when trials are on).
|
|
30
|
+
trial_started_at timestamptz,
|
|
31
|
+
created_at timestamptz not null default now(),
|
|
32
|
+
updated_at timestamptz not null default now()
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
-- Idempotency log for inbound Stripe webhooks. Each event id is inserted once,
|
|
36
|
+
-- in the same transaction as the state change it guards, so retries are no-ops.
|
|
37
|
+
-- Synthetic ids (e.g. `subgrant:<sub>:<periodStart>`) dedupe per-period grants.
|
|
38
|
+
create table if not exists billing_stripe_events (
|
|
39
|
+
event_id text primary key,
|
|
40
|
+
type text not null,
|
|
41
|
+
created_at timestamptz not null default now()
|
|
42
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ingram-tech/nk-billing",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ingram-technologies/nextkit.git",
|
|
10
|
+
"directory": "packages/nk-billing"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"migrations"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./credits": {
|
|
25
|
+
"types": "./dist/credits.d.ts",
|
|
26
|
+
"import": "./dist/credits.js"
|
|
27
|
+
},
|
|
28
|
+
"./migrations/*": "./migrations/*"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.json",
|
|
32
|
+
"type-check": "tsc -p tsconfig.json --noEmit",
|
|
33
|
+
"test": "vitest run"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"stripe": "^22.0.0",
|
|
37
|
+
"zod": "^4.0.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@ingram-tech/nk-dev": "workspace:*",
|
|
41
|
+
"@types/node": "^25.0.0",
|
|
42
|
+
"typescript": "^6.0.3",
|
|
43
|
+
"vitest": "^4.1.6"
|
|
44
|
+
}
|
|
45
|
+
}
|