@strav/payment 1.0.0-alpha.28 → 1.0.0-alpha.30

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@strav/payment",
3
- "version": "1.0.0-alpha.28",
4
- "description": "Strav payment module — provider-agnostic payment abstraction. Normalized DTOs, multi-provider routing, ledger schema, webhook dispatcher. Stripe and Omise drivers ship as subpath imports (`@strav/payment/stripe`, `@strav/payment/omise`). Paddle support comes in a later release.",
3
+ "version": "1.0.0-alpha.30",
4
+ "description": "Strav payment module — provider-agnostic payment abstraction. Normalized DTOs, multi-provider routing, ledger schema, webhook dispatcher, Cashier-style Billable mixin. Stripe and Omise drivers ship as subpath imports (`@strav/payment/stripe`, `@strav/payment/omise`). Paddle support comes in a later release.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
@@ -21,9 +21,9 @@
21
21
  "access": "public"
22
22
  },
23
23
  "dependencies": {
24
- "@strav/database": "1.0.0-alpha.28",
25
- "@strav/http": "1.0.0-alpha.28",
26
- "@strav/kernel": "1.0.0-alpha.28",
24
+ "@strav/database": "1.0.0-alpha.30",
25
+ "@strav/http": "1.0.0-alpha.30",
26
+ "@strav/kernel": "1.0.0-alpha.30",
27
27
  "stripe": "^18.0.0",
28
28
  "omise": "^0.12.0"
29
29
  },
