@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/README.md +658 -3
- package/docs/pay.md +28 -9
- package/package.json +13 -4
- package/plugin.mjs +131 -23
- package/src/checks/config.mjs +24 -0
- package/src/checks.mjs +219 -70
- package/src/drizzle.ts +33 -14
- package/src/index.ts +80 -25
- package/src/server/catalog.ts +80 -0
- package/src/server/ledger.ts +69 -0
- package/src/server/operations.ts +142 -0
- package/src/server/rails.ts +45 -0
- package/src/server.ts +74 -171
- package/src/setup.mjs +26 -9
- package/src/stripe.ts +168 -55
- package/src/test.ts +195 -63
- 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 } 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 {
|
|
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,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'>> & {
|
|
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: {
|
|
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(
|
|
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 = (
|
|
145
|
-
|
|
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 =
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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) {
|
|
194
|
+
use(rail) {
|
|
195
|
+
rails.use(rail)
|
|
196
|
+
},
|
|
301
197
|
reset() {
|
|
302
|
-
|
|
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) ?? [])
|
|
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)
|
|
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: () =>
|
|
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
|
}
|