@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,925 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import type { PluginAdminPageContext } from '@meith/plugin-kit'
4
+
5
+ import type { DuesConfig } from '../config'
6
+ import { formatMinor } from '../money'
7
+ import { describeBilling, isLifetime, loadPlans, MAX_PLAN_DAYS } from '../plans'
8
+ import {
9
+ allMemberships,
10
+ attentionCount,
11
+ listCodes,
12
+ monthlyTotals,
13
+ ordersNeedingAttention,
14
+ recentEvents,
15
+ recentLedger,
16
+ type CodeRow,
17
+ type MembershipRow,
18
+ type PlanRow,
19
+ } from '../store'
20
+ import { SUBSCRIBED_EVENT_TYPES } from '../stripe/events'
21
+
22
+ const CARD = 'flex flex-col gap-3 rounded-lg border border-border p-4'
23
+ const TH = 'px-2 py-1.5 text-left text-xs font-medium text-muted-foreground'
24
+ const TD = 'px-2 py-1.5 align-top'
25
+ const INPUT =
26
+ 'rounded-md border border-border bg-background px-3 py-2 text-sm ' +
27
+ 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
28
+ const ACT_BUTTON =
29
+ 'inline-flex h-8 items-center justify-center rounded-md border border-transparent ' +
30
+ 'bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary-hover'
31
+ const QUIET_BUTTON =
32
+ 'inline-flex h-8 items-center justify-center rounded-md border border-border px-3 text-sm'
33
+
34
+ function fmt(date: Date): string {
35
+ return date.toISOString().slice(0, 16).replace('T', ' ')
36
+ }
37
+
38
+ function GoodNotice({ children }: { children: ReactNode }) {
39
+ return (
40
+ <p role="status" className="rounded-lg border border-border bg-muted px-4 py-3 text-sm">
41
+ {children}
42
+ </p>
43
+ )
44
+ }
45
+
46
+ function BadNotice({ children }: { children: ReactNode }) {
47
+ return (
48
+ <p
49
+ role="status"
50
+ className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm"
51
+ >
52
+ {children}
53
+ </p>
54
+ )
55
+ }
56
+
57
+ function Attention({ count }: { count: number }) {
58
+ if (count === 0) return null
59
+ return (
60
+ <p className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm">
61
+ <strong>{count}</strong> record{count === 1 ? ' needs' : 's need'} attention —
62
+ a payment that could not become a membership, or an amount that did not match its
63
+ order. The members screen lists {count === 1 ? 'it' : 'them'} first.
64
+ </p>
65
+ )
66
+ }
67
+
68
+ export async function StatusPage({
69
+ config,
70
+ context,
71
+ }: {
72
+ config: DuesConfig
73
+ context: PluginAdminPageContext
74
+ }) {
75
+ const attention = await attentionCount(context.data)
76
+ const events = await recentEvents(context.data, 10)
77
+ const flaggedOrders = await ordersNeedingAttention(context.data, 20)
78
+ const plans = await loadPlans(context.data, config)
79
+
80
+ const keySet = String(context.settings.stripe_secret_key ?? '') !== ''
81
+ const webhookSet = String(context.settings.stripe_webhook_secret ?? '') !== ''
82
+
83
+ return (
84
+ <div className="flex flex-col gap-4">
85
+ {context.query.cleared !== undefined && <GoodNotice>Flag cleared.</GoodNotice>}
86
+ {context.query.error !== undefined && (
87
+ <BadNotice>That flag could not be found — it may already be cleared.</BadNotice>
88
+ )}
89
+ <Attention count={attention} />
90
+
91
+ {flaggedOrders.length > 0 && (
92
+ <section className={CARD}>
93
+ <h2 className="font-heading text-lg font-semibold">Orders needing attention</h2>
94
+ <p className="text-sm text-muted-foreground">
95
+ Money moved but the order could not settle cleanly — most often an amount
96
+ that did not match. Check the payment in Stripe&rsquo;s dashboard, put it
97
+ right there, then clear the flag here.
98
+ </p>
99
+ <ul className="flex flex-col divide-y divide-border text-sm">
100
+ {flaggedOrders.map((order) => (
101
+ <li key={order.id} className="flex flex-wrap items-center justify-between gap-2 py-2">
102
+ <span>
103
+ Order {order.id} — {order.planName},{' '}
104
+ {formatMinor(order.amountMinor, order.currency)}
105
+ <span className="block text-xs text-muted-foreground">
106
+ {order.needsAttention}
107
+ </span>
108
+ </span>
109
+ <form method="post" action="/admin/api/plugins/dues/attention/clear">
110
+ <input type="hidden" name="order" value={order.id} />
111
+ <button type="submit" className={QUIET_BUTTON}>
112
+ Clear the flag
113
+ </button>
114
+ </form>
115
+ </li>
116
+ ))}
117
+ </ul>
118
+ </section>
119
+ )}
120
+
121
+ <section className={CARD}>
122
+ <h2 className="font-heading text-lg font-semibold">Is it working?</h2>
123
+ <dl className="flex flex-col gap-1 text-sm">
124
+ <div className="flex justify-between gap-2">
125
+ <dt className="text-muted-foreground">Stripe secret key</dt>
126
+ <dd>{keySet ? 'set' : 'not set — nothing can be bought'}</dd>
127
+ </div>
128
+ <div className="flex justify-between gap-2">
129
+ <dt className="text-muted-foreground">Webhook signing secret</dt>
130
+ <dd>{webhookSet ? 'set' : 'not set — payments cannot confirm'}</dd>
131
+ </div>
132
+ <div className="flex justify-between gap-2">
133
+ <dt className="text-muted-foreground">Grace after a failed renewal</dt>
134
+ <dd>{config.graceDays} days</dd>
135
+ </div>
136
+ </dl>
137
+ <p className="text-xs text-muted-foreground">
138
+ Keys resolve environment-first: the settings form below this page shows which
139
+ source is winning.
140
+ </p>
141
+ </section>
142
+
143
+ <section className={CARD}>
144
+ <h2 className="font-heading text-lg font-semibold">The webhook to create</h2>
145
+ <p className="text-sm text-muted-foreground">
146
+ In the Stripe dashboard, add an endpoint at
147
+ <code className="mx-1 text-xs">/api/plugins/dues/hook/stripe</code>
148
+ on this board&rsquo;s public address, subscribed to exactly these events, and
149
+ put its signing secret in the settings:
150
+ </p>
151
+ <p className="flex flex-wrap gap-x-3 gap-y-1">
152
+ {SUBSCRIBED_EVENT_TYPES.map((type) => (
153
+ <code key={type} className="text-xs text-muted-foreground">
154
+ {type}
155
+ </code>
156
+ ))}
157
+ </p>
158
+ </section>
159
+
160
+ <section className={CARD}>
161
+ <h2 className="font-heading text-lg font-semibold">Plans on sale</h2>
162
+ <p className="text-sm text-muted-foreground">
163
+ Plans are made and changed on the{' '}
164
+ <a
165
+ href="/admin/plugins/dues/plans"
166
+ className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
167
+ >
168
+ plans screen
169
+ </a>
170
+ . Each grants membership of its group only while that group is marked
171
+ &ldquo;may be granted by plugins&rdquo; under Admin → Groups; a purchase
172
+ against a group that refuses shows up above as needing attention, with the
173
+ payment kept and the reason recorded.
174
+ </p>
175
+ <div className="overflow-x-auto">
176
+ <table className="w-full min-w-96 border-collapse text-sm">
177
+ <thead>
178
+ <tr className="border-b border-border">
179
+ <th className={TH}>Plan</th>
180
+ <th className={TH}>Price</th>
181
+ <th className={TH}>Billing</th>
182
+ <th className={TH}>Group</th>
183
+ <th className={TH}>Giftable</th>
184
+ </tr>
185
+ </thead>
186
+ <tbody>
187
+ {plans.filter((plan) => !plan.archived).map((plan) => (
188
+ <tr key={plan.key} className="border-b border-border">
189
+ <td className={TD}>
190
+ {plan.name}
191
+ {plan.hidden && (
192
+ <span className="text-xs text-muted-foreground"> · hidden</span>
193
+ )}
194
+ </td>
195
+ <td className={TD}>{formatMinor(plan.priceMinor, plan.currency)}</td>
196
+ <td className={TD}>{describeBilling(plan)}</td>
197
+ <td className={TD}>
198
+ <code className="text-xs">{plan.groupKey}</code>
199
+ </td>
200
+ <td className={TD}>{plan.giftable ? 'yes' : 'no'}</td>
201
+ </tr>
202
+ ))}
203
+ </tbody>
204
+ </table>
205
+ </div>
206
+ </section>
207
+
208
+ <section className={CARD}>
209
+ <h2 className="font-heading text-lg font-semibold">Latest webhook events</h2>
210
+ {events.length === 0 ? (
211
+ <p className="text-sm text-muted-foreground">
212
+ None yet. The first purchase, or Stripe&rsquo;s &ldquo;send test event&rdquo;
213
+ button, will put a row here — which is how you prove the endpoint works.
214
+ </p>
215
+ ) : (
216
+ <ul className="flex flex-col divide-y divide-border text-sm">
217
+ {events.map((event) => (
218
+ <li key={event.id} className="flex flex-wrap justify-between gap-2 py-1.5">
219
+ <code className="text-xs">{event.type}</code>
220
+ <span className="text-xs text-muted-foreground">
221
+ {fmt(event.receivedAt)} ·{' '}
222
+ {event.processedAt === null
223
+ ? 'unprocessed — the reconcile task retries it'
224
+ : (event.outcome ?? 'done')}
225
+ </span>
226
+ </li>
227
+ ))}
228
+ </ul>
229
+ )}
230
+ </section>
231
+ </div>
232
+ )
233
+ }
234
+
235
+ function statusChip(membership: MembershipRow): ReactNode {
236
+ const tone =
237
+ membership.status === 'active'
238
+ ? 'text-muted-foreground'
239
+ : membership.status === 'grace' || membership.needsAttention !== null
240
+ ? ''
241
+ : 'text-muted-foreground'
242
+ return <span className={tone}>{membership.status}</span>
243
+ }
244
+
245
+ const MEMBER_NOTICES: Record<string, string> = {
246
+ extended: 'Membership extended. The member holds their group until the new date.',
247
+ cancelled: 'Renewal cancelled. They keep what they paid for until the period ends.',
248
+ revoked: 'Membership revoked. Their access is gone as of now.',
249
+ cleared: 'Flag cleared.',
250
+ }
251
+
252
+ const MEMBER_ERRORS: Record<string, string> = {
253
+ 'bad-days': 'Extensions are 1 to 366 days.',
254
+ 'not-live': 'That membership is not live any more, so there is nothing to act on.',
255
+ 'not-cancellable': 'Only a live subscription has a renewal to cancel.',
256
+ unconfigured: 'Stripe is not configured, so the subscription cannot be reached.',
257
+ 'stripe-error': 'Stripe could not be reached. Nothing changed — try again shortly.',
258
+ 'grant-refused':
259
+ 'The extension was recorded, but the board refused the group grant — the row is ' +
260
+ 'flagged with the reason.',
261
+ }
262
+
263
+ function isLiveRow(membership: MembershipRow): boolean {
264
+ return (
265
+ membership.status === 'active' ||
266
+ membership.status === 'grace' ||
267
+ membership.status === 'closing'
268
+ )
269
+ }
270
+
271
+ function MemberActions({ membership }: { membership: MembershipRow }) {
272
+ if (!isLiveRow(membership) && membership.needsAttention === null) {
273
+ return <span className="text-xs text-muted-foreground">—</span>
274
+ }
275
+
276
+ return (
277
+ <div className="flex flex-col gap-2">
278
+ {isLiveRow(membership) && !isLifetime(membership.currentPeriodEnd) && (
279
+ <form
280
+ method="post"
281
+ action="/admin/api/plugins/dues/members/extend"
282
+ className="flex items-center gap-2"
283
+ >
284
+ <input type="hidden" name="membership" value={membership.id} />
285
+ <input
286
+ type="number"
287
+ name="days"
288
+ defaultValue={30}
289
+ min={1}
290
+ max={366}
291
+ aria-label={`Days to extend membership ${membership.id} by`}
292
+ className={`${INPUT} w-20`}
293
+ />
294
+ <button type="submit" className={QUIET_BUTTON}>
295
+ Extend
296
+ </button>
297
+ </form>
298
+ )}
299
+ {isLiveRow(membership) &&
300
+ membership.renewalMode === 'auto' &&
301
+ membership.status !== 'closing' &&
302
+ membership.stripeSubscriptionId !== null && (
303
+ <form method="post" action="/admin/api/plugins/dues/members/cancel">
304
+ <input type="hidden" name="membership" value={membership.id} />
305
+ <button type="submit" className={QUIET_BUTTON}>
306
+ Cancel renewal
307
+ </button>
308
+ </form>
309
+ )}
310
+ {isLiveRow(membership) && (
311
+ <form method="post" action="/admin/api/plugins/dues/members/revoke">
312
+ <input type="hidden" name="membership" value={membership.id} />
313
+ <button type="submit" className={QUIET_BUTTON}>
314
+ Revoke now
315
+ </button>
316
+ </form>
317
+ )}
318
+ {membership.needsAttention !== null && (
319
+ <form method="post" action="/admin/api/plugins/dues/attention/clear">
320
+ <input type="hidden" name="membership" value={membership.id} />
321
+ <button type="submit" className={QUIET_BUTTON}>
322
+ Clear the flag
323
+ </button>
324
+ </form>
325
+ )}
326
+ </div>
327
+ )
328
+ }
329
+
330
+ export async function MembersPage({ context }: { context: PluginAdminPageContext }) {
331
+ const memberships = await allMemberships(context.data, 200)
332
+
333
+ const names = new Map<number, string>()
334
+ for (const membership of memberships) {
335
+ if (!names.has(membership.userId)) {
336
+ const user = await context.users.byId(membership.userId)
337
+ names.set(membership.userId, user?.username ?? `user ${membership.userId}`)
338
+ }
339
+ }
340
+
341
+ const good = Object.keys(MEMBER_NOTICES).find((key) => context.query[key] !== undefined)
342
+ const bad = MEMBER_ERRORS[context.query.error ?? '']
343
+
344
+ return (
345
+ <div className="flex flex-col gap-4">
346
+ {good !== undefined && <GoodNotice>{MEMBER_NOTICES[good]}</GoodNotice>}
347
+ {bad !== undefined && <BadNotice>{bad}</BadNotice>}
348
+ <p className="text-sm text-muted-foreground">
349
+ Every membership this plugin has sold, flagged rows first. Extending is a grant,
350
+ not a charge; revoking removes access on the spot without touching the money —
351
+ refunds happen in Stripe&rsquo;s dashboard, and the refund webhook revokes on
352
+ its own.
353
+ </p>
354
+ {memberships.length === 0 ? (
355
+ <p className="rounded-lg border border-border p-4 text-sm text-muted-foreground">
356
+ Nothing sold yet.
357
+ </p>
358
+ ) : (
359
+ <div className="overflow-x-auto">
360
+ <table className="w-full min-w-[48rem] border-collapse text-sm">
361
+ <thead>
362
+ <tr className="border-b border-border">
363
+ <th className={TH}>Member</th>
364
+ <th className={TH}>Plan</th>
365
+ <th className={TH}>Status</th>
366
+ <th className={TH}>Period ends</th>
367
+ <th className={TH}>Grace until</th>
368
+ <th className={TH}>Subscription</th>
369
+ <th className={TH}>Actions</th>
370
+ </tr>
371
+ </thead>
372
+ <tbody>
373
+ {memberships.map((membership) => (
374
+ <tr key={membership.id} className="border-b border-border">
375
+ <td className={TD}>{names.get(membership.userId)}</td>
376
+ <td className={TD}>{membership.planKey}</td>
377
+ <td className={TD}>
378
+ {statusChip(membership)}
379
+ {membership.needsAttention !== null && (
380
+ <p className="mt-1 rounded border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs">
381
+ {membership.needsAttention}
382
+ </p>
383
+ )}
384
+ </td>
385
+ <td className={TD}>
386
+ {isLifetime(membership.currentPeriodEnd)
387
+ ? 'for good'
388
+ : fmt(membership.currentPeriodEnd)}
389
+ </td>
390
+ <td className={TD}>
391
+ {isLifetime(membership.currentPeriodEnd) ? '—' : fmt(membership.graceUntil)}
392
+ </td>
393
+ <td className={TD}>
394
+ <code className="text-xs">{membership.stripeSubscriptionId ?? '—'}</code>
395
+ </td>
396
+ <td className={TD}>
397
+ <MemberActions membership={membership} />
398
+ </td>
399
+ </tr>
400
+ ))}
401
+ </tbody>
402
+ </table>
403
+ </div>
404
+ )}
405
+ </div>
406
+ )
407
+ }
408
+
409
+ export async function LedgerPage({
410
+ config,
411
+ context,
412
+ }: {
413
+ config: DuesConfig
414
+ context: PluginAdminPageContext
415
+ }) {
416
+ const months = await monthlyTotals(context.data, 12)
417
+ const entries = await recentLedger(context.data, 50)
418
+
419
+ return (
420
+ <div className="flex flex-col gap-4">
421
+ <section className={CARD}>
422
+ <h2 className="font-heading text-lg font-semibold">By month</h2>
423
+ {months.length === 0 ? (
424
+ <p className="text-sm text-muted-foreground">No money has moved yet.</p>
425
+ ) : (
426
+ <table className="w-full border-collapse text-sm">
427
+ <thead>
428
+ <tr className="border-b border-border">
429
+ <th className={TH}>Month</th>
430
+ <th className={TH}>Charges</th>
431
+ <th className={TH}>Gross</th>
432
+ <th className={TH}>Refunded</th>
433
+ </tr>
434
+ </thead>
435
+ <tbody>
436
+ {months.map((month) => (
437
+ <tr key={`${month.month}-${month.currency}`} className="border-b border-border">
438
+ <td className={TD}>{month.month}</td>
439
+ <td className={TD}>{month.charges}</td>
440
+ <td className={TD}>{formatMinor(month.grossMinor, month.currency)}</td>
441
+ <td className={TD}>
442
+ {month.refundedMinor === 0
443
+ ? '—'
444
+ : formatMinor(month.refundedMinor, month.currency)}
445
+ </td>
446
+ </tr>
447
+ ))}
448
+ </tbody>
449
+ </table>
450
+ )}
451
+ <p className="text-xs text-muted-foreground">
452
+ Append-only, written as money moves: charges positive, refunds and chargebacks
453
+ negative. Stripe&rsquo;s dashboard is the authority; this is the board&rsquo;s
454
+ own copy in {config.currency.toUpperCase()}.
455
+ </p>
456
+ </section>
457
+
458
+ <section className={CARD}>
459
+ <h2 className="font-heading text-lg font-semibold">Latest entries</h2>
460
+ {entries.length === 0 ? (
461
+ <p className="text-sm text-muted-foreground">Empty.</p>
462
+ ) : (
463
+ <ul className="flex flex-col divide-y divide-border text-sm">
464
+ {entries.map((entry) => (
465
+ <li key={entry.id} className="flex flex-wrap justify-between gap-2 py-1.5">
466
+ <span>
467
+ {entry.kind}
468
+ {entry.note !== null && (
469
+ <span className="text-xs text-muted-foreground"> · {entry.note}</span>
470
+ )}
471
+ </span>
472
+ <span className="text-xs text-muted-foreground">
473
+ {fmt(entry.occurredAt)} · {formatMinor(entry.amountMinor, entry.currency)}
474
+ </span>
475
+ </li>
476
+ ))}
477
+ </ul>
478
+ )}
479
+ </section>
480
+ </div>
481
+ )
482
+ }
483
+
484
+ const CODE_ERRORS: Record<string, string> = {
485
+ 'bad-code': 'Codes are 3 to 32 letters, digits and hyphens. Leave the box empty to have one invented.',
486
+ 'bad-percent': 'The discount is a whole number from 1 to 100 percent.',
487
+ 'bad-plan': 'That plan does not exist on this board.',
488
+ 'bad-max': 'The redemption cap is a whole number, at least 1 — or empty for no cap.',
489
+ 'bad-expiry': 'The expiry needs to be a date in the future, like 2027-01-31.',
490
+ 'duplicate-code': 'A code by that name already exists. Every code is unique, spelt any case.',
491
+ 'no-such-code': 'That code could not be found.',
492
+ }
493
+
494
+ function codeState(code: CodeRow, now: Date): string {
495
+ if (code.disabled) return 'switched off'
496
+ if (code.expiresAt !== null && code.expiresAt <= now) return 'expired'
497
+ if (code.maxRedemptions !== null && code.redeemedCount >= code.maxRedemptions) {
498
+ return 'used up'
499
+ }
500
+ return 'live'
501
+ }
502
+
503
+ export async function CodesPage({
504
+ config,
505
+ context,
506
+ }: {
507
+ config: DuesConfig
508
+ context: PluginAdminPageContext
509
+ }) {
510
+ const codes = await listCodes(context.data, 100)
511
+ const plans = (await loadPlans(context.data, config)).filter((plan) => !plan.archived)
512
+ const now = new Date()
513
+ const created = context.query.created
514
+ const toggled = context.query.disabled ?? context.query.enabled
515
+ const error = CODE_ERRORS[context.query.error ?? '']
516
+
517
+ return (
518
+ <div className="flex flex-col gap-4">
519
+ {created !== undefined && (
520
+ <GoodNotice>
521
+ The code is live: <code className="mx-1 font-mono text-base font-semibold">{created}</code>
522
+ — hand it out however you like. It is shown in full below whenever you need it
523
+ again.
524
+ </GoodNotice>
525
+ )}
526
+ {toggled !== undefined && (
527
+ <GoodNotice>
528
+ <code className="font-mono">{toggled}</code>{' '}
529
+ {context.query.disabled !== undefined
530
+ ? 'is switched off. Anyone typing it now is told the code is not usable.'
531
+ : 'is back on.'}
532
+ </GoodNotice>
533
+ )}
534
+ {error !== undefined && <BadNotice>{error}</BadNotice>}
535
+
536
+ <section className={CARD}>
537
+ <h2 className="font-heading text-lg font-semibold">Mint a code</h2>
538
+ <p className="text-sm text-muted-foreground">
539
+ A code takes a percentage off at checkout: the whole price of a pass, the first
540
+ payment of a subscription — renewals bill in full. A 100% code on a pass skips
541
+ Stripe entirely, which is how you comp somebody.
542
+ </p>
543
+ <form
544
+ method="post"
545
+ action="/admin/api/plugins/dues/codes/create"
546
+ className="grid gap-3 sm:grid-cols-2"
547
+ >
548
+ <label className="flex flex-col gap-1 text-sm">
549
+ <span className="text-xs text-muted-foreground">
550
+ Code — leave empty to have one invented
551
+ </span>
552
+ <input name="code" autoComplete="off" placeholder="LAUNCH50" className={INPUT} />
553
+ </label>
554
+ <label className="flex flex-col gap-1 text-sm">
555
+ <span className="text-xs text-muted-foreground">Percent off, 1–100</span>
556
+ <input
557
+ type="number"
558
+ name="percent"
559
+ min={1}
560
+ max={100}
561
+ required
562
+ className={INPUT}
563
+ />
564
+ </label>
565
+ <label className="flex flex-col gap-1 text-sm">
566
+ <span className="text-xs text-muted-foreground">Which plan it works on</span>
567
+ <select name="plan" className={INPUT}>
568
+ <option value="">Any plan</option>
569
+ {plans.map((plan) => (
570
+ <option key={plan.key} value={plan.key}>
571
+ {plan.name}
572
+ </option>
573
+ ))}
574
+ </select>
575
+ </label>
576
+ <label className="flex flex-col gap-1 text-sm">
577
+ <span className="text-xs text-muted-foreground">
578
+ Redemption cap — empty for unlimited
579
+ </span>
580
+ <input type="number" name="max" min={1} className={INPUT} />
581
+ </label>
582
+ <label className="flex flex-col gap-1 text-sm">
583
+ <span className="text-xs text-muted-foreground">
584
+ Expires at end of day (UTC) — empty for never
585
+ </span>
586
+ <input type="date" name="expires" className={INPUT} />
587
+ </label>
588
+ <div className="flex items-end">
589
+ <button type="submit" className={ACT_BUTTON}>
590
+ Mint the code
591
+ </button>
592
+ </div>
593
+ </form>
594
+ </section>
595
+
596
+ <section className={CARD}>
597
+ <h2 className="font-heading text-lg font-semibold">Every code</h2>
598
+ {codes.length === 0 ? (
599
+ <p className="text-sm text-muted-foreground">None yet.</p>
600
+ ) : (
601
+ <div className="overflow-x-auto">
602
+ <table className="w-full min-w-[40rem] border-collapse text-sm">
603
+ <thead>
604
+ <tr className="border-b border-border">
605
+ <th className={TH}>Code</th>
606
+ <th className={TH}>Off</th>
607
+ <th className={TH}>Plan</th>
608
+ <th className={TH}>Redeemed</th>
609
+ <th className={TH}>Expires</th>
610
+ <th className={TH}>State</th>
611
+ <th className={TH}>Actions</th>
612
+ </tr>
613
+ </thead>
614
+ <tbody>
615
+ {codes.map((code) => (
616
+ <tr key={code.id} className="border-b border-border">
617
+ <td className={TD}>
618
+ <code className="font-mono">{code.code}</code>
619
+ </td>
620
+ <td className={TD}>{code.percentOff}%</td>
621
+ <td className={TD}>{code.planKey ?? 'any'}</td>
622
+ <td className={TD}>
623
+ {code.redeemedCount}
624
+ {code.maxRedemptions !== null && ` of ${code.maxRedemptions}`}
625
+ </td>
626
+ <td className={TD}>
627
+ {code.expiresAt === null ? 'never' : fmt(code.expiresAt)}
628
+ </td>
629
+ <td className={TD}>{codeState(code, now)}</td>
630
+ <td className={TD}>
631
+ <form method="post" action="/admin/api/plugins/dues/codes/disable">
632
+ <input type="hidden" name="code" value={code.id} />
633
+ <input
634
+ type="hidden"
635
+ name="disabled"
636
+ value={code.disabled ? '0' : '1'}
637
+ />
638
+ <button type="submit" className={QUIET_BUTTON}>
639
+ {code.disabled ? 'Switch on' : 'Switch off'}
640
+ </button>
641
+ </form>
642
+ </td>
643
+ </tr>
644
+ ))}
645
+ </tbody>
646
+ </table>
647
+ </div>
648
+ )}
649
+ <p className="text-xs text-muted-foreground">
650
+ Redemptions count when a payment settles, not when a checkout starts. A code is
651
+ never deleted — switching it off keeps the history of what it sold.
652
+ </p>
653
+ </section>
654
+ </div>
655
+ )
656
+ }
657
+
658
+ const PLAN_ERRORS: Record<string, string> = {
659
+ 'bad-key': 'Plan keys are lower-case letters, digits and hyphens, like day-pass.',
660
+ 'bad-name': 'The plan needs a name members will read.',
661
+ 'bad-group': 'That is not a valid group key.',
662
+ 'bad-price': 'The price is a positive whole number of minor units — 500 is £5.00.',
663
+ 'bad-currency': 'The currency is a three-letter ISO code, like gbp, usd or eur.',
664
+ 'bad-mode': 'Pick how the plan bills.',
665
+ 'bad-length': 'The length is a whole number of days, weeks, months or years — at least 1.',
666
+ 'too-long': `A pass plus its grace window cannot reach past two years (${MAX_PLAN_DAYS} days) — that is the board's cap on a plugin grant. Sell lifetime instead.`,
667
+ 'bad-interval': 'A subscription bills every month or every year.',
668
+ 'auto-gift': 'A subscription cannot be giftable — it would bill the buyer forever.',
669
+ 'duplicate-plan': 'A plan with that key already exists. Keys are forever; pick another.',
670
+ 'no-such-plan': 'That plan could not be found.',
671
+ 'bad-stripe-price': 'A pasted Stripe price id starts with price_.',
672
+ unconfigured:
673
+ 'Stripe is not configured, so a subscription price cannot be minted. Set the secret ' +
674
+ 'key first, or paste a price id made in the Stripe dashboard.',
675
+ 'stripe-error': 'Stripe could not be reached. Nothing was saved — try again shortly.',
676
+ }
677
+
678
+ const PLAN_NOTICES: Record<string, (key: string) => string> = {
679
+ created: (key) => `The plan ${key} is on sale from this moment.`,
680
+ updated: (key) =>
681
+ `${key} is updated. Existing memberships and running subscriptions keep what they ` +
682
+ 'bought; the change is for the next buyer.',
683
+ archived: (key) =>
684
+ `${key} is off sale. Everyone who holds it keeps it — archiving stops new purchases, ` +
685
+ 'nothing else.',
686
+ restored: (key) => `${key} is back on sale.`,
687
+ }
688
+
689
+ function planPeriodParts(plan: PlanRow): { length: number; unit: string } {
690
+ const match = /^P(\d+)([YMWD])$/.exec(plan.periodSpec ?? '')
691
+ if (match === null) return { length: 90, unit: 'days' }
692
+ const unit =
693
+ match[2] === 'Y' ? 'years' : match[2] === 'M' ? 'months' : match[2] === 'W' ? 'weeks' : 'days'
694
+ return { length: Number(match[1]), unit }
695
+ }
696
+
697
+ function PlanFields({ plan }: { plan?: PlanRow }) {
698
+ const period = plan === undefined ? { length: 90, unit: 'days' } : planPeriodParts(plan)
699
+ return (
700
+ <>
701
+ <label className="flex flex-col gap-1 text-sm">
702
+ <span className="text-xs text-muted-foreground">Name</span>
703
+ <input name="name" defaultValue={plan?.name ?? ''} required className={INPUT} />
704
+ </label>
705
+ <label className="flex flex-col gap-1 text-sm">
706
+ <span className="text-xs text-muted-foreground">Description — optional</span>
707
+ <input name="description" defaultValue={plan?.description ?? ''} className={INPUT} />
708
+ </label>
709
+ <label className="flex flex-col gap-1 text-sm">
710
+ <span className="text-xs text-muted-foreground">
711
+ Group it grants (marked plugin-grantable under Admin → Groups)
712
+ </span>
713
+ <input name="group" defaultValue={plan?.groupKey ?? ''} required className={INPUT} />
714
+ </label>
715
+ <div className="grid grid-cols-2 gap-3">
716
+ <label className="flex flex-col gap-1 text-sm">
717
+ <span className="text-xs text-muted-foreground">
718
+ Price in minor units — 500 is £5.00
719
+ </span>
720
+ <input
721
+ type="number"
722
+ name="price"
723
+ min={1}
724
+ defaultValue={plan?.priceMinor ?? ''}
725
+ required
726
+ className={INPUT}
727
+ />
728
+ </label>
729
+ <label className="flex flex-col gap-1 text-sm">
730
+ <span className="text-xs text-muted-foreground">Currency</span>
731
+ <input
732
+ name="currency"
733
+ defaultValue={plan?.currency ?? ''}
734
+ placeholder="gbp"
735
+ maxLength={3}
736
+ required
737
+ className={INPUT}
738
+ />
739
+ </label>
740
+ </div>
741
+ {(plan === undefined || plan.mode === 'fixed') && (
742
+ <div className="grid grid-cols-2 gap-3">
743
+ <label className="flex flex-col gap-1 text-sm">
744
+ <span className="text-xs text-muted-foreground">Pass length</span>
745
+ <input
746
+ type="number"
747
+ name="length"
748
+ min={1}
749
+ defaultValue={period.length}
750
+ className={INPUT}
751
+ />
752
+ </label>
753
+ <label className="flex flex-col gap-1 text-sm">
754
+ <span className="text-xs text-muted-foreground">…counted in</span>
755
+ <select name="unit" defaultValue={period.unit} className={INPUT}>
756
+ <option value="days">days</option>
757
+ <option value="weeks">weeks</option>
758
+ <option value="months">months</option>
759
+ <option value="years">years</option>
760
+ </select>
761
+ </label>
762
+ </div>
763
+ )}
764
+ {(plan === undefined || plan.mode === 'auto') && (
765
+ <label className="flex flex-col gap-1 text-sm">
766
+ <span className="text-xs text-muted-foreground">Subscription bills every</span>
767
+ <select name="interval" defaultValue={plan?.billingInterval ?? 'month'} className={INPUT}>
768
+ <option value="month">month</option>
769
+ <option value="year">year</option>
770
+ </select>
771
+ </label>
772
+ )}
773
+ {(plan === undefined || plan.mode === 'auto') && (
774
+ <label className="flex flex-col gap-1 text-sm">
775
+ <span className="text-xs text-muted-foreground">
776
+ Stripe price id — leave empty and one is minted to match
777
+ </span>
778
+ <input
779
+ name="stripe_price"
780
+ placeholder="price_…"
781
+ autoComplete="off"
782
+ className={INPUT}
783
+ />
784
+ </label>
785
+ )}
786
+ <div className="flex flex-wrap gap-4 text-sm">
787
+ <label className="flex items-center gap-2">
788
+ <input
789
+ type="checkbox"
790
+ name="giftable"
791
+ defaultChecked={plan === undefined ? true : plan.giftable}
792
+ />
793
+ Can be bought for another member
794
+ </label>
795
+ <label className="flex items-center gap-2">
796
+ <input type="checkbox" name="hidden" defaultChecked={plan?.hidden ?? false} />
797
+ Hidden from the shop
798
+ </label>
799
+ </div>
800
+ </>
801
+ )
802
+ }
803
+
804
+ export async function PlansAdminPage({
805
+ config,
806
+ context,
807
+ }: {
808
+ config: DuesConfig
809
+ context: PluginAdminPageContext
810
+ }) {
811
+ const plans = await loadPlans(context.data, config)
812
+ const notice = Object.keys(PLAN_NOTICES).find((key) => context.query[key] !== undefined)
813
+ const error = PLAN_ERRORS[context.query.error ?? '']
814
+
815
+ return (
816
+ <div className="flex flex-col gap-4">
817
+ {notice !== undefined && (
818
+ <GoodNotice>{PLAN_NOTICES[notice]!(context.query[notice]!)}</GoodNotice>
819
+ )}
820
+ {error !== undefined && <BadNotice>{error}</BadNotice>}
821
+
822
+ <p className="text-sm text-muted-foreground">
823
+ Every purchase snapshots its plan — the name, the price, the currency, the length
824
+ — so editing here never rewrites what anyone already bought. A subscription price
825
+ change mints a new Stripe price: running subscriptions keep billing what they
826
+ signed up for, and only the next buyer sees the new number.
827
+ </p>
828
+
829
+ {plans.length > 0 && (
830
+ <section className={CARD}>
831
+ <h2 className="font-heading text-lg font-semibold">The plans</h2>
832
+ <ul className="flex flex-col divide-y divide-border">
833
+ {plans.map((plan) => (
834
+ <li key={plan.id} className="flex flex-col gap-3 py-4">
835
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
836
+ <span className="font-medium">
837
+ <code className="mr-2 text-xs text-muted-foreground">{plan.key}</code>
838
+ {plan.name}
839
+ </span>
840
+ <span className="text-sm text-muted-foreground">
841
+ {formatMinor(plan.priceMinor, plan.currency)} · {describeBilling(plan)}
842
+ {plan.hidden && ' · hidden'}
843
+ {plan.archived && ' · off sale'}
844
+ </span>
845
+ </div>
846
+ {plan.mode === 'auto' && (
847
+ <p className="text-xs text-muted-foreground">
848
+ Billing against <code>{plan.stripePriceId ?? 'no price yet — not buyable'}</code>
849
+ </p>
850
+ )}
851
+ {!plan.archived && (
852
+ <details>
853
+ <summary className="cursor-pointer text-sm text-muted-foreground">
854
+ Edit this plan
855
+ </summary>
856
+ <form
857
+ method="post"
858
+ action="/admin/api/plugins/dues/plans/update"
859
+ className="mt-3 flex flex-col gap-3"
860
+ >
861
+ <input type="hidden" name="id" value={plan.id} />
862
+ <PlanFields plan={plan} />
863
+ <div>
864
+ <button type="submit" className={ACT_BUTTON}>
865
+ Save the plan
866
+ </button>
867
+ </div>
868
+ </form>
869
+ </details>
870
+ )}
871
+ <form method="post" action="/admin/api/plugins/dues/plans/archive">
872
+ <input type="hidden" name="id" value={plan.id} />
873
+ <input type="hidden" name="archived" value={plan.archived ? '0' : '1'} />
874
+ <button type="submit" className={QUIET_BUTTON}>
875
+ {plan.archived ? 'Put it back on sale' : 'Take it off sale'}
876
+ </button>
877
+ </form>
878
+ </li>
879
+ ))}
880
+ </ul>
881
+ </section>
882
+ )}
883
+
884
+ <section className={CARD}>
885
+ <h2 className="font-heading text-lg font-semibold">Add a plan</h2>
886
+ <p className="text-sm text-muted-foreground">
887
+ A <strong>pass</strong> is one payment for a fixed stretch — a day to two
888
+ years. A <strong>subscription</strong> renews by itself until cancelled.{' '}
889
+ <strong>Lifetime</strong> is one payment, forever. The key is the plan&rsquo;s
890
+ permanent name in records and cannot be changed later; everything else can.
891
+ </p>
892
+ <form
893
+ method="post"
894
+ action="/admin/api/plugins/dues/plans/create"
895
+ className="flex flex-col gap-3"
896
+ >
897
+ <div className="grid grid-cols-2 gap-3">
898
+ <label className="flex flex-col gap-1 text-sm">
899
+ <span className="text-xs text-muted-foreground">Key — permanent</span>
900
+ <input name="key" placeholder="day-pass" required className={INPUT} />
901
+ </label>
902
+ <label className="flex flex-col gap-1 text-sm">
903
+ <span className="text-xs text-muted-foreground">How it bills</span>
904
+ <select name="mode" className={INPUT}>
905
+ <option value="fixed">a pass — one payment, fixed length</option>
906
+ <option value="auto">a subscription — renews itself</option>
907
+ <option value="lifetime">lifetime — one payment, forever</option>
908
+ </select>
909
+ </label>
910
+ </div>
911
+ <PlanFields />
912
+ <div>
913
+ <button type="submit" className={ACT_BUTTON}>
914
+ Put it on sale
915
+ </button>
916
+ </div>
917
+ </form>
918
+ <p className="text-xs text-muted-foreground">
919
+ Pass length and billing interval apply to the mode that uses them; the others
920
+ ignore them. A subscription needs Stripe configured, or a pasted price id.
921
+ </p>
922
+ </section>
923
+ </div>
924
+ )
925
+ }