@weft-js/pay 0.1.1 → 0.1.3

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/src/index.ts CHANGED
@@ -1,9 +1,4 @@
1
- // @weft-js/pay (PLAN §4.4, research 10): the catalog, and what a `pay.rail` provider implements. Safe in the client graph: nothing
2
- // here reaches a rail or a server. App code names products and plans; a rail is one line in `plugins`. `@weft-js/pay/server`
3
- // holds the ledger, and `checkout()` never grants anything: the grant arrives from a verified rail event.
4
- //
5
- // export const pro = product({ id: 'pro', kind: 'subscription', use: 'digital', plans: { month: { usd: 999 } }, grants: ['pro'] })
6
- // <button onClick={() => checkout({ product: 'pro', plan: 'month' })}>Go Pro · {price(pro, 'month').display}</button>
1
+ // `checkout()` never grants anything: the grant arrives from a verified rail event.
7
2
 
8
3
  /** How a product is paid for: a subscription renews, a one-time purchase stays, a consumable is used up. */
9
4
  export type ProductKind = 'subscription' | 'one-time' | 'consumable'
@@ -16,7 +11,11 @@ export type Interval = 'day' | 'week' | 'month' | 'year'
16
11
  export type Amounts = Record<string, number>
17
12
 
18
13
  /** A subscription's billing option: `Amounts`, with an optional trial and an interval when the plan's name isn't one. */
19
- export interface Plan { [currency: string]: unknown; trialDays?: number; interval?: Interval }
14
+ export interface Plan {
15
+ [currency: string]: unknown
16
+ trialDays?: number
17
+ interval?: Interval
18
+ }
20
19
 
21
20
  /** What `product()` takes. `G` is the entitlements it grants and `P` the names of its plans. */
22
21
  export interface ProductDef<G extends string = string, P extends string = string> {
@@ -36,7 +35,10 @@ export interface ProductDef<G extends string = string, P extends string = string
36
35
  }
37
36
 
38
37
  /** A checked, frozen product, as `product()` returns it. */
39
- export interface Product<G extends string = string, P extends string = string> extends ProductDef<G, P> {
38
+ export interface Product<G extends string = string, P extends string = string> extends ProductDef<
39
+ G,
40
+ P
41
+ > {
40
42
  readonly grants: readonly G[]
41
43
  }
42
44
 
@@ -46,41 +48,73 @@ const META = new Set(['trialDays', 'interval'])
46
48
  /** Thrown with a stable `code`. `answer` is the shape @weft-js/start sends any plugin's error to the client in. */
47
49
  export class PayError extends Error {
48
50
  override name = 'PayError'
49
- constructor(readonly code: string, message: string, readonly status = 400) {
51
+ constructor(
52
+ readonly code: string,
53
+ message: string,
54
+ readonly status = 400,
55
+ ) {
50
56
  super(message)
51
57
  }
52
- get answer() { return { code: this.code, message: this.message, status: this.status } }
58
+ get answer() {
59
+ return { code: this.code, message: this.message, status: this.status }
60
+ }
53
61
  }
54
62
 
55
63
  const amounts = (p: object) => Object.entries(p).filter(([k]) => !META.has(k)) as [string, number][]
56
64
 
57
65
  function checkAmounts(id: string, list: [string, unknown][]) {
58
- if (!list.length) throw new PayError('PAY008', `\`${id}\` has no amount: give one in minor units with its currency, like \`{ usd: 999 }\``)
66
+ if (!list.length)
67
+ throw new PayError(
68
+ 'PAY008',
69
+ `\`${id}\` has no amount: give one in minor units with its currency, like \`{ usd: 999 }\``,
70
+ )
59
71
  for (const [currency, n] of list) {
60
- if (!/^[a-z]{3}$/.test(currency)) throw new PayError('PAY008', `\`${id}\`: \`${currency}\` isn't a lowercase ISO currency`)
61
- if (!Number.isSafeInteger(n) || (n as number) < 0) throw new PayError('PAY008', `\`${id}\`: ${currency} ${String(n)} isn't whole minor units (999 is $9.99)`)
72
+ if (!/^[a-z]{3}$/.test(currency))
73
+ throw new PayError('PAY008', `\`${id}\`: \`${currency}\` isn't a lowercase ISO currency`)
74
+ if (!Number.isSafeInteger(n) || (n as number) < 0)
75
+ throw new PayError(
76
+ 'PAY008',
77
+ `\`${id}\`: ${currency} ${String(n)} isn't whole minor units (999 is $9.99)`,
78
+ )
62
79
  }
63
80
  }
64
81
 
65
82
  /** One thing you sell. The only place products are defined, and safe in the client graph: it holds no rail's ids. */
66
- export function product<const G extends string = never, const P extends string = never>(def: ProductDef<G, P>): Product<G, P> {
83
+ export function product<const G extends string = never, const P extends string = never>(
84
+ def: ProductDef<G, P>,
85
+ ): Product<G, P> {
67
86
  const { id, kind, plans, price: amount } = def
68
- if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw new PayError('PAY021', `product id \`${id}\` must be lowercase letters, digits and dashes`)
87
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(id))
88
+ throw new PayError(
89
+ 'PAY021',
90
+ `product id \`${id}\` must be lowercase letters, digits and dashes`,
91
+ )
69
92
  if (kind === 'subscription') {
70
- if (!plans || !Object.keys(plans).length) throw new PayError('PAY021', `subscription \`${id}\` has no \`plans\``)
71
- if (amount) throw new PayError('PAY021', `subscription \`${id}\` is priced per plan: drop \`price\``)
93
+ if (!plans || !Object.keys(plans).length)
94
+ throw new PayError('PAY021', `subscription \`${id}\` has no \`plans\``)
95
+ if (amount)
96
+ throw new PayError('PAY021', `subscription \`${id}\` is priced per plan: drop \`price\``)
72
97
  for (const [name, p] of Object.entries<Plan>(plans)) {
73
98
  checkAmounts(`${id}.${name}`, amounts(p))
74
- if (intervalOf(name, p) === null) throw new PayError('PAY021', `plan \`${id}.${name}\` is no interval: name it \`month\` or \`year\`, or add \`interval\``)
99
+ if (intervalOf(name, p) === null)
100
+ throw new PayError(
101
+ 'PAY021',
102
+ `plan \`${id}.${name}\` is no interval: name it \`month\` or \`year\`, or add \`interval\``,
103
+ )
75
104
  }
76
105
  } else {
77
- if (plans) throw new PayError('PAY021', `\`${id}\` is ${kind}: \`plans\` are for subscriptions, so use \`price\``)
106
+ if (plans)
107
+ throw new PayError(
108
+ 'PAY021',
109
+ `\`${id}\` is ${kind}: \`plans\` are for subscriptions, so use \`price\``,
110
+ )
78
111
  checkAmounts(id, Object.entries(amount ?? {}))
79
112
  }
80
113
  return Object.freeze({ ...def, grants: Object.freeze([...(def.grants ?? [])]) })
81
114
  }
82
115
 
83
- const intervalOf = (name: string, p: Plan): Interval | null => p.interval ?? (INTERVALS.includes(name) ? (name as Interval) : null)
116
+ const intervalOf = (name: string, p: Plan): Interval | null =>
117
+ p.interval ?? (INTERVALS.includes(name) ? (name as Interval) : null)
84
118
 
85
119
  /** One figure to show a buyer, as `price()` returns it. */
86
120
  export interface Price {
@@ -100,8 +134,15 @@ function formatted(amount: number, currency: string, locale?: string) {
100
134
  }
101
135
 
102
136
  /** The price of a product or one of its plans. The first currency listed, unless you name one. */
103
- export function price<P extends string>(p: Product<string, P>, plan?: P, options: { currency?: string; locale?: string } = {}): Price {
104
- const row: Record<string, unknown> | undefined = plan === undefined ? (p.price ?? (p.plans && Object.values(p.plans)[0] as Plan)) : p.plans?.[plan]
137
+ export function price<P extends string>(
138
+ p: Product<string, P>,
139
+ plan?: P,
140
+ options: { currency?: string; locale?: string } = {},
141
+ ): Price {
142
+ const row: Record<string, unknown> | undefined =
143
+ plan === undefined
144
+ ? (p.price ?? (p.plans && (Object.values(p.plans)[0] as Plan)))
145
+ : p.plans?.[plan]
105
146
  const name = plan ?? (p.plans ? Object.keys(p.plans)[0] : undefined)
106
147
  if (!row) throw new PayError('PAY021', `\`${p.id}\` has no plan \`${String(plan)}\``)
107
148
  const list = amounts(row)
@@ -111,7 +152,12 @@ export function price<P extends string>(p: Product<string, P>, plan?: P, options
111
152
  const plain = { amount, currency, display: formatted(amount, currency, options.locale) }
112
153
  if (!name) return plain
113
154
  const meta = row as Plan
114
- return { ...plain, plan: name, interval: intervalOf(name, meta)!, ...(meta.trialDays ? { trialDays: meta.trialDays } : {}) }
155
+ return {
156
+ ...plain,
157
+ plan: name,
158
+ interval: intervalOf(name, meta)!,
159
+ ...(meta.trialDays ? { trialDays: meta.trialDays } : {}),
160
+ }
115
161
  }
116
162
 
117
163
  /** What a query reads, tagged so a verified event pokes it: `tags: (_, ctx) => [entitlementTag(ctx.user.id)]`. */
@@ -119,8 +165,17 @@ export const entitlementTag = (user: string) => `entitlements:${user}`
119
165
 
120
166
  // ---- pay.rail@1 ---------------------------------------------------------------------------------------------------
121
167
 
122
- /** What a purchase is doing now, on any rail (research 10 §3). */
123
- export type PurchaseState = 'pending' | 'active' | 'trial' | 'grace' | 'billing-retry' | 'paused' | 'expired' | 'refunded' | 'revoked'
168
+ /** What a purchase is doing now, on any rail. */
169
+ export type PurchaseState =
170
+ | 'pending'
171
+ | 'active'
172
+ | 'trial'
173
+ | 'grace'
174
+ | 'billing-retry'
175
+ | 'paused'
176
+ | 'expired'
177
+ | 'refunded'
178
+ | 'revoked'
124
179
 
125
180
  /** One line a rail charges for, from the catalog. It never comes from the client. */
126
181
  export interface CheckoutLine {
@@ -0,0 +1,80 @@
1
+ import { PayError, type CheckoutLine, type Interval, type Plan, type Product } from '../index'
2
+ import type { CheckoutInput, Pay } from '../server'
3
+
4
+ const issue = (message: string) => ({ issues: [{ message }] })
5
+
6
+ /** Resolve a checkout line from the catalog. */
7
+ export function lineOf<P extends Product>(
8
+ byId: Map<string, P>,
9
+ input: CheckoutInput<P>,
10
+ ): CheckoutLine {
11
+ const p = byId.get(input.product)
12
+ if (!p) throw new PayError('PAY021', `no product \`${input.product}\``, 404)
13
+ const subscription = p.kind === 'subscription'
14
+ const name = subscription ? (input.plan ?? Object.keys(p.plans!)[0]) : undefined
15
+ const row: Plan | undefined = subscription ? p.plans![name as keyof typeof p.plans] : p.price
16
+ if (!row) throw new PayError('PAY021', `\`${p.id}\` has no plan \`${input.plan}\``, 404)
17
+ const currency =
18
+ input.currency ?? Object.keys(row).find((k) => k !== 'trialDays' && k !== 'interval')!
19
+ const amount = row[currency]
20
+ if (typeof amount !== 'number')
21
+ throw new PayError('PAY021', `\`${p.id}\` has no ${currency} price`, 404)
22
+ return {
23
+ product: p.id,
24
+ name: p.name ?? p.id,
25
+ amount,
26
+ currency,
27
+ mode: subscription ? 'subscription' : 'payment',
28
+ quantity: subscription ? 1 : (input.quantity ?? 1),
29
+ ...(subscription
30
+ ? {
31
+ plan: name!,
32
+ interval: (row.interval ?? name) as Interval,
33
+ ...(row.trialDays ? { trialDays: row.trialDays } : {}),
34
+ }
35
+ : {}),
36
+ ...(p.shipping ? { shipping: true } : {}),
37
+ }
38
+ }
39
+
40
+ /** Validate checkout input against this catalog. */
41
+ export function checkoutInput<P extends Product>(byId: Map<string, P>): Pay<P>['input'] {
42
+ return {
43
+ '~standard': {
44
+ version: 1,
45
+ vendor: 'weft',
46
+ validate(v: unknown) {
47
+ const { product, plan, currency, quantity } = (
48
+ v && typeof v === 'object' ? v : {}
49
+ ) as Partial<Record<keyof CheckoutInput, unknown>>
50
+ if (typeof product !== 'string') return issue('`product` is required')
51
+ const p = byId.get(product)
52
+ if (!p) return issue(`no product \`${product}\``)
53
+ if (plan !== undefined && (typeof plan !== 'string' || !p.plans || !(plan in p.plans)))
54
+ return issue(
55
+ `\`${p.id}\` has no plan \`${typeof plan === 'string' ? plan : JSON.stringify(plan)}\``,
56
+ )
57
+ if (currency !== undefined && typeof currency !== 'string')
58
+ return issue('`currency` is a string')
59
+ if (
60
+ quantity !== undefined &&
61
+ !(
62
+ typeof quantity === 'number' &&
63
+ Number.isInteger(quantity) &&
64
+ quantity > 0 &&
65
+ quantity < 1000
66
+ )
67
+ )
68
+ return issue('`quantity` is a whole number under 1000')
69
+ return {
70
+ value: {
71
+ product,
72
+ ...(plan !== undefined && { plan }),
73
+ ...(currency !== undefined && { currency }),
74
+ ...(quantity !== undefined && { quantity }),
75
+ },
76
+ }
77
+ },
78
+ },
79
+ }
80
+ }
@@ -0,0 +1,69 @@
1
+ import { entitlementTag, type Product, type PurchaseState, type RailEvent } from '../index'
2
+ import type { Entitlements, PayReport, PayStore, Purchase } from '../server'
3
+
4
+ const ACCESS: ReadonlySet<PurchaseState> = new Set(['active', 'trial', 'grace'])
5
+ const FINAL: ReadonlySet<PurchaseState> = new Set(['refunded', 'revoked'])
6
+
7
+ /** Derive access and apply verified events against one store. */
8
+ export function makeLedger<P extends Product>(
9
+ store: PayStore,
10
+ byId: Map<string, P>,
11
+ leeway: number,
12
+ ) {
13
+ const live = (p: Purchase) =>
14
+ ACCESS.has(p.state) && (p.expires === null || Date.now() < p.expires + leeway)
15
+
16
+ async function granted(user: string) {
17
+ const out = new Set<string>()
18
+ for (const p of await store.byUser(user))
19
+ if (live(p)) for (const g of byId.get(p.product!)?.grants ?? []) out.add(g)
20
+ return [...out].sort()
21
+ }
22
+
23
+ const entitlements = (user: string | null | undefined): Entitlements => {
24
+ let list: Promise<string[]> | undefined
25
+ const read = () => (list ??= user ? granted(user) : Promise.resolve([]))
26
+ return { has: (id) => read().then((l) => l.includes(id)), list: read }
27
+ }
28
+
29
+ /** One event onto its purchase. Identity always merges; state moves only forward on the rail's clock. */
30
+ async function apply(rail: string, e: RailEvent, report?: PayReport): Promise<string | null> {
31
+ const id = `${rail}:${e.purchase}`
32
+ const had = await store.purchase(id)
33
+ const newer =
34
+ !had || e.at > had.at || (e.at === had.at && (!FINAL.has(had.state) || FINAL.has(e.state)))
35
+ const next: Purchase = {
36
+ id,
37
+ rail,
38
+ user: had?.user ?? e.user ?? null,
39
+ product: had?.product ?? e.product ?? null,
40
+ plan: e.plan ?? had?.plan ?? null,
41
+ customer: e.customer ?? had?.customer ?? null,
42
+ state: newer ? e.state : had.state,
43
+ at: newer ? e.at : had.at,
44
+ expires: newer ? (e.expires ?? had?.expires ?? null) : had.expires,
45
+ }
46
+ await store.save(next)
47
+ if (next.product && !byId.has(next.product))
48
+ report?.({
49
+ code: 'PAY021',
50
+ severity: 'warning',
51
+ message: `purchase \`${id}\` is for \`${next.product}\`, which isn't in the catalog: it grants nothing`,
52
+ })
53
+ return next.user && entitlementTag(next.user)
54
+ }
55
+
56
+ async function ingest(rail: string, events: RailEvent[], report?: PayReport) {
57
+ const tags = new Set<string>()
58
+ for (const e of events) {
59
+ const key = `${rail}:${e.id}`
60
+ if (await store.seen(key)) continue
61
+ const tag = await apply(rail, e, report)
62
+ await store.mark(key, e.at)
63
+ if (tag) tags.add(tag)
64
+ }
65
+ return [...tags]
66
+ }
67
+
68
+ return { live, entitlements, ingest }
69
+ }
@@ -0,0 +1,142 @@
1
+ import { PayError, type PayRail, type Product, type RailEvent } from '../index'
2
+ import type {
3
+ CheckoutInput,
4
+ PayContext,
5
+ PayOptions,
6
+ PayReport,
7
+ PayStore,
8
+ Purchase,
9
+ } from '../server'
10
+ import { lineOf } from './catalog'
11
+
12
+ const BODY = 1 << 20
13
+ const hex = async (s: string) =>
14
+ [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)))]
15
+ .map((b) => b.toString(16).padStart(2, '0'))
16
+ .join('')
17
+ const errorMessage = (e: unknown) => String((e as Error | null)?.message ?? e)
18
+
19
+ type Services<P extends Product> = {
20
+ options: PayOptions<P>
21
+ urls: PayOptions<P>['urls']
22
+ userOf: (ctx: PayContext) => string | null | undefined
23
+ store: PayStore
24
+ railFor: (key?: string) => Promise<PayRail>
25
+ live: (purchase: Purchase) => boolean
26
+ byId: Map<string, P>
27
+ }
28
+
29
+ /** Create the checkout operation for this pay instance. */
30
+ export function checkoutFor<P extends Product>({
31
+ options,
32
+ urls,
33
+ userOf,
34
+ store,
35
+ railFor,
36
+ live,
37
+ byId,
38
+ }: Services<P>) {
39
+ async function checkout(ctx: PayContext, input: CheckoutInput<P>) {
40
+ const user = userOf(ctx)
41
+ if (!user)
42
+ throw new PayError(
43
+ 'PAY010',
44
+ 'Sign in before buying: a purchase has to belong to someone',
45
+ 401,
46
+ )
47
+ const line = lineOf(byId, input)
48
+ const rail = await railFor()
49
+ const mine = await store.byUser(user)
50
+ if (line.mode === 'subscription' && mine.some((p) => p.product === line.product && live(p)))
51
+ throw new PayError('PAY023', `You already have \`${line.product}\``, 409)
52
+ const origin = options.origin ?? new URL(ctx.request.url).origin
53
+ const customer = mine.find((p) => p.customer && p.rail === rail.id)?.customer ?? undefined
54
+ const idempotencyKey = await hex(JSON.stringify([user, line, Math.floor(Date.now() / 60_000)]))
55
+ const { url } = await rail.checkout({
56
+ user,
57
+ ...(customer && { customer }),
58
+ line,
59
+ successUrl: new URL(urls.success, origin).href,
60
+ cancelUrl: new URL(urls.cancel, origin).href,
61
+ idempotencyKey,
62
+ })
63
+ return url
64
+ }
65
+ return checkout
66
+ }
67
+
68
+ /** Create the portal operation for this pay instance. */
69
+ export function portalFor<P extends Product>({
70
+ options,
71
+ urls,
72
+ userOf,
73
+ store,
74
+ railFor,
75
+ }: Services<P>) {
76
+ async function portal(ctx: PayContext) {
77
+ const user = userOf(ctx)
78
+ if (!user) throw new PayError('PAY010', 'Sign in first', 401)
79
+ const rail = await railFor()
80
+ const customer = (await store.byUser(user)).find(
81
+ (p) => p.customer && p.rail === rail.id,
82
+ )?.customer
83
+ if (!customer)
84
+ throw new PayError('PAY024', 'Nothing to manage yet: this user has bought nothing here', 404)
85
+ const origin = options.origin ?? new URL(ctx.request.url).origin
86
+ return (
87
+ await rail.portal({ customer, returnUrl: new URL(urls.portal ?? urls.cancel, origin).href })
88
+ ).url
89
+ }
90
+ return portal
91
+ }
92
+
93
+ /** Create the verified delivery operation for this pay instance. */
94
+ export function webhookFor(
95
+ railFor: (key?: string) => Promise<PayRail>,
96
+ ingest: (rail: string, events: RailEvent[], report?: PayReport) => Promise<string[]>,
97
+ ) {
98
+ async function webhook(key: string, request: Request, report?: PayReport) {
99
+ const plain = (body: string, status: number) => ({
100
+ response: new Response(body, { status, headers: { 'content-type': 'text/plain' } }),
101
+ tags: [],
102
+ })
103
+ if (request.method !== 'POST') return plain('Method not allowed', 405)
104
+ let rail: PayRail
105
+ try {
106
+ rail = await railFor(key)
107
+ } catch {
108
+ return plain('Not found', 404)
109
+ }
110
+ if (Number(request.headers.get('content-length') ?? 0) > BODY) return plain('Too large', 413)
111
+ const body = await request.text() // the exact bytes: a parsed and re-encoded body can't be verified
112
+ if (body.length > BODY) return plain('Too large', 413)
113
+ let events: RailEvent[]
114
+ try {
115
+ events = await rail.verify({ body, headers: request.headers })
116
+ } catch (e) {
117
+ // A retry can't fix this one, and the reason stays in the log: a forger learns nothing.
118
+ report?.({
119
+ code: (e as { code?: string } | null)?.code ?? 'PAY011',
120
+ severity: 'error',
121
+ message: `a ${key} webhook failed verification: ${errorMessage(e)}`,
122
+ })
123
+ return plain('Bad signature', 400)
124
+ }
125
+ try {
126
+ return {
127
+ response: new Response('{"received":true}', {
128
+ headers: { 'content-type': 'application/json' },
129
+ }),
130
+ tags: await ingest(rail.id, events, report),
131
+ }
132
+ } catch (e) {
133
+ report?.({
134
+ code: 'E_PAY',
135
+ severity: 'error',
136
+ message: `a ${key} webhook failed, and it will be redelivered: ${errorMessage(e)}`,
137
+ })
138
+ return plain('Try again', 500)
139
+ }
140
+ }
141
+ return webhook
142
+ }
@@ -0,0 +1,45 @@
1
+ import { PayError, type PayRail, type Product } from '../index'
2
+ import type { PayOptions } from '../server'
3
+
4
+ /** Keep one override and provider cache for this pay instance. */
5
+ export function makeRails<P extends Product>(
6
+ options: PayOptions<P>,
7
+ slot: () => Promise<{
8
+ load: (key: string) => Promise<unknown>
9
+ providers?: Record<string, unknown>
10
+ }>,
11
+ ) {
12
+ let override: PayRail | undefined
13
+ const rails = new Map<string, Promise<PayRail>>()
14
+
15
+ async function railFor(key?: string): Promise<PayRail> {
16
+ if (override) return override
17
+ const { load, providers = {} } = await slot()
18
+ const bound = Object.keys(providers).filter((k) => providers[k])
19
+ if (!bound.length)
20
+ throw new PayError(
21
+ 'P101',
22
+ '`pay.rail` has no provider: add one to `plugins` in weft.config.ts',
23
+ 500,
24
+ )
25
+ const pick = key ?? options.rail ?? (bound.length === 1 ? bound[0] : undefined)
26
+ if (!pick || !bound.includes(pick))
27
+ throw new PayError(
28
+ 'PAY022',
29
+ pick
30
+ ? `no rail \`${pick}\` is bound (${bound.join(', ')})`
31
+ : `${bound.join(' and ')} are both bound: set \`rail\` in \`definePay\``,
32
+ 500,
33
+ )
34
+ let r = rails.get(pick)
35
+ if (!r) rails.set(pick, (r = load(pick) as Promise<PayRail>))
36
+ return r
37
+ }
38
+
39
+ return {
40
+ railFor,
41
+ use: (rail: PayRail | undefined) => {
42
+ override = rail
43
+ },
44
+ }
45
+ }