@@ -0,0 +1,284 @@
1
+ /**
2
+ * `billable()` — Cashier-style billing mixin for app models.
3
+ *
4
+ * Layers customer / charge / subscription convenience methods onto a
5
+ * domain model (typically `User`) so the call sites read as:
6
+ *
7
+ * const user = await users.findOrFail(id)
8
+ * await user.createCustomer(payments, { email: user.email })
9
+ * await user.charge(payments, { amount: 4900, currency: 'usd', ... })
10
+ * const subs = await user.subscriptions(payments.ledger!)
11
+ *
12
+ * **Manager + ledger are passed explicitly per call.** No static
13
+ * singleton, no implicit container lookup — Strav favours explicit
14
+ * injection. The trade-off is a bit more typing at the call site for
15
+ * compile-time clarity about which provider the call touches and
16
+ * which DB the read hits.
17
+ *
18
+ * **Storage contract.** A billable needs somewhere to remember the
19
+ * customer id Stripe / Omise minted for it. The default lives on a
20
+ * `payment_customers: Record<provider, string>` JSON field — apps
21
+ * that already have a `payment_customers` column get persistence for
22
+ * free. Apps with a different schema override `paymentCustomerId()`
23
+ * + `setPaymentCustomerId()` directly:
24
+ *
25
+ * class User extends billable(Model) {
26
+ * stripe_customer_id?: string | null
27
+ * override paymentCustomerId(provider: string): string | undefined {
28
+ * return provider === 'stripe' ? this.stripe_customer_id ?? undefined : undefined
29
+ * }
30
+ * override async setPaymentCustomerId(provider: string, id: string) {
31
+ * if (provider === 'stripe') this.stripe_customer_id = id
32
+ * }
33
+ * }
34
+ *
35
+ * The setter is sync OR async — apps that want to persist immediately
36
+ * `await users.save(this)` inside the setter. Apps that prefer batch
37
+ * saves leave the setter sync and call `save()` themselves.
38
+ *
39
+ * Two factory forms:
40
+ * - `class User extends Billable { ... }` — the base class itself,
41
+ * handy for new domain models.
42
+ * - `class User extends billable(BaseModel) { ... }` — the mixin
43
+ * form, for apps already extending another base (`Model`,
44
+ * `AuthenticatableUser`, …).
45
+ */
46
+
47
+ import type {
48
+ CreateChargeInput,
49
+ CreateCustomerInput,
50
+ CreateSubscriptionInput,
51
+ PaymentCharge,
52
+ PaymentCustomer,
53
+ PaymentMethod,
54
+ PaymentSubscription,
55
+ } from './dto/index.ts'
56
+ import type { PaymentLedger } from './ledger/payment_ledger.ts'
57
+ import type { PaymentInvoiceRow, PaymentSubscriptionRow } from './ledger/payment_ledger_models.ts'
58
+ import type { PaymentManager } from './payment_manager.ts'
59
+
60
+ const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['active', 'trialing', 'past_due'])
61
+
62
+ export interface BillableStorage {
63
+ /** The provider-side customer id for `provider`, or `undefined` when not yet provisioned. */
64
+ paymentCustomerId(provider: string): string | undefined
65
+ /**
66
+ * Persist `id` as this billable's customer id with `provider`. Sync
67
+ * OR async — apps that hit the DB inside the setter return the
68
+ * persistence promise.
69
+ */
70
+ setPaymentCustomerId(provider: string, id: string): void | Promise<void>
71
+ }
72
+
73
+ /**
74
+ * Base class form — `class User extends Billable { ... }`. Subclasses
75
+ * MUST also declare `static schema = ...` if they extend `Model`
76
+ * (the mixin form does this in the chain automatically).
77
+ *
78
+ * Override `paymentCustomerId()` / `setPaymentCustomerId()` to use a
79
+ * storage layout other than the default `payment_customers` map.
80
+ */
81
+ export class Billable implements BillableStorage {
82
+ /**
83
+ * Default storage — apps that have a `payment_customers` jsonb
84
+ * column on their model get this for free. The mixin reads /
85
+ * writes through here unless the subclass overrides.
86
+ */
87
+ payment_customers?: Record<string, string>
88
+
89
+ paymentCustomerId(provider: string): string | undefined {
90
+ return this.payment_customers?.[provider]
91
+ }
92
+
93
+ setPaymentCustomerId(provider: string, id: string): void {
94
+ this.payment_customers = { ...(this.payment_customers ?? {}), [provider]: id }
95
+ }
96
+
97
+ // ─── Customer ─────────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Fetch the current `PaymentCustomer` for `provider` (default:
101
+ * `manager.config.default`). Returns `null` when no customer id is
102
+ * stored — useful for "user hasn't paid yet" branches.
103
+ */
104
+ async customer(manager: PaymentManager, provider?: string): Promise<PaymentCustomer | null> {
105
+ const p = provider ?? manager.config.default
106
+ const cid = this.paymentCustomerId(p)
107
+ if (cid === undefined) return null
108
+ return manager.use(p).customers.retrieve(cid)
109
+ }
110
+
111
+ /**
112
+ * Create a customer with `manager.use(provider).customers.create(input)`
113
+ * and persist the returned id via `setPaymentCustomerId(provider, id)`.
114
+ * Apps usually call this once during checkout / onboarding.
115
+ */
116
+ async createCustomer(
117
+ manager: PaymentManager,
118
+ input: CreateCustomerInput,
119
+ provider?: string,
120
+ ): Promise<PaymentCustomer> {
121
+ const p = provider ?? manager.config.default
122
+ const created = await manager.use(p).customers.create(input)
123
+ await this.setPaymentCustomerId(p, created.id)
124
+ return created
125
+ }
126
+
127
+ /**
128
+ * Return the existing customer (if any) or create + persist one.
129
+ * Idempotent under retries when `input.idempotencyKey` is set on
130
+ * drivers with the `idempotency` capability.
131
+ */
132
+ async customerOrCreate(
133
+ manager: PaymentManager,
134
+ input: CreateCustomerInput,
135
+ provider?: string,
136
+ ): Promise<PaymentCustomer> {
137
+ const existing = await this.customer(manager, provider)
138
+ if (existing !== null) return existing
139
+ return this.createCustomer(manager, input, provider)
140
+ }
141
+
142
+ // ─── Charges + subscriptions + payment methods ────────────────────────
143
+
144
+ /**
145
+ * One-shot charge against this billable's customer. The mixin
146
+ * injects `customer` from storage so callers omit it.
147
+ *
148
+ * Apps that explicitly want to charge a different customer reach
149
+ * `manager.charges.create(...)` directly — the mixin is sugar, not
150
+ * a gate.
151
+ */
152
+ async charge(
153
+ manager: PaymentManager,
154
+ input: Omit<CreateChargeInput, 'customer'>,
155
+ provider?: string,
156
+ ): Promise<PaymentCharge> {
157
+ const p = provider ?? manager.config.default
158
+ return manager.use(p).charges.create({
159
+ ...input,
160
+ customer: this.requireCustomerId(p),
161
+ })
162
+ }
163
+
164
+ /** Subscribe this billable to `price`. Same omitted-customer ergonomics as `charge()`. */
165
+ async subscribe(
166
+ manager: PaymentManager,
167
+ input: Omit<CreateSubscriptionInput, 'customer'>,
168
+ provider?: string,
169
+ ): Promise<PaymentSubscription> {
170
+ const p = provider ?? manager.config.default
171
+ return manager.use(p).subscriptions.create({
172
+ ...input,
173
+ customer: this.requireCustomerId(p),
174
+ })
175
+ }
176
+
177
+ /** Payment methods attached to this billable's customer. */
178
+ async paymentMethods(manager: PaymentManager, provider?: string): Promise<PaymentMethod[]> {
179
+ const p = provider ?? manager.config.default
180
+ const cid = this.paymentCustomerId(p)
181
+ if (cid === undefined) return []
182
+ const result = await manager.use(p).paymentMethods.list(cid)
183
+ return result.data
184
+ }
185
+
186
+ // ─── Ledger reads ─────────────────────────────────────────────────────
187
+
188
+ /**
189
+ * Subscriptions stored in the local ledger for this billable's
190
+ * customer (newest first). Returns an empty array when there's no
191
+ * customer id yet.
192
+ *
193
+ * Reads `payment_subscription` joined by `(provider,
194
+ * customer_provider_id)`. Honours the tenant the caller is in
195
+ * (RLS).
196
+ */
197
+ async subscriptions(ledger: PaymentLedger, provider: string): Promise<PaymentSubscriptionRow[]> {
198
+ const cid = this.paymentCustomerId(provider)
199
+ if (cid === undefined) return []
200
+ return ledger.subscriptionsForCustomer(provider, cid)
201
+ }
202
+
203
+ /** Invoices for this billable's customer (newest first, default 50). */
204
+ async invoices(
205
+ ledger: PaymentLedger,
206
+ provider: string,
207
+ options: { limit?: number } = {},
208
+ ): Promise<PaymentInvoiceRow[]> {
209
+ const cid = this.paymentCustomerId(provider)
210
+ if (cid === undefined) return []
211
+ return ledger.invoicesForCustomer(provider, cid, options)
212
+ }
213
+
214
+ /**
215
+ * `true` when the billable has at least one subscription whose
216
+ * status is `active` / `trialing` / `past_due`. Cancelled, ended,
217
+ * and incomplete don't count.
218
+ */
219
+ async hasActiveSubscription(ledger: PaymentLedger, provider: string): Promise<boolean> {
220
+ const subs = await this.subscriptions(ledger, provider)
221
+ return subs.some((s) => ACTIVE_SUBSCRIPTION_STATUSES.has(s.status))
222
+ }
223
+
224
+ /**
225
+ * `true` when the billable has an active subscription on the
226
+ * given `priceProviderId`. Useful for entitlement checks
227
+ * (`if (await user.subscribedToPrice(ledger, 'price_pro')) { ... }`).
228
+ */
229
+ async subscribedToPrice(
230
+ ledger: PaymentLedger,
231
+ priceProviderId: string,
232
+ provider: string,
233
+ ): Promise<boolean> {
234
+ const subs = await this.subscriptions(ledger, provider)
235
+ return subs.some(
236
+ (s) => s.price_provider_id === priceProviderId && ACTIVE_SUBSCRIPTION_STATUSES.has(s.status),
237
+ )
238
+ }
239
+
240
+ // ─── Internals ────────────────────────────────────────────────────────
241
+
242
+ private requireCustomerId(provider: string): string {
243
+ const cid = this.paymentCustomerId(provider)
244
+ if (cid === undefined) {
245
+ throw new Error(
246
+ `Billable: no customer id stored for provider "${provider}". Call createCustomer() / customerOrCreate() first.`,
247
+ )
248
+ }
249
+ return cid
250
+ }
251
+ }
252
+
253
+ // Constructor type used by the mixin form.
254
+ // biome-ignore lint/suspicious/noExplicitAny: mixin signatures need any[] constructor args.
255
+ type Ctor<T = object> = new (...args: any[]) => T
256
+
257
+ /**
258
+ * Mixin form — `class User extends billable(Model) { ... }`.
259
+ *
260
+ * Layers every `Billable` method onto `Base` and threads the same
261
+ * default-storage contract. Apps with a `payment_customers` jsonb
262
+ * column work zero-config; apps with a different layout override the
263
+ * two storage hooks on the subclass.
264
+ *
265
+ * Why both this and the `Billable` base class — apps that need a
266
+ * fresh class extend `Billable` directly; apps that already extend
267
+ * `Model` / a domain base reach for `billable(Model)`. The two share
268
+ * the same Billable prototype so behaviour is identical.
269
+ */
270
+ export function billable<TBase extends Ctor>(Base: TBase): TBase & Ctor<Billable> {
271
+ abstract class _Billable extends (Base as Ctor) {}
272
+ // Copy every Billable method (own props of the prototype) onto the
273
+ // mixin chain. We mutate the chain directly instead of `extends
274
+ // Billable` because Billable doesn't share `Base`'s ancestry —
275
+ // multiple-extends isn't a thing in JS.
276
+ for (const key of Object.getOwnPropertyNames(Billable.prototype)) {
277
+ if (key === 'constructor') continue
278
+ const desc = Object.getOwnPropertyDescriptor(Billable.prototype, key)
279
+ if (desc !== undefined) {
280
+ Object.defineProperty(_Billable.prototype, key, desc)
281
+ }
282
+ }
283
+ return _Billable as unknown as TBase & Ctor<Billable>
284
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  // `@strav/payment-omise`. The `MockDriver` in `./drivers` is
11
11
  // for tests and as the reference implementation.
12
12
 
13
+ export { Billable, type BillableStorage, billable } from './billable.ts'
13
14
  export type * from './dto/index.ts'
14
15
  export {
15
16
  extractCardToken,
@@ -29,13 +29,14 @@
29
29
  */
30
30
 
31
31
  // biome-ignore lint/style/useImportType: PostgresDatabase value import for @inject() metadata.
32
- import {
33
- PostgresDatabase,
34
- quoteIdent,
35
- type DatabaseExecutor,
36
- } from '@strav/database'
32
+ import { type DatabaseExecutor, PostgresDatabase, quoteIdent } from '@strav/database'
37
33
  import { inject, ulid } from '@strav/kernel'
38
34
  import type { NormalizedWebhookEvent } from '../dto/payment_event.ts'
35
+ import {
36
+ PaymentCustomerRow,
37
+ PaymentInvoiceRow,
38
+ PaymentSubscriptionRow,
39
+ } from './payment_ledger_models.ts'
39
40
  import { paymentCustomerSchema } from './schemas/payment_customer_schema.ts'
40
41
  import { paymentInvoiceSchema } from './schemas/payment_invoice_schema.ts'
41
42
  import { paymentSubscriptionSchema } from './schemas/payment_subscription_schema.ts'
@@ -65,10 +66,7 @@ export class PaymentLedger {
65
66
  * during `normalize`). When `_fields` is absent the upsert
66
67
  * no-ops (apps still get the event, just no local mirror).
67
68
  */
68
- async applyEvent(
69
- event: NormalizedWebhookEvent,
70
- executor?: DatabaseExecutor,
71
- ): Promise<void> {
69
+ async applyEvent(event: NormalizedWebhookEvent, executor?: DatabaseExecutor): Promise<void> {
72
70
  const fields = (event as { _fields?: Record<string, unknown> })._fields
73
71
  if (!fields) return
74
72
  const exec = executor ?? this.db
@@ -100,6 +98,67 @@ export class PaymentLedger {
100
98
  }
101
99
  }
102
100
 
101
+ // ─── Reads ────────────────────────────────────────────────────────────
102
+ //
103
+ // Lightweight query helpers for the `billable()` mixin and for app
104
+ // code that wants to render "this user's billing history" without
105
+ // pulling in a Repository. All reads honour the same `app.tenant_id`
106
+ // session setting the upserts do, so tenancy is implicit.
107
+
108
+ /**
109
+ * Find a customer row by (`provider`, `provider_id`). Returns
110
+ * `null` when no row exists — typical for billables that haven't
111
+ * been charged through this provider yet.
112
+ */
113
+ async customerByProviderId(
114
+ provider: string,
115
+ providerId: string,
116
+ ): Promise<PaymentCustomerRow | null> {
117
+ const table = quoteIdent(paymentCustomerSchema.name)
118
+ const row = await this.db.queryOne<Record<string, unknown>>(
119
+ `SELECT * FROM ${table} WHERE "provider" = $1 AND "provider_id" = $2`,
120
+ [provider, providerId],
121
+ )
122
+ return row === null ? null : hydrate(PaymentCustomerRow, row)
123
+ }
124
+
125
+ /** Subscriptions for `(provider, customer_provider_id)`, newest first. */
126
+ async subscriptionsForCustomer(
127
+ provider: string,
128
+ customerProviderId: string,
129
+ ): Promise<PaymentSubscriptionRow[]> {
130
+ const table = quoteIdent(paymentSubscriptionSchema.name)
131
+ const rows = await this.db.query<Record<string, unknown>>(
132
+ `SELECT * FROM ${table}
133
+ WHERE "provider" = $1 AND "customer_provider_id" = $2
134
+ ORDER BY "created_at" DESC`,
135
+ [provider, customerProviderId],
136
+ )
137
+ return rows.map((r) => hydrate(PaymentSubscriptionRow, r))
138
+ }
139
+
140
+ /**
141
+ * Invoices for `(provider, customer_provider_id)`. Returned newest
142
+ * first. `limit` defaults to 50 — apps rendering long lists pass a
143
+ * higher number or paginate at the app layer.
144
+ */
145
+ async invoicesForCustomer(
146
+ provider: string,
147
+ customerProviderId: string,
148
+ options: { limit?: number } = {},
149
+ ): Promise<PaymentInvoiceRow[]> {
150
+ const limit = options.limit ?? 50
151
+ const table = quoteIdent(paymentInvoiceSchema.name)
152
+ const rows = await this.db.query<Record<string, unknown>>(
153
+ `SELECT * FROM ${table}
154
+ WHERE "provider" = $1 AND "customer_provider_id" = $2
155
+ ORDER BY "created_at" DESC
156
+ LIMIT $3`,
157
+ [provider, customerProviderId, limit],
158
+ )
159
+ return rows.map((r) => hydrate(PaymentInvoiceRow, r))
160
+ }
161
+
103
162
  private async upsertCustomer(
104
163
  exec: DatabaseExecutor,
105
164
  provider: string,
@@ -138,10 +197,10 @@ export class PaymentLedger {
138
197
  providerId: string,
139
198
  ): Promise<void> {
140
199
  const table = quoteIdent(paymentCustomerSchema.name)
141
- await exec.execute(
142
- `DELETE FROM ${table} WHERE "provider" = $1 AND "provider_id" = $2`,
143
- [provider, providerId],
144
- )
200
+ await exec.execute(`DELETE FROM ${table} WHERE "provider" = $1 AND "provider_id" = $2`, [
201
+ provider,
202
+ providerId,
203
+ ])
145
204
  }
146
205
 
147
206
  private async upsertSubscription(
@@ -252,6 +311,13 @@ function nullable(v: unknown): string | null {
252
311
  return v
253
312
  }
254
313
 
314
+ /** Materialise a SELECT row into the matching Model subclass. */
315
+ function hydrate<T extends object>(Ctor: new () => T, row: Record<string, unknown>): T {
316
+ const instance = new Ctor()
317
+ Object.assign(instance, row)
318
+ return instance
319
+ }
320
+
255
321
  function toDate(v: unknown): Date | null {
256
322
  if (v === null || v === undefined) return null
257
323
  if (v instanceof Date) return v