@weft-js/pay 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +654 -3
- package/docs/pay.md +55 -24
- package/package.json +35 -22
- package/plugin.mjs +131 -23
- package/square-plugin.d.mts +9 -0
- package/square-plugin.mjs +47 -0
- package/src/checks/config.mjs +24 -0
- package/src/checks.mjs +219 -70
- package/src/drizzle.ts +33 -14
- package/src/index.ts +89 -25
- package/src/server/catalog.ts +80 -0
- package/src/server/ledger.ts +84 -0
- package/src/server/operations.ts +215 -0
- package/src/server/rails.ts +45 -0
- package/src/server.ts +113 -172
- package/src/setup.mjs +26 -9
- package/src/square.ts +231 -0
- package/src/stripe.ts +170 -56
- package/src/test.ts +209 -65
- package/stripe-plugin.d.mts +6 -1
- package/stripe-plugin.mjs +24 -5
package/src/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,16 +152,33 @@ 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)]`. */
|
|
118
164
|
export const entitlementTag = (user: string) => `entitlements:${user}`
|
|
119
165
|
|
|
166
|
+
/** What a verified event pokes for one order: `tags: (input) => [orderTag(input.ref)]`. Every order also pokes `orders`. */
|
|
167
|
+
export const orderTag = (ref: string) => `order:${ref}`
|
|
168
|
+
|
|
120
169
|
// ---- pay.rail@1 ---------------------------------------------------------------------------------------------------
|
|
121
170
|
|
|
122
|
-
/** What a purchase is doing now, on any rail
|
|
123
|
-
export type PurchaseState =
|
|
171
|
+
/** What a purchase is doing now, on any rail. */
|
|
172
|
+
export type PurchaseState =
|
|
173
|
+
| 'pending'
|
|
174
|
+
| 'active'
|
|
175
|
+
| 'trial'
|
|
176
|
+
| 'grace'
|
|
177
|
+
| 'billing-retry'
|
|
178
|
+
| 'paused'
|
|
179
|
+
| 'expired'
|
|
180
|
+
| 'refunded'
|
|
181
|
+
| 'revoked'
|
|
124
182
|
|
|
125
183
|
/** One line a rail charges for, from the catalog. It never comes from the client. */
|
|
126
184
|
export interface CheckoutLine {
|
|
@@ -143,6 +201,10 @@ export interface CheckoutRequest {
|
|
|
143
201
|
/** This user's id on the rail, when a purchase gave one. */
|
|
144
202
|
customer?: string
|
|
145
203
|
line: CheckoutLine
|
|
204
|
+
/** An order's own lines, priced on the server (`pay.order`): charged in place of `line`, which sums them. */
|
|
205
|
+
lines?: readonly CheckoutLine[]
|
|
206
|
+
/** A guest's email, for the receipt, when the rail has no customer for them. */
|
|
207
|
+
email?: string
|
|
146
208
|
successUrl: string
|
|
147
209
|
cancelUrl: string
|
|
148
210
|
/** The same click twice gives one session. */
|
|
@@ -173,6 +235,8 @@ export interface RailEvent {
|
|
|
173
235
|
*/
|
|
174
236
|
export interface PayRail {
|
|
175
237
|
readonly id: string
|
|
238
|
+
/** What it can sell. Default both; a rail without `subscription` has no portal to offer. */
|
|
239
|
+
readonly modes?: readonly ('payment' | 'subscription')[]
|
|
176
240
|
checkout(request: CheckoutRequest): Promise<{ url: string }>
|
|
177
241
|
/** Where a buyer manages or cancels. */
|
|
178
242
|
portal(request: { customer: string; returnUrl: string }): Promise<{ url: string }>
|
|
@@ -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,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entitlementTag,
|
|
3
|
+
orderTag,
|
|
4
|
+
type Product,
|
|
5
|
+
type PurchaseState,
|
|
6
|
+
type RailEvent,
|
|
7
|
+
} from '../index'
|
|
8
|
+
import type { Entitlements, OrderHook, PayReport, PayStore, Purchase } from '../server'
|
|
9
|
+
|
|
10
|
+
const ACCESS: ReadonlySet<PurchaseState> = new Set(['active', 'trial', 'grace'])
|
|
11
|
+
const FINAL: ReadonlySet<PurchaseState> = new Set(['refunded', 'revoked'])
|
|
12
|
+
/** The product id every `pay.order` is recorded under; its plan is the app's order ref. */
|
|
13
|
+
export const ORDER = 'order'
|
|
14
|
+
|
|
15
|
+
/** Derive access and apply verified events against one store. */
|
|
16
|
+
export function makeLedger<P extends Product>(
|
|
17
|
+
store: PayStore,
|
|
18
|
+
byId: Map<string, P>,
|
|
19
|
+
leeway: number,
|
|
20
|
+
onOrder?: OrderHook,
|
|
21
|
+
) {
|
|
22
|
+
const live = (p: Purchase) =>
|
|
23
|
+
ACCESS.has(p.state) && (p.expires === null || Date.now() < p.expires + leeway)
|
|
24
|
+
|
|
25
|
+
async function granted(user: string) {
|
|
26
|
+
const out = new Set<string>()
|
|
27
|
+
for (const p of await store.byUser(user))
|
|
28
|
+
if (live(p)) for (const g of byId.get(p.product!)?.grants ?? []) out.add(g)
|
|
29
|
+
return [...out].sort()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const entitlements = (user: string | null | undefined): Entitlements => {
|
|
33
|
+
let list: Promise<string[]> | undefined
|
|
34
|
+
const read = () => (list ??= user ? granted(user) : Promise.resolve([]))
|
|
35
|
+
return { has: (id) => read().then((l) => l.includes(id)), list: read }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One event onto its purchase. Identity always merges; state moves only forward on the rail's clock. */
|
|
39
|
+
async function apply(rail: string, e: RailEvent, report?: PayReport): Promise<string[]> {
|
|
40
|
+
const id = `${rail}:${e.purchase}`
|
|
41
|
+
const had = await store.purchase(id)
|
|
42
|
+
const newer =
|
|
43
|
+
!had || e.at > had.at || (e.at === had.at && (!FINAL.has(had.state) || FINAL.has(e.state)))
|
|
44
|
+
const next: Purchase = {
|
|
45
|
+
id,
|
|
46
|
+
rail,
|
|
47
|
+
user: had?.user ?? e.user ?? null,
|
|
48
|
+
product: had?.product ?? e.product ?? null,
|
|
49
|
+
plan: e.plan ?? had?.plan ?? null,
|
|
50
|
+
customer: e.customer ?? had?.customer ?? null,
|
|
51
|
+
state: newer ? e.state : had.state,
|
|
52
|
+
at: newer ? e.at : had.at,
|
|
53
|
+
expires: newer ? (e.expires ?? had?.expires ?? null) : had.expires,
|
|
54
|
+
}
|
|
55
|
+
await store.save(next)
|
|
56
|
+
// An order (`pay.order`) grants nothing: the app hears its state, before the event is marked, so a throw redelivers.
|
|
57
|
+
if (next.product === ORDER && next.plan) {
|
|
58
|
+
if (newer && had?.state !== next.state)
|
|
59
|
+
await onOrder?.({ ref: next.plan, state: next.state, purchase: next })
|
|
60
|
+
return ['orders', orderTag(next.plan)]
|
|
61
|
+
}
|
|
62
|
+
if (next.product && !byId.has(next.product))
|
|
63
|
+
report?.({
|
|
64
|
+
code: 'PAY021',
|
|
65
|
+
severity: 'warning',
|
|
66
|
+
message: `purchase \`${id}\` is for \`${next.product}\`, which isn't in the catalog: it grants nothing`,
|
|
67
|
+
})
|
|
68
|
+
return next.user ? [entitlementTag(next.user)] : []
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function ingest(rail: string, events: RailEvent[], report?: PayReport) {
|
|
72
|
+
const tags = new Set<string>()
|
|
73
|
+
for (const e of events) {
|
|
74
|
+
const key = `${rail}:${e.id}`
|
|
75
|
+
if (await store.seen(key)) continue
|
|
76
|
+
const poked = await apply(rail, e, report)
|
|
77
|
+
await store.mark(key, e.at)
|
|
78
|
+
for (const t of poked) tags.add(t)
|
|
79
|
+
}
|
|
80
|
+
return [...tags]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { live, entitlements, ingest }
|
|
84
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { PayError, type CheckoutLine, type PayRail, type Product, type RailEvent } from '../index'
|
|
2
|
+
import type {
|
|
3
|
+
CheckoutInput,
|
|
4
|
+
OrderInput,
|
|
5
|
+
PayContext,
|
|
6
|
+
PayOptions,
|
|
7
|
+
PayReport,
|
|
8
|
+
PayStore,
|
|
9
|
+
Purchase,
|
|
10
|
+
} from '../server'
|
|
11
|
+
import { lineOf } from './catalog'
|
|
12
|
+
import { ORDER } from './ledger'
|
|
13
|
+
|
|
14
|
+
const BODY = 1 << 20
|
|
15
|
+
const hex = async (s: string) =>
|
|
16
|
+
[...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)))]
|
|
17
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
18
|
+
.join('')
|
|
19
|
+
const errorMessage = (e: unknown) => String((e as Error | null)?.message ?? e)
|
|
20
|
+
|
|
21
|
+
type Services<P extends Product> = {
|
|
22
|
+
options: PayOptions<P>
|
|
23
|
+
urls: PayOptions<P>['urls']
|
|
24
|
+
userOf: (ctx: PayContext) => string | null | undefined
|
|
25
|
+
store: PayStore
|
|
26
|
+
railFor: (key?: string) => Promise<PayRail>
|
|
27
|
+
live: (purchase: Purchase) => boolean
|
|
28
|
+
byId: Map<string, P>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Create the checkout operation for this pay instance. */
|
|
32
|
+
export function checkoutFor<P extends Product>({
|
|
33
|
+
options,
|
|
34
|
+
urls,
|
|
35
|
+
userOf,
|
|
36
|
+
store,
|
|
37
|
+
railFor,
|
|
38
|
+
live,
|
|
39
|
+
byId,
|
|
40
|
+
}: Services<P>) {
|
|
41
|
+
async function checkout(ctx: PayContext, input: CheckoutInput<P>) {
|
|
42
|
+
const user = userOf(ctx)
|
|
43
|
+
if (!user)
|
|
44
|
+
throw new PayError(
|
|
45
|
+
'PAY010',
|
|
46
|
+
'Sign in before buying: a purchase has to belong to someone',
|
|
47
|
+
401,
|
|
48
|
+
)
|
|
49
|
+
const line = lineOf(byId, input)
|
|
50
|
+
const rail = await railFor()
|
|
51
|
+
const mine = await store.byUser(user)
|
|
52
|
+
if (line.mode === 'subscription' && mine.some((p) => p.product === line.product && live(p)))
|
|
53
|
+
throw new PayError('PAY023', `You already have \`${line.product}\``, 409)
|
|
54
|
+
const origin = options.origin ?? new URL(ctx.request.url).origin
|
|
55
|
+
const customer = mine.find((p) => p.customer && p.rail === rail.id)?.customer ?? undefined
|
|
56
|
+
const idempotencyKey = await hex(JSON.stringify([user, line, Math.floor(Date.now() / 60_000)]))
|
|
57
|
+
const { url } = await rail.checkout({
|
|
58
|
+
user,
|
|
59
|
+
...(customer && { customer }),
|
|
60
|
+
line,
|
|
61
|
+
successUrl: new URL(urls.success, origin).href,
|
|
62
|
+
cancelUrl: new URL(urls.cancel, origin).href,
|
|
63
|
+
idempotencyKey,
|
|
64
|
+
})
|
|
65
|
+
return url
|
|
66
|
+
}
|
|
67
|
+
return checkout
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Create the portal operation for this pay instance. */
|
|
71
|
+
export function portalFor<P extends Product>({
|
|
72
|
+
options,
|
|
73
|
+
urls,
|
|
74
|
+
userOf,
|
|
75
|
+
store,
|
|
76
|
+
railFor,
|
|
77
|
+
}: Services<P>) {
|
|
78
|
+
async function portal(ctx: PayContext) {
|
|
79
|
+
const user = userOf(ctx)
|
|
80
|
+
if (!user) throw new PayError('PAY010', 'Sign in first', 401)
|
|
81
|
+
const rail = await railFor()
|
|
82
|
+
const customer = (await store.byUser(user)).find(
|
|
83
|
+
(p) => p.customer && p.rail === rail.id,
|
|
84
|
+
)?.customer
|
|
85
|
+
if (!customer)
|
|
86
|
+
throw new PayError('PAY024', 'Nothing to manage yet: this user has bought nothing here', 404)
|
|
87
|
+
const origin = options.origin ?? new URL(ctx.request.url).origin
|
|
88
|
+
return (
|
|
89
|
+
await rail.portal({ customer, returnUrl: new URL(urls.portal ?? urls.cancel, origin).href })
|
|
90
|
+
).url
|
|
91
|
+
}
|
|
92
|
+
return portal
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Create the verified delivery operation for this pay instance. */
|
|
96
|
+
export function webhookFor(
|
|
97
|
+
railFor: (key?: string) => Promise<PayRail>,
|
|
98
|
+
ingest: (rail: string, events: RailEvent[], report?: PayReport) => Promise<string[]>,
|
|
99
|
+
) {
|
|
100
|
+
async function webhook(key: string, request: Request, report?: PayReport) {
|
|
101
|
+
const plain = (body: string, status: number) => ({
|
|
102
|
+
response: new Response(body, { status, headers: { 'content-type': 'text/plain' } }),
|
|
103
|
+
tags: [],
|
|
104
|
+
})
|
|
105
|
+
if (request.method !== 'POST') return plain('Method not allowed', 405)
|
|
106
|
+
let rail: PayRail
|
|
107
|
+
try {
|
|
108
|
+
rail = await railFor(key)
|
|
109
|
+
} catch {
|
|
110
|
+
return plain('Not found', 404)
|
|
111
|
+
}
|
|
112
|
+
if (Number(request.headers.get('content-length') ?? 0) > BODY) return plain('Too large', 413)
|
|
113
|
+
const body = await request.text() // the exact bytes: a parsed and re-encoded body can't be verified
|
|
114
|
+
if (body.length > BODY) return plain('Too large', 413)
|
|
115
|
+
let events: RailEvent[]
|
|
116
|
+
try {
|
|
117
|
+
events = await rail.verify({ body, headers: request.headers })
|
|
118
|
+
} catch (e) {
|
|
119
|
+
// A retry can't fix this one, and the reason stays in the log: a forger learns nothing.
|
|
120
|
+
report?.({
|
|
121
|
+
code: (e as { code?: string } | null)?.code ?? 'PAY011',
|
|
122
|
+
severity: 'error',
|
|
123
|
+
message: `a ${key} webhook failed verification: ${errorMessage(e)}`,
|
|
124
|
+
})
|
|
125
|
+
return plain('Bad signature', 400)
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
return {
|
|
129
|
+
response: new Response('{"received":true}', {
|
|
130
|
+
headers: { 'content-type': 'application/json' },
|
|
131
|
+
}),
|
|
132
|
+
tags: await ingest(rail.id, events, report),
|
|
133
|
+
}
|
|
134
|
+
} catch (e) {
|
|
135
|
+
report?.({
|
|
136
|
+
code: 'E_PAY',
|
|
137
|
+
severity: 'error',
|
|
138
|
+
message: `a ${key} webhook failed, and it will be redelivered: ${errorMessage(e)}`,
|
|
139
|
+
})
|
|
140
|
+
return plain('Try again', 500)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return webhook
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const REF = /^[A-Za-z0-9_-]{1,64}$/
|
|
147
|
+
|
|
148
|
+
/** Check an order's lines, which the app priced on the server, and sum them into the ledger's one line. */
|
|
149
|
+
export function orderLines(input: OrderInput): { line: CheckoutLine; lines: CheckoutLine[] } {
|
|
150
|
+
const { ref, currency = 'usd' } = input
|
|
151
|
+
if (!REF.test(ref))
|
|
152
|
+
throw new PayError(
|
|
153
|
+
'PAY021',
|
|
154
|
+
`order ref \`${ref}\` must be 1–64 letters, digits, \`-\` or \`_\``,
|
|
155
|
+
)
|
|
156
|
+
if (!/^[a-z]{3}$/.test(currency))
|
|
157
|
+
throw new PayError('PAY008', `\`${currency}\` isn't a lowercase ISO currency`)
|
|
158
|
+
if (!input.lines.length || input.lines.length > 100)
|
|
159
|
+
throw new PayError('PAY021', `order \`${ref}\` needs 1 to 100 lines`)
|
|
160
|
+
const lines = input.lines.map((l): CheckoutLine => {
|
|
161
|
+
const quantity = l.quantity ?? 1
|
|
162
|
+
if (!Number.isSafeInteger(l.amount) || l.amount < 0)
|
|
163
|
+
throw new PayError(
|
|
164
|
+
'PAY008',
|
|
165
|
+
`\`${l.name}\`: ${l.amount} isn't whole minor units (999 is $9.99)`,
|
|
166
|
+
)
|
|
167
|
+
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 999)
|
|
168
|
+
throw new PayError('PAY021', `\`${l.name}\`: quantity ${quantity} is out of range`)
|
|
169
|
+
return {
|
|
170
|
+
product: ORDER,
|
|
171
|
+
plan: ref,
|
|
172
|
+
name: l.name.slice(0, 250),
|
|
173
|
+
amount: l.amount,
|
|
174
|
+
currency,
|
|
175
|
+
mode: 'payment',
|
|
176
|
+
quantity,
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
const total = lines.reduce((sum, l) => sum + l.amount * l.quantity, 0)
|
|
180
|
+
if (total <= 0) throw new PayError('PAY021', `order \`${ref}\` totals nothing`)
|
|
181
|
+
return {
|
|
182
|
+
line: {
|
|
183
|
+
product: ORDER,
|
|
184
|
+
plan: ref,
|
|
185
|
+
name: input.title ?? `Order ${ref}`,
|
|
186
|
+
amount: total,
|
|
187
|
+
currency,
|
|
188
|
+
mode: 'payment',
|
|
189
|
+
quantity: 1,
|
|
190
|
+
},
|
|
191
|
+
lines,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Create the order operation: one payment for lines the app priced, signed in or not. */
|
|
196
|
+
export function orderFor<P extends Product>({ options, urls, userOf, railFor }: Services<P>) {
|
|
197
|
+
async function order(ctx: PayContext, input: OrderInput) {
|
|
198
|
+
const { line, lines } = orderLines(input)
|
|
199
|
+
const rail = await railFor()
|
|
200
|
+
const origin = options.origin ?? new URL(ctx.request.url).origin
|
|
201
|
+
const back = (path: string) => new URL(path.replaceAll('{ref}', input.ref), origin).href
|
|
202
|
+
const { url } = await rail.checkout({
|
|
203
|
+
user: userOf(ctx) ?? `guest:${input.ref}`,
|
|
204
|
+
line,
|
|
205
|
+
lines,
|
|
206
|
+
...(input.email && { email: input.email }),
|
|
207
|
+
successUrl: back(urls.order ?? urls.success),
|
|
208
|
+
cancelUrl: back(urls.orderCancel ?? urls.cancel),
|
|
209
|
+
// One session per order: paying twice for the same ref can't happen from a double click.
|
|
210
|
+
idempotencyKey: await hex(JSON.stringify(['order', input.ref, line.amount])),
|
|
211
|
+
})
|
|
212
|
+
return url
|
|
213
|
+
}
|
|
214
|
+
return order
|
|
215
|
+
}
|
|
@@ -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
|
+
}
|