@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/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 } from './server/ledger'
8
+ import { checkoutInput } from './server/catalog'
9
+ import { checkoutFor, 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,7 +81,10 @@ 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> {
@@ -109,7 +120,14 @@ export interface CheckoutInput<P extends Product = Product> {
109
120
  export interface Pay<P extends Product = Product> extends ServerExtension {
110
121
  readonly catalog: readonly P[]
111
122
  /** 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> } } }
123
+ readonly input: {
124
+ readonly '~standard': {
125
+ version: 1
126
+ vendor: 'weft'
127
+ validate(v: unknown): { value: CheckoutInput<P> } | { issues: { message: string }[] }
128
+ types?: { input: CheckoutInput<P>; output: CheckoutInput<P> }
129
+ }
130
+ }
113
131
  /** The hosted checkout's URL, for this buyer. Grants nothing. */
114
132
  checkout(ctx: PayContext, input: CheckoutInput<P>): Promise<string>
115
133
  /** Where this buyer manages or cancels. */
@@ -120,7 +138,11 @@ export interface Pay<P extends Product = Product> extends ServerExtension {
120
138
  forContext(ctx: PayContext): Entitlements
121
139
  purchases(user: string): Promise<Purchase[]>
122
140
  /** 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[] }>
141
+ webhook(
142
+ rail: string,
143
+ request: Request,
144
+ report?: PayReport,
145
+ ): Promise<{ response: Response; tags: string[] }>
124
146
  /** Apply events that have been verified. Anything else must go through `webhook`. */
125
147
  ingest(rail: string, events: RailEvent[], report?: PayReport): Promise<string[]>
126
148
  refund(purchase: string): Promise<void>
@@ -130,166 +152,38 @@ export interface Pay<P extends Product = Product> extends ServerExtension {
130
152
  reset(): void
131
153
  }
132
154
 
133
- const ACCESS: ReadonlySet<PurchaseState> = new Set(['active', 'trial', 'grace'])
134
- const FINAL: ReadonlySet<PurchaseState> = new Set(['refunded', 'revoked'])
135
155
  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
156
 
142
157
  /** The one ledger of an app: `src/pay.server.ts` default-exports what this returns. */
143
158
  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]))
159
+ const catalog = (
160
+ Array.isArray(options.catalog) ? options.catalog : Object.values(options.catalog)
161
+ ) as P[]
162
+ const byId = new Map(catalog.map((p) => [p.id, p]))
146
163
  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
- }
164
+ const userOf = options.user ?? ((ctx) => ctx?.user?.id)
165
+ const slot =
166
+ options.slot ??
167
+ (() => {
168
+ // A generated module, declared in .weft/slots.d.ts only once bound: an expect-error would be wrong in one case or the other.
169
+ // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- the cast is what lets this compile when the slot isn't declared
170
+ return import('weft:slot/pay.rail' as string)
171
+ })
172
+ const rails = makeRails(options, slot)
173
+ const { railFor } = rails
174
+
175
+ const { live, entitlements, ingest } = makeLedger(store, byId, leeway)
225
176
 
226
177
  const pay: Pay<P> = {
227
178
  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
- },
179
+ input: checkoutInput(byId),
180
+ checkout: checkoutFor({ options, urls, userOf, store, railFor, live, byId }),
181
+ portal: portalFor({ options, urls, userOf, store, railFor, live, byId }),
266
182
  entitlements,
267
- forContext: ctx => entitlements(userOf(ctx)),
268
- purchases: user => store.byUser(user),
183
+ forContext: (ctx) => entitlements(userOf(ctx)),
184
+ purchases: (user) => store.byUser(user),
269
185
  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
- },
186
+ webhook: webhookFor(railFor, ingest),
293
187
  async refund(id) {
294
188
  const p = await store.purchase(id)
295
189
  if (!p) throw new PayError('PAY024', `no purchase \`${id}\``, 404)
@@ -297,26 +191,35 @@ export function definePay<P extends Product>(options: PayOptions<P>): Pay<P> {
297
191
  if (!rail.refund) throw new PayError('PAY022', `\`${rail.id}\` can't refund from here`, 501)
298
192
  await rail.refund({ purchase: id.slice(p.rail.length + 1) })
299
193
  },
300
- use(rail) { override = rail },
194
+ use(rail) {
195
+ rails.use(rail)
196
+ },
301
197
  reset() {
302
- override = undefined
198
+ rails.use(undefined)
303
199
  ;(store as { clear?(): void }).clear?.()
304
200
  },
305
201
 
306
202
  // ---- what makes this a `server.extension` ---------------------------------------------------------------------
307
203
  async handle(request, path, api) {
308
- const { response, tags } = await pay.webhook(path, request, d => api.report(d))
204
+ const { response, tags } = await pay.webhook(path, request, (d) => api.report(d))
309
205
  await api.invalidate(tags)
310
206
  return response
311
207
  },
312
- context: ctx => ({ entitlements: pay.forContext(ctx) }),
208
+ context: (ctx) => ({ entitlements: pay.forContext(ctx) }),
313
209
  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)
210
+ for (const id of (fn.def.requires as readonly string[] | undefined) ?? [])
211
+ if (!(await ctx.entitlements.has(id)))
212
+ throw new PayError('ENTITLEMENT_REQUIRED', `This needs \`${id}\``, 403)
315
213
  },
316
214
  options: ['requires'],
317
215
  personal: ['entitlements'],
318
216
  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)
217
+ if (store.memory && !dev)
218
+ throw new PayError(
219
+ 'PAY016',
220
+ 'The ledger is in memory: every purchase is gone on the next deploy. Pass a durable `store` to `definePay`',
221
+ 500,
222
+ )
320
223
  },
321
224
  }
322
225
  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
  }