@weft-js/pay 0.1.0 → 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/docs/pay.md
CHANGED
|
@@ -12,8 +12,13 @@ App code names products, never a rail:
|
|
|
12
12
|
// src/pay/catalog.ts: reaches the client, so names and amounts only
|
|
13
13
|
import { product } from '@weft-js/pay'
|
|
14
14
|
|
|
15
|
-
export const pro = product({
|
|
16
|
-
|
|
15
|
+
export const pro = product({
|
|
16
|
+
id: 'pro',
|
|
17
|
+
kind: 'subscription',
|
|
18
|
+
use: 'digital',
|
|
19
|
+
plans: { month: { usd: 999 }, year: { usd: 7999, trialDays: 7 } },
|
|
20
|
+
grants: ['pro'],
|
|
21
|
+
})
|
|
17
22
|
```
|
|
18
23
|
|
|
19
24
|
<!-- example: skip needs what `weft add pay` writes -->
|
|
@@ -27,8 +32,15 @@ export default function Pricing() {
|
|
|
27
32
|
const mine = entitlements.live()
|
|
28
33
|
return (
|
|
29
34
|
<section>
|
|
30
|
-
<button
|
|
31
|
-
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
onClick={() => checkout({ product: 'pro', plan: 'year' })}
|
|
38
|
+
>
|
|
39
|
+
Go Pro · {price(pro, 'year').display}
|
|
40
|
+
</button>
|
|
41
|
+
<button type="button" onClick={() => portal()}>
|
|
42
|
+
Manage
|
|
43
|
+
</button>
|
|
32
44
|
{mine.value?.includes('pro') && <p>You have Pro.</p>}
|
|
33
45
|
</section>
|
|
34
46
|
)
|
|
@@ -48,8 +60,13 @@ A bad signature answers 400; a failure answers 500, so the rail delivers it agai
|
|
|
48
60
|
|
|
49
61
|
<!-- example: skip fragments of a server module -->
|
|
50
62
|
```ts
|
|
51
|
-
export const report = query({
|
|
52
|
-
|
|
63
|
+
export const report = query({
|
|
64
|
+
auth: 'user',
|
|
65
|
+
requires: ['pro'],
|
|
66
|
+
run: ({ ctx }) => buildReport(ctx),
|
|
67
|
+
}) // a typed 403: ENTITLEMENT_REQUIRED
|
|
68
|
+
// Await it (PAY015).
|
|
69
|
+
if (!(await ctx.entitlements.has('pro'))) redirect('/pricing')
|
|
53
70
|
```
|
|
54
71
|
|
|
55
72
|
`ctx.entitlements` has `has(id)` and `list()`, and works in any `query`, `mutation` or `load`. A query with `cache: { life }`
|
|
@@ -82,13 +99,15 @@ generated file (PAY003).
|
|
|
82
99
|
ledger, so a test sees what a user would:
|
|
83
100
|
|
|
84
101
|
```tsx
|
|
85
|
-
const fake = fakePay(pay)
|
|
102
|
+
const fake = fakePay(pay) // your `definePay`, or fakePay({ catalog })
|
|
86
103
|
net.context({ user: { id: 'u1' } })
|
|
87
104
|
render(Paywall)
|
|
88
105
|
await user.click(screen.getByRole('button', { name: /Go Pro/ }))
|
|
89
|
-
await fake.complete()
|
|
106
|
+
await fake.complete() // the buyer finishes the hosted page
|
|
90
107
|
expect(screen.getByLabelText('plan').textContent).toBe('Pro')
|
|
91
|
-
|
|
108
|
+
// renewal, recovered, grace-expired, cancel, pause, resume,
|
|
109
|
+
// refund, revoke
|
|
110
|
+
await fake.simulate('renewal-failed')
|
|
92
111
|
```
|
|
93
112
|
|
|
94
113
|
`fake.buy(user, product, plan)` skips the page. `renewal` and `renewal-failed` move `clock` to the period's end when it's
|
package/package.json
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weft-js/pay",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "@weft-js/pay: products, one ledger for every rail, and entitlements. weft's reference plugin: read it to build your own",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Ravonus/weft.git",
|
|
9
|
+
"directory": "packages/pay"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Ravonus/weft/tree/main/packages/pay#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Ravonus/weft/issues"
|
|
14
|
+
},
|
|
6
15
|
"type": "module",
|
|
7
16
|
"exports": {
|
|
8
17
|
".": "./src/index.ts",
|
|
@@ -27,9 +36,9 @@
|
|
|
27
36
|
"typescript": "6.0.3"
|
|
28
37
|
},
|
|
29
38
|
"peerDependencies": {
|
|
30
|
-
"@weft-js/start": "^0.1.
|
|
39
|
+
"@weft-js/start": "^0.1.3",
|
|
31
40
|
"drizzle-orm": "^0.45.0",
|
|
32
|
-
"@weft-js/core": "^0.1.
|
|
41
|
+
"@weft-js/core": "^0.1.3"
|
|
33
42
|
},
|
|
34
43
|
"peerDependenciesMeta": {
|
|
35
44
|
"drizzle-orm": {
|
|
@@ -45,4 +54,4 @@
|
|
|
45
54
|
"publishConfig": {
|
|
46
55
|
"access": "public"
|
|
47
56
|
}
|
|
48
|
-
}
|
|
57
|
+
}
|
package/plugin.mjs
CHANGED
|
@@ -5,22 +5,92 @@
|
|
|
5
5
|
import { definePlugin, defineSlot, provide } from '@weft-js/core/plugin'
|
|
6
6
|
import { setup } from './src/setup.mjs'
|
|
7
7
|
|
|
8
|
-
const extension = defineSlot({
|
|
8
|
+
const extension = defineSlot({
|
|
9
|
+
id: 'server.extension',
|
|
10
|
+
version: 1,
|
|
11
|
+
cardinality: 'keyed',
|
|
12
|
+
sides: ['server'],
|
|
13
|
+
})
|
|
9
14
|
|
|
10
15
|
const diagnostics = [
|
|
11
|
-
[
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
[
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
[
|
|
16
|
+
[
|
|
17
|
+
'PAY003',
|
|
18
|
+
'error',
|
|
19
|
+
'A Stripe secret, restricted or webhook key in the source, a server secret read in client code, or a key in plugin options',
|
|
20
|
+
'Move it to the server env (`src/env.server.ts`), and roll any real key that was committed',
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
'PAY004',
|
|
24
|
+
'error',
|
|
25
|
+
"Paid state written on the client after `checkout()`, which only opens the rail's page",
|
|
26
|
+
'Read `entitlements` instead: a grant arrives from a verified event',
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
'PAY005',
|
|
30
|
+
'warning',
|
|
31
|
+
'A price typed into a page that sells something',
|
|
32
|
+
'`price(product, plan).display`',
|
|
33
|
+
],
|
|
34
|
+
[
|
|
35
|
+
'PAY008',
|
|
36
|
+
'error',
|
|
37
|
+
'Money as a float, or with no currency, in `product()`',
|
|
38
|
+
'Whole minor units with a currency: `{ usd: 999 }`',
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
'PAY010',
|
|
42
|
+
'error',
|
|
43
|
+
"A mutation that opens a checkout without `auth: 'user'`",
|
|
44
|
+
"Add `auth: 'user'`: a purchase has to belong to someone",
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
'PAY011',
|
|
48
|
+
'error',
|
|
49
|
+
'A webhook left unverified: a handler of payment events with no signature check, a `PayRail` whose `verify` ignores `headers`, or no `STRIPE_WEBHOOK_SECRET`',
|
|
50
|
+
'Delete the handler: `/_weft/x/pay/<rail>` verifies. Give Stripe that URL, and set `STRIPE_WEBHOOK_SECRET`',
|
|
51
|
+
],
|
|
52
|
+
[
|
|
53
|
+
'PAY012',
|
|
54
|
+
'error',
|
|
55
|
+
"A rail's price or product id in client code",
|
|
56
|
+
"Name the catalog: `checkout({ product: 'pro', plan: 'month' })`. Map ids on the server: `stripe({ prices: { 'pro.month': … } })`",
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
'PAY013',
|
|
60
|
+
'error',
|
|
61
|
+
'An entitlement checked only on the client: nothing on the server enforces it',
|
|
62
|
+
"`requires: ['pro']` on the query or mutation, or `await ctx.entitlements.has('pro')` in a `load`",
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
'PAY014',
|
|
66
|
+
'error',
|
|
67
|
+
'The plugin with no `src/pay.server.ts`: no webhook is mounted, so payments grant nothing',
|
|
68
|
+
'Default-export `definePay({ … })` from `src/pay.server.ts`',
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
'PAY015',
|
|
72
|
+
'error',
|
|
73
|
+
'`ctx.entitlements.has()` tested without `await`: a promise is truthy, so everyone passes',
|
|
74
|
+
'Await it (`weft fix` does, in an `async` function)',
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
'PAY016',
|
|
78
|
+
'warning',
|
|
79
|
+
'`memoryPayStore()` in `src/pay.server.ts`: a production server refuses to start with it',
|
|
80
|
+
'`drizzlePayStore(db, { purchases, payEvents })`, or your own `PayStore`',
|
|
81
|
+
],
|
|
82
|
+
[
|
|
83
|
+
'PAY017',
|
|
84
|
+
'error',
|
|
85
|
+
'`@weft-js/pay/server` or `/drizzle` imported in client code',
|
|
86
|
+
'Import them in a server module; the client gets `@weft-js/pay`',
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
'PAY018',
|
|
90
|
+
'error',
|
|
91
|
+
'`src/pay.server.ts` with no `pay.rail` provider',
|
|
92
|
+
'Add a rail to `plugins`: `weft add pay-stripe`',
|
|
93
|
+
],
|
|
24
94
|
['PAY031', 'error', 'A live key in a test', '`fakePay` from `@weft-js/pay/test`'],
|
|
25
95
|
].map(([code, severity, catches, fix]) => ({ code, severity, catches, fix }))
|
|
26
96
|
|
|
@@ -31,19 +101,57 @@ export const pay = definePlugin({
|
|
|
31
101
|
namespace: 'PAY',
|
|
32
102
|
permissions: {},
|
|
33
103
|
contribute: () => ({
|
|
34
|
-
provides: [
|
|
104
|
+
provides: [
|
|
105
|
+
provide(extension, {
|
|
106
|
+
key: 'pay',
|
|
107
|
+
sides: { server: { module: '@weft-js/pay/server', export: 'extension' } },
|
|
108
|
+
}),
|
|
109
|
+
],
|
|
35
110
|
diagnostics,
|
|
36
|
-
check: {
|
|
37
|
-
|
|
111
|
+
check: {
|
|
112
|
+
include: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}', 'test/**/*.{ts,tsx,js,mjs}', 'weft.config.*'],
|
|
113
|
+
run: (ctx) => import('./src/checks.mjs').then((c) => c.run(ctx)),
|
|
114
|
+
},
|
|
115
|
+
docs: {
|
|
116
|
+
topic: 'pay',
|
|
117
|
+
summary:
|
|
118
|
+
'Payments: products, checkout, entitlements, webhooks, fakePay, another provider; PAY-codes',
|
|
119
|
+
file: new URL('./docs/pay.md', import.meta.url).pathname,
|
|
120
|
+
},
|
|
38
121
|
setup,
|
|
39
|
-
agents: {
|
|
122
|
+
agents: {
|
|
123
|
+
title: 'Payments',
|
|
124
|
+
text: 'Products are in `src/pay/catalog.ts`, in minor units. `checkout` and `entitlements` are in `src/server/pay.ts`. App code never names a rail or a price id, and `checkout` grants nothing: a verified webhook does. Enforce on the server, with `requires: [...]` or `await ctx.entitlements.has(...)`: a check only on the client protects nothing. Test with `fakePay` from `@weft-js/pay/test`. `weft docs pay`.',
|
|
125
|
+
},
|
|
40
126
|
catalog: [
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
127
|
+
{
|
|
128
|
+
name: 'product',
|
|
129
|
+
import: '@weft-js/pay',
|
|
130
|
+
summary: 'One thing you sell, in integer minor units',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'price',
|
|
134
|
+
import: '@weft-js/pay',
|
|
135
|
+
summary: "A product's price, formatted for the reader",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'definePay',
|
|
139
|
+
import: '@weft-js/pay/server',
|
|
140
|
+
summary: 'The ledger: src/pay.server.ts default-exports it',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'fakePay',
|
|
144
|
+
import: '@weft-js/pay/test',
|
|
145
|
+
summary: 'A rail for tests: renewals, refunds and disputes on the clock',
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
map: [
|
|
149
|
+
{
|
|
150
|
+
want: 'Sell something',
|
|
151
|
+
call: 'add it to `src/pay/catalog.ts`, then `checkout({ product, plan })`',
|
|
152
|
+
have: '`weft docs pay`',
|
|
153
|
+
},
|
|
45
154
|
],
|
|
46
|
-
map: [{ want: 'Sell something', call: 'add it to `src/pay/catalog.ts`, then `checkout({ product, plan })`', have: '`weft docs pay`' }],
|
|
47
155
|
}),
|
|
48
156
|
})
|
|
49
157
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Check config and test files for exposed credentials. */
|
|
2
|
+
export function configChecks({ ctx, out, secrets, spanOf, isTest, SECRET_OPTION }) {
|
|
3
|
+
for (const rel of ctx.files.filter((r) => r.startsWith('weft.config.'))) {
|
|
4
|
+
const text = ctx.read(rel)
|
|
5
|
+
secrets(rel, text, true, out)
|
|
6
|
+
const m = SECRET_OPTION.exec(text)
|
|
7
|
+
if (m)
|
|
8
|
+
out.push({
|
|
9
|
+
code: 'PAY003',
|
|
10
|
+
...spanOf(rel, m.index, m[0].length),
|
|
11
|
+
why: 'Plugin options are written into `.weft/slots/`, so a key given here sits in a generated file.',
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
for (const rel of ctx.files.filter(isTest)) {
|
|
16
|
+
const text = ctx.read(rel)
|
|
17
|
+
for (const m of text.matchAll(/\b(?:sk|rk)_live_\w+/g))
|
|
18
|
+
out.push({
|
|
19
|
+
code: 'PAY031',
|
|
20
|
+
...spanOf(rel, m.index, m[0].length),
|
|
21
|
+
why: 'A live key in a test: it charges real cards, and it is now in git.',
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
}
|