@suluk/stripe 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,28 @@
1
+ {
2
+ "name": "@suluk/stripe",
3
+ "version": "0.1.0",
4
+ "description": "First-class Stripe via a swappable PaymentProvider: usage-based billing (Billing Meters + meter events), customers, metered prices, subscriptions, webhooks — and a bridge from @suluk/cost to Stripe usage. Other processors follow Stripe. CANDIDATE tooling.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "dependencies": {
11
+ "@suluk/cost": "0.1.0"
12
+ },
13
+ "peerDependencies": {
14
+ "stripe": "^17.0.0 || ^18.0.0"
15
+ },
16
+ "peerDependenciesMeta": {
17
+ "stripe": {
18
+ "optional": true
19
+ }
20
+ },
21
+ "devDependencies": {
22
+ "@types/bun": "latest"
23
+ },
24
+ "scripts": {
25
+ "test": "bun test",
26
+ "typecheck": "tsc --noEmit -p ."
27
+ }
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @suluk/stripe — first-class Stripe behind a swappable PaymentProvider. Usage-based billing via the modern
3
+ * Billing Meters API (meters + meter events + metered prices), customers, subscriptions, and webhooks — plus
4
+ * a bridge that turns @suluk/cost events into the usage you bill on. Stripe is the reference processor; the
5
+ * PaymentProvider interface is the swap point for the others that follow it. CANDIDATE tooling.
6
+ */
7
+ export type { PaymentProvider, StripeLike, Customer, Subscription, WebhookEvent } from "./types";
8
+ export {
9
+ customerParams, productParams, meterParams, meteredPriceParams, subscriptionParams, meterEventParams,
10
+ setupUsageBilling, stripeProvider, usageEventsFromCost, reportCostUsage,
11
+ type UsageBillingConfig, type CostBillingConfig,
12
+ } from "./stripe";
package/src/stripe.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * First-class Stripe. The param builders are PURE (exact Stripe API payloads, testable with no SDK); the
3
+ * provider + setup call a duck-typed StripeLike client (the real `stripe` SDK satisfies it). Usage-based
4
+ * billing uses the modern Billing Meters API (meters + meter events + metered prices), which is what other
5
+ * processors are converging on. The cost bridge turns @suluk/cost events into the usage you bill on.
6
+ */
7
+ import type { CostEvent } from "@suluk/cost";
8
+ import type { PaymentProvider, StripeLike } from "./types";
9
+
10
+ type Aggregation = "sum" | "count" | "last";
11
+
12
+ // ── pure param builders (produce exact Stripe API params) ─────────────────────────────────────────────
13
+ export function customerParams(i: { email?: string; name?: string; metadata?: Record<string, string> }): Record<string, unknown> {
14
+ const p: Record<string, unknown> = {};
15
+ if (i.email) p.email = i.email;
16
+ if (i.name) p.name = i.name;
17
+ if (i.metadata) p.metadata = i.metadata;
18
+ return p;
19
+ }
20
+
21
+ export function productParams(name: string): Record<string, unknown> {
22
+ return { name };
23
+ }
24
+
25
+ /** A Billing Meter — aggregates incoming meter events for one event_name (default: sum). */
26
+ export function meterParams(eventName: string, displayName: string, formula: Aggregation = "sum"): Record<string, unknown> {
27
+ return { display_name: displayName, event_name: eventName, default_aggregation: { formula } };
28
+ }
29
+
30
+ /** A METERED recurring price tied to a meter (usage_type:"metered"). unitAmountDecimal is per aggregated unit. */
31
+ export function meteredPriceParams(i: { productId: string; currency: string; unitAmountDecimal: string; meterId: string; interval?: "day" | "week" | "month" | "year" }): Record<string, unknown> {
32
+ return {
33
+ product: i.productId,
34
+ currency: i.currency,
35
+ unit_amount_decimal: i.unitAmountDecimal,
36
+ recurring: { interval: i.interval ?? "month", usage_type: "metered", meter: i.meterId },
37
+ };
38
+ }
39
+
40
+ export function subscriptionParams(i: { customerId: string; priceId: string }): Record<string, unknown> {
41
+ return { customer: i.customerId, items: [{ price: i.priceId }] };
42
+ }
43
+
44
+ /** A meter event — reports usage for a customer (the `value` is what you price on). */
45
+ export function meterEventParams(i: { eventName: string; customerId: string; value: number; at?: number }): Record<string, unknown> {
46
+ const payload: Record<string, unknown> = { stripe_customer_id: i.customerId, value: String(i.value) };
47
+ const p: Record<string, unknown> = { event_name: i.eventName, payload };
48
+ if (i.at != null) p.timestamp = Math.floor(i.at / 1000); // unix seconds
49
+ return p;
50
+ }
51
+
52
+ // ── flows over a StripeLike client ────────────────────────────────────────────────────────────────────
53
+ export interface UsageBillingConfig {
54
+ productName: string;
55
+ eventName: string;
56
+ currency: string;
57
+ /** Price per aggregated unit, as a decimal string (e.g. "0.000002" to charge per cost-µ$). */
58
+ unitAmountDecimal: string;
59
+ interval?: "day" | "week" | "month" | "year";
60
+ aggregation?: Aggregation;
61
+ }
62
+
63
+ /** Wire up usage-based billing: a product + a meter + a metered price. Returns the ids to subscribe customers. */
64
+ export async function setupUsageBilling(client: StripeLike, cfg: UsageBillingConfig): Promise<{ productId: string; meterId: string; priceId: string; eventName: string }> {
65
+ const product = await client.products.create(productParams(cfg.productName));
66
+ const meter = await client.billing.meters.create(meterParams(cfg.eventName, `${cfg.productName} usage`, cfg.aggregation ?? "sum"));
67
+ const price = await client.prices.create(meteredPriceParams({ productId: product.id, currency: cfg.currency, unitAmountDecimal: cfg.unitAmountDecimal, meterId: meter.id, interval: cfg.interval }));
68
+ return { productId: product.id, meterId: meter.id, priceId: price.id, eventName: cfg.eventName };
69
+ }
70
+
71
+ /** The Stripe PaymentProvider (the swap point — other processors implement the same interface). */
72
+ export function stripeProvider(client: StripeLike, cfg: { webhookSecret?: string } = {}): PaymentProvider {
73
+ return {
74
+ name: "stripe",
75
+ async createCustomer(i) { return { id: (await client.customers.create(customerParams(i))).id }; },
76
+ async subscribeMetered(i) { return { id: (await client.subscriptions.create(subscriptionParams(i))).id }; },
77
+ async reportUsage(i) { await client.billing.meterEvents.create(meterEventParams(i)); },
78
+ verifyWebhook(body, signature) {
79
+ const e = client.webhooks.constructEvent(body, signature, cfg.webhookSecret ?? "");
80
+ return { type: e.type, data: e.data };
81
+ },
82
+ };
83
+ }
84
+
85
+ // ── the cost → Stripe bridge ──────────────────────────────────────────────────────────────────────────
86
+ export interface CostBillingConfig {
87
+ eventName: string;
88
+ /** principal id → Stripe customer id. Principals without a customer are skipped. */
89
+ customerOf: (principal: string) => string | undefined;
90
+ /** What to meter: the raw cost in µ$ (default — price per-µ$ for a markup) or the request count. */
91
+ basis?: "cost-micro-usd" | "request-count";
92
+ }
93
+
94
+ /** Aggregate cost events per principal into Stripe meter-event params — the usage you report to bill on. */
95
+ export function usageEventsFromCost(events: CostEvent[], cfg: CostBillingConfig): Record<string, unknown>[] {
96
+ const byPrincipal = new Map<string, number>();
97
+ for (const e of events) {
98
+ if (!e.principal) continue;
99
+ const v = cfg.basis === "request-count" ? 1 : e.totalMicroUsd;
100
+ byPrincipal.set(e.principal, (byPrincipal.get(e.principal) ?? 0) + v);
101
+ }
102
+ const out: Record<string, unknown>[] = [];
103
+ for (const [principal, value] of byPrincipal) {
104
+ const customerId = cfg.customerOf(principal);
105
+ if (customerId) out.push(meterEventParams({ eventName: cfg.eventName, customerId, value }));
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** Report each principal's accrued cost-usage to the provider (one meter event per principal). */
111
+ export async function reportCostUsage(provider: PaymentProvider, events: CostEvent[], cfg: CostBillingConfig): Promise<number> {
112
+ const byPrincipal = new Map<string, number>();
113
+ for (const e of events) {
114
+ if (!e.principal) continue;
115
+ byPrincipal.set(e.principal, (byPrincipal.get(e.principal) ?? 0) + (cfg.basis === "request-count" ? 1 : e.totalMicroUsd));
116
+ }
117
+ let reported = 0;
118
+ for (const [principal, value] of byPrincipal) {
119
+ const customerId = cfg.customerOf(principal);
120
+ if (!customerId) continue;
121
+ await provider.reportUsage({ customerId, eventName: cfg.eventName, value });
122
+ reported++;
123
+ }
124
+ return reported;
125
+ }
package/src/types.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The payment abstraction — SWAPPABLE by design. Stripe is the first (and reference) provider because the
3
+ * other processors largely follow its feature set + API shape. The PaymentProvider interface is processor-
4
+ * neutral; the Stripe-specific param builders live in ./stripe and produce exact Stripe API payloads.
5
+ *
6
+ * A duck-typed `StripeLike` lets the real `stripe` SDK plug in while staying testable with a mock — no hard
7
+ * dependency on the SDK in this package.
8
+ */
9
+
10
+ export interface Customer { id: string }
11
+ export interface Subscription { id: string }
12
+ export interface WebhookEvent { type: string; data: unknown }
13
+
14
+ /** A processor-neutral payment surface. Other processors implement the same interface. */
15
+ export interface PaymentProvider {
16
+ name: string;
17
+ /** Create (or reference) a billing customer for a principal. */
18
+ createCustomer(input: { email?: string; name?: string; metadata?: Record<string, string> }): Promise<Customer>;
19
+ /** Start a usage-metered subscription for a customer on a metered price. */
20
+ subscribeMetered(input: { customerId: string; priceId: string }): Promise<Subscription>;
21
+ /** Report usage for billing — one meter event (the value you price on). `at` is an input (reproducible). */
22
+ reportUsage(input: { customerId: string; eventName: string; value: number; at?: number }): Promise<void>;
23
+ /** Verify + parse a webhook from the raw body + signature. */
24
+ verifyWebhook(rawBody: string, signature: string): WebhookEvent;
25
+ }
26
+
27
+ /** The minimal Stripe surface this package calls — satisfied by the real `stripe` SDK and by test mocks. */
28
+ export interface StripeLike {
29
+ customers: { create(params: Record<string, unknown>): Promise<{ id: string }> };
30
+ products: { create(params: Record<string, unknown>): Promise<{ id: string }> };
31
+ prices: { create(params: Record<string, unknown>): Promise<{ id: string }> };
32
+ subscriptions: { create(params: Record<string, unknown>): Promise<{ id: string }> };
33
+ billing: {
34
+ meters: { create(params: Record<string, unknown>): Promise<{ id: string }> };
35
+ meterEvents: { create(params: Record<string, unknown>): Promise<{ identifier?: string }> };
36
+ };
37
+ webhooks: { constructEvent(body: string, sig: string, secret: string): { type: string; data: unknown } };
38
+ }
@@ -0,0 +1,97 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import type { CostEvent } from "@suluk/cost";
3
+ import {
4
+ customerParams, meterParams, meteredPriceParams, subscriptionParams, meterEventParams,
5
+ setupUsageBilling, stripeProvider, usageEventsFromCost, reportCostUsage, type StripeLike,
6
+ } from "../src/index";
7
+
8
+ /** A recording mock that satisfies StripeLike — records every call + returns fake ids. */
9
+ function mockStripe() {
10
+ const calls: { method: string; params: unknown }[] = [];
11
+ const rec = (method: string, id: string) => async (params: Record<string, unknown>) => { calls.push({ method, params }); return { id, identifier: id }; };
12
+ const client: StripeLike = {
13
+ customers: { create: rec("customers.create", "cus_1") },
14
+ products: { create: rec("products.create", "prod_1") },
15
+ prices: { create: rec("prices.create", "price_1") },
16
+ subscriptions: { create: rec("subscriptions.create", "sub_1") },
17
+ billing: {
18
+ meters: { create: rec("billing.meters.create", "mtr_1") },
19
+ meterEvents: { create: rec("billing.meterEvents.create", "evt_1") },
20
+ },
21
+ webhooks: { constructEvent: (b, s, secret) => { calls.push({ method: "webhooks.constructEvent", params: { b, s, secret } }); return { type: "invoice.paid", data: { ok: true } }; } },
22
+ };
23
+ return { client, calls };
24
+ }
25
+
26
+ describe("pure param builders → exact Stripe payloads", () => {
27
+ test("meter uses default_aggregation.formula", () => {
28
+ expect(meterParams("api_cost", "API usage")).toEqual({ display_name: "API usage", event_name: "api_cost", default_aggregation: { formula: "sum" } });
29
+ });
30
+ test("metered price attaches the meter + usage_type metered", () => {
31
+ const p = meteredPriceParams({ productId: "prod_1", currency: "usd", unitAmountDecimal: "0.000002", meterId: "mtr_1" }) as any;
32
+ expect(p.recurring).toEqual({ interval: "month", usage_type: "metered", meter: "mtr_1" });
33
+ expect(p.product).toBe("prod_1");
34
+ });
35
+ test("meter event carries stripe_customer_id + a string value (+ unix timestamp)", () => {
36
+ expect(meterEventParams({ eventName: "api_cost", customerId: "cus_1", value: 4050, at: 2000 })).toEqual({
37
+ event_name: "api_cost", payload: { stripe_customer_id: "cus_1", value: "4050" }, timestamp: 2,
38
+ });
39
+ });
40
+ test("customer + subscription params", () => {
41
+ expect(customerParams({ email: "a@b.c" })).toEqual({ email: "a@b.c" });
42
+ expect(subscriptionParams({ customerId: "cus_1", priceId: "price_1" })).toEqual({ customer: "cus_1", items: [{ price: "price_1" }] });
43
+ });
44
+ });
45
+
46
+ describe("flows over a StripeLike client", () => {
47
+ test("setupUsageBilling creates product → meter → metered price", async () => {
48
+ const { client, calls } = mockStripe();
49
+ const out = await setupUsageBilling(client, { productName: "API", eventName: "api_cost", currency: "usd", unitAmountDecimal: "0.000002" });
50
+ expect(out).toEqual({ productId: "prod_1", meterId: "mtr_1", priceId: "price_1", eventName: "api_cost" });
51
+ expect(calls.map((c) => c.method)).toEqual(["products.create", "billing.meters.create", "prices.create"]);
52
+ expect((calls[2].params as any).recurring.meter).toBe("mtr_1"); // the price is tied to the meter
53
+ });
54
+
55
+ test("stripeProvider: customer, metered subscription, usage, webhook", async () => {
56
+ const { client, calls } = mockStripe();
57
+ const stripe = stripeProvider(client, { webhookSecret: "whsec_x" });
58
+ expect(stripe.name).toBe("stripe");
59
+ expect((await stripe.createCustomer({ email: "a@b.c" })).id).toBe("cus_1");
60
+ expect((await stripe.subscribeMetered({ customerId: "cus_1", priceId: "price_1" })).id).toBe("sub_1");
61
+ await stripe.reportUsage({ customerId: "cus_1", eventName: "api_cost", value: 4050 });
62
+ const evt = stripe.verifyWebhook("{raw}", "sig_1");
63
+ expect(evt.type).toBe("invoice.paid");
64
+ expect(calls.find((c) => c.method === "billing.meterEvents.create")!.params).toMatchObject({ event_name: "api_cost", payload: { stripe_customer_id: "cus_1", value: "4050" } });
65
+ expect(calls.find((c) => c.method === "webhooks.constructEvent")!.params).toMatchObject({ secret: "whsec_x" });
66
+ });
67
+ });
68
+
69
+ describe("the @suluk/cost → Stripe bridge", () => {
70
+ const events: CostEvent[] = [
71
+ { at: 1, principal: "user_a", operation: "ask", breakdown: [{ source: "openai", microUsd: 3000 }], totalMicroUsd: 3000 },
72
+ { at: 2, principal: "user_a", operation: "ask", breakdown: [{ source: "openai", microUsd: 1000 }], totalMicroUsd: 1000 },
73
+ { at: 3, principal: "user_b", operation: "ask", breakdown: [{ source: "openai", microUsd: 500 }], totalMicroUsd: 500 },
74
+ { at: 4, operation: "ping", breakdown: [], totalMicroUsd: 0 }, // no principal → skipped
75
+ ];
76
+ const customerOf = (p: string) => ({ user_a: "cus_a", user_b: "cus_b" }[p]);
77
+
78
+ test("usageEventsFromCost aggregates per principal (raw cost-µ$) into meter events", () => {
79
+ const usage = usageEventsFromCost(events, { eventName: "api_cost", customerOf });
80
+ expect(usage).toEqual([
81
+ { event_name: "api_cost", payload: { stripe_customer_id: "cus_a", value: "4000" } },
82
+ { event_name: "api_cost", payload: { stripe_customer_id: "cus_b", value: "500" } },
83
+ ]);
84
+ });
85
+
86
+ test("request-count basis meters 1 per request", () => {
87
+ const usage = usageEventsFromCost(events, { eventName: "api_calls", customerOf, basis: "request-count" }) as any[];
88
+ expect(usage.find((u) => u.payload.stripe_customer_id === "cus_a").payload.value).toBe("2");
89
+ });
90
+
91
+ test("reportCostUsage pushes one meter event per principal to the provider", async () => {
92
+ const { client, calls } = mockStripe();
93
+ const reported = await reportCostUsage(stripeProvider(client), events, { eventName: "api_cost", customerOf });
94
+ expect(reported).toBe(2);
95
+ expect(calls.filter((c) => c.method === "billing.meterEvents.create").length).toBe(2);
96
+ });
97
+ });
package/tsconfig.json ADDED
@@ -0,0 +1 @@
1
+ { "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }