@meith/plugin-dues 0.1.0

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.
@@ -0,0 +1,292 @@
1
+ import { definePlugin, type PluginDefinition, type PluginRequest, type PluginResponse, type PluginRuntimeContext } from '@meith/plugin-kit'
2
+
3
+ import { parseDuesConfig, type DuesConfigInput } from './config'
4
+ import {
5
+ buildServices,
6
+ entitlementDeps,
7
+ handleCancel,
8
+ handleCheckout,
9
+ handlePortal,
10
+ handleWebhook,
11
+ type DuesServices,
12
+ } from './handlers'
13
+ import {
14
+ handleAdminCancel,
15
+ handleAdminClear,
16
+ handleAdminCodeCreate,
17
+ handleAdminCodeDisable,
18
+ handleAdminExtend,
19
+ handleAdminPlanArchive,
20
+ handleAdminPlanCreate,
21
+ handleAdminPlanUpdate,
22
+ handleAdminRevoke,
23
+ } from './handlers-admin'
24
+ import { DUES_MIGRATIONS } from './schema'
25
+ import { runReconcile, runSweep } from './tasks'
26
+ import { CodesPage, LedgerPage, MembersPage, PlansAdminPage, StatusPage } from './ui/admin'
27
+ import { GoPage, ManagePage, PlansPage, ReturnPage } from './ui/pages'
28
+
29
+ export function dues(input: DuesConfigInput): PluginDefinition {
30
+ const config = parseDuesConfig(input)
31
+
32
+ const route =
33
+ (
34
+ fn: (services: DuesServices, request: PluginRequest) => Promise<PluginResponse>,
35
+ ): ((request: PluginRequest, context: PluginRuntimeContext) => Promise<PluginResponse>) =>
36
+ (request, context) =>
37
+ fn(buildServices(config, context), request)
38
+
39
+ return definePlugin({
40
+ key: 'dues',
41
+ name: 'Dues',
42
+ version: '0.1.0',
43
+ description:
44
+ `Sells ${config.label.toLowerCase()} — time-limited membership of a group — ` +
45
+ 'through Stripe, for yourself or as a gift.',
46
+ apiVersion: '0',
47
+
48
+ settings: [
49
+ {
50
+ key: 'stripe_secret_key',
51
+ label: 'Stripe secret key',
52
+ type: 'secret',
53
+ env: 'DUES_STRIPE_SECRET_KEY',
54
+ required: true,
55
+ default: '',
56
+ description: 'The sk_live_… or sk_test_… key from the Stripe dashboard.',
57
+ },
58
+ {
59
+ key: 'stripe_webhook_secret',
60
+ label: 'Webhook signing secret',
61
+ type: 'secret',
62
+ env: 'DUES_STRIPE_WEBHOOK_SECRET',
63
+ required: true,
64
+ default: '',
65
+ description:
66
+ 'The whsec_… secret of the webhook endpoint. Without it, payments are taken ' +
67
+ 'but can never confirm.',
68
+ },
69
+ {
70
+ key: 'stripe_api_version',
71
+ label: 'Stripe API version',
72
+ default: '2024-12-18.acacia',
73
+ advanced: true,
74
+ description:
75
+ 'Pinned so Stripe’s payload shapes change on a deploy, not on a Tuesday. ' +
76
+ 'Change it only alongside a plugin upgrade that expects the new shapes.',
77
+ },
78
+ {
79
+ key: 'stripe_api_base',
80
+ label: 'Stripe API address',
81
+ env: 'DUES_STRIPE_API_BASE',
82
+ default: 'https://api.stripe.com',
83
+ advanced: true,
84
+ description: 'Only a stripe-mock or a test double ever changes this.',
85
+ },
86
+ ],
87
+
88
+ migrations: DUES_MIGRATIONS,
89
+
90
+ tasks: [
91
+ {
92
+ id: 'reconcile',
93
+ intervalSeconds: 300,
94
+ run: async (context) => {
95
+ const services = buildServices(config, context)
96
+ const result = await runReconcile(entitlementDeps(services), services.stripe)
97
+ if (
98
+ result.ordersSettled + result.ordersClosed + result.eventsReplayed + result.subscriptionsCorrected >
99
+ 0
100
+ ) {
101
+ context.logger.info('dues: reconciled', { ...result })
102
+ }
103
+ },
104
+ },
105
+ {
106
+ id: 'sweep',
107
+ intervalSeconds: 3600,
108
+ run: async (context) => {
109
+ const services = buildServices(config, context)
110
+ const expired = await runSweep(entitlementDeps(services))
111
+ if (expired > 0) context.logger.info('dues: memberships expired', { expired })
112
+ },
113
+ },
114
+ ],
115
+
116
+ notifications: [
117
+ {
118
+ key: 'gift_received',
119
+ title: 'Somebody gifts you a membership',
120
+ description: 'A member bought a membership in your name. It starts the moment the payment confirms.',
121
+ },
122
+ {
123
+ key: 'renewal_trouble',
124
+ title: 'A membership payment fails',
125
+ description: 'A renewal did not go through. Access holds during the grace window while Stripe retries.',
126
+ },
127
+ ],
128
+
129
+ routes: [
130
+ {
131
+ path: 'checkout',
132
+ method: 'POST',
133
+ access: 'member',
134
+ rateLimit: { limit: 10, windowSeconds: 60 },
135
+ handler: route(handleCheckout),
136
+ },
137
+ {
138
+ path: 'portal',
139
+ method: 'POST',
140
+ access: 'member',
141
+ rateLimit: { limit: 10, windowSeconds: 60 },
142
+ handler: route(handlePortal),
143
+ },
144
+ {
145
+ path: 'cancel',
146
+ method: 'POST',
147
+ access: 'member',
148
+ rateLimit: { limit: 10, windowSeconds: 60 },
149
+ handler: route(handleCancel),
150
+ },
151
+ {
152
+ path: 'hook/stripe',
153
+ method: 'POST',
154
+ access: 'anonymous',
155
+ rawBody: true,
156
+ maxBodyBytes: 262_144,
157
+ handler: route(handleWebhook),
158
+ },
159
+ {
160
+ path: 'codes/create',
161
+ method: 'POST',
162
+ access: 'admin',
163
+ handler: route(handleAdminCodeCreate),
164
+ },
165
+ {
166
+ path: 'codes/disable',
167
+ method: 'POST',
168
+ access: 'admin',
169
+ handler: route(handleAdminCodeDisable),
170
+ },
171
+ {
172
+ path: 'members/extend',
173
+ method: 'POST',
174
+ access: 'admin',
175
+ handler: route(handleAdminExtend),
176
+ },
177
+ {
178
+ path: 'members/cancel',
179
+ method: 'POST',
180
+ access: 'admin',
181
+ handler: route(handleAdminCancel),
182
+ },
183
+ {
184
+ path: 'members/revoke',
185
+ method: 'POST',
186
+ access: 'admin',
187
+ handler: route(handleAdminRevoke),
188
+ },
189
+ {
190
+ path: 'attention/clear',
191
+ method: 'POST',
192
+ access: 'admin',
193
+ handler: route(handleAdminClear),
194
+ },
195
+ {
196
+ path: 'plans/create',
197
+ method: 'POST',
198
+ access: 'admin',
199
+ handler: route(handleAdminPlanCreate),
200
+ },
201
+ {
202
+ path: 'plans/update',
203
+ method: 'POST',
204
+ access: 'admin',
205
+ handler: route(handleAdminPlanUpdate),
206
+ },
207
+ {
208
+ path: 'plans/archive',
209
+ method: 'POST',
210
+ access: 'admin',
211
+ handler: route(handleAdminPlanArchive),
212
+ },
213
+ ],
214
+
215
+ pages: [
216
+ {
217
+ path: '',
218
+ title: config.label,
219
+ access: 'anonymous',
220
+ render: (context) => PlansPage({ config, context }),
221
+ },
222
+ {
223
+ path: 'return',
224
+ title: 'Confirming your payment',
225
+ access: 'member',
226
+ render: (context) => ReturnPage({ config, context }),
227
+ },
228
+ {
229
+ path: 'manage',
230
+ title: `Your ${config.label.toLowerCase()}`,
231
+ access: 'member',
232
+ render: (context) => ManagePage({ config, context }),
233
+ },
234
+ {
235
+ path: 'go',
236
+ title: 'Over to Stripe',
237
+ access: 'member',
238
+ render: (context) =>
239
+ GoPage({
240
+ config,
241
+ context,
242
+ allowedHosts: [
243
+ 'checkout.stripe.com',
244
+ 'billing.stripe.com',
245
+ ...config.extraRedirectHosts,
246
+ ],
247
+ }),
248
+ },
249
+ ],
250
+
251
+ adminPages: [
252
+ {
253
+ path: 'status',
254
+ title: 'Dues — status',
255
+ render: (context) => StatusPage({ config, context }),
256
+ },
257
+ {
258
+ path: 'plans',
259
+ title: 'Dues — plans',
260
+ render: (context) => PlansAdminPage({ config, context }),
261
+ },
262
+ {
263
+ path: 'members',
264
+ title: 'Dues — memberships',
265
+ render: (context) => MembersPage({ context }),
266
+ },
267
+ {
268
+ path: 'codes',
269
+ title: 'Dues — discount codes',
270
+ render: (context) => CodesPage({ config, context }),
271
+ },
272
+ {
273
+ path: 'ledger',
274
+ title: 'Dues — ledger',
275
+ render: (context) => LedgerPage({ config, context }),
276
+ },
277
+ ],
278
+
279
+ hooks: {
280
+ 'view.header': (value) => ({
281
+ ...value,
282
+ navigation: [...value.navigation, { label: config.label, href: '/plugins/dues' }],
283
+ }),
284
+ },
285
+
286
+ allowedRedirectHosts: [
287
+ 'checkout.stripe.com',
288
+ 'billing.stripe.com',
289
+ ...config.extraRedirectHosts,
290
+ ],
291
+ })
292
+ }