@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,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
+ }