@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.
- package/LICENSE.md +165 -0
- package/README.md +182 -0
- package/package.json +31 -0
- package/src/codes.ts +47 -0
- package/src/config.ts +153 -0
- package/src/definition.tsx +292 -0
- package/src/demo.ts +526 -0
- package/src/entitlement.ts +371 -0
- package/src/handlers-admin.ts +405 -0
- package/src/handlers.ts +397 -0
- package/src/index.ts +18 -0
- package/src/money.ts +42 -0
- package/src/period.ts +76 -0
- package/src/plans.ts +183 -0
- package/src/schema.ts +153 -0
- package/src/store.ts +966 -0
- package/src/stripe/client.ts +303 -0
- package/src/stripe/events.ts +189 -0
- package/src/stripe/webhook.ts +64 -0
- package/src/tasks.ts +156 -0
- package/src/ui/admin.tsx +925 -0
- package/src/ui/pages.tsx +557 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import type { PluginRequest, PluginResponse } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
import { generateCode, normalizeCode, validCodeShape } from './codes'
|
|
4
|
+
import { anyPlanByKey, clampGrantUntil, isLifetime, parsePlanForm } from './plans'
|
|
5
|
+
import { addDays } from './period'
|
|
6
|
+
import type { DuesServices } from './handlers'
|
|
7
|
+
import {
|
|
8
|
+
clearMembershipAttention,
|
|
9
|
+
clearOrderAttention,
|
|
10
|
+
codeById,
|
|
11
|
+
extendMembership,
|
|
12
|
+
flagMembership,
|
|
13
|
+
insertCode,
|
|
14
|
+
insertPlan,
|
|
15
|
+
membershipById,
|
|
16
|
+
orderById,
|
|
17
|
+
planRowById,
|
|
18
|
+
setCodeDisabled,
|
|
19
|
+
setMembershipStatus,
|
|
20
|
+
setPlanArchived,
|
|
21
|
+
setPlanStripePrice,
|
|
22
|
+
updatePlan,
|
|
23
|
+
type MembershipRow,
|
|
24
|
+
} from './store'
|
|
25
|
+
|
|
26
|
+
function toAdmin(
|
|
27
|
+
page: 'codes' | 'members' | 'status' | 'plans',
|
|
28
|
+
query: Record<string, string>,
|
|
29
|
+
): PluginResponse {
|
|
30
|
+
const params = new URLSearchParams(query).toString()
|
|
31
|
+
return {
|
|
32
|
+
kind: 'redirect',
|
|
33
|
+
to: `/admin/plugins/dues/${page}${params === '' ? '' : `?${params}`}`,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function asId(value: string | undefined): number | null {
|
|
38
|
+
const id = Number(value ?? '')
|
|
39
|
+
return Number.isSafeInteger(id) && id > 0 ? id : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isLive(membership: MembershipRow): boolean {
|
|
43
|
+
return (
|
|
44
|
+
membership.status === 'active' ||
|
|
45
|
+
membership.status === 'grace' ||
|
|
46
|
+
membership.status === 'closing'
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function handleAdminCodeCreate(
|
|
51
|
+
services: DuesServices,
|
|
52
|
+
request: PluginRequest,
|
|
53
|
+
): Promise<PluginResponse> {
|
|
54
|
+
const typed = normalizeCode(request.form?.code ?? '')
|
|
55
|
+
const code = typed === '' ? generateCode() : typed
|
|
56
|
+
if (!validCodeShape(code)) return toAdmin('codes', { error: 'bad-code', code: typed })
|
|
57
|
+
|
|
58
|
+
const percentOff = Number(request.form?.percent ?? '')
|
|
59
|
+
if (!Number.isInteger(percentOff) || percentOff < 1 || percentOff > 100) {
|
|
60
|
+
return toAdmin('codes', { error: 'bad-percent', code: typed })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const planInput = (request.form?.plan ?? '').trim()
|
|
64
|
+
if (
|
|
65
|
+
planInput !== '' &&
|
|
66
|
+
(await anyPlanByKey(services.context.data, services.config, planInput)) === null
|
|
67
|
+
) {
|
|
68
|
+
return toAdmin('codes', { error: 'bad-plan', code: typed })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const maxInput = (request.form?.max ?? '').trim()
|
|
72
|
+
const maxRedemptions = maxInput === '' ? null : Number(maxInput)
|
|
73
|
+
if (maxRedemptions !== null && (!Number.isInteger(maxRedemptions) || maxRedemptions < 1)) {
|
|
74
|
+
return toAdmin('codes', { error: 'bad-max', code: typed })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const expiresInput = (request.form?.expires ?? '').trim()
|
|
78
|
+
let expiresAt: Date | null = null
|
|
79
|
+
if (expiresInput !== '') {
|
|
80
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(expiresInput)) {
|
|
81
|
+
return toAdmin('codes', { error: 'bad-expiry', code: typed })
|
|
82
|
+
}
|
|
83
|
+
expiresAt = new Date(`${expiresInput}T23:59:59Z`)
|
|
84
|
+
if (Number.isNaN(expiresAt.getTime()) || expiresAt <= services.now()) {
|
|
85
|
+
return toAdmin('codes', { error: 'bad-expiry', code: typed })
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const inserted = await insertCode(services.context.data, {
|
|
90
|
+
code,
|
|
91
|
+
percentOff,
|
|
92
|
+
planKey: planInput === '' ? null : planInput,
|
|
93
|
+
maxRedemptions,
|
|
94
|
+
expiresAt,
|
|
95
|
+
createdByUserId: request.viewer.userId ?? 0,
|
|
96
|
+
})
|
|
97
|
+
if (inserted === null) return toAdmin('codes', { error: 'duplicate-code', code })
|
|
98
|
+
|
|
99
|
+
return toAdmin('codes', { created: inserted.code })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function handleAdminCodeDisable(
|
|
103
|
+
services: DuesServices,
|
|
104
|
+
request: PluginRequest,
|
|
105
|
+
): Promise<PluginResponse> {
|
|
106
|
+
const id = asId(request.form?.code)
|
|
107
|
+
const code = id === null ? null : await codeById(services.context.data, id)
|
|
108
|
+
if (code === null) return toAdmin('codes', { error: 'no-such-code' })
|
|
109
|
+
|
|
110
|
+
const disabled = request.form?.disabled === '1'
|
|
111
|
+
await setCodeDisabled(services.context.data, code.id, disabled)
|
|
112
|
+
return toAdmin('codes', { [disabled ? 'disabled' : 'enabled']: code.code })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function handleAdminExtend(
|
|
116
|
+
services: DuesServices,
|
|
117
|
+
request: PluginRequest,
|
|
118
|
+
): Promise<PluginResponse> {
|
|
119
|
+
const days = Number(request.form?.days ?? '')
|
|
120
|
+
if (!Number.isInteger(days) || days < 1 || days > 366) {
|
|
121
|
+
return toAdmin('members', { error: 'bad-days' })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const id = asId(request.form?.membership)
|
|
125
|
+
const membership = id === null ? null : await membershipById(services.context.data, id)
|
|
126
|
+
if (membership === null || !isLive(membership) || isLifetime(membership.currentPeriodEnd)) {
|
|
127
|
+
return toAdmin('members', { error: 'not-live' })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const now = services.now()
|
|
131
|
+
const base = membership.currentPeriodEnd > now ? membership.currentPeriodEnd : now
|
|
132
|
+
const periodEnd = addDays(base, days)
|
|
133
|
+
const graceUntil = addDays(periodEnd, services.config.graceDays)
|
|
134
|
+
|
|
135
|
+
await extendMembership(services.context.data, membership.id, {
|
|
136
|
+
currentPeriodEnd: periodEnd,
|
|
137
|
+
graceUntil,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
await services.context.grants.grant({
|
|
142
|
+
userId: membership.userId,
|
|
143
|
+
groupKey: membership.groupKey,
|
|
144
|
+
until: clampGrantUntil(graceUntil, now),
|
|
145
|
+
reason: `dues: an administrator extended membership ${membership.id} by ${days} days`,
|
|
146
|
+
})
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
149
|
+
await flagMembership(services.context.data, membership.id, `grant refused: ${reason}`)
|
|
150
|
+
return toAdmin('members', { error: 'grant-refused' })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return toAdmin('members', { extended: String(membership.id) })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function handleAdminCancel(
|
|
157
|
+
services: DuesServices,
|
|
158
|
+
request: PluginRequest,
|
|
159
|
+
): Promise<PluginResponse> {
|
|
160
|
+
const id = asId(request.form?.membership)
|
|
161
|
+
const membership = id === null ? null : await membershipById(services.context.data, id)
|
|
162
|
+
if (
|
|
163
|
+
membership === null ||
|
|
164
|
+
!isLive(membership) ||
|
|
165
|
+
membership.renewalMode !== 'auto' ||
|
|
166
|
+
membership.stripeSubscriptionId === null
|
|
167
|
+
) {
|
|
168
|
+
return toAdmin('members', { error: 'not-cancellable' })
|
|
169
|
+
}
|
|
170
|
+
if (services.stripe === null) return toAdmin('members', { error: 'unconfigured' })
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
await services.stripe.setCancelAtPeriodEnd(membership.stripeSubscriptionId, true)
|
|
174
|
+
} catch (error) {
|
|
175
|
+
services.context.logger.error('dues: admin cancel at period end failed', {
|
|
176
|
+
membershipId: membership.id,
|
|
177
|
+
message: error instanceof Error ? error.message : String(error),
|
|
178
|
+
})
|
|
179
|
+
return toAdmin('members', { error: 'stripe-error' })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await setMembershipStatus(services.context.data, membership.id, 'closing')
|
|
183
|
+
return toAdmin('members', { cancelled: String(membership.id) })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function handleAdminRevoke(
|
|
187
|
+
services: DuesServices,
|
|
188
|
+
request: PluginRequest,
|
|
189
|
+
): Promise<PluginResponse> {
|
|
190
|
+
const id = asId(request.form?.membership)
|
|
191
|
+
const membership = id === null ? null : await membershipById(services.context.data, id)
|
|
192
|
+
if (membership === null || !isLive(membership)) {
|
|
193
|
+
return toAdmin('members', { error: 'not-live' })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await setMembershipStatus(services.context.data, membership.id, 'revoked')
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
await services.context.grants.revoke({
|
|
200
|
+
userId: membership.userId,
|
|
201
|
+
groupKey: membership.groupKey,
|
|
202
|
+
reason: `dues: an administrator revoked membership ${membership.id}`,
|
|
203
|
+
})
|
|
204
|
+
} catch (error) {
|
|
205
|
+
services.context.logger.error('dues: revoke grant removal failed', {
|
|
206
|
+
membershipId: membership.id,
|
|
207
|
+
message: error instanceof Error ? error.message : String(error),
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (
|
|
212
|
+
membership.renewalMode === 'auto' &&
|
|
213
|
+
membership.stripeSubscriptionId !== null &&
|
|
214
|
+
services.stripe !== null
|
|
215
|
+
) {
|
|
216
|
+
try {
|
|
217
|
+
await services.stripe.setCancelAtPeriodEnd(membership.stripeSubscriptionId, true)
|
|
218
|
+
} catch {
|
|
219
|
+
await flagMembership(
|
|
220
|
+
services.context.data,
|
|
221
|
+
membership.id,
|
|
222
|
+
'revoked here, but Stripe still holds the subscription — cancel it in the dashboard',
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return toAdmin('members', { revoked: String(membership.id) })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
type PriceResolution =
|
|
231
|
+
| { readonly ok: true; readonly priceId: string; readonly productId: string | null }
|
|
232
|
+
| { readonly ok: false; readonly error: string }
|
|
233
|
+
|
|
234
|
+
async function resolveAutoPrice(
|
|
235
|
+
services: DuesServices,
|
|
236
|
+
plan: {
|
|
237
|
+
readonly name: string
|
|
238
|
+
readonly priceMinor: number
|
|
239
|
+
readonly currency: string
|
|
240
|
+
readonly interval: 'month' | 'year'
|
|
241
|
+
readonly key: string
|
|
242
|
+
},
|
|
243
|
+
pasted: string,
|
|
244
|
+
existingProductId: string | null,
|
|
245
|
+
): Promise<PriceResolution> {
|
|
246
|
+
if (pasted !== '') {
|
|
247
|
+
if (!pasted.startsWith('price_')) return { ok: false, error: 'bad-stripe-price' }
|
|
248
|
+
return { ok: true, priceId: pasted, productId: existingProductId }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (services.stripe === null) return { ok: false, error: 'unconfigured' }
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const productId =
|
|
255
|
+
existingProductId ??
|
|
256
|
+
(await services.stripe.createProduct({ name: plan.name, reference: plan.key })).id
|
|
257
|
+
const price = await services.stripe.createPrice({
|
|
258
|
+
productId,
|
|
259
|
+
unitAmount: plan.priceMinor,
|
|
260
|
+
currency: plan.currency,
|
|
261
|
+
interval: plan.interval,
|
|
262
|
+
reference: `${plan.key}-${plan.priceMinor}-${plan.currency}-${plan.interval}`,
|
|
263
|
+
})
|
|
264
|
+
return { ok: true, priceId: price.id, productId }
|
|
265
|
+
} catch (error) {
|
|
266
|
+
services.context.logger.error('dues: could not mint the Stripe price', {
|
|
267
|
+
plan: plan.key,
|
|
268
|
+
message: error instanceof Error ? error.message : String(error),
|
|
269
|
+
})
|
|
270
|
+
return { ok: false, error: 'stripe-error' }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function handleAdminPlanCreate(
|
|
275
|
+
services: DuesServices,
|
|
276
|
+
request: PluginRequest,
|
|
277
|
+
): Promise<PluginResponse> {
|
|
278
|
+
const parsed = parsePlanForm(request.form ?? {}, services.config.graceDays)
|
|
279
|
+
if (!parsed.ok) return toAdmin('plans', { error: parsed.error })
|
|
280
|
+
const plan = parsed.plan
|
|
281
|
+
|
|
282
|
+
if ((await anyPlanByKey(services.context.data, services.config, plan.planKey)) !== null) {
|
|
283
|
+
return toAdmin('plans', { error: 'duplicate-plan' })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let stripePriceId: string | null = null
|
|
287
|
+
let stripeProductId: string | null = null
|
|
288
|
+
if (plan.mode === 'auto') {
|
|
289
|
+
const price = await resolveAutoPrice(
|
|
290
|
+
services,
|
|
291
|
+
{
|
|
292
|
+
name: plan.name,
|
|
293
|
+
priceMinor: plan.priceMinor,
|
|
294
|
+
currency: plan.currency,
|
|
295
|
+
interval: plan.billingInterval ?? 'month',
|
|
296
|
+
key: plan.planKey,
|
|
297
|
+
},
|
|
298
|
+
(request.form?.stripe_price ?? '').trim(),
|
|
299
|
+
null,
|
|
300
|
+
)
|
|
301
|
+
if (!price.ok) return toAdmin('plans', { error: price.error })
|
|
302
|
+
stripePriceId = price.priceId
|
|
303
|
+
stripeProductId = price.productId
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const inserted = await insertPlan(services.context.data, {
|
|
307
|
+
...plan,
|
|
308
|
+
stripePriceId,
|
|
309
|
+
stripeProductId,
|
|
310
|
+
})
|
|
311
|
+
if (inserted === null) return toAdmin('plans', { error: 'duplicate-plan' })
|
|
312
|
+
|
|
313
|
+
return toAdmin('plans', { created: inserted.key })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function handleAdminPlanUpdate(
|
|
317
|
+
services: DuesServices,
|
|
318
|
+
request: PluginRequest,
|
|
319
|
+
): Promise<PluginResponse> {
|
|
320
|
+
const id = asId(request.form?.id)
|
|
321
|
+
const existing = id === null ? null : await planRowById(services.context.data, id)
|
|
322
|
+
if (existing === null) return toAdmin('plans', { error: 'no-such-plan' })
|
|
323
|
+
|
|
324
|
+
const parsed = parsePlanForm(
|
|
325
|
+
{ ...request.form, key: existing.key, mode: existing.mode },
|
|
326
|
+
services.config.graceDays,
|
|
327
|
+
)
|
|
328
|
+
if (!parsed.ok) return toAdmin('plans', { error: parsed.error })
|
|
329
|
+
const plan = parsed.plan
|
|
330
|
+
|
|
331
|
+
if (existing.mode === 'auto') {
|
|
332
|
+
const pasted = (request.form?.stripe_price ?? '').trim()
|
|
333
|
+
const changed =
|
|
334
|
+
plan.priceMinor !== existing.priceMinor ||
|
|
335
|
+
plan.currency !== existing.currency ||
|
|
336
|
+
plan.billingInterval !== existing.billingInterval
|
|
337
|
+
|
|
338
|
+
if (pasted !== '' || changed || existing.stripePriceId === null) {
|
|
339
|
+
const price = await resolveAutoPrice(
|
|
340
|
+
services,
|
|
341
|
+
{
|
|
342
|
+
name: plan.name,
|
|
343
|
+
priceMinor: plan.priceMinor,
|
|
344
|
+
currency: plan.currency,
|
|
345
|
+
interval: plan.billingInterval ?? 'month',
|
|
346
|
+
key: existing.key,
|
|
347
|
+
},
|
|
348
|
+
pasted,
|
|
349
|
+
existing.stripeProductId,
|
|
350
|
+
)
|
|
351
|
+
if (!price.ok) return toAdmin('plans', { error: price.error })
|
|
352
|
+
await setPlanStripePrice(services.context.data, existing.id, price.priceId, price.productId)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
await updatePlan(services.context.data, existing.id, {
|
|
357
|
+
name: plan.name,
|
|
358
|
+
description: plan.description,
|
|
359
|
+
groupKey: plan.groupKey,
|
|
360
|
+
priceMinor: plan.priceMinor,
|
|
361
|
+
currency: plan.currency,
|
|
362
|
+
periodSpec: existing.mode === 'fixed' ? plan.periodSpec : existing.periodSpec,
|
|
363
|
+
billingInterval: existing.mode === 'auto' ? plan.billingInterval : existing.billingInterval,
|
|
364
|
+
giftable: plan.giftable,
|
|
365
|
+
hidden: plan.hidden,
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
return toAdmin('plans', { updated: existing.key })
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export async function handleAdminPlanArchive(
|
|
372
|
+
services: DuesServices,
|
|
373
|
+
request: PluginRequest,
|
|
374
|
+
): Promise<PluginResponse> {
|
|
375
|
+
const id = asId(request.form?.id)
|
|
376
|
+
const plan = id === null ? null : await planRowById(services.context.data, id)
|
|
377
|
+
if (plan === null) return toAdmin('plans', { error: 'no-such-plan' })
|
|
378
|
+
|
|
379
|
+
const archive = request.form?.archived === '1'
|
|
380
|
+
await setPlanArchived(services.context.data, plan.id, archive)
|
|
381
|
+
return toAdmin('plans', { [archive ? 'archived' : 'restored']: plan.key })
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function handleAdminClear(
|
|
385
|
+
services: DuesServices,
|
|
386
|
+
request: PluginRequest,
|
|
387
|
+
): Promise<PluginResponse> {
|
|
388
|
+
const membershipId = asId(request.form?.membership)
|
|
389
|
+
if (membershipId !== null) {
|
|
390
|
+
const membership = await membershipById(services.context.data, membershipId)
|
|
391
|
+
if (membership === null) return toAdmin('members', { error: 'not-live' })
|
|
392
|
+
await clearMembershipAttention(services.context.data, membership.id)
|
|
393
|
+
return toAdmin('members', { cleared: String(membership.id) })
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const orderId = asId(request.form?.order)
|
|
397
|
+
if (orderId !== null) {
|
|
398
|
+
const order = await orderById(services.context.data, orderId)
|
|
399
|
+
if (order === null) return toAdmin('status', { error: 'no-such-order' })
|
|
400
|
+
await clearOrderAttention(services.context.data, order.id)
|
|
401
|
+
return toAdmin('status', { cleared: String(order.id) })
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return toAdmin('status', { error: 'nothing-to-clear' })
|
|
405
|
+
}
|