@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/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 { entitlementTag, PayError, type CheckoutLine, type Interval, type PayRail, type Plan, type Product, type PurchaseState, type RailEvent } from './index'
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) { 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() },
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'>> & { request: { url: string }; user?: { id?: string | null } | null }
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: { readonly '~standard': { version: 1; vendor: 'weft'; validate(v: unknown): { value: CheckoutInput<P> } | { issues: { message: string }[] }; types?: { input: CheckoutInput<P>; output: CheckoutInput<P> } } }
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(rail: string, request: Request, report?: PayReport): Promise<{ response: Response; tags: string[] }>
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 = (Array.isArray(options.catalog) ? options.catalog : Object.values(options.catalog)) as P[]
145
- const byId = new Map(catalog.map(p => [p.id, p]))
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 = 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>>()
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
- 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
- }
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
- '~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
- },
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
- 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
- },
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) { override = rail },
232
+ use(rail) {
233
+ rails.use(rail)
234
+ },
301
235
  reset() {
302
- override = undefined
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) ?? []) if (!(await ctx.entitlements.has(id))) throw new PayError('ENTITLEMENT_REQUIRED', `This needs \`${id}\``, 403)
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) throw new PayError('PAY016', 'The ledger is in memory: every purchase is gone on the next deploy. Pass a durable `store` to `definePay`', 500)
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: () => `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' })" })}`,
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 = 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'
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)) throw new Error('src/env.server.ts has no `server: {` block to add the Stripe keys to')
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') && { '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` }),
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 ? ['`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)']),
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
  }