@weft-js/pay 0.0.0-stage → 0.1.1
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/LICENSE +16 -0
- package/README.md +8 -2
- package/docs/pay.md +118 -0
- package/package.json +46 -4
- package/plugin.d.mts +5 -0
- package/plugin.mjs +50 -0
- package/src/checks.mjs +230 -0
- package/src/drizzle.ts +38 -0
- package/src/index.ts +181 -0
- package/src/server.ts +328 -0
- package/src/setup.mjs +99 -0
- package/src/stripe.ts +198 -0
- package/src/test.ts +169 -0
- package/stripe-plugin.d.mts +5 -0
- package/stripe-plugin.mjs +19 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
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>
|
|
7
|
+
|
|
8
|
+
/** How a product is paid for: a subscription renews, a one-time purchase stays, a consumable is used up. */
|
|
9
|
+
export type ProductKind = 'subscription' | 'one-time' | 'consumable'
|
|
10
|
+
/** What the buyer gets: app stores care, and `physical` goods ship. */
|
|
11
|
+
export type ProductUse = 'digital' | 'physical' | 'service'
|
|
12
|
+
/** How often a subscription bills. */
|
|
13
|
+
export type Interval = 'day' | 'week' | 'month' | 'year'
|
|
14
|
+
|
|
15
|
+
/** Minor units per ISO currency: `{ usd: 999 }` is $9.99. Integers, always (PAY008). */
|
|
16
|
+
export type Amounts = Record<string, number>
|
|
17
|
+
|
|
18
|
+
/** 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 }
|
|
20
|
+
|
|
21
|
+
/** What `product()` takes. `G` is the entitlements it grants and `P` the names of its plans. */
|
|
22
|
+
export interface ProductDef<G extends string = string, P extends string = string> {
|
|
23
|
+
id: string
|
|
24
|
+
kind: ProductKind
|
|
25
|
+
use: ProductUse
|
|
26
|
+
/** Display name at checkout. Default: the id. */
|
|
27
|
+
name?: string
|
|
28
|
+
/** A subscription's billing options: `{ month: { usd: 999 }, year: { usd: 7999, trialDays: 7 } }`. */
|
|
29
|
+
plans?: Record<P, Plan>
|
|
30
|
+
/** A one-time or consumable product's price. */
|
|
31
|
+
price?: Amounts
|
|
32
|
+
/** The entitlements a live purchase of this product gives. */
|
|
33
|
+
grants?: readonly G[]
|
|
34
|
+
/** One-time and physical goods: collect an address. */
|
|
35
|
+
shipping?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A checked, frozen product, as `product()` returns it. */
|
|
39
|
+
export interface Product<G extends string = string, P extends string = string> extends ProductDef<G, P> {
|
|
40
|
+
readonly grants: readonly G[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const INTERVALS: readonly string[] = ['day', 'week', 'month', 'year']
|
|
44
|
+
const META = new Set(['trialDays', 'interval'])
|
|
45
|
+
|
|
46
|
+
/** Thrown with a stable `code`. `answer` is the shape @weft-js/start sends any plugin's error to the client in. */
|
|
47
|
+
export class PayError extends Error {
|
|
48
|
+
override name = 'PayError'
|
|
49
|
+
constructor(readonly code: string, message: string, readonly status = 400) {
|
|
50
|
+
super(message)
|
|
51
|
+
}
|
|
52
|
+
get answer() { return { code: this.code, message: this.message, status: this.status } }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const amounts = (p: object) => Object.entries(p).filter(([k]) => !META.has(k)) as [string, number][]
|
|
56
|
+
|
|
57
|
+
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 }\``)
|
|
59
|
+
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)`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 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> {
|
|
67
|
+
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`)
|
|
69
|
+
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\``)
|
|
72
|
+
for (const [name, p] of Object.entries<Plan>(plans)) {
|
|
73
|
+
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\``)
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
if (plans) throw new PayError('PAY021', `\`${id}\` is ${kind}: \`plans\` are for subscriptions, so use \`price\``)
|
|
78
|
+
checkAmounts(id, Object.entries(amount ?? {}))
|
|
79
|
+
}
|
|
80
|
+
return Object.freeze({ ...def, grants: Object.freeze([...(def.grants ?? [])]) })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const intervalOf = (name: string, p: Plan): Interval | null => p.interval ?? (INTERVALS.includes(name) ? (name as Interval) : null)
|
|
84
|
+
|
|
85
|
+
/** One figure to show a buyer, as `price()` returns it. */
|
|
86
|
+
export interface Price {
|
|
87
|
+
amount: number
|
|
88
|
+
currency: string
|
|
89
|
+
/** Formatted for the reader's locale: `$9.99`. */
|
|
90
|
+
display: string
|
|
91
|
+
plan?: string
|
|
92
|
+
interval?: Interval
|
|
93
|
+
trialDays?: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The figure in major units, by the currency's own exponent: JPY has none, KWD has three. */
|
|
97
|
+
function formatted(amount: number, currency: string, locale?: string) {
|
|
98
|
+
const f = new Intl.NumberFormat(locale, { style: 'currency', currency: currency.toUpperCase() })
|
|
99
|
+
return f.format(amount / 10 ** (f.resolvedOptions().maximumFractionDigits ?? 2))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 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]
|
|
105
|
+
const name = plan ?? (p.plans ? Object.keys(p.plans)[0] : undefined)
|
|
106
|
+
if (!row) throw new PayError('PAY021', `\`${p.id}\` has no plan \`${String(plan)}\``)
|
|
107
|
+
const list = amounts(row)
|
|
108
|
+
const pick = options.currency ? list.find(([c]) => c === options.currency) : list[0]
|
|
109
|
+
if (!pick) throw new PayError('PAY021', `\`${p.id}\` has no ${options.currency} price`)
|
|
110
|
+
const [currency, amount] = pick
|
|
111
|
+
const plain = { amount, currency, display: formatted(amount, currency, options.locale) }
|
|
112
|
+
if (!name) return plain
|
|
113
|
+
const meta = row as Plan
|
|
114
|
+
return { ...plain, plan: name, interval: intervalOf(name, meta)!, ...(meta.trialDays ? { trialDays: meta.trialDays } : {}) }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What a query reads, tagged so a verified event pokes it: `tags: (_, ctx) => [entitlementTag(ctx.user.id)]`. */
|
|
118
|
+
export const entitlementTag = (user: string) => `entitlements:${user}`
|
|
119
|
+
|
|
120
|
+
// ---- pay.rail@1 ---------------------------------------------------------------------------------------------------
|
|
121
|
+
|
|
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'
|
|
124
|
+
|
|
125
|
+
/** One line a rail charges for, from the catalog. It never comes from the client. */
|
|
126
|
+
export interface CheckoutLine {
|
|
127
|
+
product: string
|
|
128
|
+
plan?: string
|
|
129
|
+
name: string
|
|
130
|
+
amount: number
|
|
131
|
+
currency: string
|
|
132
|
+
mode: 'subscription' | 'payment'
|
|
133
|
+
interval?: Interval
|
|
134
|
+
trialDays?: number
|
|
135
|
+
quantity: number
|
|
136
|
+
shipping?: boolean
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What `checkout` asks of a rail: who is buying, what, and where the buyer comes back to. */
|
|
140
|
+
export interface CheckoutRequest {
|
|
141
|
+
/** Who is buying: attached to the rail's records, and read back from its events. */
|
|
142
|
+
user: string
|
|
143
|
+
/** This user's id on the rail, when a purchase gave one. */
|
|
144
|
+
customer?: string
|
|
145
|
+
line: CheckoutLine
|
|
146
|
+
successUrl: string
|
|
147
|
+
cancelUrl: string
|
|
148
|
+
/** The same click twice gives one session. */
|
|
149
|
+
idempotencyKey: string
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A rail's event, normalised. `checkout` and these are all that a rail and the ledger say to each other. */
|
|
153
|
+
export interface RailEvent {
|
|
154
|
+
/** The rail's own id for the event: what makes a redelivery a no-op. */
|
|
155
|
+
id: string
|
|
156
|
+
/** The rail's own clock, in ms: events arrive out of order, and this orders them. */
|
|
157
|
+
at: number
|
|
158
|
+
/** The rail's id for the purchase: a subscription, a payment. */
|
|
159
|
+
purchase: string
|
|
160
|
+
state: PurchaseState
|
|
161
|
+
/** Set when the rail's event carries them; an event for a purchase nobody knows without them is dropped. */
|
|
162
|
+
user?: string
|
|
163
|
+
product?: string
|
|
164
|
+
plan?: string
|
|
165
|
+
customer?: string
|
|
166
|
+
/** When a paid period ends, in ms. */
|
|
167
|
+
expires?: number
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `pay.rail@1`. A provider implements this and declares it for the `server` side. `verify` is the only way in for an
|
|
172
|
+
* event: it gets the exact bytes received, and throws `PayError('PAY011', …)` for anything it can't prove.
|
|
173
|
+
*/
|
|
174
|
+
export interface PayRail {
|
|
175
|
+
readonly id: string
|
|
176
|
+
checkout(request: CheckoutRequest): Promise<{ url: string }>
|
|
177
|
+
/** Where a buyer manages or cancels. */
|
|
178
|
+
portal(request: { customer: string; returnUrl: string }): Promise<{ url: string }>
|
|
179
|
+
verify(request: { body: string; headers: Headers }): RailEvent[] | Promise<RailEvent[]>
|
|
180
|
+
refund?(request: { purchase: string }): Promise<void>
|
|
181
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// @weft-js/pay/server (research 10 §3, 37): one ledger for every rail. A rail's `verify` turns the bytes it received into events;
|
|
2
|
+
// `ingest` folds them into `purchases`; entitlements are read from those, against the catalog, so nothing else can grant
|
|
3
|
+
// anything. `src/pay.server.ts` default-exports what `definePay` returns, and that value is a `server.extension`: it is
|
|
4
|
+
// how this package reaches the server, through the slot any plugin can use. No line of @weft-js/start names payments.
|
|
5
|
+
//
|
|
6
|
+
// Delivery is at least once, in any order. An event applies by the rail's own clock, so an old one changes nothing and a
|
|
7
|
+
// repeat lands on what it already wrote; `mark` comes last, because a crash before it should redeliver. A failure answers
|
|
8
|
+
// 500 for the same reason. A terminal state wins a tie: a refund and a renewal in the same second is a refund.
|
|
9
|
+
|
|
10
|
+
import type { Context } from '@weft-js/start'
|
|
11
|
+
import type { ServerExtension } from '@weft-js/start/extension'
|
|
12
|
+
import { entitlementTag, PayError, type CheckoutLine, type Interval, type PayRail, type Plan, type Product, type PurchaseState, type RailEvent } from './index'
|
|
13
|
+
|
|
14
|
+
declare module '@weft-js/start' {
|
|
15
|
+
interface Context {
|
|
16
|
+
/** What this user holds, from verified rail events. Await it. */
|
|
17
|
+
entitlements: Entitlements
|
|
18
|
+
}
|
|
19
|
+
interface FnOptions {
|
|
20
|
+
/** Entitlements this call needs, on the server: a typed 403 otherwise. */
|
|
21
|
+
requires?: readonly string[]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One row of the ledger: where a purchase stands now, whatever rail it came through. */
|
|
26
|
+
export interface Purchase {
|
|
27
|
+
/** `<rail>:<rail's id>`. */
|
|
28
|
+
id: string
|
|
29
|
+
rail: string
|
|
30
|
+
/** Null until an event names them: a dispute can arrive before the payment it disputes. */
|
|
31
|
+
user: string | null
|
|
32
|
+
product: string | null
|
|
33
|
+
plan: string | null
|
|
34
|
+
state: PurchaseState
|
|
35
|
+
/** The rail's time of the last event applied. */
|
|
36
|
+
at: number
|
|
37
|
+
expires: number | null
|
|
38
|
+
customer: string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where the ledger lives. `drizzlePayStore` in `@weft-js/start/pay/drizzle`, or write these five for any database. */
|
|
42
|
+
export interface PayStore {
|
|
43
|
+
/** Not durable: refused on a production server (PAY016). */
|
|
44
|
+
readonly memory?: boolean
|
|
45
|
+
seen(event: string): Promise<boolean>
|
|
46
|
+
mark(event: string, at: number): Promise<void>
|
|
47
|
+
purchase(id: string): Promise<Purchase | null>
|
|
48
|
+
/** Insert or replace by `id`. */
|
|
49
|
+
save(purchase: Purchase): Promise<void>
|
|
50
|
+
byUser(user: string): Promise<Purchase[]>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A ledger in memory, for dev and tests: `clear` empties it. */
|
|
54
|
+
export function memoryPayStore(): PayStore & { clear(): void } {
|
|
55
|
+
const events = new Set<string>()
|
|
56
|
+
const rows = new Map<string, Purchase>()
|
|
57
|
+
return {
|
|
58
|
+
memory: true,
|
|
59
|
+
seen: e => Promise.resolve(events.has(e)),
|
|
60
|
+
mark(e) { events.add(e); return Promise.resolve() },
|
|
61
|
+
purchase: id => Promise.resolve(rows.get(id) ?? null),
|
|
62
|
+
save(p) { rows.set(p.id, p); return Promise.resolve() },
|
|
63
|
+
byUser: user => Promise.resolve([...rows.values()].filter(p => p.user === user)),
|
|
64
|
+
clear() { events.clear(); rows.clear() },
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What one user holds, from the ledger and the catalog. */
|
|
69
|
+
export interface Entitlements {
|
|
70
|
+
/** Await it: a promise is always truthy (PAY015). */
|
|
71
|
+
has(id: string): Promise<boolean>
|
|
72
|
+
list(): Promise<string[]>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What pay reads of `ctx`: the request's URL, and what `user` reads, `user.id` by default. A real `ctx` fits, and so does a test's. */
|
|
76
|
+
export type PayContext = Partial<Omit<Context, 'request'>> & { request: { url: string }; user?: { id?: string | null } | null }
|
|
77
|
+
|
|
78
|
+
/** What `definePay` takes. */
|
|
79
|
+
export interface PayOptions<P extends Product> {
|
|
80
|
+
/** `import * as catalog from './pay/catalog'`, or a list. */
|
|
81
|
+
catalog: readonly P[] | Record<string, P>
|
|
82
|
+
store: PayStore
|
|
83
|
+
/** Paths or URLs a buyer comes back to. They never come from the client. */
|
|
84
|
+
urls: { success: string; cancel: string; portal?: string }
|
|
85
|
+
/** Who a request is. Default `ctx.user?.id`. */
|
|
86
|
+
user?: (ctx: PayContext) => string | null | undefined
|
|
87
|
+
/** With several rails bound, the one that takes payments. One rail needs no choosing. */
|
|
88
|
+
rail?: string
|
|
89
|
+
/** The `pay.rail` slot. Default the generated `weft:slot/pay.rail`, as any consumer of a slot reads it. */
|
|
90
|
+
slot?: () => Promise<{ load(key: string): Promise<unknown>; providers?: Record<string, unknown> }>
|
|
91
|
+
/** Absolute origin for `urls`, behind a proxy. Default the request's. */
|
|
92
|
+
origin?: string
|
|
93
|
+
/** How long past a paid period access lasts, while a late renewal event arrives. Default 3 days. */
|
|
94
|
+
leeway?: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The server log: @weft-js/start passes its own. */
|
|
98
|
+
export type PayReport = (d: { code: string; severity: string; message: string }) => void
|
|
99
|
+
|
|
100
|
+
/** What a client may ask for: names only. The catalog supplies every amount. */
|
|
101
|
+
export interface CheckoutInput<P extends Product = Product> {
|
|
102
|
+
product: P['id']
|
|
103
|
+
plan?: string
|
|
104
|
+
currency?: string
|
|
105
|
+
quantity?: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** What `definePay` returns: the ledger's calls, and a `server.extension` that carries them to the server. */
|
|
109
|
+
export interface Pay<P extends Product = Product> extends ServerExtension {
|
|
110
|
+
readonly catalog: readonly P[]
|
|
111
|
+
/** For a mutation's `input`: the catalog decides what can be bought, and the client sends only names. */
|
|
112
|
+
readonly input: { readonly '~standard': { version: 1; vendor: 'weft'; validate(v: unknown): { value: CheckoutInput<P> } | { issues: { message: string }[] }; types?: { input: CheckoutInput<P>; output: CheckoutInput<P> } } }
|
|
113
|
+
/** The hosted checkout's URL, for this buyer. Grants nothing. */
|
|
114
|
+
checkout(ctx: PayContext, input: CheckoutInput<P>): Promise<string>
|
|
115
|
+
/** Where this buyer manages or cancels. */
|
|
116
|
+
portal(ctx: PayContext): Promise<string>
|
|
117
|
+
/** Read for a user id, as a webhook or a job would. In a request, `ctx.entitlements`. */
|
|
118
|
+
entitlements(user: string): Entitlements
|
|
119
|
+
/** `ctx.entitlements` for this context. */
|
|
120
|
+
forContext(ctx: PayContext): Entitlements
|
|
121
|
+
purchases(user: string): Promise<Purchase[]>
|
|
122
|
+
/** One verified delivery. `tags` go on to the app's live readers. */
|
|
123
|
+
webhook(rail: string, request: Request, report?: PayReport): Promise<{ response: Response; tags: string[] }>
|
|
124
|
+
/** Apply events that have been verified. Anything else must go through `webhook`. */
|
|
125
|
+
ingest(rail: string, events: RailEvent[], report?: PayReport): Promise<string[]>
|
|
126
|
+
refund(purchase: string): Promise<void>
|
|
127
|
+
/** A rail in place of the slot: `fakePay`. */
|
|
128
|
+
use(rail: PayRail | undefined): void
|
|
129
|
+
/** Back to the slot, with an empty ledger if it can be emptied: `net.extension` calls this after each test. */
|
|
130
|
+
reset(): void
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ACCESS: ReadonlySet<PurchaseState> = new Set(['active', 'trial', 'grace'])
|
|
134
|
+
const FINAL: ReadonlySet<PurchaseState> = new Set(['refunded', 'revoked'])
|
|
135
|
+
const DAY = 86_400_000
|
|
136
|
+
const BODY = 1 << 20
|
|
137
|
+
|
|
138
|
+
const hex = async (s: string) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)))].map(b => b.toString(16).padStart(2, '0')).join('')
|
|
139
|
+
const issue = (message: string) => ({ issues: [{ message }] })
|
|
140
|
+
const errorMessage = (e: unknown) => String((e as Error | null)?.message ?? e)
|
|
141
|
+
|
|
142
|
+
/** The one ledger of an app: `src/pay.server.ts` default-exports what this returns. */
|
|
143
|
+
export function definePay<P extends Product>(options: PayOptions<P>): Pay<P> {
|
|
144
|
+
const catalog = (Array.isArray(options.catalog) ? options.catalog : Object.values(options.catalog)) as P[]
|
|
145
|
+
const byId = new Map(catalog.map(p => [p.id, p]))
|
|
146
|
+
const { store, urls, leeway = 3 * DAY } = options
|
|
147
|
+
const userOf = options.user ?? (ctx => ctx?.user?.id)
|
|
148
|
+
const slot = options.slot ?? (() => {
|
|
149
|
+
// A generated module, declared in .weft/slots.d.ts only once bound: an expect-error would be wrong in one case or the other.
|
|
150
|
+
// oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- the cast is what lets this compile when the slot isn't declared
|
|
151
|
+
return import('weft:slot/pay.rail' as string)
|
|
152
|
+
})
|
|
153
|
+
let override: PayRail | undefined
|
|
154
|
+
const rails = new Map<string, Promise<PayRail>>()
|
|
155
|
+
|
|
156
|
+
async function railFor(key?: string): Promise<PayRail> {
|
|
157
|
+
if (override) return override
|
|
158
|
+
const { load, providers = {} } = await slot()
|
|
159
|
+
const bound = Object.keys(providers).filter(k => providers[k])
|
|
160
|
+
if (!bound.length) throw new PayError('P101', '`pay.rail` has no provider: add one to `plugins` in weft.config.ts', 500)
|
|
161
|
+
const pick = key ?? options.rail ?? (bound.length === 1 ? bound[0] : undefined)
|
|
162
|
+
if (!pick || !bound.includes(pick)) throw new PayError('PAY022', pick ? `no rail \`${pick}\` is bound (${bound.join(', ')})` : `${bound.join(' and ')} are both bound: set \`rail\` in \`definePay\``, 500)
|
|
163
|
+
let r = rails.get(pick)
|
|
164
|
+
if (!r) rails.set(pick, (r = load(pick) as Promise<PayRail>))
|
|
165
|
+
return r
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function lineOf(input: CheckoutInput<P>): CheckoutLine {
|
|
169
|
+
const p = byId.get(input.product)
|
|
170
|
+
if (!p) throw new PayError('PAY021', `no product \`${input.product}\``, 404)
|
|
171
|
+
const subscription = p.kind === 'subscription'
|
|
172
|
+
const name = subscription ? input.plan ?? Object.keys(p.plans!)[0] : undefined
|
|
173
|
+
const row: Plan | undefined = subscription ? p.plans![name as keyof typeof p.plans] : p.price
|
|
174
|
+
if (!row) throw new PayError('PAY021', `\`${p.id}\` has no plan \`${input.plan}\``, 404)
|
|
175
|
+
const currency = input.currency ?? Object.keys(row).find(k => k !== 'trialDays' && k !== 'interval')!
|
|
176
|
+
const amount = row[currency]
|
|
177
|
+
if (typeof amount !== 'number') throw new PayError('PAY021', `\`${p.id}\` has no ${currency} price`, 404)
|
|
178
|
+
return {
|
|
179
|
+
product: p.id, name: p.name ?? p.id, amount, currency, mode: subscription ? 'subscription' : 'payment', quantity: subscription ? 1 : input.quantity ?? 1,
|
|
180
|
+
...(subscription ? { plan: name!, interval: (row.interval ?? name) as Interval, ...(row.trialDays ? { trialDays: row.trialDays } : {}) } : {}),
|
|
181
|
+
...(p.shipping ? { shipping: true } : {}),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const live = (p: Purchase) => ACCESS.has(p.state) && (p.expires === null || Date.now() < p.expires + leeway)
|
|
186
|
+
|
|
187
|
+
async function granted(user: string) {
|
|
188
|
+
const out = new Set<string>()
|
|
189
|
+
for (const p of await store.byUser(user)) if (live(p)) for (const g of byId.get(p.product!)?.grants ?? []) out.add(g)
|
|
190
|
+
return [...out].sort()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const entitlements = (user: string | null | undefined): Entitlements => {
|
|
194
|
+
let list: Promise<string[]> | undefined
|
|
195
|
+
const read = () => (list ??= user ? granted(user) : Promise.resolve([]))
|
|
196
|
+
return { has: id => read().then(l => l.includes(id)), list: read }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One event onto its purchase. Identity always merges; state moves only forward on the rail's clock. */
|
|
200
|
+
async function apply(rail: string, e: RailEvent, report?: PayReport): Promise<string | null> {
|
|
201
|
+
const id = `${rail}:${e.purchase}`
|
|
202
|
+
const had = await store.purchase(id)
|
|
203
|
+
const newer = !had || e.at > had.at || (e.at === had.at && (!FINAL.has(had.state) || FINAL.has(e.state)))
|
|
204
|
+
const next: Purchase = {
|
|
205
|
+
id, rail,
|
|
206
|
+
user: had?.user ?? e.user ?? null, product: had?.product ?? e.product ?? null, plan: e.plan ?? had?.plan ?? null, customer: e.customer ?? had?.customer ?? null,
|
|
207
|
+
state: newer ? e.state : had.state, at: newer ? e.at : had.at, expires: newer ? e.expires ?? had?.expires ?? null : had.expires,
|
|
208
|
+
}
|
|
209
|
+
await store.save(next)
|
|
210
|
+
if (next.product && !byId.has(next.product)) report?.({ code: 'PAY021', severity: 'warning', message: `purchase \`${id}\` is for \`${next.product}\`, which isn't in the catalog: it grants nothing` })
|
|
211
|
+
return next.user && entitlementTag(next.user)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function ingest(rail: string, events: RailEvent[], report?: PayReport) {
|
|
215
|
+
const tags = new Set<string>()
|
|
216
|
+
for (const e of events) {
|
|
217
|
+
const key = `${rail}:${e.id}`
|
|
218
|
+
if (await store.seen(key)) continue
|
|
219
|
+
const tag = await apply(rail, e, report)
|
|
220
|
+
await store.mark(key, e.at)
|
|
221
|
+
if (tag) tags.add(tag)
|
|
222
|
+
}
|
|
223
|
+
return [...tags]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const pay: Pay<P> = {
|
|
227
|
+
catalog,
|
|
228
|
+
input: {
|
|
229
|
+
'~standard': {
|
|
230
|
+
version: 1,
|
|
231
|
+
vendor: 'weft',
|
|
232
|
+
validate(v: unknown) {
|
|
233
|
+
const { product, plan, currency, quantity } = (v && typeof v === 'object' ? v : {}) as Partial<Record<keyof CheckoutInput, unknown>>
|
|
234
|
+
if (typeof product !== 'string') return issue('`product` is required')
|
|
235
|
+
const p = byId.get(product)
|
|
236
|
+
if (!p) return issue(`no product \`${product}\``)
|
|
237
|
+
if (plan !== undefined && (typeof plan !== 'string' || !p.plans || !(plan in p.plans))) return issue(`\`${p.id}\` has no plan \`${typeof plan === 'string' ? plan : JSON.stringify(plan)}\``)
|
|
238
|
+
if (currency !== undefined && typeof currency !== 'string') return issue('`currency` is a string')
|
|
239
|
+
if (quantity !== undefined && !(typeof quantity === 'number' && Number.isInteger(quantity) && quantity > 0 && quantity < 1000)) return issue('`quantity` is a whole number under 1000')
|
|
240
|
+
return { value: { product, ...(plan !== undefined && { plan }), ...(currency !== undefined && { currency }), ...(quantity !== undefined && { quantity }) } }
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
async checkout(ctx, input) {
|
|
245
|
+
const user = userOf(ctx)
|
|
246
|
+
if (!user) throw new PayError('PAY010', 'Sign in before buying: a purchase has to belong to someone', 401)
|
|
247
|
+
const line = lineOf(input)
|
|
248
|
+
const rail = await railFor()
|
|
249
|
+
const mine = await store.byUser(user)
|
|
250
|
+
if (line.mode === 'subscription' && mine.some(p => p.product === line.product && live(p))) throw new PayError('PAY023', `You already have \`${line.product}\``, 409)
|
|
251
|
+
const origin = options.origin ?? new URL(ctx.request.url).origin
|
|
252
|
+
const customer = mine.find(p => p.customer && p.rail === rail.id)?.customer ?? undefined
|
|
253
|
+
const idempotencyKey = await hex(JSON.stringify([user, line, Math.floor(Date.now() / 60_000)]))
|
|
254
|
+
const { url } = await rail.checkout({ user, ...(customer && { customer }), line, successUrl: new URL(urls.success, origin).href, cancelUrl: new URL(urls.cancel, origin).href, idempotencyKey })
|
|
255
|
+
return url
|
|
256
|
+
},
|
|
257
|
+
async portal(ctx) {
|
|
258
|
+
const user = userOf(ctx)
|
|
259
|
+
if (!user) throw new PayError('PAY010', 'Sign in first', 401)
|
|
260
|
+
const rail = await railFor()
|
|
261
|
+
const customer = (await store.byUser(user)).find(p => p.customer && p.rail === rail.id)?.customer
|
|
262
|
+
if (!customer) throw new PayError('PAY024', 'Nothing to manage yet: this user has bought nothing here', 404)
|
|
263
|
+
const origin = options.origin ?? new URL(ctx.request.url).origin
|
|
264
|
+
return (await rail.portal({ customer, returnUrl: new URL(urls.portal ?? urls.cancel, origin).href })).url
|
|
265
|
+
},
|
|
266
|
+
entitlements,
|
|
267
|
+
forContext: ctx => entitlements(userOf(ctx)),
|
|
268
|
+
purchases: user => store.byUser(user),
|
|
269
|
+
ingest,
|
|
270
|
+
async webhook(key, request, report) {
|
|
271
|
+
const plain = (body: string, status: number) => ({ response: new Response(body, { status, headers: { 'content-type': 'text/plain' } }), tags: [] })
|
|
272
|
+
if (request.method !== 'POST') return plain('Method not allowed', 405)
|
|
273
|
+
let rail: PayRail
|
|
274
|
+
try { rail = await railFor(key) } catch { return plain('Not found', 404) }
|
|
275
|
+
if (Number(request.headers.get('content-length') ?? 0) > BODY) return plain('Too large', 413)
|
|
276
|
+
const body = await request.text() // the exact bytes: a parsed and re-encoded body can't be verified
|
|
277
|
+
if (body.length > BODY) return plain('Too large', 413)
|
|
278
|
+
let events: RailEvent[]
|
|
279
|
+
try {
|
|
280
|
+
events = await rail.verify({ body, headers: request.headers })
|
|
281
|
+
} catch (e) {
|
|
282
|
+
// A retry can't fix this one, and the reason stays in the log: a forger learns nothing.
|
|
283
|
+
report?.({ code: (e as { code?: string } | null)?.code ?? 'PAY011', severity: 'error', message: `a ${key} webhook failed verification: ${errorMessage(e)}` })
|
|
284
|
+
return plain('Bad signature', 400)
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
return { response: new Response('{"received":true}', { headers: { 'content-type': 'application/json' } }), tags: await ingest(rail.id, events, report) }
|
|
288
|
+
} catch (e) {
|
|
289
|
+
report?.({ code: 'E_PAY', severity: 'error', message: `a ${key} webhook failed, and it will be redelivered: ${errorMessage(e)}` })
|
|
290
|
+
return plain('Try again', 500)
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
async refund(id) {
|
|
294
|
+
const p = await store.purchase(id)
|
|
295
|
+
if (!p) throw new PayError('PAY024', `no purchase \`${id}\``, 404)
|
|
296
|
+
const rail = await railFor(p.rail)
|
|
297
|
+
if (!rail.refund) throw new PayError('PAY022', `\`${rail.id}\` can't refund from here`, 501)
|
|
298
|
+
await rail.refund({ purchase: id.slice(p.rail.length + 1) })
|
|
299
|
+
},
|
|
300
|
+
use(rail) { override = rail },
|
|
301
|
+
reset() {
|
|
302
|
+
override = undefined
|
|
303
|
+
;(store as { clear?(): void }).clear?.()
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
// ---- what makes this a `server.extension` ---------------------------------------------------------------------
|
|
307
|
+
async handle(request, path, api) {
|
|
308
|
+
const { response, tags } = await pay.webhook(path, request, d => api.report(d))
|
|
309
|
+
await api.invalidate(tags)
|
|
310
|
+
return response
|
|
311
|
+
},
|
|
312
|
+
context: ctx => ({ entitlements: pay.forContext(ctx) }),
|
|
313
|
+
async guard(fn, ctx) {
|
|
314
|
+
for (const id of (fn.def.requires as readonly string[] | undefined) ?? []) if (!(await ctx.entitlements.has(id))) throw new PayError('ENTITLEMENT_REQUIRED', `This needs \`${id}\``, 403)
|
|
315
|
+
},
|
|
316
|
+
options: ['requires'],
|
|
317
|
+
personal: ['entitlements'],
|
|
318
|
+
check(dev) {
|
|
319
|
+
if (store.memory && !dev) throw new PayError('PAY016', 'The ledger is in memory: every purchase is gone on the next deploy. Pass a durable `store` to `definePay`', 500)
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
return pay
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The `server.extension` provider: `src/pay.server.ts` already built it, so this hands it over. */
|
|
326
|
+
export const extension = (app: Pay): ServerExtension => app
|
|
327
|
+
|
|
328
|
+
export type { PayRail, RailEvent } from './index'
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// What `weft add pay` writes, as data: `weft add` does the writing, so a dry run and a refusal come for free. Plain JS
|
|
2
|
+
// strings, because this loads with the config. `run` only describes: the plugin never touches the disk.
|
|
3
|
+
|
|
4
|
+
const PURCHASE = ['user', 'product', 'plan']
|
|
5
|
+
|
|
6
|
+
/** `purchases` and `pay_events`, as `drizzlePayStore` reads them: times in ms, so a 32-bit integer won't do. */
|
|
7
|
+
function tables(engine) {
|
|
8
|
+
const columns = ({ table, id, str, long }) => `export const purchases = ${table}('purchases', {
|
|
9
|
+
id: ${id}.primaryKey(),
|
|
10
|
+
rail: ${str}.notNull(),
|
|
11
|
+
${PURCHASE.map(c => ` ${c}: ${str},`).join('\n')}
|
|
12
|
+
state: ${str}.notNull(),
|
|
13
|
+
at: ${long}.notNull(),
|
|
14
|
+
expires: ${long},
|
|
15
|
+
customer: ${str},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export const payEvents = ${table}('pay_events', {
|
|
19
|
+
id: ${id}.primaryKey(),
|
|
20
|
+
at: ${long}.notNull(),
|
|
21
|
+
})
|
|
22
|
+
`
|
|
23
|
+
return {
|
|
24
|
+
sqlite: () => `import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'\n\n${columns({ table: 'sqliteTable', id: 'text()', str: 'text()', long: 'integer()' })}`,
|
|
25
|
+
postgres: () => `import { bigint, pgTable, text } from 'drizzle-orm/pg-core'\n\n${columns({ table: 'pgTable', id: 'text()', str: 'text()', long: "bigint({ mode: 'number' })" })}`,
|
|
26
|
+
mysql: () => `import { bigint, mysqlTable, varchar } from 'drizzle-orm/mysql-core'\n\n${columns({ table: 'mysqlTable', id: 'varchar({ length: 191 })', str: 'varchar({ length: 191 })', long: "bigint({ mode: 'number' })" })}`,
|
|
27
|
+
}[engine]()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const catalog = `import { product } from '@weft-js/pay'
|
|
31
|
+
|
|
32
|
+
// What you sell. Amounts are minor units (999 is $9.99), and no rail's ids belong here: this file reaches the client.
|
|
33
|
+
export const pro = product({
|
|
34
|
+
id: 'pro',
|
|
35
|
+
kind: 'subscription',
|
|
36
|
+
use: 'digital',
|
|
37
|
+
name: 'Pro',
|
|
38
|
+
plans: { month: { usd: 999 }, year: { usd: 7999, trialDays: 7 } },
|
|
39
|
+
grants: ['pro'],
|
|
40
|
+
})
|
|
41
|
+
`
|
|
42
|
+
|
|
43
|
+
const ledger = store => `${store === 'drizzle' ? "import { drizzlePayStore } from '@weft-js/pay/drizzle'\nimport { definePay } from '@weft-js/pay/server'\nimport { db } from '#/db/index.server'\nimport { payEvents, purchases } from '#/db/schema'\n" : "import { definePay, memoryPayStore } from '@weft-js/pay/server'\n"}import * as catalog from './pay/catalog'
|
|
44
|
+
|
|
45
|
+
// Served at /_weft/x/pay/<rail>, the webhook URL to give your provider. A verified event is the only grant.
|
|
46
|
+
export default definePay({ catalog, store: ${store === 'drizzle' ? 'drizzlePayStore(db, { purchases, payEvents })' : 'memoryPayStore()'}, urls: { success: '/', cancel: '/' } })
|
|
47
|
+
`
|
|
48
|
+
|
|
49
|
+
const functions = `import { entitlementTag } from '@weft-js/pay'
|
|
50
|
+
import { mutation, query, redirect } from '@weft-js/start'
|
|
51
|
+
import pay from '../pay.server'
|
|
52
|
+
|
|
53
|
+
/** What the signed-in user holds, live: a verified event pokes this user's tag. Render from it; enforce with \`requires\`. */
|
|
54
|
+
export const entitlements = query({
|
|
55
|
+
auth: 'user',
|
|
56
|
+
tags: (_, ctx) => [entitlementTag(ctx.user.id)],
|
|
57
|
+
run: ({ ctx }) => ctx.entitlements.list(),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/** \`checkout({ product: 'pro', plan: 'month' })\`: on to the rail's page. It grants nothing. */
|
|
61
|
+
export const checkout = mutation({
|
|
62
|
+
auth: 'user',
|
|
63
|
+
input: pay.input,
|
|
64
|
+
run: async ({ input, ctx }) => redirect(await pay.checkout(ctx, input), 303),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** Where a buyer manages or cancels. */
|
|
68
|
+
export const portal = mutation({
|
|
69
|
+
auth: 'user',
|
|
70
|
+
run: async ({ ctx }) => redirect(await pay.portal(ctx), 303),
|
|
71
|
+
})
|
|
72
|
+
`
|
|
73
|
+
|
|
74
|
+
export const setup = {
|
|
75
|
+
with: ['@weft-js/pay/stripe'],
|
|
76
|
+
needs: ['db', 'auth'],
|
|
77
|
+
run({ engine, layer, read }) {
|
|
78
|
+
const drizzle = layer === 'drizzle'
|
|
79
|
+
const env = read('src/env.server.ts') ?? ''
|
|
80
|
+
if (!env.includes('STRIPE_SECRET_KEY') && !/server: \{\n/.test(env)) throw new Error('src/env.server.ts has no `server: {` block to add the Stripe keys to')
|
|
81
|
+
return {
|
|
82
|
+
files: {
|
|
83
|
+
'src/pay/catalog.ts': catalog,
|
|
84
|
+
'src/pay.server.ts': ledger(drizzle ? 'drizzle' : 'memory'),
|
|
85
|
+
'src/server/pay.ts': functions,
|
|
86
|
+
...(drizzle && { 'src/db/pay.schema.ts': tables(engine) }),
|
|
87
|
+
},
|
|
88
|
+
edits: {
|
|
89
|
+
...(!env.includes('STRIPE_SECRET_KEY') && { 'src/env.server.ts': env.replace('server: {\n', 'server: {\n STRIPE_SECRET_KEY: z.string().min(1),\n STRIPE_WEBHOOK_SECRET: z.string().min(1),\n') }),
|
|
90
|
+
...(drizzle && { 'src/db/schema.ts': `${read('src/db/schema.ts').trimEnd()}\n\nexport * from './pay.schema'\n` }),
|
|
91
|
+
},
|
|
92
|
+
env: { STRIPE_SECRET_KEY: 'sk_test_placeholder', STRIPE_WEBHOOK_SECRET: 'whsec_placeholder' },
|
|
93
|
+
next: [
|
|
94
|
+
...(drizzle ? ['`weft db generate`, then reload: the migration applies on the next request'] : ['`src/pay.server.ts` starts on `memoryPayStore()`: write a `PayStore` on your data layer (PAY016)']),
|
|
95
|
+
'put your real test keys in .env, and give Stripe the webhook URL /_weft/x/pay/stripe',
|
|
96
|
+
],
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
}
|