@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/index.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// here reaches a rail or a server. App code names products and plans; a rail is one line in `plugins`. `@weft-js/pay/server`
|
|
3
|
-
// holds the ledger, and `checkout()` never grants anything: the grant arrives from a verified rail event.
|
|
4
|
-
//
|
|
5
|
-
// export const pro = product({ id: 'pro', kind: 'subscription', use: 'digital', plans: { month: { usd: 999 } }, grants: ['pro'] })
|
|
6
|
-
// <button onClick={() => checkout({ product: 'pro', plan: 'month' })}>Go Pro · {price(pro, 'month').display}</button>
|
|
1
|
+
// `checkout()` never grants anything: the grant arrives from a verified rail event.
|
|
7
2
|
|
|
8
3
|
/** How a product is paid for: a subscription renews, a one-time purchase stays, a consumable is used up. */
|
|
9
4
|
export type ProductKind = 'subscription' | 'one-time' | 'consumable'
|
|
@@ -16,7 +11,11 @@ export type Interval = 'day' | 'week' | 'month' | 'year'
|
|
|
16
11
|
export type Amounts = Record<string, number>
|
|
17
12
|
|
|
18
13
|
/** A subscription's billing option: `Amounts`, with an optional trial and an interval when the plan's name isn't one. */
|
|
19
|
-
export interface Plan {
|
|
14
|
+
export interface Plan {
|
|
15
|
+
[currency: string]: unknown
|
|
16
|
+
trialDays?: number
|
|
17
|
+
interval?: Interval
|
|
18
|
+
}
|
|
20
19
|
|
|
21
20
|
/** What `product()` takes. `G` is the entitlements it grants and `P` the names of its plans. */
|
|
22
21
|
export interface ProductDef<G extends string = string, P extends string = string> {
|
|
@@ -36,7 +35,10 @@ export interface ProductDef<G extends string = string, P extends string = string
|
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
/** A checked, frozen product, as `product()` returns it. */
|
|
39
|
-
export interface Product<G extends string = string, P extends string = string> extends ProductDef<
|
|
38
|
+
export interface Product<G extends string = string, P extends string = string> extends ProductDef<
|
|
39
|
+
G,
|
|
40
|
+
P
|
|
41
|
+
> {
|
|
40
42
|
readonly grants: readonly G[]
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -46,41 +48,73 @@ const META = new Set(['trialDays', 'interval'])
|
|
|
46
48
|
/** Thrown with a stable `code`. `answer` is the shape @weft-js/start sends any plugin's error to the client in. */
|
|
47
49
|
export class PayError extends Error {
|
|
48
50
|
override name = 'PayError'
|
|
49
|
-
constructor(
|
|
51
|
+
constructor(
|
|
52
|
+
readonly code: string,
|
|
53
|
+
message: string,
|
|
54
|
+
readonly status = 400,
|
|
55
|
+
) {
|
|
50
56
|
super(message)
|
|
51
57
|
}
|
|
52
|
-
get answer() {
|
|
58
|
+
get answer() {
|
|
59
|
+
return { code: this.code, message: this.message, status: this.status }
|
|
60
|
+
}
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
const amounts = (p: object) => Object.entries(p).filter(([k]) => !META.has(k)) as [string, number][]
|
|
56
64
|
|
|
57
65
|
function checkAmounts(id: string, list: [string, unknown][]) {
|
|
58
|
-
if (!list.length)
|
|
66
|
+
if (!list.length)
|
|
67
|
+
throw new PayError(
|
|
68
|
+
'PAY008',
|
|
69
|
+
`\`${id}\` has no amount: give one in minor units with its currency, like \`{ usd: 999 }\``,
|
|
70
|
+
)
|
|
59
71
|
for (const [currency, n] of list) {
|
|
60
|
-
if (!/^[a-z]{3}$/.test(currency))
|
|
61
|
-
|
|
72
|
+
if (!/^[a-z]{3}$/.test(currency))
|
|
73
|
+
throw new PayError('PAY008', `\`${id}\`: \`${currency}\` isn't a lowercase ISO currency`)
|
|
74
|
+
if (!Number.isSafeInteger(n) || (n as number) < 0)
|
|
75
|
+
throw new PayError(
|
|
76
|
+
'PAY008',
|
|
77
|
+
`\`${id}\`: ${currency} ${String(n)} isn't whole minor units (999 is $9.99)`,
|
|
78
|
+
)
|
|
62
79
|
}
|
|
63
80
|
}
|
|
64
81
|
|
|
65
82
|
/** One thing you sell. The only place products are defined, and safe in the client graph: it holds no rail's ids. */
|
|
66
|
-
export function product<const G extends string = never, const P extends string = never>(
|
|
83
|
+
export function product<const G extends string = never, const P extends string = never>(
|
|
84
|
+
def: ProductDef<G, P>,
|
|
85
|
+
): Product<G, P> {
|
|
67
86
|
const { id, kind, plans, price: amount } = def
|
|
68
|
-
if (!/^[a-z0-9][a-z0-9-]*$/.test(id))
|
|
87
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(id))
|
|
88
|
+
throw new PayError(
|
|
89
|
+
'PAY021',
|
|
90
|
+
`product id \`${id}\` must be lowercase letters, digits and dashes`,
|
|
91
|
+
)
|
|
69
92
|
if (kind === 'subscription') {
|
|
70
|
-
if (!plans || !Object.keys(plans).length)
|
|
71
|
-
|
|
93
|
+
if (!plans || !Object.keys(plans).length)
|
|
94
|
+
throw new PayError('PAY021', `subscription \`${id}\` has no \`plans\``)
|
|
95
|
+
if (amount)
|
|
96
|
+
throw new PayError('PAY021', `subscription \`${id}\` is priced per plan: drop \`price\``)
|
|
72
97
|
for (const [name, p] of Object.entries<Plan>(plans)) {
|
|
73
98
|
checkAmounts(`${id}.${name}`, amounts(p))
|
|
74
|
-
if (intervalOf(name, p) === null)
|
|
99
|
+
if (intervalOf(name, p) === null)
|
|
100
|
+
throw new PayError(
|
|
101
|
+
'PAY021',
|
|
102
|
+
`plan \`${id}.${name}\` is no interval: name it \`month\` or \`year\`, or add \`interval\``,
|
|
103
|
+
)
|
|
75
104
|
}
|
|
76
105
|
} else {
|
|
77
|
-
if (plans)
|
|
106
|
+
if (plans)
|
|
107
|
+
throw new PayError(
|
|
108
|
+
'PAY021',
|
|
109
|
+
`\`${id}\` is ${kind}: \`plans\` are for subscriptions, so use \`price\``,
|
|
110
|
+
)
|
|
78
111
|
checkAmounts(id, Object.entries(amount ?? {}))
|
|
79
112
|
}
|
|
80
113
|
return Object.freeze({ ...def, grants: Object.freeze([...(def.grants ?? [])]) })
|
|
81
114
|
}
|
|
82
115
|
|
|
83
|
-
const intervalOf = (name: string, p: Plan): Interval | null =>
|
|
116
|
+
const intervalOf = (name: string, p: Plan): Interval | null =>
|
|
117
|
+
p.interval ?? (INTERVALS.includes(name) ? (name as Interval) : null)
|
|
84
118
|
|
|
85
119
|
/** One figure to show a buyer, as `price()` returns it. */
|
|
86
120
|
export interface Price {
|
|
@@ -100,8 +134,15 @@ function formatted(amount: number, currency: string, locale?: string) {
|
|
|
100
134
|
}
|
|
101
135
|
|
|
102
136
|
/** The price of a product or one of its plans. The first currency listed, unless you name one. */
|
|
103
|
-
export function price<P extends string>(
|
|
104
|
-
|
|
137
|
+
export function price<P extends string>(
|
|
138
|
+
p: Product<string, P>,
|
|
139
|
+
plan?: P,
|
|
140
|
+
options: { currency?: string; locale?: string } = {},
|
|
141
|
+
): Price {
|
|
142
|
+
const row: Record<string, unknown> | undefined =
|
|
143
|
+
plan === undefined
|
|
144
|
+
? (p.price ?? (p.plans && (Object.values(p.plans)[0] as Plan)))
|
|
145
|
+
: p.plans?.[plan]
|
|
105
146
|
const name = plan ?? (p.plans ? Object.keys(p.plans)[0] : undefined)
|
|
106
147
|
if (!row) throw new PayError('PAY021', `\`${p.id}\` has no plan \`${String(plan)}\``)
|
|
107
148
|
const list = amounts(row)
|
|
@@ -111,7 +152,12 @@ export function price<P extends string>(p: Product<string, P>, plan?: P, options
|
|
|
111
152
|
const plain = { amount, currency, display: formatted(amount, currency, options.locale) }
|
|
112
153
|
if (!name) return plain
|
|
113
154
|
const meta = row as Plan
|
|
114
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
...plain,
|
|
157
|
+
plan: name,
|
|
158
|
+
interval: intervalOf(name, meta)!,
|
|
159
|
+
...(meta.trialDays ? { trialDays: meta.trialDays } : {}),
|
|
160
|
+
}
|
|
115
161
|
}
|
|
116
162
|
|
|
117
163
|
/** What a query reads, tagged so a verified event pokes it: `tags: (_, ctx) => [entitlementTag(ctx.user.id)]`. */
|
|
@@ -119,8 +165,17 @@ export const entitlementTag = (user: string) => `entitlements:${user}`
|
|
|
119
165
|
|
|
120
166
|
// ---- pay.rail@1 ---------------------------------------------------------------------------------------------------
|
|
121
167
|
|
|
122
|
-
/** What a purchase is doing now, on any rail
|
|
123
|
-
export type PurchaseState =
|
|
168
|
+
/** What a purchase is doing now, on any rail. */
|
|
169
|
+
export type PurchaseState =
|
|
170
|
+
| 'pending'
|
|
171
|
+
| 'active'
|
|
172
|
+
| 'trial'
|
|
173
|
+
| 'grace'
|
|
174
|
+
| 'billing-retry'
|
|
175
|
+
| 'paused'
|
|
176
|
+
| 'expired'
|
|
177
|
+
| 'refunded'
|
|
178
|
+
| 'revoked'
|
|
124
179
|
|
|
125
180
|
/** One line a rail charges for, from the catalog. It never comes from the client. */
|
|
126
181
|
export interface CheckoutLine {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { PayError, type CheckoutLine, type Interval, type Plan, type Product } from '../index'
|
|
2
|
+
import type { CheckoutInput, Pay } from '../server'
|
|
3
|
+
|
|
4
|
+
const issue = (message: string) => ({ issues: [{ message }] })
|
|
5
|
+
|
|
6
|
+
/** Resolve a checkout line from the catalog. */
|
|
7
|
+
export function lineOf<P extends Product>(
|
|
8
|
+
byId: Map<string, P>,
|
|
9
|
+
input: CheckoutInput<P>,
|
|
10
|
+
): CheckoutLine {
|
|
11
|
+
const p = byId.get(input.product)
|
|
12
|
+
if (!p) throw new PayError('PAY021', `no product \`${input.product}\``, 404)
|
|
13
|
+
const subscription = p.kind === 'subscription'
|
|
14
|
+
const name = subscription ? (input.plan ?? Object.keys(p.plans!)[0]) : undefined
|
|
15
|
+
const row: Plan | undefined = subscription ? p.plans![name as keyof typeof p.plans] : p.price
|
|
16
|
+
if (!row) throw new PayError('PAY021', `\`${p.id}\` has no plan \`${input.plan}\``, 404)
|
|
17
|
+
const currency =
|
|
18
|
+
input.currency ?? Object.keys(row).find((k) => k !== 'trialDays' && k !== 'interval')!
|
|
19
|
+
const amount = row[currency]
|
|
20
|
+
if (typeof amount !== 'number')
|
|
21
|
+
throw new PayError('PAY021', `\`${p.id}\` has no ${currency} price`, 404)
|
|
22
|
+
return {
|
|
23
|
+
product: p.id,
|
|
24
|
+
name: p.name ?? p.id,
|
|
25
|
+
amount,
|
|
26
|
+
currency,
|
|
27
|
+
mode: subscription ? 'subscription' : 'payment',
|
|
28
|
+
quantity: subscription ? 1 : (input.quantity ?? 1),
|
|
29
|
+
...(subscription
|
|
30
|
+
? {
|
|
31
|
+
plan: name!,
|
|
32
|
+
interval: (row.interval ?? name) as Interval,
|
|
33
|
+
...(row.trialDays ? { trialDays: row.trialDays } : {}),
|
|
34
|
+
}
|
|
35
|
+
: {}),
|
|
36
|
+
...(p.shipping ? { shipping: true } : {}),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Validate checkout input against this catalog. */
|
|
41
|
+
export function checkoutInput<P extends Product>(byId: Map<string, P>): Pay<P>['input'] {
|
|
42
|
+
return {
|
|
43
|
+
'~standard': {
|
|
44
|
+
version: 1,
|
|
45
|
+
vendor: 'weft',
|
|
46
|
+
validate(v: unknown) {
|
|
47
|
+
const { product, plan, currency, quantity } = (
|
|
48
|
+
v && typeof v === 'object' ? v : {}
|
|
49
|
+
) as Partial<Record<keyof CheckoutInput, unknown>>
|
|
50
|
+
if (typeof product !== 'string') return issue('`product` is required')
|
|
51
|
+
const p = byId.get(product)
|
|
52
|
+
if (!p) return issue(`no product \`${product}\``)
|
|
53
|
+
if (plan !== undefined && (typeof plan !== 'string' || !p.plans || !(plan in p.plans)))
|
|
54
|
+
return issue(
|
|
55
|
+
`\`${p.id}\` has no plan \`${typeof plan === 'string' ? plan : JSON.stringify(plan)}\``,
|
|
56
|
+
)
|
|
57
|
+
if (currency !== undefined && typeof currency !== 'string')
|
|
58
|
+
return issue('`currency` is a string')
|
|
59
|
+
if (
|
|
60
|
+
quantity !== undefined &&
|
|
61
|
+
!(
|
|
62
|
+
typeof quantity === 'number' &&
|
|
63
|
+
Number.isInteger(quantity) &&
|
|
64
|
+
quantity > 0 &&
|
|
65
|
+
quantity < 1000
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
return issue('`quantity` is a whole number under 1000')
|
|
69
|
+
return {
|
|
70
|
+
value: {
|
|
71
|
+
product,
|
|
72
|
+
...(plan !== undefined && { plan }),
|
|
73
|
+
...(currency !== undefined && { currency }),
|
|
74
|
+
...(quantity !== undefined && { quantity }),
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { entitlementTag, type Product, type PurchaseState, type RailEvent } from '../index'
|
|
2
|
+
import type { Entitlements, PayReport, PayStore, Purchase } from '../server'
|
|
3
|
+
|
|
4
|
+
const ACCESS: ReadonlySet<PurchaseState> = new Set(['active', 'trial', 'grace'])
|
|
5
|
+
const FINAL: ReadonlySet<PurchaseState> = new Set(['refunded', 'revoked'])
|
|
6
|
+
|
|
7
|
+
/** Derive access and apply verified events against one store. */
|
|
8
|
+
export function makeLedger<P extends Product>(
|
|
9
|
+
store: PayStore,
|
|
10
|
+
byId: Map<string, P>,
|
|
11
|
+
leeway: number,
|
|
12
|
+
) {
|
|
13
|
+
const live = (p: Purchase) =>
|
|
14
|
+
ACCESS.has(p.state) && (p.expires === null || Date.now() < p.expires + leeway)
|
|
15
|
+
|
|
16
|
+
async function granted(user: string) {
|
|
17
|
+
const out = new Set<string>()
|
|
18
|
+
for (const p of await store.byUser(user))
|
|
19
|
+
if (live(p)) for (const g of byId.get(p.product!)?.grants ?? []) out.add(g)
|
|
20
|
+
return [...out].sort()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const entitlements = (user: string | null | undefined): Entitlements => {
|
|
24
|
+
let list: Promise<string[]> | undefined
|
|
25
|
+
const read = () => (list ??= user ? granted(user) : Promise.resolve([]))
|
|
26
|
+
return { has: (id) => read().then((l) => l.includes(id)), list: read }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One event onto its purchase. Identity always merges; state moves only forward on the rail's clock. */
|
|
30
|
+
async function apply(rail: string, e: RailEvent, report?: PayReport): Promise<string | null> {
|
|
31
|
+
const id = `${rail}:${e.purchase}`
|
|
32
|
+
const had = await store.purchase(id)
|
|
33
|
+
const newer =
|
|
34
|
+
!had || e.at > had.at || (e.at === had.at && (!FINAL.has(had.state) || FINAL.has(e.state)))
|
|
35
|
+
const next: Purchase = {
|
|
36
|
+
id,
|
|
37
|
+
rail,
|
|
38
|
+
user: had?.user ?? e.user ?? null,
|
|
39
|
+
product: had?.product ?? e.product ?? null,
|
|
40
|
+
plan: e.plan ?? had?.plan ?? null,
|
|
41
|
+
customer: e.customer ?? had?.customer ?? null,
|
|
42
|
+
state: newer ? e.state : had.state,
|
|
43
|
+
at: newer ? e.at : had.at,
|
|
44
|
+
expires: newer ? (e.expires ?? had?.expires ?? null) : had.expires,
|
|
45
|
+
}
|
|
46
|
+
await store.save(next)
|
|
47
|
+
if (next.product && !byId.has(next.product))
|
|
48
|
+
report?.({
|
|
49
|
+
code: 'PAY021',
|
|
50
|
+
severity: 'warning',
|
|
51
|
+
message: `purchase \`${id}\` is for \`${next.product}\`, which isn't in the catalog: it grants nothing`,
|
|
52
|
+
})
|
|
53
|
+
return next.user && entitlementTag(next.user)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function ingest(rail: string, events: RailEvent[], report?: PayReport) {
|
|
57
|
+
const tags = new Set<string>()
|
|
58
|
+
for (const e of events) {
|
|
59
|
+
const key = `${rail}:${e.id}`
|
|
60
|
+
if (await store.seen(key)) continue
|
|
61
|
+
const tag = await apply(rail, e, report)
|
|
62
|
+
await store.mark(key, e.at)
|
|
63
|
+
if (tag) tags.add(tag)
|
|
64
|
+
}
|
|
65
|
+
return [...tags]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { live, entitlements, ingest }
|
|
69
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { PayError, type PayRail, type Product, type RailEvent } from '../index'
|
|
2
|
+
import type {
|
|
3
|
+
CheckoutInput,
|
|
4
|
+
PayContext,
|
|
5
|
+
PayOptions,
|
|
6
|
+
PayReport,
|
|
7
|
+
PayStore,
|
|
8
|
+
Purchase,
|
|
9
|
+
} from '../server'
|
|
10
|
+
import { lineOf } from './catalog'
|
|
11
|
+
|
|
12
|
+
const BODY = 1 << 20
|
|
13
|
+
const hex = async (s: string) =>
|
|
14
|
+
[...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)))]
|
|
15
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
16
|
+
.join('')
|
|
17
|
+
const errorMessage = (e: unknown) => String((e as Error | null)?.message ?? e)
|
|
18
|
+
|
|
19
|
+
type Services<P extends Product> = {
|
|
20
|
+
options: PayOptions<P>
|
|
21
|
+
urls: PayOptions<P>['urls']
|
|
22
|
+
userOf: (ctx: PayContext) => string | null | undefined
|
|
23
|
+
store: PayStore
|
|
24
|
+
railFor: (key?: string) => Promise<PayRail>
|
|
25
|
+
live: (purchase: Purchase) => boolean
|
|
26
|
+
byId: Map<string, P>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Create the checkout operation for this pay instance. */
|
|
30
|
+
export function checkoutFor<P extends Product>({
|
|
31
|
+
options,
|
|
32
|
+
urls,
|
|
33
|
+
userOf,
|
|
34
|
+
store,
|
|
35
|
+
railFor,
|
|
36
|
+
live,
|
|
37
|
+
byId,
|
|
38
|
+
}: Services<P>) {
|
|
39
|
+
async function checkout(ctx: PayContext, input: CheckoutInput<P>) {
|
|
40
|
+
const user = userOf(ctx)
|
|
41
|
+
if (!user)
|
|
42
|
+
throw new PayError(
|
|
43
|
+
'PAY010',
|
|
44
|
+
'Sign in before buying: a purchase has to belong to someone',
|
|
45
|
+
401,
|
|
46
|
+
)
|
|
47
|
+
const line = lineOf(byId, input)
|
|
48
|
+
const rail = await railFor()
|
|
49
|
+
const mine = await store.byUser(user)
|
|
50
|
+
if (line.mode === 'subscription' && mine.some((p) => p.product === line.product && live(p)))
|
|
51
|
+
throw new PayError('PAY023', `You already have \`${line.product}\``, 409)
|
|
52
|
+
const origin = options.origin ?? new URL(ctx.request.url).origin
|
|
53
|
+
const customer = mine.find((p) => p.customer && p.rail === rail.id)?.customer ?? undefined
|
|
54
|
+
const idempotencyKey = await hex(JSON.stringify([user, line, Math.floor(Date.now() / 60_000)]))
|
|
55
|
+
const { url } = await rail.checkout({
|
|
56
|
+
user,
|
|
57
|
+
...(customer && { customer }),
|
|
58
|
+
line,
|
|
59
|
+
successUrl: new URL(urls.success, origin).href,
|
|
60
|
+
cancelUrl: new URL(urls.cancel, origin).href,
|
|
61
|
+
idempotencyKey,
|
|
62
|
+
})
|
|
63
|
+
return url
|
|
64
|
+
}
|
|
65
|
+
return checkout
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Create the portal operation for this pay instance. */
|
|
69
|
+
export function portalFor<P extends Product>({
|
|
70
|
+
options,
|
|
71
|
+
urls,
|
|
72
|
+
userOf,
|
|
73
|
+
store,
|
|
74
|
+
railFor,
|
|
75
|
+
}: Services<P>) {
|
|
76
|
+
async function portal(ctx: PayContext) {
|
|
77
|
+
const user = userOf(ctx)
|
|
78
|
+
if (!user) throw new PayError('PAY010', 'Sign in first', 401)
|
|
79
|
+
const rail = await railFor()
|
|
80
|
+
const customer = (await store.byUser(user)).find(
|
|
81
|
+
(p) => p.customer && p.rail === rail.id,
|
|
82
|
+
)?.customer
|
|
83
|
+
if (!customer)
|
|
84
|
+
throw new PayError('PAY024', 'Nothing to manage yet: this user has bought nothing here', 404)
|
|
85
|
+
const origin = options.origin ?? new URL(ctx.request.url).origin
|
|
86
|
+
return (
|
|
87
|
+
await rail.portal({ customer, returnUrl: new URL(urls.portal ?? urls.cancel, origin).href })
|
|
88
|
+
).url
|
|
89
|
+
}
|
|
90
|
+
return portal
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Create the verified delivery operation for this pay instance. */
|
|
94
|
+
export function webhookFor(
|
|
95
|
+
railFor: (key?: string) => Promise<PayRail>,
|
|
96
|
+
ingest: (rail: string, events: RailEvent[], report?: PayReport) => Promise<string[]>,
|
|
97
|
+
) {
|
|
98
|
+
async function webhook(key: string, request: Request, report?: PayReport) {
|
|
99
|
+
const plain = (body: string, status: number) => ({
|
|
100
|
+
response: new Response(body, { status, headers: { 'content-type': 'text/plain' } }),
|
|
101
|
+
tags: [],
|
|
102
|
+
})
|
|
103
|
+
if (request.method !== 'POST') return plain('Method not allowed', 405)
|
|
104
|
+
let rail: PayRail
|
|
105
|
+
try {
|
|
106
|
+
rail = await railFor(key)
|
|
107
|
+
} catch {
|
|
108
|
+
return plain('Not found', 404)
|
|
109
|
+
}
|
|
110
|
+
if (Number(request.headers.get('content-length') ?? 0) > BODY) return plain('Too large', 413)
|
|
111
|
+
const body = await request.text() // the exact bytes: a parsed and re-encoded body can't be verified
|
|
112
|
+
if (body.length > BODY) return plain('Too large', 413)
|
|
113
|
+
let events: RailEvent[]
|
|
114
|
+
try {
|
|
115
|
+
events = await rail.verify({ body, headers: request.headers })
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// A retry can't fix this one, and the reason stays in the log: a forger learns nothing.
|
|
118
|
+
report?.({
|
|
119
|
+
code: (e as { code?: string } | null)?.code ?? 'PAY011',
|
|
120
|
+
severity: 'error',
|
|
121
|
+
message: `a ${key} webhook failed verification: ${errorMessage(e)}`,
|
|
122
|
+
})
|
|
123
|
+
return plain('Bad signature', 400)
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
return {
|
|
127
|
+
response: new Response('{"received":true}', {
|
|
128
|
+
headers: { 'content-type': 'application/json' },
|
|
129
|
+
}),
|
|
130
|
+
tags: await ingest(rail.id, events, report),
|
|
131
|
+
}
|
|
132
|
+
} catch (e) {
|
|
133
|
+
report?.({
|
|
134
|
+
code: 'E_PAY',
|
|
135
|
+
severity: 'error',
|
|
136
|
+
message: `a ${key} webhook failed, and it will be redelivered: ${errorMessage(e)}`,
|
|
137
|
+
})
|
|
138
|
+
return plain('Try again', 500)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return webhook
|
|
142
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { PayError, type PayRail, type Product } from '../index'
|
|
2
|
+
import type { PayOptions } from '../server'
|
|
3
|
+
|
|
4
|
+
/** Keep one override and provider cache for this pay instance. */
|
|
5
|
+
export function makeRails<P extends Product>(
|
|
6
|
+
options: PayOptions<P>,
|
|
7
|
+
slot: () => Promise<{
|
|
8
|
+
load: (key: string) => Promise<unknown>
|
|
9
|
+
providers?: Record<string, unknown>
|
|
10
|
+
}>,
|
|
11
|
+
) {
|
|
12
|
+
let override: PayRail | undefined
|
|
13
|
+
const rails = new Map<string, Promise<PayRail>>()
|
|
14
|
+
|
|
15
|
+
async function railFor(key?: string): Promise<PayRail> {
|
|
16
|
+
if (override) return override
|
|
17
|
+
const { load, providers = {} } = await slot()
|
|
18
|
+
const bound = Object.keys(providers).filter((k) => providers[k])
|
|
19
|
+
if (!bound.length)
|
|
20
|
+
throw new PayError(
|
|
21
|
+
'P101',
|
|
22
|
+
'`pay.rail` has no provider: add one to `plugins` in weft.config.ts',
|
|
23
|
+
500,
|
|
24
|
+
)
|
|
25
|
+
const pick = key ?? options.rail ?? (bound.length === 1 ? bound[0] : undefined)
|
|
26
|
+
if (!pick || !bound.includes(pick))
|
|
27
|
+
throw new PayError(
|
|
28
|
+
'PAY022',
|
|
29
|
+
pick
|
|
30
|
+
? `no rail \`${pick}\` is bound (${bound.join(', ')})`
|
|
31
|
+
: `${bound.join(' and ')} are both bound: set \`rail\` in \`definePay\``,
|
|
32
|
+
500,
|
|
33
|
+
)
|
|
34
|
+
let r = rails.get(pick)
|
|
35
|
+
if (!r) rails.set(pick, (r = load(pick) as Promise<PayRail>))
|
|
36
|
+
return r
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
railFor,
|
|
41
|
+
use: (rail: PayRail | undefined) => {
|
|
42
|
+
override = rail
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
}
|