@weft-js/pay 0.1.1 → 0.1.4
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/README.md +654 -3
- package/docs/pay.md +55 -24
- package/package.json +35 -22
- package/plugin.mjs +131 -23
- package/square-plugin.d.mts +9 -0
- package/square-plugin.mjs +47 -0
- package/src/checks/config.mjs +24 -0
- package/src/checks.mjs +219 -70
- package/src/drizzle.ts +33 -14
- package/src/index.ts +89 -25
- package/src/server/catalog.ts +80 -0
- package/src/server/ledger.ts +84 -0
- package/src/server/operations.ts +215 -0
- package/src/server/rails.ts +45 -0
- package/src/server.ts +113 -172
- package/src/setup.mjs +26 -9
- package/src/square.ts +231 -0
- package/src/stripe.ts +170 -56
- package/src/test.ts +209 -65
- package/stripe-plugin.d.mts +6 -1
- package/stripe-plugin.mjs +24 -5
package/src/server.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
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
1
|
// 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
2
|
// repeat lands on what it already wrote; `mark` comes last, because a crash before it should redeliver. A failure answers
|
|
8
3
|
// 500 for the same reason. A terminal state wins a tie: a refund and a renewal in the same second is a refund.
|
|
9
4
|
|
|
10
5
|
import type { Context } from '@weft-js/start'
|
|
6
|
+
import { makeRails } from './server/rails'
|
|
7
|
+
import { makeLedger, ORDER } from './server/ledger'
|
|
8
|
+
import { checkoutInput } from './server/catalog'
|
|
9
|
+
import { checkoutFor, orderFor, portalFor, webhookFor } from './server/operations'
|
|
11
10
|
import type { ServerExtension } from '@weft-js/start/extension'
|
|
12
|
-
import {
|
|
11
|
+
import { PayError, type PayRail, type Product, type PurchaseState, type RailEvent } from './index'
|
|
13
12
|
|
|
14
13
|
declare module '@weft-js/start' {
|
|
15
14
|
interface Context {
|
|
@@ -56,12 +55,21 @@ export function memoryPayStore(): PayStore & { clear(): void } {
|
|
|
56
55
|
const rows = new Map<string, Purchase>()
|
|
57
56
|
return {
|
|
58
57
|
memory: true,
|
|
59
|
-
seen: e => Promise.resolve(events.has(e)),
|
|
60
|
-
mark(e) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
seen: (e) => Promise.resolve(events.has(e)),
|
|
59
|
+
mark(e) {
|
|
60
|
+
events.add(e)
|
|
61
|
+
return Promise.resolve()
|
|
62
|
+
},
|
|
63
|
+
purchase: (id) => Promise.resolve(rows.get(id) ?? null),
|
|
64
|
+
save(p) {
|
|
65
|
+
rows.set(p.id, p)
|
|
66
|
+
return Promise.resolve()
|
|
67
|
+
},
|
|
68
|
+
byUser: (user) => Promise.resolve([...rows.values()].filter((p) => p.user === user)),
|
|
69
|
+
clear() {
|
|
70
|
+
events.clear()
|
|
71
|
+
rows.clear()
|
|
72
|
+
},
|
|
65
73
|
}
|
|
66
74
|
}
|
|
67
75
|
|
|
@@ -73,15 +81,20 @@ export interface Entitlements {
|
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
/** 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'>> & {
|
|
84
|
+
export type PayContext = Partial<Omit<Context, 'request'>> & {
|
|
85
|
+
request: { url: string }
|
|
86
|
+
user?: { id?: string | null } | null
|
|
87
|
+
}
|
|
77
88
|
|
|
78
89
|
/** What `definePay` takes. */
|
|
79
90
|
export interface PayOptions<P extends Product> {
|
|
80
91
|
/** `import * as catalog from './pay/catalog'`, or a list. */
|
|
81
92
|
catalog: readonly P[] | Record<string, P>
|
|
82
93
|
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 }
|
|
94
|
+
/** Paths or URLs a buyer comes back to. They never come from the client. `order` and `orderCancel` may hold `{ref}`. */
|
|
95
|
+
urls: { success: string; cancel: string; portal?: string; order?: string; orderCancel?: string }
|
|
96
|
+
/** Hears each state an order (`pay.order`) moves to, from verified events only: mark it paid here. A throw redelivers. */
|
|
97
|
+
onOrder?: OrderHook
|
|
85
98
|
/** Who a request is. Default `ctx.user?.id`. */
|
|
86
99
|
user?: (ctx: PayContext) => string | null | undefined
|
|
87
100
|
/** With several rails bound, the one that takes payments. One rail needs no choosing. */
|
|
@@ -105,13 +118,50 @@ export interface CheckoutInput<P extends Product = Product> {
|
|
|
105
118
|
quantity?: number
|
|
106
119
|
}
|
|
107
120
|
|
|
121
|
+
/** One line of an order, priced by the app on the server: never from the client. */
|
|
122
|
+
export interface OrderLine {
|
|
123
|
+
name: string
|
|
124
|
+
/** Whole minor units, each. */
|
|
125
|
+
amount: number
|
|
126
|
+
quantity?: number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** What `pay.order` takes: the app's own order id and the lines it priced. */
|
|
130
|
+
export interface OrderInput {
|
|
131
|
+
/** The app's order id, 1–64 of `A-Z a-z 0-9 - _`. It comes back in `onOrder` and in `orderTag(ref)`. */
|
|
132
|
+
ref: string
|
|
133
|
+
lines: readonly OrderLine[]
|
|
134
|
+
/** Lowercase ISO. Default `usd`. */
|
|
135
|
+
currency?: string
|
|
136
|
+
/** What the ledger's one line is called. Default `Order <ref>`. */
|
|
137
|
+
title?: string
|
|
138
|
+
/** A guest's email, for the rail's receipt. */
|
|
139
|
+
email?: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `onOrder`: an order's new state, and its row in the ledger. */
|
|
143
|
+
export type OrderHook = (order: {
|
|
144
|
+
ref: string
|
|
145
|
+
state: PurchaseState
|
|
146
|
+
purchase: Purchase
|
|
147
|
+
}) => Promise<void> | void
|
|
148
|
+
|
|
108
149
|
/** What `definePay` returns: the ledger's calls, and a `server.extension` that carries them to the server. */
|
|
109
150
|
export interface Pay<P extends Product = Product> extends ServerExtension {
|
|
110
151
|
readonly catalog: readonly P[]
|
|
111
152
|
/** For a mutation's `input`: the catalog decides what can be bought, and the client sends only names. */
|
|
112
|
-
readonly input: {
|
|
153
|
+
readonly input: {
|
|
154
|
+
readonly '~standard': {
|
|
155
|
+
version: 1
|
|
156
|
+
vendor: 'weft'
|
|
157
|
+
validate(v: unknown): { value: CheckoutInput<P> } | { issues: { message: string }[] }
|
|
158
|
+
types?: { input: CheckoutInput<P>; output: CheckoutInput<P> }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
113
161
|
/** The hosted checkout's URL, for this buyer. Grants nothing. */
|
|
114
162
|
checkout(ctx: PayContext, input: CheckoutInput<P>): Promise<string>
|
|
163
|
+
/** The hosted page for one order the app priced, signed in or a guest. Pays nothing until `onOrder` hears `active`. */
|
|
164
|
+
order(ctx: PayContext, input: OrderInput): Promise<string>
|
|
115
165
|
/** Where this buyer manages or cancels. */
|
|
116
166
|
portal(ctx: PayContext): Promise<string>
|
|
117
167
|
/** Read for a user id, as a webhook or a job would. In a request, `ctx.entitlements`. */
|
|
@@ -120,7 +170,11 @@ export interface Pay<P extends Product = Product> extends ServerExtension {
|
|
|
120
170
|
forContext(ctx: PayContext): Entitlements
|
|
121
171
|
purchases(user: string): Promise<Purchase[]>
|
|
122
172
|
/** One verified delivery. `tags` go on to the app's live readers. */
|
|
123
|
-
webhook(
|
|
173
|
+
webhook(
|
|
174
|
+
rail: string,
|
|
175
|
+
request: Request,
|
|
176
|
+
report?: PayReport,
|
|
177
|
+
): Promise<{ response: Response; tags: string[] }>
|
|
124
178
|
/** Apply events that have been verified. Anything else must go through `webhook`. */
|
|
125
179
|
ingest(rail: string, events: RailEvent[], report?: PayReport): Promise<string[]>
|
|
126
180
|
refund(purchase: string): Promise<void>
|
|
@@ -130,166 +184,44 @@ export interface Pay<P extends Product = Product> extends ServerExtension {
|
|
|
130
184
|
reset(): void
|
|
131
185
|
}
|
|
132
186
|
|
|
133
|
-
const ACCESS: ReadonlySet<PurchaseState> = new Set(['active', 'trial', 'grace'])
|
|
134
|
-
const FINAL: ReadonlySet<PurchaseState> = new Set(['refunded', 'revoked'])
|
|
135
187
|
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
188
|
|
|
142
189
|
/** The one ledger of an app: `src/pay.server.ts` default-exports what this returns. */
|
|
143
190
|
export function definePay<P extends Product>(options: PayOptions<P>): Pay<P> {
|
|
144
|
-
const catalog = (
|
|
145
|
-
|
|
191
|
+
const catalog = (
|
|
192
|
+
Array.isArray(options.catalog) ? options.catalog : Object.values(options.catalog)
|
|
193
|
+
) as P[]
|
|
194
|
+
const byId = new Map(catalog.map((p) => [p.id, p]))
|
|
195
|
+
if (byId.has(ORDER))
|
|
196
|
+
throw new PayError(
|
|
197
|
+
'PAY021',
|
|
198
|
+
`\`${ORDER}\` is the id \`pay.order\` records under: name the product something else`,
|
|
199
|
+
)
|
|
146
200
|
const { store, urls, leeway = 3 * DAY } = options
|
|
147
|
-
const userOf = options.user ?? (ctx => ctx?.user?.id)
|
|
148
|
-
const slot =
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
201
|
+
const userOf = options.user ?? ((ctx) => ctx?.user?.id)
|
|
202
|
+
const slot =
|
|
203
|
+
options.slot ??
|
|
204
|
+
(() => {
|
|
205
|
+
// A generated module, declared in .weft/slots.d.ts only once bound: an expect-error would be wrong in one case or the other.
|
|
206
|
+
// oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- the cast is what lets this compile when the slot isn't declared
|
|
207
|
+
return import('weft:slot/pay.rail' as string)
|
|
208
|
+
})
|
|
209
|
+
const rails = makeRails(options, slot)
|
|
210
|
+
const { railFor } = rails
|
|
155
211
|
|
|
156
|
-
|
|
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
|
-
}
|
|
212
|
+
const { live, entitlements, ingest } = makeLedger(store, byId, leeway, options.onOrder)
|
|
225
213
|
|
|
226
214
|
const pay: Pay<P> = {
|
|
227
215
|
catalog,
|
|
228
|
-
input:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
},
|
|
216
|
+
input: checkoutInput(byId),
|
|
217
|
+
checkout: checkoutFor({ options, urls, userOf, store, railFor, live, byId }),
|
|
218
|
+
order: orderFor({ options, urls, userOf, store, railFor, live, byId }),
|
|
219
|
+
portal: portalFor({ options, urls, userOf, store, railFor, live, byId }),
|
|
266
220
|
entitlements,
|
|
267
|
-
forContext: ctx => entitlements(userOf(ctx)),
|
|
268
|
-
purchases: user => store.byUser(user),
|
|
221
|
+
forContext: (ctx) => entitlements(userOf(ctx)),
|
|
222
|
+
purchases: (user) => store.byUser(user),
|
|
269
223
|
ingest,
|
|
270
|
-
|
|
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
|
-
},
|
|
224
|
+
webhook: webhookFor(railFor, ingest),
|
|
293
225
|
async refund(id) {
|
|
294
226
|
const p = await store.purchase(id)
|
|
295
227
|
if (!p) throw new PayError('PAY024', `no purchase \`${id}\``, 404)
|
|
@@ -297,26 +229,35 @@ export function definePay<P extends Product>(options: PayOptions<P>): Pay<P> {
|
|
|
297
229
|
if (!rail.refund) throw new PayError('PAY022', `\`${rail.id}\` can't refund from here`, 501)
|
|
298
230
|
await rail.refund({ purchase: id.slice(p.rail.length + 1) })
|
|
299
231
|
},
|
|
300
|
-
use(rail) {
|
|
232
|
+
use(rail) {
|
|
233
|
+
rails.use(rail)
|
|
234
|
+
},
|
|
301
235
|
reset() {
|
|
302
|
-
|
|
236
|
+
rails.use(undefined)
|
|
303
237
|
;(store as { clear?(): void }).clear?.()
|
|
304
238
|
},
|
|
305
239
|
|
|
306
240
|
// ---- what makes this a `server.extension` ---------------------------------------------------------------------
|
|
307
241
|
async handle(request, path, api) {
|
|
308
|
-
const { response, tags } = await pay.webhook(path, request, d => api.report(d))
|
|
242
|
+
const { response, tags } = await pay.webhook(path, request, (d) => api.report(d))
|
|
309
243
|
await api.invalidate(tags)
|
|
310
244
|
return response
|
|
311
245
|
},
|
|
312
|
-
context: ctx => ({ entitlements: pay.forContext(ctx) }),
|
|
246
|
+
context: (ctx) => ({ entitlements: pay.forContext(ctx) }),
|
|
313
247
|
async guard(fn, ctx) {
|
|
314
|
-
for (const id of (fn.def.requires as readonly string[] | undefined) ?? [])
|
|
248
|
+
for (const id of (fn.def.requires as readonly string[] | undefined) ?? [])
|
|
249
|
+
if (!(await ctx.entitlements.has(id)))
|
|
250
|
+
throw new PayError('ENTITLEMENT_REQUIRED', `This needs \`${id}\``, 403)
|
|
315
251
|
},
|
|
316
252
|
options: ['requires'],
|
|
317
253
|
personal: ['entitlements'],
|
|
318
254
|
check(dev) {
|
|
319
|
-
if (store.memory && !dev)
|
|
255
|
+
if (store.memory && !dev)
|
|
256
|
+
throw new PayError(
|
|
257
|
+
'PAY016',
|
|
258
|
+
'The ledger is in memory: every purchase is gone on the next deploy. Pass a durable `store` to `definePay`',
|
|
259
|
+
500,
|
|
260
|
+
)
|
|
320
261
|
},
|
|
321
262
|
}
|
|
322
263
|
return pay
|
package/src/setup.mjs
CHANGED
|
@@ -8,7 +8,7 @@ function tables(engine) {
|
|
|
8
8
|
const columns = ({ table, id, str, long }) => `export const purchases = ${table}('purchases', {
|
|
9
9
|
id: ${id}.primaryKey(),
|
|
10
10
|
rail: ${str}.notNull(),
|
|
11
|
-
${PURCHASE.map(c => ` ${c}: ${str},`).join('\n')}
|
|
11
|
+
${PURCHASE.map((c) => ` ${c}: ${str},`).join('\n')}
|
|
12
12
|
state: ${str}.notNull(),
|
|
13
13
|
at: ${long}.notNull(),
|
|
14
14
|
expires: ${long},
|
|
@@ -21,9 +21,12 @@ export const payEvents = ${table}('pay_events', {
|
|
|
21
21
|
})
|
|
22
22
|
`
|
|
23
23
|
return {
|
|
24
|
-
sqlite: () =>
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
sqlite: () =>
|
|
25
|
+
`import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'\n\n${columns({ table: 'sqliteTable', id: 'text()', str: 'text()', long: 'integer()' })}`,
|
|
26
|
+
postgres: () =>
|
|
27
|
+
`import { bigint, pgTable, text } from 'drizzle-orm/pg-core'\n\n${columns({ table: 'pgTable', id: 'text()', str: 'text()', long: "bigint({ mode: 'number' })" })}`,
|
|
28
|
+
mysql: () =>
|
|
29
|
+
`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
30
|
}[engine]()
|
|
28
31
|
}
|
|
29
32
|
|
|
@@ -40,7 +43,9 @@ export const pro = product({
|
|
|
40
43
|
})
|
|
41
44
|
`
|
|
42
45
|
|
|
43
|
-
const ledger =
|
|
46
|
+
const ledger = (
|
|
47
|
+
store,
|
|
48
|
+
) => `${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
49
|
|
|
45
50
|
// Served at /_weft/x/pay/<rail>, the webhook URL to give your provider. A verified event is the only grant.
|
|
46
51
|
export default definePay({ catalog, store: ${store === 'drizzle' ? 'drizzlePayStore(db, { purchases, payEvents })' : 'memoryPayStore()'}, urls: { success: '/', cancel: '/' } })
|
|
@@ -77,7 +82,8 @@ export const setup = {
|
|
|
77
82
|
run({ engine, layer, read }) {
|
|
78
83
|
const drizzle = layer === 'drizzle'
|
|
79
84
|
const env = read('src/env.server.ts') ?? ''
|
|
80
|
-
if (!env.includes('STRIPE_SECRET_KEY') && !/server: \{\n/.test(env))
|
|
85
|
+
if (!env.includes('STRIPE_SECRET_KEY') && !/server: \{\n/.test(env))
|
|
86
|
+
throw new Error('src/env.server.ts has no `server: {` block to add the Stripe keys to')
|
|
81
87
|
return {
|
|
82
88
|
files: {
|
|
83
89
|
'src/pay/catalog.ts': catalog,
|
|
@@ -86,12 +92,23 @@ export const setup = {
|
|
|
86
92
|
...(drizzle && { 'src/db/pay.schema.ts': tables(engine) }),
|
|
87
93
|
},
|
|
88
94
|
edits: {
|
|
89
|
-
...(!env.includes('STRIPE_SECRET_KEY') && {
|
|
90
|
-
|
|
95
|
+
...(!env.includes('STRIPE_SECRET_KEY') && {
|
|
96
|
+
'src/env.server.ts': env.replace(
|
|
97
|
+
'server: {\n',
|
|
98
|
+
'server: {\n STRIPE_SECRET_KEY: z.string().min(1),\n STRIPE_WEBHOOK_SECRET: z.string().min(1),\n',
|
|
99
|
+
),
|
|
100
|
+
}),
|
|
101
|
+
...(drizzle && {
|
|
102
|
+
'src/db/schema.ts': `${read('src/db/schema.ts').trimEnd()}\n\nexport * from './pay.schema'\n`,
|
|
103
|
+
}),
|
|
91
104
|
},
|
|
92
105
|
env: { STRIPE_SECRET_KEY: 'sk_test_placeholder', STRIPE_WEBHOOK_SECRET: 'whsec_placeholder' },
|
|
93
106
|
next: [
|
|
94
|
-
...(drizzle
|
|
107
|
+
...(drizzle
|
|
108
|
+
? ['`weft db generate`, then reload: the migration applies on the next request']
|
|
109
|
+
: [
|
|
110
|
+
'`src/pay.server.ts` starts on `memoryPayStore()`: write a `PayStore` on your data layer (PAY016)',
|
|
111
|
+
]),
|
|
95
112
|
'put your real test keys in .env, and give Stripe the webhook URL /_weft/x/pay/stripe',
|
|
96
113
|
],
|
|
97
114
|
}
|