@suluk/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/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@suluk/billing",
3
+ "version": "0.1.0",
4
+ "description": "Stripe PLUMBING over an injected config (secret key + a mockable fetch): the HTTP transport (with the refund idempotency-key), customer/SetupIntent/PaymentIntent creation, the saved-card surface, the money-moving paths (hosted Checkout + portal + on-default-card top-up + off-session charge), Stripe Tax, the subscription mechanics over a generic SubPlan catalog, and the package-owned billing-account store. Pure Stripe wrappers — the webhook dispatch, credit grants, branded email, the pricing matrix, and refund/pooling stay in the app. Extracted from a real app (C046). CANDIDATE tooling.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "Apache-2.0",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
12
+ "directory": "tooling/ts/packages/billing"
13
+ },
14
+ "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
15
+ "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
16
+ "type": "module",
17
+ "main": "src/index.ts",
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "dependencies": {
22
+ "@suluk/payments": "^0.1.0",
23
+ "@suluk/drizzle": "^0.1.6",
24
+ "drizzle-orm": "^0.45.2"
25
+ },
26
+ "devDependencies": {
27
+ "@types/bun": "latest"
28
+ },
29
+ "scripts": {
30
+ "test": "bun test",
31
+ "typecheck": "tsc --noEmit -p ."
32
+ }
33
+ }
package/src/account.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The billing-account store (C046, v2) — the app's user ↔ Stripe link: the customer id + the active subscription id,
3
+ * owned by @suluk/billing and applied by the app's migrations. `userId` is the PK as a PLAIN column (the app owns the
4
+ * `user` table; add the FK + cascade in your migration), exactly like @suluk/credits owns its ledger with a plain
5
+ * `userId`. The app injects a Drizzle handle (`DrizzleD1Database` in prod; bun:sqlite bridged to it in tests). Extracted
6
+ * verbatim from the source.
7
+ */
8
+ import { eq } from "drizzle-orm";
9
+ import type { DrizzleD1Database } from "drizzle-orm/d1";
10
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
11
+
12
+ /** The injected DB handle. Prod is drizzle/d1; tests bridge drizzle/bun-sqlite to this type (a runtime-identity narrow). */
13
+ export type BillingDB = DrizzleD1Database;
14
+
15
+ export const billingAccount = sqliteTable("billing_account", {
16
+ /** the user id — PK; a plain column (the app owns the user table; add the FK + onDelete cascade in your migration). */
17
+ userId: text("userId").primaryKey(),
18
+ /** the Stripe customer id (set on the first top-up/subscribe; reused so a saved card is never orphaned). */
19
+ stripeCustomerId: text("stripeCustomerId"),
20
+ /** the ACTIVE subscription id, or null when the user has none (cleared by customer.subscription.deleted). */
21
+ subscriptionId: text("subscriptionId"),
22
+ updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull(),
23
+ });
24
+
25
+ /** The user's Stripe customer id, or null when they have no billing account yet. */
26
+ export async function billingCustomerId(db: BillingDB, userId: string): Promise<string | null> {
27
+ const rows = await db.select().from(billingAccount).where(eq(billingAccount.userId, userId)).limit(1);
28
+ return rows[0]?.stripeCustomerId ?? null;
29
+ }
30
+
31
+ /** The user's recorded Stripe subscription id, or null when they have no subscription. */
32
+ export async function billingSubscriptionId(db: BillingDB, userId: string): Promise<string | null> {
33
+ const rows = await db.select().from(billingAccount).where(eq(billingAccount.userId, userId)).limit(1);
34
+ return rows[0]?.subscriptionId ?? null;
35
+ }
36
+
37
+ /** Persist the user's Stripe customer id WITHOUT touching subscriptionId — so a one-time top-up never clears a
38
+ * subscriber's `subscriptionId` (unlike {@link upsertBillingAccount}, which sets it). Idempotent on the userId PK. */
39
+ export async function linkBillingCustomer(db: BillingDB, userId: string, customerId: string): Promise<void> {
40
+ await db
41
+ .insert(billingAccount)
42
+ .values({ userId, stripeCustomerId: customerId, subscriptionId: null, updatedAt: new Date() })
43
+ .onConflictDoUpdate({ target: billingAccount.userId, set: { stripeCustomerId: customerId, updatedAt: new Date() } })
44
+ .run();
45
+ }
46
+
47
+ /** Persist customer + subscription together (the subscribe path sets both). Idempotent on the userId PK. */
48
+ export async function upsertBillingAccount(db: BillingDB, userId: string, customerId: string, subscriptionId: string | null): Promise<void> {
49
+ await db
50
+ .insert(billingAccount)
51
+ .values({ userId, stripeCustomerId: customerId, subscriptionId, updatedAt: new Date() })
52
+ .onConflictDoUpdate({ target: billingAccount.userId, set: { stripeCustomerId: customerId, subscriptionId, updatedAt: new Date() } })
53
+ .run();
54
+ }
55
+
56
+ /** Clear the recorded subscription (on customer.subscription.deleted) — leaves the customer id (+ its saved card) intact. */
57
+ export async function clearSubscription(db: BillingDB, userId: string): Promise<void> {
58
+ await db.update(billingAccount).set({ subscriptionId: null, updatedAt: new Date() }).where(eq(billingAccount.userId, userId)).run();
59
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The agnostic payment bridge (C048) — billing's server-side charge + customer paths now run through @suluk/payments
3
+ * (Stripe today, swappable by construction) instead of hand-rolled Stripe calls. This is the first step of deprecating
4
+ * the direct @suluk/stripe coupling: `createCustomer` + `chargeOffSession` are re-expressed over the unified
5
+ * {@link PaymentConnector}; the browser/Element/hosted-Checkout + subscription flows stay direct for now (they need the
6
+ * client-token surface — the follow-on migration). Parity-tested: the agnostic path emits the same Stripe request and
7
+ * preserves the off-session decline taxonomy.
8
+ */
9
+ import { stripeConnector, PaymentStatus, type PaymentConnector } from "@suluk/payments";
10
+ import type { StripeConfig } from "./transport";
11
+
12
+ /** The payment connector bound to this billing config. Stripe now; changing the backend is a one-line swap here. */
13
+ export function paymentConnector(cfg: StripeConfig): PaymentConnector {
14
+ return stripeConnector({ apiKey: { value: cfg.secretKey } }, { fetch: cfg.fetch });
15
+ }
16
+
17
+ /** Map a unified PaymentStatus back to the Stripe-ish status STRING billing's callers already branch on ("succeeded" /
18
+ * "requires_action" / "processing" / "failed"), so the rewire is drop-in for the existing auto-top-up flow. */
19
+ const STATUS_STRING: Partial<Record<PaymentStatus, string>> = {
20
+ [PaymentStatus.CHARGED]: "succeeded",
21
+ [PaymentStatus.AUTHORIZED]: "requires_capture",
22
+ [PaymentStatus.AUTHENTICATION_PENDING]: "requires_action",
23
+ [PaymentStatus.PENDING]: "processing",
24
+ [PaymentStatus.FAILURE]: "failed",
25
+ };
26
+ export const statusString = (s: PaymentStatus): string => STATUS_STRING[s] ?? "failed";
package/src/billing.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Stripe wrappers (C046, v1) — customer + intent creation and the saved-card surface, over the injected {@link
3
+ * StripeConfig}. Extracted from the source with the `res.ok` + field-presence + throw semantics PRESERVED VERBATIM; the
4
+ * only deliberate change is dropping the Effect-`Schema` defensive response-decode (a robustness layer the app can
5
+ * re-add) in favour of plain JSON access — so this package carries no `effect` dependency. The money-MOVING paths
6
+ * (checkout, charge-off-session), the pricing-woven subscription logic (status/change), the webhook dispatch, the credit
7
+ * grant, email, and the billing-account DB linking are NOT here — they stay in the app (the careful follow-on).
8
+ */
9
+ import { type StripeConfig, stripePost, stripeGet, toForm } from "./transport";
10
+ import { paymentConnector } from "./agnostic";
11
+ import { Currency } from "@suluk/payments";
12
+
13
+ type StripeErr = { error?: { message?: string } };
14
+
15
+ /** Create a Stripe customer for the user (the caller persists the id). Routed through @suluk/payments (C048) — the
16
+ * processor is swappable; the Stripe request (POST /customers with email + metadata[userId]) is unchanged. */
17
+ export async function createCustomer(cfg: StripeConfig, email: string | null, userId: string): Promise<string> {
18
+ const { customerId } = await paymentConnector(cfg).createCustomer!({ email: email ?? undefined, metadata: { userId } });
19
+ return customerId;
20
+ }
21
+
22
+ /** Create a $0 SetupIntent to vault a card without charging ("Add card"). Returns the client secret. */
23
+ export async function createSetupIntent(cfg: StripeConfig, customerId: string, userId: string): Promise<string> {
24
+ // C048 — vault a card ("add card") via @suluk/payments' client-token surface; returns the client secret the browser confirms.
25
+ const session = await paymentConnector(cfg).createSetupSession!({ customerId, metadata: { userId } });
26
+ return session.clientSecret;
27
+ }
28
+
29
+ /** Create a PaymentIntent for an on-site one-time top-up (saves the card; the webhook credits it). Returns the client secret. */
30
+ export async function createPaymentIntent(cfg: StripeConfig, customerId: string, amountCents: number, meta: { userId: string; credits: number; taxCalculation?: string | null }): Promise<string> {
31
+ // C048 — the Payment-Element flow via @suluk/payments' client-token surface: creates the PaymentIntent (no
32
+ // paymentMethod → automatic_payment_methods; saves the card) and returns the client secret the browser confirms.
33
+ const session = await paymentConnector(cfg).createPaymentSession!({
34
+ amount: { minorAmount: amountCents, currency: Currency.USD },
35
+ customerId,
36
+ setupFutureUsage: true,
37
+ metadata: { userId: meta.userId, credits: String(meta.credits), source: "onsite_topup", ...(meta.taxCalculation ? { tax_calculation: meta.taxCalculation } : {}) },
38
+ });
39
+ return session.clientSecret;
40
+ }
41
+
42
+ /** A buyer's tax location (from a saved card's billing address). */
43
+ export interface TaxAddress {
44
+ country: string;
45
+ state: string | null;
46
+ postalCode: string | null;
47
+ city: string | null;
48
+ line1: string | null;
49
+ }
50
+
51
+ /** A payment method as the billing panel shows it — card + its billing address + whether it's the customer's default. */
52
+ export interface PaymentMethodWire {
53
+ id: string;
54
+ brand: string;
55
+ last4: string;
56
+ expMonth: number;
57
+ expYear: number;
58
+ name: string | null;
59
+ line1: string | null;
60
+ line2: string | null;
61
+ city: string | null;
62
+ region: string | null;
63
+ postalCode: string | null;
64
+ country: string | null;
65
+ isDefault: boolean;
66
+ }
67
+
68
+ interface StripePmRaw {
69
+ id: string;
70
+ card?: { brand?: string; last4?: string; exp_month?: number; exp_year?: number };
71
+ billing_details?: { name?: string | null; address?: { line1?: string | null; line2?: string | null; city?: string | null; state?: string | null; postal_code?: string | null; country?: string | null } | null };
72
+ }
73
+
74
+ /** List a customer's saved cards (each with its billing address), marking the invoice default. */
75
+ export async function listPaymentMethods(cfg: StripeConfig, customerId: string): Promise<PaymentMethodWire[]> {
76
+ const [pmRes, custRes] = await Promise.all([
77
+ stripeGet(cfg, `payment_methods?customer=${customerId}&type=card&limit=20`),
78
+ stripeGet(cfg, `customers/${customerId}`),
79
+ ]);
80
+ const list = (await pmRes.json()) as { data?: StripePmRaw[] };
81
+ const cust = (await custRes.json()) as { invoice_settings?: { default_payment_method?: string | null } };
82
+ const defaultId = cust?.invoice_settings?.default_payment_method ?? null;
83
+ return (list.data ?? []).map((pm) => {
84
+ const addr = pm.billing_details?.address ?? null;
85
+ return {
86
+ id: pm.id,
87
+ brand: pm.card?.brand ?? "card",
88
+ last4: pm.card?.last4 ?? "",
89
+ expMonth: pm.card?.exp_month ?? 0,
90
+ expYear: pm.card?.exp_year ?? 0,
91
+ name: pm.billing_details?.name ?? null,
92
+ line1: addr?.line1 ?? null,
93
+ line2: addr?.line2 ?? null,
94
+ city: addr?.city ?? null,
95
+ region: addr?.state ?? null,
96
+ postalCode: addr?.postal_code ?? null,
97
+ country: addr?.country ?? null,
98
+ isDefault: pm.id === defaultId,
99
+ };
100
+ });
101
+ }
102
+
103
+ /** The customer's DEFAULT saved card — its id (to charge) + its billing address (to locate tax). Graceful: a transient
104
+ * Stripe error returns null rather than blocking a top-up (this is only tax LOCATION / an off-session skip). */
105
+ export async function defaultCard(cfg: StripeConfig, customerId: string): Promise<{ pmId: string; address: TaxAddress | null } | null> {
106
+ try {
107
+ const cards = await listPaymentMethods(cfg, customerId);
108
+ const d = cards.find((c) => c.isDefault);
109
+ if (!d) return null;
110
+ const address: TaxAddress | null = d.country ? { country: d.country, state: d.region, postalCode: d.postalCode, city: d.city, line1: d.line1 } : null;
111
+ return { pmId: d.id, address };
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /** The customer's default payment-method id (to charge off-session), or null. */
118
+ export async function defaultPaymentMethodId(cfg: StripeConfig, customerId: string): Promise<string | null> {
119
+ return (await defaultCard(cfg, customerId))?.pmId ?? null;
120
+ }
121
+
122
+ /** Whether `pmId` belongs to `customerId` — guards set-default / detach against another customer's card. */
123
+ export async function ownsPaymentMethod(cfg: StripeConfig, customerId: string, pmId: string): Promise<boolean> {
124
+ return (await listPaymentMethods(cfg, customerId)).some((m) => m.id === pmId);
125
+ }
126
+
127
+ /** Make `pmId` the customer's default payment method for invoices. */
128
+ export async function setDefaultPaymentMethod(cfg: StripeConfig, customerId: string, pmId: string): Promise<void> {
129
+ const res = await stripePost(cfg, `customers/${customerId}`, toForm({ invoice_settings: { default_payment_method: pmId } }));
130
+ if (!res.ok) throw new Error(`Stripe set-default failed (${res.status})`);
131
+ }
132
+
133
+ /** Point an ACTIVE subscription at `pmId` too, so changing the default card moves the recurring charge to it. */
134
+ export async function setSubscriptionDefaultCard(cfg: StripeConfig, subscriptionId: string, pmId: string): Promise<void> {
135
+ const res = await stripePost(cfg, `subscriptions/${subscriptionId}`, toForm({ default_payment_method: pmId }));
136
+ if (!res.ok) throw new Error(`Stripe subscription set-default-card failed (${res.status})`);
137
+ }
138
+
139
+ /** Detach (remove) a saved card from the customer. */
140
+ export async function detachPaymentMethod(cfg: StripeConfig, pmId: string): Promise<void> {
141
+ const res = await stripePost(cfg, `payment_methods/${pmId}/detach`, toForm({}));
142
+ if (!res.ok) throw new Error(`Stripe detach failed (${res.status})`);
143
+ }
144
+
145
+ /** Schedule the subscription to cancel at the period end (`cancel=true`) or resume it (`cancel=false`). */
146
+ export async function setSubscriptionCancel(cfg: StripeConfig, subscriptionId: string, cancel: boolean): Promise<void> {
147
+ const res = await stripePost(cfg, `subscriptions/${subscriptionId}`, toForm({ cancel_at_period_end: cancel }));
148
+ if (!res.ok) {
149
+ const body = (await res.json()) as StripeErr;
150
+ throw new Error(body?.error?.message ?? `Stripe subscription update failed (${res.status})`);
151
+ }
152
+ }
153
+
154
+ /** Best-effort: if the subscription's latest invoice is still OPEN (a failed renewal), retry it NOW. No-op when there's
155
+ * nothing open to pay; never throws (a fix-billing flow must not 500 on the retry). */
156
+ export async function payOpenInvoice(cfg: StripeConfig, subscriptionId: string): Promise<void> {
157
+ try {
158
+ const res = await stripeGet(cfg, `subscriptions/${subscriptionId}?expand[]=latest_invoice`);
159
+ if (!res.ok) return;
160
+ const sub = (await res.json()) as { latest_invoice?: { id?: string; status?: string } };
161
+ const inv = sub?.latest_invoice;
162
+ if (!inv?.id || inv.status !== "open") return;
163
+ await stripePost(cfg, `invoices/${inv.id}/pay`, toForm({}));
164
+ } catch (e) {
165
+ console.warn(`[billing] payOpenInvoice ${subscriptionId} failed:`, e instanceof Error ? e.message : String(e));
166
+ }
167
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @suluk/billing — Stripe plumbing over an injected config (C046). The transport + customer/intent creation + the
3
+ * saved-card surface (v1), plus the money-MOVING paths (hosted Checkout, portal, on-default-card top-up, off-session
4
+ * charge), the pricing-woven subscription logic made generic over a SubPlan catalog, the Stripe Tax mechanics, and the
5
+ * package-owned billing-account store (v2). Ported with the source's `res.ok`/field semantics verbatim; the Effect-Schema
6
+ * defensive decode is dropped (plain typed JSON access → no `effect` dep). STAYS APP (policy, not library): the Stripe
7
+ * WEBHOOK dispatch (composes @suluk/stripe webhookRouter + these primitives + @suluk/credits.grantOnce), the branded
8
+ * email templates, payment-alert kinds, and refund/subscription-pooling (operator-excluded from the start).
9
+ */
10
+ export { type StripeConfig, stripePost, stripeGet, toForm } from "./transport";
11
+ export {
12
+ createCustomer, createSetupIntent, createPaymentIntent,
13
+ listPaymentMethods, defaultCard, defaultPaymentMethodId, ownsPaymentMethod,
14
+ setDefaultPaymentMethod, setSubscriptionDefaultCard, detachPaymentMethod,
15
+ setSubscriptionCancel, payOpenInvoice,
16
+ type PaymentMethodWire, type TaxAddress,
17
+ } from "./billing";
18
+ // v2 — money-moving Stripe primitives (app supplies product name + success/cancel/return URLs).
19
+ export {
20
+ createCheckout, createSubscriptionCheckout, createPortalSessionForCustomer,
21
+ createPaymentIntentOnDefaultCard, chargeOffSession,
22
+ type CheckoutOpts, type SubscriptionCheckoutOpts, type TopupMeta,
23
+ } from "./payments";
24
+ // v2 — Stripe Tax mechanics (graceful: any failure → taxCents 0, the top-up always proceeds).
25
+ export { calculateTax, recordTaxTransaction, type TaxResult, type TaxLocation } from "./tax";
26
+ // v2 — subscription mechanics over a generic SubPlan catalog (the pricing matrix stays in the app).
27
+ export {
28
+ type SubPlan, planById, planByPrice, ceilingFor, ensurePlanPrice, createSubscriptionOnDefaultCard,
29
+ getSubscriptionStatus, changeSubscriptionPlan,
30
+ type SubscriptionBranding, type SubscriptionStatus, type ChangePlanResult,
31
+ } from "./subscriptions";
32
+ // v2 — the package-owned billing-account store (the user ↔ Stripe link; the app injects a Drizzle handle).
33
+ export {
34
+ billingAccount, type BillingDB,
35
+ billingCustomerId, billingSubscriptionId, linkBillingCustomer, upsertBillingAccount, clearSubscription,
36
+ } from "./account";
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The money-MOVING Stripe primitives (C046, v2) — hosted Checkout (one-time + subscription), the billing portal, the
3
+ * on-saved-card top-up PaymentIntent, and the OFF-SESSION auto-top-up charge with its decline/3DS handling. Pure Stripe
4
+ * mechanics over the injected {@link StripeConfig}; the app supplies its product NAME + success/cancel/return URLs (no app
5
+ * routes or branding baked in) and reads the returned secrets. Extracted verbatim with the source's res.ok / field /
6
+ * throw semantics; plain typed JSON access (no Effect-Schema decode). The credit GRANT for these charges happens on the
7
+ * Stripe webhook via @suluk/credits.grantOnce — app POLICY, not here.
8
+ */
9
+ import { type StripeConfig, stripePost, toForm } from "./transport";
10
+ import { defaultPaymentMethodId } from "./billing";
11
+ import { paymentConnector, statusString } from "./agnostic";
12
+ import { PaymentStatus, CaptureMethod, AuthenticationType, Currency } from "@suluk/payments";
13
+ import type { SubPlan } from "./subscriptions";
14
+
15
+ type StripeErr = { error?: { message?: string; code?: string; payment_intent?: { id?: string; status?: string } } };
16
+
17
+ /** The metadata tag the webhook reads to decide whether (and how) to credit a PaymentIntent. */
18
+ export type TopupMeta = { userId: string; credits: number; taxCalculation?: string | null };
19
+
20
+ export interface CheckoutOpts {
21
+ userId: string;
22
+ /** the user's existing customer (reused so a saved card isn't orphaned), or null to let Checkout create one. */
23
+ customerId: string | null;
24
+ amountCents: number;
25
+ credits: number;
26
+ /** the URL Stripe returns to on success (the app composes it from its origin + route). */
27
+ successUrl: string;
28
+ cancelUrl: string;
29
+ /** the line-item product name shown on the hosted page, e.g. "acme — 600 credits". */
30
+ productName: string;
31
+ }
32
+
33
+ /** Create a Stripe Checkout Session (one-time top-up) — the hosted FALLBACK to the on-site Payment Element. Reuses the
34
+ * user's existing customer or has Checkout create one, captures the billing address, and saves the card for future
35
+ * off-session use. Returns the hosted checkout URL. */
36
+ export async function createCheckout(cfg: StripeConfig, o: CheckoutOpts): Promise<string> {
37
+ const form = toForm({
38
+ mode: "payment",
39
+ success_url: o.successUrl,
40
+ cancel_url: o.cancelUrl,
41
+ client_reference_id: o.userId,
42
+ customer: o.customerId ?? undefined,
43
+ customer_creation: o.customerId ? undefined : "always",
44
+ // Capture the billing address WITH the card → Stripe stores it on the PaymentMethod (no separate table; no shipping).
45
+ billing_address_collection: "required",
46
+ // Stripe Tax on the charge; customer_update.address:auto saves the collected address to an EXISTING customer so the tax
47
+ // location resolves. $0 until registered, then auto-collects.
48
+ automatic_tax: { enabled: true },
49
+ customer_update: o.customerId ? { address: "auto" } : undefined,
50
+ line_items: [{ quantity: 1, price_data: { currency: "usd", unit_amount: o.amountCents, product_data: { name: o.productName } } }],
51
+ metadata: { userId: o.userId, credits: o.credits },
52
+ // setup_future_usage saves the card. NO `source` here, so payment_intent.succeeded skips this PI — a Checkout charge
53
+ // is credited via checkout.session.completed, never twice.
54
+ payment_intent_data: { metadata: { userId: o.userId, credits: o.credits }, setup_future_usage: "off_session" },
55
+ });
56
+ const res = await stripePost(cfg, "checkout/sessions", form);
57
+ const session = (await res.json()) as StripeErr & { url?: string };
58
+ if (!res.ok || !session.url) throw new Error(session.error?.message ?? `Stripe checkout failed (${res.status})`);
59
+ return session.url;
60
+ }
61
+
62
+ export interface SubscriptionCheckoutOpts {
63
+ userId: string;
64
+ plan: SubPlan;
65
+ successUrl: string;
66
+ cancelUrl: string;
67
+ /** the line-item product name shown on the hosted page, e.g. "acme — Pro". */
68
+ productName: string;
69
+ }
70
+
71
+ /** Stripe Checkout in SUBSCRIPTION mode (recurring). subscription_data.metadata carries who + how many credits/cycle. */
72
+ export async function createSubscriptionCheckout(cfg: StripeConfig, o: SubscriptionCheckoutOpts): Promise<string> {
73
+ const form = toForm({
74
+ mode: "subscription",
75
+ success_url: o.successUrl,
76
+ cancel_url: o.cancelUrl,
77
+ client_reference_id: o.userId,
78
+ billing_address_collection: "required",
79
+ line_items: [
80
+ { quantity: 1, price_data: { currency: "usd", unit_amount: o.plan.priceCents, recurring: { interval: "month" }, product_data: { name: o.productName } } },
81
+ ],
82
+ subscription_data: { metadata: { userId: o.userId, credits: o.plan.credits, planId: o.plan.id } },
83
+ automatic_tax: { enabled: true },
84
+ metadata: { userId: o.userId },
85
+ });
86
+ const res = await stripePost(cfg, "checkout/sessions", form);
87
+ const session = (await res.json()) as StripeErr & { url?: string };
88
+ if (!res.ok || !session.url) throw new Error(session.error?.message ?? `Stripe subscription checkout failed (${res.status})`);
89
+ return session.url;
90
+ }
91
+
92
+ /** Open the Stripe billing portal (manage/cancel) for an existing customer. Returns the URL; throws on a Stripe error. */
93
+ export async function createPortalSessionForCustomer(cfg: StripeConfig, customerId: string, returnUrl: string): Promise<string> {
94
+ const res = await stripePost(cfg, "billing_portal/sessions", toForm({ customer: customerId, return_url: returnUrl }));
95
+ const session = (await res.json()) as StripeErr & { url?: string };
96
+ if (!res.ok || !session.url) throw new Error(`Stripe billing portal failed (${res.status})`);
97
+ return session.url;
98
+ }
99
+
100
+ /** Create a PaymentIntent on the customer's SAVED DEFAULT card — the one-click top-up path. The server resolves the
101
+ * default payment method (client can't inject one), pins the PI to it, and returns the client secret; the browser
102
+ * confirms (3DS in-page if needed). Returns null when there's no default card to charge. No setup_future_usage — the
103
+ * card is already saved. */
104
+ export async function createPaymentIntentOnDefaultCard(cfg: StripeConfig, customerId: string, amountCents: number, meta: TopupMeta): Promise<string | null> {
105
+ // C048 — the one-click flow via @suluk/payments' client-token surface: resolve the default card (client can't inject
106
+ // one), pin the session to it, return the client secret the browser confirms. Null when there's no default card.
107
+ const pmId = await defaultPaymentMethodId(cfg, customerId);
108
+ if (!pmId) return null;
109
+ const session = await paymentConnector(cfg).createPaymentSession!({
110
+ amount: { minorAmount: amountCents, currency: Currency.USD },
111
+ customerId,
112
+ paymentMethod: { value: pmId },
113
+ metadata: { userId: meta.userId, credits: String(meta.credits), source: "onsite_topup", ...(meta.taxCalculation ? { tax_calculation: meta.taxCalculation } : {}) },
114
+ });
115
+ return session.clientSecret;
116
+ }
117
+
118
+ /** An OFF-SESSION charge on a saved card (auto-top-up). Confirms immediately; metadata carries who + credits + `source`
119
+ * so the payment_intent.succeeded webhook credits idempotently on the SAME `pi:<id>` key. Returns the PaymentIntent id +
120
+ * status, plus `authRequired` when the card needs 3DS (a decline to NOTIFY, not throw on). A hard 402 card decline is
121
+ * also returned (not thrown) so the caller can alert; a transient/transport failure throws (it may recover). */
122
+ export async function chargeOffSession(
123
+ cfg: StripeConfig,
124
+ customerId: string,
125
+ pmId: string,
126
+ amountCents: number,
127
+ meta: TopupMeta,
128
+ ): Promise<{ id: string | null; status: string | null; authRequired: boolean }> {
129
+ // C048 — routed through @suluk/payments (Stripe today, swappable). The connector emits the SAME off-session PI request
130
+ // (amount/currency/customer/payment_method/off_session/confirm + the app metadata) and returns the unified status; we
131
+ // map it back to billing's `{ id, status, authRequired }` shape, preserving the decline taxonomy: a 3DS decline
132
+ // (AUTHENTICATION_PENDING) sets `authRequired` (email the user), a hard card decline (FAILURE) is RETURNED not thrown
133
+ // (raise the alert), and a transport/unexpected error still THROWS (from the connector). A fresh idempotency key per
134
+ // call (unique merchantTransactionId) preserves the original's "every call is a new charge" behaviour.
135
+ const res = await paymentConnector(cfg).authorize({
136
+ merchantTransactionId: crypto.randomUUID(),
137
+ amount: { minorAmount: amountCents, currency: Currency.USD },
138
+ captureMethod: CaptureMethod.AUTOMATIC,
139
+ paymentMethod: { token: { value: pmId } },
140
+ authType: AuthenticationType.NO_THREE_DS,
141
+ customerId,
142
+ offSession: true,
143
+ metadata: { userId: meta.userId, credits: String(meta.credits), source: "auto_topup", ...(meta.taxCalculation ? { tax_calculation: meta.taxCalculation } : {}) },
144
+ });
145
+ const authRequired = res.status === PaymentStatus.AUTHENTICATION_PENDING;
146
+ return { id: res.connectorTransactionId ?? null, status: statusString(res.status), authRequired };
147
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Subscription mechanics (C046, v2) — the recurring-billing logic, made generic over a {@link SubPlan} shape so the
3
+ * pricing MATRIX stays in the app (it owns COGS/markup/tier discounts) while the Stripe wiring lives here. The app passes
4
+ * its plan catalog (`SubPlan[]`) + a branding seam; the package owns find-or-create pricing, the create-on-default-card
5
+ * one-click path, live status, and the in-place plan CHANGE with its paid-CEILING semantics. Extracted verbatim with the
6
+ * source's res.ok / field semantics; plain typed JSON access (no Effect-Schema decode). The webhook DISPATCH that grants
7
+ * the cycle's credits on `invoice.paid` is app POLICY (it composes @suluk/credits.grantOnce + these primitives).
8
+ */
9
+ import { type StripeConfig, stripePost, stripeGet, toForm } from "./transport";
10
+ import { defaultPaymentMethodId } from "./billing";
11
+
12
+ /** A subscription plan as the app prices it — generic shape; the app derives `priceCents`/`credits` from its COGS model. */
13
+ export interface SubPlan {
14
+ id: string;
15
+ name: string;
16
+ credits: number;
17
+ priceCents: number;
18
+ label: string;
19
+ }
20
+
21
+ /** The app's plan whose id is `id`, or undefined — the generic lookup the orchestrators use against the injected catalog. */
22
+ export const planById = (plans: SubPlan[], id: string): SubPlan | undefined => plans.find((p) => p.id === id);
23
+
24
+ /** The plan whose monthly price is exactly `priceCents` (each tier has a distinct price), or undefined — maps a live
25
+ * Stripe item price back to a plan (e.g. resolving the paid-ceiling plan). */
26
+ export const planByPrice = (plans: SubPlan[], priceCents: number): SubPlan | undefined => plans.find((p) => p.priceCents === priceCents);
27
+
28
+ /** Branding seam for the Stripe Product/Price a plan creates — app-controlled so find-or-create stays stable + on-brand. */
29
+ export interface SubscriptionBranding {
30
+ /** the recurring Price's product name; default `${plan.name} (monthly)`. */
31
+ productName?: (plan: SubPlan) => string;
32
+ /** the lookup_key PREFIX that makes find-or-create idempotent across repricing; default "sub". KEEP STABLE per app. */
33
+ lookupKeyPrefix?: string;
34
+ }
35
+
36
+ type StripeErr = { error?: { message?: string } };
37
+ interface SubItem {
38
+ id?: string;
39
+ current_period_end?: number;
40
+ price?: { unit_amount?: number };
41
+ }
42
+ interface SubObject {
43
+ id?: string;
44
+ status?: string;
45
+ items?: { data?: SubItem[] };
46
+ current_period_end?: number;
47
+ cancel_at_period_end?: boolean;
48
+ metadata?: Record<string, string>;
49
+ latest_invoice?: { status?: string; confirmation_secret?: { client_secret?: string }; payment_intent?: { client_secret?: string } };
50
+ error?: { message?: string };
51
+ }
52
+
53
+ /** Find (by lookup_key) or create the recurring Stripe Price for a plan. The lookup_key embeds the price + credits, so a
54
+ * repricing mints a FRESH price rather than reusing a stale one. Returns the price id. */
55
+ export async function ensurePlanPrice(cfg: StripeConfig, plan: SubPlan, branding?: SubscriptionBranding): Promise<string> {
56
+ const prefix = branding?.lookupKeyPrefix ?? "sub";
57
+ const productName = branding?.productName ?? ((p: SubPlan) => `${p.name} (monthly)`);
58
+ const lookupKey = `${prefix}_${plan.id}_${plan.priceCents}_${plan.credits}_m`;
59
+ const found = (await (await stripeGet(cfg, `prices?lookup_keys[0]=${lookupKey}&active=true&limit=1`)).json()) as { data?: { id?: string }[] };
60
+ if (found?.data?.[0]?.id) return found.data[0].id;
61
+ const prodRes = await stripePost(cfg, "products", toForm({ name: productName(plan) }));
62
+ const product = (await prodRes.json()) as StripeErr & { id?: string };
63
+ if (!prodRes.ok || !product.id) throw new Error(product.error?.message ?? `Stripe product create failed (${prodRes.status})`);
64
+ const priceRes = await stripePost(
65
+ cfg,
66
+ "prices",
67
+ toForm({ product: product.id, currency: "usd", unit_amount: plan.priceCents, recurring: { interval: "month" }, lookup_key: lookupKey }),
68
+ );
69
+ const price = (await priceRes.json()) as StripeErr & { id?: string };
70
+ if (!priceRes.ok || !price.id) throw new Error(price.error?.message ?? `Stripe price create failed (${priceRes.status})`);
71
+ return price.id;
72
+ }
73
+
74
+ /** Create a subscription ON the saved default card (one-click). payment_behavior=default_incomplete leaves the first
75
+ * invoice unpaid with a PaymentIntent the browser confirms (confirmCardPayment → 3DS in-page) → the subscription
76
+ * activates → invoice.paid grants the cycle's credits. Returns the first invoice's client secret + the subscription id,
77
+ * or null when there's no default card. */
78
+ export async function createSubscriptionOnDefaultCard(
79
+ cfg: StripeConfig,
80
+ customerId: string,
81
+ plan: SubPlan,
82
+ userId: string,
83
+ branding?: SubscriptionBranding,
84
+ ): Promise<{ clientSecret: string; subscriptionId: string } | null> {
85
+ const pmId = await defaultPaymentMethodId(cfg, customerId);
86
+ if (!pmId) return null;
87
+ const price = await ensurePlanPrice(cfg, plan, branding);
88
+ const res = await stripePost(
89
+ cfg,
90
+ "subscriptions",
91
+ toForm({
92
+ customer: customerId,
93
+ items: [{ price }],
94
+ default_payment_method: pmId,
95
+ payment_behavior: "default_incomplete",
96
+ // Stripe Tax computes + adds tax to each invoice automatically (from the saved card's billing address). $0 until the
97
+ // account has a head-office address + active tax registrations, then starts collecting with no code change.
98
+ automatic_tax: { enabled: true },
99
+ // `confirmation_secret` is the current field (Stripe ≥ 2025-03-31 removed `invoice.payment_intent`); `payment_intent`
100
+ // is still read below as a fallback for accounts pinned to an older API version.
101
+ expand: ["latest_invoice.confirmation_secret"],
102
+ metadata: { userId, credits: plan.credits, planId: plan.id },
103
+ }),
104
+ );
105
+ const sub = (await res.json()) as SubObject;
106
+ if (!res.ok || sub.error || !sub.id) throw new Error(sub.error?.message ?? `Stripe subscription create failed (${res.status})`);
107
+ const clientSecret = sub.latest_invoice?.confirmation_secret?.client_secret ?? sub.latest_invoice?.payment_intent?.client_secret;
108
+ if (!clientSecret) throw new Error("Stripe subscription created without a first-invoice client secret");
109
+ return { clientSecret, subscriptionId: sub.id };
110
+ }
111
+
112
+ /** The "paid ceiling" for the CURRENT cycle = the highest plan price already CHARGED this cycle. Persisted in subscription
113
+ * metadata (raised on each above-ceiling upgrade), guarded by the period end so it auto-resets at the next renewal. Falls
114
+ * back to the current item price — a defer-change lowers the item price (for next cycle's billing) but NOT the ceiling, so
115
+ * a later change to ANY plan ≤ the ceiling stays free (you already paid for that level); only EXCEEDING it charges. PURE. */
116
+ export const ceilingFor = (metadata: Record<string, string> | undefined, periodEndSec: number, currentPriceCents: number): number => {
117
+ const stored = Number(metadata?.cycleCeilingCents ?? 0);
118
+ const storedEnd = Number(metadata?.cycleCeilingEnd ?? 0);
119
+ return stored > 0 && storedEnd === periodEndSec ? Math.max(stored, currentPriceCents) : currentPriceCents;
120
+ };
121
+
122
+ /** The user's CURRENT subscription as the UI needs it (plan + status + period end + pending-cancel + the cycle's paid
123
+ * ceiling), or null when there's no live subscription. Live state from Stripe; `plans` is the app catalog for the
124
+ * price fallback when the live item lacks one. */
125
+ export interface SubscriptionStatus {
126
+ planId: string | null;
127
+ status: string;
128
+ currentPeriodEnd: number; // ms epoch
129
+ cancelAtPeriodEnd: boolean;
130
+ paidCeilingCents: number; // highest plan price charged this cycle — a change ≤ this defers to next cycle (no charge)
131
+ }
132
+ export async function getSubscriptionStatus(cfg: StripeConfig, subscriptionId: string, plans: SubPlan[]): Promise<SubscriptionStatus | null> {
133
+ const res = await stripeGet(cfg, `subscriptions/${subscriptionId}`);
134
+ if (!res.ok) return null;
135
+ const sub = (await res.json()) as SubObject;
136
+ if (!sub?.status || !["active", "trialing", "past_due"].includes(sub.status)) return null; // not a live plan
137
+ const item = sub.items?.data?.[0];
138
+ const periodEnd = sub.current_period_end ?? item?.current_period_end ?? 0;
139
+ const currentPriceCents = item?.price?.unit_amount ?? planById(plans, sub.metadata?.planId ?? "")?.priceCents ?? 0;
140
+ return {
141
+ planId: sub.metadata?.planId ?? null,
142
+ status: sub.status,
143
+ currentPeriodEnd: periodEnd * 1000,
144
+ cancelAtPeriodEnd: sub.cancel_at_period_end ?? false,
145
+ paidCeilingCents: ceilingFor(sub.metadata, periodEnd, currentPriceCents),
146
+ };
147
+ }
148
+
149
+ /** Change the subscriber's plan IN PLACE against the cycle's PAID CEILING (see {@link ceilingFor}). ABOVE the ceiling = an
150
+ * upgrade: immediate + prorated for the difference ABOVE THE CEILING, charged off-session; the matching prorated credits
151
+ * land on that invoice.paid (3DS-safe). AT OR BELOW the ceiling = a deferred change: no charge + no new credits now — it
152
+ * re-prices for the NEXT renewal (so up→down→up within a cycle never re-charges). Returns the kind, the period end, and a
153
+ * clientSecret ONLY when the upgrade's prorated charge needs in-page 3DS. `plans` is the app catalog. */
154
+ export interface ChangePlanResult {
155
+ kind: "upgrade" | "downgrade";
156
+ clientSecret: string | null;
157
+ currentPeriodEnd: number; // ms epoch
158
+ }
159
+ export async function changeSubscriptionPlan(
160
+ cfg: StripeConfig,
161
+ subscriptionId: string,
162
+ newPlan: SubPlan,
163
+ userId: string,
164
+ plans: SubPlan[],
165
+ branding?: SubscriptionBranding,
166
+ ): Promise<ChangePlanResult> {
167
+ const getRes = await stripeGet(cfg, `subscriptions/${subscriptionId}`);
168
+ const current = (await getRes.json()) as SubObject;
169
+ const item = current?.items?.data?.[0];
170
+ if (!getRes.ok || !item?.id) throw new Error(current?.error?.message ?? `Stripe subscription retrieve failed (${getRes.status})`);
171
+ const currentPriceCents = item.price?.unit_amount ?? planById(plans, current?.metadata?.planId ?? "")?.priceCents ?? 0;
172
+ if (currentPriceCents <= 0) throw new Error("Could not determine the current plan price"); // don't guess "upgrade" + charge
173
+ const periodEndSec = current?.current_period_end ?? item.current_period_end ?? 0;
174
+ const periodEnd = periodEndSec * 1000;
175
+ const wasScheduledToCancel = current?.cancel_at_period_end === true; // switching plans on a canceling sub means "keep it"
176
+
177
+ // Decide against the PAID CEILING, not the current price: a prior defer-downgrade lowers the item price but not the
178
+ // ceiling, so going back UP to a plan you already paid for this cycle must NOT re-charge.
179
+ const ceilingCents = ceilingFor(current?.metadata, periodEndSec, currentPriceCents);
180
+ const isUpgrade = newPlan.priceCents > ceilingCents;
181
+ const newCeiling = Math.max(ceilingCents, newPlan.priceCents); // an upgrade raises it; a deferred change preserves it
182
+ const newPriceId = await ensurePlanPrice(cfg, newPlan, branding);
183
+
184
+ // EXCEEDING the ceiling after a prior defer-downgrade left the item BELOW it: first bump the item up to the ceiling price
185
+ // with NO proration, so Stripe measures the upgrade proration from the CEILING (newPlan − ceiling), not from the lower
186
+ // current price. No-op when current == ceiling.
187
+ if (isUpgrade && currentPriceCents < ceilingCents) {
188
+ const ceilingPlan = planByPrice(plans, ceilingCents);
189
+ if (ceilingPlan) {
190
+ const ceilingPriceId = await ensurePlanPrice(cfg, ceilingPlan, branding);
191
+ const bump = await stripePost(
192
+ cfg,
193
+ `subscriptions/${subscriptionId}`,
194
+ toForm({ items: [{ id: item.id, price: ceilingPriceId }], proration_behavior: "none" }),
195
+ );
196
+ if (!bump.ok) console.warn(`[billing] couldn't bump sub ${subscriptionId} to the ceiling before upgrade (http ${bump.status})`);
197
+ }
198
+ }
199
+
200
+ const res = await stripePost(
201
+ cfg,
202
+ `subscriptions/${subscriptionId}`,
203
+ toForm({
204
+ items: [{ id: item.id, price: newPriceId }], // re-price the SAME item (don't add a second line)
205
+ proration_behavior: isUpgrade ? "always_invoice" : "none", // above ceiling bills the diff now; ≤ ceiling waits for renewal
206
+ // pending_if_incomplete holds the UPGRADE as a pending change until its prorated charge is actually paid — so an
207
+ // abandoned/declined 3DS never moves the user to the higher plan without collecting. (Deferred changes don't charge.)
208
+ payment_behavior: isUpgrade ? "pending_if_incomplete" : undefined,
209
+ // (re)assert Stripe Tax only on a DEFERRED change. NOT on an upgrade: pending_if_incomplete REJECTS automatic_tax as
210
+ // an unsupported param (a hard 400); a sub created after the Tax wiring already has it enabled from subscribe.
211
+ automatic_tax: isUpgrade ? undefined : { enabled: true },
212
+ // cycleCeiling{Cents,End}: the paid ceiling for this cycle — an upgrade raises it to newPlan, a deferred change keeps
213
+ // it (so a later re-change ≤ it stays free); the End stamp lets it auto-reset at the next renewal.
214
+ metadata: { userId, planId: newPlan.id, credits: newPlan.credits, cycleCeilingCents: newCeiling, cycleCeilingEnd: periodEndSec },
215
+ expand: ["latest_invoice.confirmation_secret"],
216
+ }),
217
+ );
218
+ const sub = (await res.json()) as SubObject;
219
+ if (!res.ok || sub.error || !sub.id) throw new Error(sub.error?.message ?? `Stripe plan change failed (${res.status})`);
220
+
221
+ // A plan change on a subscription that was scheduled to cancel means the user wants to KEEP it → un-schedule the
222
+ // cancellation. A SEPARATE update because cancel_at_period_end is NOT a supported pending-update param (it would 400 the
223
+ // upgrade re-price). Best-effort: the plan change already succeeded, so a resume failure is logged, never thrown.
224
+ if (wasScheduledToCancel) {
225
+ const resumeRes = await stripePost(cfg, `subscriptions/${subscriptionId}`, toForm({ cancel_at_period_end: false }));
226
+ if (!resumeRes.ok) console.warn(`[billing] plan change couldn't resume cancel-scheduled sub ${subscriptionId} (http ${resumeRes.status})`);
227
+ }
228
+
229
+ const inv = sub.latest_invoice;
230
+ const needsAction = isUpgrade && inv?.status !== "paid"; // off-session success → status "paid" → no in-page step
231
+ const clientSecret = needsAction ? (inv?.confirmation_secret?.client_secret ?? inv?.payment_intent?.client_secret ?? null) : null;
232
+ return { kind: isUpgrade ? "upgrade" : "downgrade", clientSecret, currentPeriodEnd: periodEnd };
233
+ }
package/src/tax.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Stripe Tax mechanics (C046, v2) — sales-tax / VAT for an on-site or auto top-up via the Tax Calculation API (raw
3
+ * PaymentIntents don't support `automatic_tax`, unlike subscriptions/checkout), plus recording the finished calculation
4
+ * as a Tax Transaction for compliance. GRACEFUL by design: no location or ANY failure yields `{ taxCents: 0 }` so a
5
+ * top-up ALWAYS proceeds (covering the period before Stripe Tax is set up + any unregistered jurisdiction at $0).
6
+ * Extracted verbatim with the source's res.ok / field semantics; plain typed JSON access (no Effect-Schema decode).
7
+ */
8
+ import { type StripeConfig, stripePost, toForm } from "./transport";
9
+ import type { TaxAddress } from "./billing";
10
+
11
+ export interface TaxResult {
12
+ taxCents: number;
13
+ calculationId: string | null;
14
+ }
15
+
16
+ /** A buyer's tax location. The saved card's BILLING ADDRESS is preferred (precise + works off-session); the request IP is
17
+ * the fallback for a first on-session purchase where no card is saved yet. */
18
+ export interface TaxLocation {
19
+ address?: TaxAddress | null;
20
+ ip?: string | null;
21
+ }
22
+
23
+ /** Build Stripe `customer_details` for the Calculation API from the best available location, or null when none. */
24
+ const taxCustomerDetails = (loc: TaxLocation): Record<string, unknown> | null => {
25
+ if (loc.address?.country)
26
+ return {
27
+ address: {
28
+ country: loc.address.country,
29
+ state: loc.address.state ?? undefined,
30
+ postal_code: loc.address.postalCode ?? undefined,
31
+ city: loc.address.city ?? undefined,
32
+ line1: loc.address.line1 ?? undefined,
33
+ },
34
+ address_source: "billing",
35
+ };
36
+ if (loc.ip) return { ip_address: loc.ip };
37
+ return null;
38
+ };
39
+
40
+ /**
41
+ * Sales-tax / VAT for an on-site or auto top-up. The taxable base is the credits `subtotalCents` (tax_behavior=exclusive →
42
+ * tax added on top); the processing service fee is a pass-through, not part of the taxable sale. Located by the saved
43
+ * card's billing address (preferred — works off-session) or the request IP. GRACEFUL — no location or any failure yields
44
+ * `{ taxCents: 0 }` so a top-up always proceeds. When active, the returned `calculationId` is recorded via
45
+ * {@link recordTaxTransaction}.
46
+ */
47
+ export async function calculateTax(cfg: StripeConfig, customerId: string, subtotalCents: number, loc: TaxLocation): Promise<TaxResult> {
48
+ const customer_details = taxCustomerDetails(loc);
49
+ if (!customer_details) return { taxCents: 0, calculationId: null }; // no location → can't compute; skip
50
+ try {
51
+ const res = await stripePost(
52
+ cfg,
53
+ "tax/calculations",
54
+ toForm({
55
+ currency: "usd",
56
+ customer: customerId,
57
+ line_items: [{ amount: subtotalCents, reference: "credits", tax_behavior: "exclusive" }],
58
+ customer_details,
59
+ }),
60
+ );
61
+ if (!res.ok) return { taxCents: 0, calculationId: null }; // inactive / unlocatable → no tax, top-up still proceeds
62
+ const calc = (await res.json()) as { tax_amount_exclusive?: number; id?: string };
63
+ return { taxCents: calc?.tax_amount_exclusive ?? 0, calculationId: calc?.id ?? null };
64
+ } catch {
65
+ return { taxCents: 0, calculationId: null };
66
+ }
67
+ }
68
+
69
+ /** Record a finished tax calculation as a Tax Transaction (the compliance/reporting step), keyed to a `reference`
70
+ * (e.g. `pi:<id>`) which is Stripe's idempotency anchor — a replay of the same reference returns the existing
71
+ * transaction rather than creating a second, so re-delivery can't double-record. Best-effort — never throws into the
72
+ * caller (the charge already succeeded; a missed record is reconciled manually). */
73
+ export async function recordTaxTransaction(cfg: StripeConfig, calculationId: string, reference: string): Promise<void> {
74
+ try {
75
+ const res = await stripePost(cfg, "tax/transactions/create_from_calculation", toForm({ calculation: calculationId, reference }));
76
+ if (!res.ok) console.warn(`[stripe-tax] tax transaction NOT recorded for ${reference} (calc ${calculationId}, http ${res.status}) — reconcile manually`);
77
+ } catch (e) {
78
+ console.warn(`[stripe-tax] tax transaction record threw for ${reference} (calc ${calculationId})`, e);
79
+ }
80
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The Stripe HTTP transport (C046 → C048) — the seam every billing wrapper rides. Now re-exported from @suluk/payments so
3
+ * ALL of billing's Stripe HTTP (the agnostic payment flows AND the Stripe-platform ops: checkout, subscriptions, saved
4
+ * cards, tax) rides ONE Stripe client. The legacy @suluk/stripe coupling is gone — there's no separate path to reach for
5
+ * by accident. Config-injected: the secret key + a mockable `fetch`.
6
+ */
7
+ export { type StripeConfig, stripePost, stripeGet, toForm } from "@suluk/payments";
@@ -0,0 +1,47 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { paymentConnector, statusString } from "../src/agnostic";
3
+ import { createCustomer } from "../src/index";
4
+ import { PaymentStatus } from "@suluk/payments";
5
+ import type { StripeConfig } from "../src/transport";
6
+
7
+ /**
8
+ * C048 — billing's server-side charge + customer paths now run through @suluk/payments. The existing billing-v2 suite is
9
+ * the behavioural parity net (same tests, new innards, still green); this pins the new bridge directly: the connector is
10
+ * a real Stripe connector, and the unified→billing status mapping is exact.
11
+ */
12
+ interface Call { path: string; body: string }
13
+ function mockStripe(routes: Record<string, unknown>): { cfg: StripeConfig; calls: Call[] } {
14
+ const calls: Call[] = [];
15
+ const fetchMock = (async (url: string | URL, init?: RequestInit) => {
16
+ const path = String(url).replace("https://api.stripe.com/v1/", "");
17
+ calls.push({ path, body: (init?.body as string) ?? "" });
18
+ const key = Object.keys(routes).find((k) => path.startsWith(k));
19
+ return new Response(JSON.stringify(key ? routes[key] : {}), { status: 200 });
20
+ }) as unknown as typeof fetch;
21
+ return { cfg: { secretKey: "sk_test", fetch: fetchMock }, calls };
22
+ }
23
+
24
+ describe("the agnostic bridge", () => {
25
+ test("paymentConnector(cfg) is a Stripe-backed connector bound to the billing config", async () => {
26
+ const { cfg, calls } = mockStripe({ customers: { id: "cus_9" } });
27
+ const c = paymentConnector(cfg);
28
+ expect(c.name).toBe("stripe");
29
+ expect(await c.createCustomer!({ email: "x@y.com" })).toEqual({ customerId: "cus_9" });
30
+ expect(calls[0].path).toBe("customers");
31
+ });
32
+
33
+ test("createCustomer routes through @suluk/payments (same POST /customers request)", async () => {
34
+ const { cfg, calls } = mockStripe({ customers: { id: "cus_1" } });
35
+ expect(await createCustomer(cfg, "a@b.com", "user_1")).toBe("cus_1");
36
+ expect(calls[0].body).toContain("email=a%40b.com");
37
+ expect(calls[0].body).toContain("metadata%5BuserId%5D=user_1");
38
+ });
39
+
40
+ test("statusString maps the unified status back to billing's Stripe-ish strings", () => {
41
+ expect(statusString(PaymentStatus.CHARGED)).toBe("succeeded");
42
+ expect(statusString(PaymentStatus.AUTHENTICATION_PENDING)).toBe("requires_action");
43
+ expect(statusString(PaymentStatus.PENDING)).toBe("processing");
44
+ expect(statusString(PaymentStatus.FAILURE)).toBe("failed");
45
+ expect(statusString(PaymentStatus.AUTHORIZATION_FAILED)).toBe("failed"); // any other → failed
46
+ });
47
+ });
@@ -0,0 +1,49 @@
1
+ import { test, expect, describe, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { drizzle } from "drizzle-orm/bun-sqlite";
4
+ import {
5
+ billingCustomerId, billingSubscriptionId, linkBillingCustomer, upsertBillingAccount, clearSubscription, type BillingDB,
6
+ } from "../src/index";
7
+
8
+ /** C046 v2 — the billing-account store, witnessed against a REAL bun:sqlite. The package owns the schema; we apply it
9
+ * here and bridge bun:sqlite → BillingDB (a runtime-identity narrow, as the source app does in tests). */
10
+ function freshDb(): BillingDB {
11
+ const sqlite = new Database(":memory:");
12
+ sqlite.run(`CREATE TABLE billing_account (userId TEXT PRIMARY KEY, stripeCustomerId TEXT, subscriptionId TEXT, updatedAt INTEGER NOT NULL)`);
13
+ return drizzle(sqlite) as unknown as BillingDB;
14
+ }
15
+
16
+ let db: BillingDB;
17
+ const U = "user_1";
18
+ beforeEach(() => {
19
+ db = freshDb();
20
+ });
21
+
22
+ describe("the billing-account store", () => {
23
+ test("empty by default", async () => {
24
+ expect(await billingCustomerId(db, U)).toBeNull();
25
+ expect(await billingSubscriptionId(db, U)).toBeNull();
26
+ });
27
+
28
+ test("linkBillingCustomer sets the customer id (idempotent on userId)", async () => {
29
+ await linkBillingCustomer(db, U, "cus_1");
30
+ expect(await billingCustomerId(db, U)).toBe("cus_1");
31
+ await linkBillingCustomer(db, U, "cus_2"); // re-link → updates, doesn't duplicate
32
+ expect(await billingCustomerId(db, U)).toBe("cus_2");
33
+ });
34
+
35
+ test("a top-up link NEVER clears a subscriber's subscriptionId", async () => {
36
+ await upsertBillingAccount(db, U, "cus_1", "sub_1"); // subscribe sets both
37
+ expect(await billingSubscriptionId(db, U)).toBe("sub_1");
38
+ await linkBillingCustomer(db, U, "cus_1"); // a later one-time top-up
39
+ expect(await billingSubscriptionId(db, U)).toBe("sub_1"); // still subscribed — the load-bearing guard
40
+ expect(await billingCustomerId(db, U)).toBe("cus_1");
41
+ });
42
+
43
+ test("upsertBillingAccount sets both; clearSubscription drops only the subscription", async () => {
44
+ await upsertBillingAccount(db, U, "cus_1", "sub_1");
45
+ await clearSubscription(db, U); // customer.subscription.deleted
46
+ expect(await billingSubscriptionId(db, U)).toBeNull();
47
+ expect(await billingCustomerId(db, U)).toBe("cus_1"); // the saved card stays reachable
48
+ });
49
+ });
@@ -0,0 +1,193 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import {
3
+ createCheckout, createSubscriptionCheckout, createPortalSessionForCustomer,
4
+ createPaymentIntentOnDefaultCard, chargeOffSession,
5
+ ceilingFor, planById, planByPrice, ensurePlanPrice, getSubscriptionStatus, changeSubscriptionPlan,
6
+ createSubscriptionOnDefaultCard, type StripeConfig, type SubPlan,
7
+ } from "../src/index";
8
+
9
+ /** C046 v2 — the money-moving + subscription wrappers, witnessed with a MOCK fetch (the injected transport seam): we
10
+ * assert the right Stripe request (path + method + form body) and the response handling. No live Stripe, no effect dep. */
11
+ interface Call { path: string; method: string; body: string; headers: Record<string, string> }
12
+ type Route = { status?: number; body: unknown };
13
+ function mockStripe(routes: Record<string, Route>): { cfg: StripeConfig; calls: Call[] } {
14
+ const calls: Call[] = [];
15
+ const fetchMock = (async (url: string | URL, init?: RequestInit) => {
16
+ const path = String(url).replace("https://api.stripe.com/v1/", "");
17
+ const method = (init?.method ?? "GET").toUpperCase();
18
+ calls.push({ path, method, body: (init?.body as string) ?? "", headers: (init?.headers as Record<string, string>) ?? {} });
19
+ const key = Object.keys(routes).find((k) => `${method} ${path}`.startsWith(k));
20
+ const r = key ? routes[key] : { body: {} };
21
+ return new Response(JSON.stringify(r.body), { status: r.status ?? 200 });
22
+ }) as unknown as typeof fetch;
23
+ return { cfg: { secretKey: "sk_test", fetch: fetchMock }, calls };
24
+ }
25
+
26
+ const STARTER: SubPlan = { id: "starter", name: "Starter", credits: 200, priceCents: 1000, label: "200 credits / month" };
27
+ const PRO: SubPlan = { id: "pro", name: "Pro", credits: 700, priceCents: 3000, label: "700 credits / month" };
28
+ const PLANS = [STARTER, PRO];
29
+ const TF = { lookupKeyPrefix: "tf", productName: (p: SubPlan) => `acme — ${p.name} (monthly)` };
30
+
31
+ /** A customer whose default card is `pm_1`, so the default-card paths resolve a PM. */
32
+ const withDefaultCard = (routes: Record<string, Route>) =>
33
+ mockStripe({
34
+ "GET payment_methods": { body: { data: [{ id: "pm_1", card: { brand: "visa", last4: "4242" }, billing_details: { address: { country: "US" } } }] } },
35
+ "GET customers/cus_1": { body: { invoice_settings: { default_payment_method: "pm_1" } } },
36
+ ...routes,
37
+ });
38
+
39
+ describe("money-moving: checkout + portal", () => {
40
+ test("createCheckout posts mode=payment with the product name, metadata, saved card; returns the hosted url", async () => {
41
+ const { cfg, calls } = mockStripe({ "POST checkout/sessions": { body: { url: "https://checkout.stripe.com/c/x" } } });
42
+ const url = await createCheckout(cfg, { userId: "u1", customerId: "cus_1", amountCents: 2000, credits: 600, successUrl: "https://app/ok", cancelUrl: "https://app/no", productName: "acme — 600 credits" });
43
+ expect(url).toBe("https://checkout.stripe.com/c/x");
44
+ const body = calls[0].body;
45
+ expect(body).toContain("mode=payment");
46
+ expect(body).toContain("product_data%5D%5Bname%5D=acme"); // ...[product_data][name]=acme...
47
+ expect(body).toContain("setup_future_usage%5D=off_session");
48
+ expect(body).toContain("metadata%5Bcredits%5D=600");
49
+ });
50
+
51
+ test("createSubscriptionCheckout posts mode=subscription with the recurring price + plan metadata", async () => {
52
+ const { cfg, calls } = mockStripe({ "POST checkout/sessions": { body: { url: "https://checkout.stripe.com/c/sub" } } });
53
+ const url = await createSubscriptionCheckout(cfg, { userId: "u1", plan: PRO, successUrl: "https://app/ok", cancelUrl: "https://app/no", productName: "acme — Pro" });
54
+ expect(url).toBe("https://checkout.stripe.com/c/sub");
55
+ expect(calls[0].body).toContain("mode=subscription");
56
+ expect(calls[0].body).toContain("recurring%5D%5Binterval%5D=month");
57
+ expect(calls[0].body).toContain("subscription_data%5Bmetadata%5D%5BplanId%5D=pro");
58
+ });
59
+
60
+ test("createCheckout throws the Stripe error message on failure", async () => {
61
+ const { cfg } = mockStripe({ "POST checkout/sessions": { status: 400, body: { error: { message: "amount too small" } } } });
62
+ await expect(createCheckout(cfg, { userId: "u1", customerId: null, amountCents: 1, credits: 0, successUrl: "s", cancelUrl: "c", productName: "x" })).rejects.toThrow("amount too small");
63
+ });
64
+
65
+ test("createPortalSessionForCustomer returns the portal url with the return_url", async () => {
66
+ const { cfg, calls } = mockStripe({ "POST billing_portal/sessions": { body: { url: "https://billing.stripe.com/p/x" } } });
67
+ expect(await createPortalSessionForCustomer(cfg, "cus_1", "https://app/account")).toBe("https://billing.stripe.com/p/x");
68
+ expect(calls[0].body).toContain("return_url=https%3A%2F%2Fapp%2Faccount");
69
+ });
70
+ });
71
+
72
+ describe("money-moving: on-default-card top-up", () => {
73
+ test("createPaymentIntentOnDefaultCard pins the resolved default PM and returns the client secret", async () => {
74
+ const { cfg, calls } = withDefaultCard({ "POST payment_intents": { body: { client_secret: "pi_secret_1" } } });
75
+ const secret = await createPaymentIntentOnDefaultCard(cfg, "cus_1", 2000, { userId: "u1", credits: 600 });
76
+ expect(secret).toBe("pi_secret_1");
77
+ const pi = calls.find((c) => c.path === "payment_intents")!;
78
+ expect(pi.body).toContain("payment_method=pm_1");
79
+ expect(pi.body).toContain("metadata%5Bsource%5D=onsite_topup");
80
+ });
81
+
82
+ test("returns null when the customer has no default card (no charge attempted)", async () => {
83
+ const { cfg, calls } = mockStripe({
84
+ "GET payment_methods": { body: { data: [] } },
85
+ "GET customers/cus_1": { body: { invoice_settings: { default_payment_method: null } } },
86
+ });
87
+ expect(await createPaymentIntentOnDefaultCard(cfg, "cus_1", 2000, { userId: "u1", credits: 600 })).toBeNull();
88
+ expect(calls.some((c) => c.path === "payment_intents" && c.method === "POST")).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe("money-moving: off-session charge (auto-top-up)", () => {
93
+ test("a succeeded charge returns id + status, authRequired false", async () => {
94
+ const { cfg, calls } = mockStripe({ "POST payment_intents": { body: { id: "pi_1", status: "succeeded" } } });
95
+ expect(await chargeOffSession(cfg, "cus_1", "pm_1", 2000, { userId: "u1", credits: 600 })).toEqual({ id: "pi_1", status: "succeeded", authRequired: false });
96
+ expect(calls[0].body).toContain("off_session=true");
97
+ expect(calls[0].body).toContain("metadata%5Bsource%5D=auto_topup");
98
+ });
99
+
100
+ test("a 3DS decline (authentication_required) returns authRequired:true — NOT a throw", async () => {
101
+ const { cfg } = mockStripe({ "POST payment_intents": { status: 402, body: { error: { code: "authentication_required", payment_intent: { id: "pi_2", status: "requires_action" } } } } });
102
+ expect(await chargeOffSession(cfg, "cus_1", "pm_1", 2000, { userId: "u1", credits: 600 })).toEqual({ id: "pi_2", status: "requires_action", authRequired: true });
103
+ });
104
+
105
+ test("a hard 402 card decline is RETURNED (status failed), not thrown", async () => {
106
+ const { cfg } = mockStripe({ "POST payment_intents": { status: 402, body: { error: { code: "card_declined", payment_intent: { id: "pi_3", status: "failed" } } } } });
107
+ expect(await chargeOffSession(cfg, "cus_1", "pm_1", 2000, { userId: "u1", credits: 600 })).toEqual({ id: "pi_3", status: "failed", authRequired: false });
108
+ });
109
+
110
+ test("a transient/transport failure THROWS (it may recover)", async () => {
111
+ const { cfg } = mockStripe({ "POST payment_intents": { status: 500, body: { error: { message: "stripe down" } } } });
112
+ await expect(chargeOffSession(cfg, "cus_1", "pm_1", 2000, { userId: "u1", credits: 600 })).rejects.toThrow("stripe down");
113
+ });
114
+ });
115
+
116
+ describe("subscriptions: ceilingFor (pure paid-ceiling math)", () => {
117
+ test("honours a stored ceiling only when its End matches this cycle; else falls back to the current price", () => {
118
+ const meta = { cycleCeilingCents: "3000", cycleCeilingEnd: "100" };
119
+ expect(ceilingFor(meta, 100, 1000)).toBe(3000); // same cycle → the higher paid ceiling stands
120
+ expect(ceilingFor(meta, 200, 1000)).toBe(1000); // different cycle → reset to current price
121
+ expect(ceilingFor(undefined, 100, 1000)).toBe(1000); // no metadata → current price
122
+ expect(ceilingFor({ cycleCeilingCents: "500", cycleCeilingEnd: "100" }, 100, 1000)).toBe(1000); // max(500,1000)
123
+ });
124
+
125
+ test("planById / planByPrice resolve against the injected catalog", () => {
126
+ expect(planById(PLANS, "pro")).toBe(PRO);
127
+ expect(planById(PLANS, "nope")).toBeUndefined();
128
+ expect(planByPrice(PLANS, 1000)).toBe(STARTER);
129
+ expect(planByPrice(PLANS, 999)).toBeUndefined();
130
+ });
131
+ });
132
+
133
+ describe("subscriptions: pricing + status + change", () => {
134
+ test("ensurePlanPrice REUSES an existing lookup_key price (no product/price create)", async () => {
135
+ const { cfg, calls } = mockStripe({ "GET prices": { body: { data: [{ id: "price_existing" }] } } });
136
+ expect(await ensurePlanPrice(cfg, PRO, TF)).toBe("price_existing");
137
+ expect(calls.length).toBe(1); // only the GET — no product/price POST
138
+ expect(calls[0].path).toContain("lookup_keys[0]=tf_pro_3000_700_m"); // literal brackets in the GET path
139
+ });
140
+
141
+ test("ensurePlanPrice CREATES product + price when none is found", async () => {
142
+ const { cfg, calls } = mockStripe({
143
+ "GET prices": { body: { data: [] } },
144
+ "POST products": { body: { id: "prod_1" } },
145
+ "POST prices": { body: { id: "price_new" } },
146
+ });
147
+ expect(await ensurePlanPrice(cfg, PRO, TF)).toBe("price_new");
148
+ expect(calls.map((c) => `${c.method} ${c.path.split("?")[0]}`)).toEqual(["GET prices", "POST products", "POST prices"]);
149
+ });
150
+
151
+ test("getSubscriptionStatus maps a live sub; returns null for a non-live one", async () => {
152
+ const live = mockStripe({ "GET subscriptions/sub_1": { body: { status: "active", cancel_at_period_end: false, current_period_end: 1000, items: { data: [{ price: { unit_amount: 3000 } }] }, metadata: { planId: "pro" } } } });
153
+ expect(await getSubscriptionStatus(live.cfg, "sub_1", PLANS)).toEqual({ planId: "pro", status: "active", currentPeriodEnd: 1_000_000, cancelAtPeriodEnd: false, paidCeilingCents: 3000 });
154
+ const dead = mockStripe({ "GET subscriptions/sub_1": { body: { status: "canceled" } } });
155
+ expect(await getSubscriptionStatus(dead.cfg, "sub_1", PLANS)).toBeNull();
156
+ });
157
+
158
+ test("changeSubscriptionPlan: ABOVE the ceiling = an upgrade (always_invoice + a 3DS clientSecret)", async () => {
159
+ const { cfg, calls } = mockStripe({
160
+ "GET subscriptions/sub_1": { body: { status: "active", cancel_at_period_end: false, current_period_end: 1000, items: { data: [{ id: "si_1", price: { unit_amount: 1000 } }] }, metadata: { planId: "starter" } } },
161
+ "GET prices": { body: { data: [{ id: "price_pro" }] } },
162
+ "POST subscriptions/sub_1": { body: { id: "sub_1", latest_invoice: { status: "open", confirmation_secret: { client_secret: "pi_up_secret" } } } },
163
+ });
164
+ const r = await changeSubscriptionPlan(cfg, "sub_1", PRO, "u1", PLANS, TF);
165
+ expect(r).toEqual({ kind: "upgrade", clientSecret: "pi_up_secret", currentPeriodEnd: 1_000_000 });
166
+ const post = calls.find((c) => c.method === "POST" && c.path === "subscriptions/sub_1")!;
167
+ expect(post.body).toContain("proration_behavior=always_invoice");
168
+ expect(post.body).toContain("payment_behavior=pending_if_incomplete");
169
+ });
170
+
171
+ test("changeSubscriptionPlan: AT/BELOW the ceiling = a deferred downgrade (proration none, no charge, no secret)", async () => {
172
+ const { cfg, calls } = mockStripe({
173
+ "GET subscriptions/sub_1": { body: { status: "active", cancel_at_period_end: false, current_period_end: 1000, items: { data: [{ id: "si_1", price: { unit_amount: 3000 } }] }, metadata: { planId: "pro" } } },
174
+ "GET prices": { body: { data: [{ id: "price_starter" }] } },
175
+ "POST subscriptions/sub_1": { body: { id: "sub_1", latest_invoice: { status: "paid" } } },
176
+ });
177
+ const r = await changeSubscriptionPlan(cfg, "sub_1", STARTER, "u1", PLANS, TF);
178
+ expect(r).toEqual({ kind: "downgrade", clientSecret: null, currentPeriodEnd: 1_000_000 });
179
+ const post = calls.find((c) => c.method === "POST" && c.path === "subscriptions/sub_1")!;
180
+ expect(post.body).toContain("proration_behavior=none");
181
+ });
182
+
183
+ test("createSubscriptionOnDefaultCard charges the saved card; null when there's none", async () => {
184
+ const ok = withDefaultCard({
185
+ "GET prices": { body: { data: [{ id: "price_pro" }] } },
186
+ "POST subscriptions": { body: { id: "sub_new", latest_invoice: { confirmation_secret: { client_secret: "sub_secret" } } } },
187
+ });
188
+ expect(await createSubscriptionOnDefaultCard(ok.cfg, "cus_1", PRO, "u1", TF)).toEqual({ clientSecret: "sub_secret", subscriptionId: "sub_new" });
189
+
190
+ const noCard = mockStripe({ "GET payment_methods": { body: { data: [] } }, "GET customers/cus_1": { body: { invoice_settings: {} } } });
191
+ expect(await createSubscriptionOnDefaultCard(noCard.cfg, "cus_1", PRO, "u1", TF)).toBeNull();
192
+ });
193
+ });
@@ -0,0 +1,87 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import {
3
+ createCustomer, createSetupIntent, listPaymentMethods, setDefaultPaymentMethod, detachPaymentMethod,
4
+ stripePost, toForm, type StripeConfig,
5
+ } from "../src/index";
6
+
7
+ /**
8
+ * C046 — @suluk/billing v1, witnessed with a MOCK fetch (the injected transport seam). We assert the right Stripe request
9
+ * (path + method + auth header + form body) and the response handling — no live Stripe, no effect dep.
10
+ */
11
+ interface Call {
12
+ path: string;
13
+ method: string;
14
+ body: string;
15
+ headers: Record<string, string>;
16
+ }
17
+ type Route = { status?: number; body: unknown };
18
+ function mockStripe(routes: Record<string, Route>): { cfg: StripeConfig; calls: Call[] } {
19
+ const calls: Call[] = [];
20
+ const fetchMock = (async (url: string | URL, init?: RequestInit) => {
21
+ const path = String(url).replace("https://api.stripe.com/v1/", "");
22
+ const method = (init?.method ?? "GET").toUpperCase();
23
+ calls.push({ path, method, body: (init?.body as string) ?? "", headers: (init?.headers as Record<string, string>) ?? {} });
24
+ const key = Object.keys(routes).find((k) => `${method} ${path}`.startsWith(k));
25
+ const r = key ? routes[key] : { body: {} };
26
+ return new Response(JSON.stringify(r.body), { status: r.status ?? 200 });
27
+ }) as unknown as typeof fetch;
28
+ return { cfg: { secretKey: "sk_test", fetch: fetchMock }, calls };
29
+ }
30
+
31
+ describe("the transport seam", () => {
32
+ test("POSTs to Stripe with the bearer auth + form body; passes the idempotency-key when given", async () => {
33
+ const { cfg, calls } = mockStripe({ "POST refunds": { body: { id: "re_1" } } });
34
+ await stripePost(cfg, "refunds", toForm({ charge: "ch_1", amount: 500 }), "scope:ch_1");
35
+ expect(calls[0].method).toBe("POST");
36
+ expect(calls[0].path).toBe("refunds");
37
+ expect(calls[0].headers.authorization).toBe("Bearer sk_test");
38
+ expect(calls[0].headers["idempotency-key"]).toBe("scope:ch_1");
39
+ expect(calls[0].body).toContain("charge=ch_1");
40
+ expect(calls[0].body).toContain("amount=500");
41
+ });
42
+ });
43
+
44
+ describe("customer + intent creation", () => {
45
+ test("createCustomer returns the id and sends email + metadata", async () => {
46
+ const { cfg, calls } = mockStripe({ "POST customers": { body: { id: "cus_1" } } });
47
+ expect(await createCustomer(cfg, "a@b.com", "user_1")).toBe("cus_1");
48
+ expect(calls[0].body).toContain("email=a%40b.com");
49
+ expect(calls[0].body).toContain("metadata%5BuserId%5D=user_1"); // metadata[userId]=user_1
50
+ });
51
+
52
+ test("createSetupIntent returns the client secret", async () => {
53
+ const { cfg } = mockStripe({ "POST setup_intents": { body: { client_secret: "si_secret_1" } } });
54
+ expect(await createSetupIntent(cfg, "cus_1", "user_1")).toBe("si_secret_1");
55
+ });
56
+
57
+ test("a Stripe error throws with the error message", async () => {
58
+ const { cfg } = mockStripe({ "POST customers": { status: 402, body: { error: { message: "card declined" } } } });
59
+ await expect(createCustomer(cfg, "a@b.com", "user_1")).rejects.toThrow("card declined");
60
+ });
61
+ });
62
+
63
+ describe("the saved-card surface", () => {
64
+ test("listPaymentMethods maps the wire shape + marks the invoice default", async () => {
65
+ const { cfg } = mockStripe({
66
+ "GET payment_methods": { body: { data: [{ id: "pm_1", card: { brand: "visa", last4: "4242", exp_month: 12, exp_year: 2030 }, billing_details: { name: "A B", address: { line1: "1 St", city: "SF", state: "CA", postal_code: "94000", country: "US" } } }] } },
67
+ "GET customers/cus_1": { body: { invoice_settings: { default_payment_method: "pm_1" } } },
68
+ });
69
+ const cards = await listPaymentMethods(cfg, "cus_1");
70
+ expect(cards).toEqual([{ id: "pm_1", brand: "visa", last4: "4242", expMonth: 12, expYear: 2030, name: "A B", line1: "1 St", line2: null, city: "SF", region: "CA", postalCode: "94000", country: "US", isDefault: true }]);
71
+ });
72
+
73
+ test("setDefaultPaymentMethod posts the nested invoice_settings; detach hits the detach path", async () => {
74
+ const setMock = mockStripe({ "POST customers/cus_1": { body: { id: "cus_1" } } });
75
+ await setDefaultPaymentMethod(setMock.cfg, "cus_1", "pm_9");
76
+ expect(setMock.calls[0].body).toContain("invoice_settings%5Bdefault_payment_method%5D=pm_9");
77
+
78
+ const detachMock = mockStripe({ "POST payment_methods/pm_9/detach": { body: { id: "pm_9" } } });
79
+ await detachPaymentMethod(detachMock.cfg, "pm_9");
80
+ expect(detachMock.calls[0].path).toBe("payment_methods/pm_9/detach");
81
+ });
82
+
83
+ test("a failed set-default throws", async () => {
84
+ const { cfg } = mockStripe({ "POST customers/cus_1": { status: 400, body: {} } });
85
+ await expect(setDefaultPaymentMethod(cfg, "cus_1", "pm_9")).rejects.toThrow(/set-default failed/);
86
+ });
87
+ });
package/tsconfig.json ADDED
@@ -0,0 +1 @@
1
+ { "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }