@meith/plugin-dues 0.36.2 → 0.37.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/README.md CHANGED
@@ -30,11 +30,6 @@ membership — but their standing does not move, and they are still shown as
30
30
  staff. Selling a badge is fine; selling somebody out of the one that says who
31
31
  answers for the board is not.
32
32
 
33
- **Try it before you read any of this**: [demo.meith.dev](https://demo.meith.dev)
34
- runs this plugin against a Stripe that is not Stripe — a shop with a year of
35
- history behind it, and a checkout a visitor can actually go through. See
36
- [demo mode](../../docs/guides/operations/demo-mode.md#the-shop-and-a-stripe-that-is-not-stripe).
37
-
38
33
  ## Setting up a board
39
34
 
40
35
  1. **Register the plugin** where plugins are registered:
@@ -52,10 +47,10 @@ history behind it, and a checkout a visitor can actually go through. See
52
47
 
53
48
  A board that registers plugins in code and genuinely needs a code-only
54
49
  escape hatch — an extra redirect host for a proxy or a loopback address,
55
- or code-declared seed plans for a demo or test board — calls `createDues`
50
+ or code-declared seed plans for a test board — calls `createDues`
56
51
  instead: `createDues({ extraRedirectHosts: ['proxy.example'] })`. This
57
- repository's own demo and test boards do exactly that, in
58
- `meith.demo.plugins.ts`, behind `DEMO_MODE` and `DUES_TEST_BOARD`. A
52
+ repository's test boards do exactly that, in
53
+ `meith.test.plugins.ts`, behind `DUES_TEST_BOARD`. A
59
54
  seed plan populates the plan table on the board's first run and is
60
55
  ignored once it has rows — after that, the panel owns the plans, exactly
61
56
  as for a board that never declared any.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/plugin-dues",
3
- "version": "0.36.2",
3
+ "version": "0.37.0",
4
4
  "description": "Membership dues for a Meith board: Stripe-backed subscriptions as a contained plugin.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,9 +20,9 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "@meith/plugin-kit": "^0.36.2",
24
- "@meith/theme-kit": "^0.36.2",
25
- "@meith/ui": "^0.36.2"
23
+ "@meith/plugin-kit": "^0.37.0",
24
+ "@meith/theme-kit": "^0.37.0",
25
+ "@meith/ui": "^0.37.0"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": "^19.2.0"
@@ -57,7 +57,7 @@ export function createDues(input: DuesConfigInput = {}): PluginDefinition {
57
57
  return definePlugin({
58
58
  key: 'dues',
59
59
  name: 'Dues',
60
- version: '0.36.2',
60
+ version: '0.37.0',
61
61
  description: en['dues.definition.description'].replace(
62
62
  '{label}',
63
63
  staticConfig.label.toLowerCase(),
package/src/index.ts CHANGED
@@ -1,17 +1,5 @@
1
1
  export type { DuesConfigInput, DuesPlanInput } from './config'
2
2
  export { createDues, dues, dues as plugin } from './definition'
3
- export {
4
- DUES_DEMO_CODES,
5
- DUES_DEMO_CURRENCY,
6
- DUES_DEMO_GRACE_DAYS,
7
- DUES_DEMO_GROUP,
8
- DUES_DEMO_PLANS,
9
- DUES_DEMO_PRICES,
10
- type DuesDemoCast,
11
- type DuesDemoDeps,
12
- type DuesDemoSummary,
13
- seedDuesDemo,
14
- } from './demo'
15
3
  export { duesMessages, duesMessages as messages } from './messages'
16
4
  export { SUBSCRIBED_EVENT_TYPES } from './stripe/events'
17
5
  export { signStripePayload } from './stripe/webhook'
package/src/demo.ts DELETED
@@ -1,526 +0,0 @@
1
- import type { PluginData, PluginGrants, PluginNotify } from '@meith/plugin-kit'
2
-
3
- import { discountedPrice } from './codes'
4
- import { type DuesConfig, parseDuesConfig, resolveDuesConfig } from './config'
5
- import { applyInternalEvent, type EntitlementDeps, settlePaidOrder } from './entitlement'
6
- import { addDays } from './period'
7
- import {
8
- attachCheckoutSession,
9
- type CodeRow,
10
- insertCode,
11
- insertOrder,
12
- insertPlan,
13
- markEventProcessed,
14
- type NewPlan,
15
- type OrderRow,
16
- type PlanRow,
17
- planRowByKey,
18
- recordEvent,
19
- setCodeDisabled,
20
- setPlanArchived,
21
- settleOrder,
22
- } from './store'
23
- import { type StripeEventEnvelope, toInternalEvent } from './stripe/events'
24
-
25
- export const DUES_DEMO_GROUP = 'supporters'
26
-
27
- export const DUES_DEMO_CURRENCY = 'eur'
28
- export const DUES_DEMO_GRACE_DAYS = 7
29
-
30
- export const DUES_DEMO_PLANS = {
31
- month: 'supporter-month',
32
- pass: 'pass-90',
33
- lifetime: 'lifetime',
34
- founder: 'founder',
35
- } as const
36
-
37
- export const DUES_DEMO_CODES = {
38
- comp: 'COMP-LIFETIME',
39
- thanks: 'THANKS-2026',
40
- early: 'EARLY-BIRD',
41
- } as const
42
-
43
- export const DUES_DEMO_PRICES: readonly {
44
- readonly id: string
45
- readonly name: string
46
- readonly unitAmount: number
47
- readonly currency: string
48
- readonly interval: 'month' | 'year'
49
- }[] = [
50
- {
51
- id: 'price_demo_supporter_month',
52
- name: 'Supporter',
53
- unitAmount: 500,
54
- currency: DUES_DEMO_CURRENCY,
55
- interval: 'month',
56
- },
57
- ]
58
-
59
- export interface DuesDemoCast {
60
- readonly comped: number
61
- readonly founder: number
62
- readonly subscriber: number
63
- readonly passHolder: number
64
- readonly lapsing: number
65
- readonly leaving: number
66
- readonly refunded: number
67
- readonly gifter: number
68
- readonly gifted: number
69
- readonly staff: number
70
- }
71
-
72
- export interface DuesDemoDeps {
73
- readonly data: PluginData
74
- readonly grants: (now: () => Date) => PluginGrants
75
- readonly notify?: PluginNotify | undefined
76
- readonly cast: DuesDemoCast
77
- readonly now: Date
78
- readonly log?: ((message: string, detail?: Record<string, unknown>) => void) | undefined
79
- }
80
-
81
- export interface DuesDemoSummary {
82
- readonly plans: number
83
- readonly codes: number
84
- readonly orders: number
85
- readonly memberships: number
86
- readonly events: number
87
- }
88
-
89
- export async function seedDuesDemo(deps: DuesDemoDeps): Promise<DuesDemoSummary> {
90
- return new DemoSeed(deps).run()
91
- }
92
-
93
- const NO_OP_NOTIFY: PluginNotify = { send: async () => {} }
94
-
95
- const PASS_PRICE = 1200
96
- const MONTH_PRICE = 500
97
-
98
- class DemoSeed {
99
- private readonly config: DuesConfig
100
- private readonly grants: PluginGrants
101
- private clock: Date
102
- private eventCounter = 0
103
- private counts = { plans: 0, codes: 0, orders: 0, memberships: 0, events: 0 }
104
-
105
- constructor(private readonly deps: DuesDemoDeps) {
106
- this.config = resolveDuesConfig(parseDuesConfig({}), {
107
- currency: DUES_DEMO_CURRENCY,
108
- grace_days: DUES_DEMO_GRACE_DAYS,
109
- })
110
- this.clock = deps.now
111
- this.grants = deps.grants(() => this.clock)
112
- }
113
-
114
- async run(): Promise<DuesDemoSummary> {
115
- await this.plans()
116
- const comp = await this.codes()
117
-
118
- await this.at(300, () => this.buyFounderPass())
119
- await this.at(180, () => this.compLifetime(comp))
120
-
121
- await this.subscription({
122
- member: this.deps.cast.subscriber,
123
- subscriptionId: 'sub_demo_supporter',
124
- boughtDaysAgo: 158,
125
- renewedDaysAgo: [128, 97, 66, 35, 4],
126
- })
127
- await this.subscription({
128
- member: this.deps.cast.lapsing,
129
- subscriptionId: 'sub_demo_lapsed',
130
- boughtDaysAgo: 126,
131
- renewedDaysAgo: [96, 65, 34],
132
- failedDaysAgo: 1,
133
- })
134
- await this.subscription({
135
- member: this.deps.cast.leaving,
136
- subscriptionId: 'sub_demo_leaving',
137
- boughtDaysAgo: 95,
138
- renewedDaysAgo: [65, 34, 3],
139
- cancelledDaysAgo: 2,
140
- })
141
-
142
- await this.at(40, () => this.buyPass(this.deps.cast.passHolder))
143
- await this.at(26, () => this.buyPass(this.deps.cast.refunded))
144
- await this.at(19, () => this.refund(this.deps.cast.refunded))
145
- await this.at(12, () => this.gift())
146
- await this.at(8, () => this.abandonedCheckout())
147
-
148
- return { ...this.counts }
149
- }
150
-
151
- private async plans(): Promise<void> {
152
- await this.plan({
153
- planKey: DUES_DEMO_PLANS.month,
154
- name: 'Supporter',
155
- description: 'The board’s bills, split honestly. Renews monthly; cancel whenever you like.',
156
- groupKey: DUES_DEMO_GROUP,
157
- priceMinor: MONTH_PRICE,
158
- currency: DUES_DEMO_CURRENCY,
159
- mode: 'auto',
160
- periodSpec: null,
161
- billingInterval: 'month',
162
- stripePriceId: DUES_DEMO_PRICES[0]!.id,
163
- giftable: false,
164
- hidden: false,
165
- })
166
-
167
- await this.plan({
168
- planKey: DUES_DEMO_PLANS.pass,
169
- name: '90-day pass',
170
- description:
171
- 'Three months among the supporters, paid once. Buy it for yourself or for somebody else.',
172
- groupKey: DUES_DEMO_GROUP,
173
- priceMinor: PASS_PRICE,
174
- currency: DUES_DEMO_CURRENCY,
175
- mode: 'fixed',
176
- periodSpec: 'P90D',
177
- billingInterval: null,
178
- giftable: true,
179
- hidden: false,
180
- })
181
-
182
- await this.plan({
183
- planKey: DUES_DEMO_PLANS.lifetime,
184
- name: 'Lifetime membership',
185
- description: 'Once, and never again. No renewal, no end date.',
186
- groupKey: DUES_DEMO_GROUP,
187
- priceMinor: 9900,
188
- currency: DUES_DEMO_CURRENCY,
189
- mode: 'lifetime',
190
- periodSpec: null,
191
- billingInterval: null,
192
- giftable: true,
193
- hidden: false,
194
- })
195
-
196
- const founder = await this.plan({
197
- planKey: DUES_DEMO_PLANS.founder,
198
- name: 'Founding supporter',
199
- description: 'A year at the old price, sold before the plans settled. Not on sale now.',
200
- groupKey: DUES_DEMO_GROUP,
201
- priceMinor: 2500,
202
- currency: DUES_DEMO_CURRENCY,
203
- mode: 'fixed',
204
- periodSpec: 'P1Y',
205
- billingInterval: null,
206
- giftable: false,
207
- hidden: false,
208
- })
209
- if (founder !== null) await setPlanArchived(this.deps.data, founder.id, true)
210
- }
211
-
212
- private async plan(input: NewPlan): Promise<PlanRow | null> {
213
- const row = await insertPlan(this.deps.data, input)
214
- if (row !== null) this.counts.plans += 1
215
- return row
216
- }
217
-
218
- private async codes(): Promise<CodeRow> {
219
- const comp = await this.code({
220
- code: DUES_DEMO_CODES.comp,
221
- percentOff: 100,
222
- planKey: DUES_DEMO_PLANS.lifetime,
223
- maxRedemptions: 5,
224
- expiresAt: null,
225
- createdByUserId: this.deps.cast.staff,
226
- })
227
- if (comp === null) {
228
- throw new Error('the demo comp code could not be minted into an empty code table')
229
- }
230
-
231
- await this.code({
232
- code: DUES_DEMO_CODES.thanks,
233
- percentOff: 25,
234
- planKey: null,
235
- maxRedemptions: null,
236
- expiresAt: addDays(this.deps.now, 60),
237
- createdByUserId: this.deps.cast.staff,
238
- })
239
-
240
- const early = await this.code({
241
- code: DUES_DEMO_CODES.early,
242
- percentOff: 50,
243
- planKey: DUES_DEMO_PLANS.pass,
244
- maxRedemptions: 25,
245
- expiresAt: addDays(this.deps.now, -30),
246
- createdByUserId: this.deps.cast.staff,
247
- })
248
- if (early !== null) await setCodeDisabled(this.deps.data, early.id, true)
249
-
250
- return comp
251
- }
252
-
253
- private async code(input: Parameters<typeof insertCode>[1]): Promise<CodeRow | null> {
254
- const row = await insertCode(this.deps.data, input)
255
- if (row !== null) this.counts.codes += 1
256
- return row
257
- }
258
-
259
- private async buyFounderPass(): Promise<void> {
260
- const order = await this.order({
261
- buyer: this.deps.cast.founder,
262
- planKey: DUES_DEMO_PLANS.founder,
263
- sessionId: 'cs_demo_founder',
264
- })
265
- await this.settle(order, 'cs_demo_founder', { paymentIntentId: 'pi_demo_founder' })
266
- }
267
-
268
- private async compLifetime(code: CodeRow): Promise<void> {
269
- const order = await this.order({
270
- buyer: this.deps.cast.comped,
271
- planKey: DUES_DEMO_PLANS.lifetime,
272
- sessionId: null,
273
- code,
274
- })
275
-
276
- await settlePaidOrder(this.entitlement(), order, {
277
- amountTotal: 0,
278
- currency: DUES_DEMO_CURRENCY,
279
- subscriptionId: null,
280
- paymentIntentId: null,
281
- })
282
- this.counts.memberships += 1
283
- }
284
-
285
- private async buyPass(buyer: number): Promise<void> {
286
- const sessionId = `cs_demo_pass_${buyer}`
287
- const order = await this.order({ buyer, planKey: DUES_DEMO_PLANS.pass, sessionId })
288
- await this.settle(order, sessionId, { paymentIntentId: paymentIntentFor(buyer) })
289
- }
290
-
291
- private async gift(): Promise<void> {
292
- const order = await this.order({
293
- buyer: this.deps.cast.gifter,
294
- recipient: this.deps.cast.gifted,
295
- planKey: DUES_DEMO_PLANS.pass,
296
- sessionId: 'cs_demo_gift',
297
- })
298
- await this.settle(order, 'cs_demo_gift', { paymentIntentId: 'pi_demo_gift' })
299
- }
300
-
301
- private async refund(buyer: number): Promise<void> {
302
- await this.event('charge.refunded', {
303
- id: `ch_demo_refund_${buyer}`,
304
- payment_intent: paymentIntentFor(buyer),
305
- amount_refunded: PASS_PRICE,
306
- currency: DUES_DEMO_CURRENCY,
307
- })
308
- }
309
-
310
- private async abandonedCheckout(): Promise<void> {
311
- const order = await this.order({
312
- buyer: this.deps.cast.gifter,
313
- planKey: DUES_DEMO_PLANS.lifetime,
314
- sessionId: 'cs_demo_abandoned',
315
- })
316
- await settleOrder(this.deps.data, order.id, { status: 'cancelled' })
317
- }
318
-
319
- private async subscription(input: {
320
- readonly member: number
321
- readonly subscriptionId: string
322
- readonly boughtDaysAgo: number
323
- readonly renewedDaysAgo: readonly number[]
324
- readonly failedDaysAgo?: number
325
- readonly cancelledDaysAgo?: number
326
- }): Promise<void> {
327
- const sessionId = `cs_${input.subscriptionId}`
328
-
329
- await this.at(input.boughtDaysAgo, async () => {
330
- const order = await this.order({
331
- buyer: input.member,
332
- planKey: DUES_DEMO_PLANS.month,
333
- sessionId,
334
- })
335
- await this.settle(order, sessionId, { subscriptionId: input.subscriptionId })
336
- })
337
-
338
- for (const daysAgo of input.renewedDaysAgo) {
339
- await this.at(daysAgo, () =>
340
- this.event('invoice.paid', {
341
- id: `in_${input.subscriptionId}_${daysAgo}`,
342
- subscription: input.subscriptionId,
343
- amount_paid: MONTH_PRICE,
344
- currency: DUES_DEMO_CURRENCY,
345
- billing_reason: 'subscription_cycle',
346
- period_end: unix(addDays(this.clock, 31)),
347
- }),
348
- )
349
- }
350
-
351
- if (input.failedDaysAgo !== undefined) {
352
- await this.at(input.failedDaysAgo, () =>
353
- this.event('invoice.payment_failed', {
354
- id: `in_${input.subscriptionId}_failed`,
355
- subscription: input.subscriptionId,
356
- }),
357
- )
358
- }
359
-
360
- if (input.cancelledDaysAgo !== undefined) {
361
- await this.at(input.cancelledDaysAgo, () =>
362
- this.event('customer.subscription.updated', {
363
- id: input.subscriptionId,
364
- status: 'active',
365
- cancel_at_period_end: true,
366
- }),
367
- )
368
- }
369
- }
370
-
371
- private async order(input: {
372
- readonly buyer: number
373
- readonly recipient?: number
374
- readonly planKey: string
375
- readonly sessionId: string | null
376
- readonly code?: CodeRow
377
- }): Promise<OrderRow> {
378
- const plan = await planRowByKey(this.deps.data, input.planKey)
379
- if (plan === null) {
380
- throw new Error(`the demo names a dues plan "${input.planKey}" that it never made`)
381
- }
382
-
383
- const charge =
384
- input.code === undefined
385
- ? plan.priceMinor
386
- : discountedPrice(plan.priceMinor, input.code.percentOff)
387
-
388
- const { order } = await insertOrder(this.deps.data, {
389
- buyerUserId: input.buyer,
390
- recipientUserId: input.recipient ?? input.buyer,
391
- planKey: plan.key,
392
- planName: plan.name,
393
- groupKey: plan.groupKey,
394
- amountMinor: charge,
395
- currency: plan.currency,
396
- billingMode: plan.mode,
397
- periodSpec: plan.mode === 'fixed' ? plan.periodSpec : null,
398
- idempotencyKey: `demo:${plan.key}:${input.buyer}:${this.clock.toISOString()}`,
399
- codeId: input.code?.id ?? null,
400
- discountMinor: plan.priceMinor - charge,
401
- })
402
- this.counts.orders += 1
403
-
404
- if (input.sessionId !== null) {
405
- await attachCheckoutSession(this.deps.data, order.id, { id: input.sessionId, url: null })
406
- }
407
-
408
- return order
409
- }
410
-
411
- private async settle(
412
- order: OrderRow,
413
- sessionId: string,
414
- stripe: { readonly subscriptionId?: string; readonly paymentIntentId?: string },
415
- ): Promise<void> {
416
- await this.event('checkout.session.completed', {
417
- id: sessionId,
418
- object: 'checkout.session',
419
- payment_status: 'paid',
420
- amount_total: order.amountMinor,
421
- currency: order.currency,
422
- subscription: stripe.subscriptionId ?? null,
423
- payment_intent: stripe.paymentIntentId ?? null,
424
- customer: `cus_demo_${order.buyerUserId}`,
425
- })
426
- this.counts.memberships += 1
427
- }
428
-
429
- private async event(type: string, object: Record<string, unknown>): Promise<void> {
430
- this.eventCounter += 1
431
- const envelope: StripeEventEnvelope = { id: `evt_demo_${this.eventCounter}`, type, object }
432
-
433
- const recorded = await recordEvent(this.deps.data, {
434
- stripeEventId: envelope.id,
435
- type: envelope.type,
436
- payload: { id: envelope.id, type: envelope.type, data: { object } },
437
- })
438
- this.counts.events += 1
439
-
440
- const outcome = await applyInternalEvent(this.entitlement(), toInternalEvent(envelope))
441
- await markEventProcessed(this.deps.data, recorded.id, outcome)
442
- }
443
-
444
- private entitlement(): EntitlementDeps {
445
- return {
446
- config: this.config,
447
- data: this.deps.data,
448
- grants: this.grants,
449
- notify: this.deps.notify ?? NO_OP_NOTIFY,
450
- log: this.deps.log ?? (() => {}),
451
- now: () => this.clock,
452
- }
453
- }
454
-
455
- private async at(daysAgo: number, work: () => Promise<void>): Promise<void> {
456
- const marks = await this.highWaterMarks()
457
- this.clock = addDays(this.deps.now, -daysAgo)
458
- await work()
459
- await this.backdate(this.clock, marks)
460
- this.clock = this.deps.now
461
- }
462
-
463
- private async highWaterMarks(): Promise<Marks> {
464
- const row = await this.deps.data.one(`
465
- select
466
- (select coalesce(max(id), 0) from plugin_dues_order)::int as orders,
467
- (select coalesce(max(id), 0) from plugin_dues_membership)::int as memberships,
468
- (select coalesce(max(id), 0) from plugin_dues_ledger)::int as ledger,
469
- (select coalesce(max(id), 0) from plugin_dues_event)::int as events
470
- `)
471
- return {
472
- orders: Number(row?.orders ?? 0),
473
- memberships: Number(row?.memberships ?? 0),
474
- ledger: Number(row?.ledger ?? 0),
475
- events: Number(row?.events ?? 0),
476
- }
477
- }
478
-
479
- private async backdate(at: Date, marks: Marks): Promise<void> {
480
- await this.deps.data.query(
481
- `update plugin_dues_order
482
- set created_at = $1::timestamptz,
483
- settled_at = case when settled_at is null then null else $1::timestamptz end
484
- where id > $2`,
485
- [at, marks.orders],
486
- )
487
- await this.deps.data.query(
488
- `update plugin_dues_membership
489
- set created_at = $1::timestamptz, updated_at = $1::timestamptz
490
- where id > $2`,
491
- [at, marks.memberships],
492
- )
493
- await this.deps.data.query(
494
- `update plugin_dues_membership
495
- set updated_at = $1::timestamptz
496
- where id <= $2 and updated_at > $1::timestamptz`,
497
- [at, marks.memberships],
498
- )
499
- await this.deps.data.query(
500
- `update plugin_dues_ledger set occurred_at = $1::timestamptz where id > $2`,
501
- [at, marks.ledger],
502
- )
503
- await this.deps.data.query(
504
- `update plugin_dues_event
505
- set received_at = $1::timestamptz,
506
- processed_at = case when processed_at is null then null else $1::timestamptz end
507
- where id > $2`,
508
- [at, marks.events],
509
- )
510
- }
511
- }
512
-
513
- interface Marks {
514
- readonly orders: number
515
- readonly memberships: number
516
- readonly ledger: number
517
- readonly events: number
518
- }
519
-
520
- function paymentIntentFor(buyer: number): string {
521
- return `pi_demo_pass_${buyer}`
522
- }
523
-
524
- function unix(at: Date): number {
525
- return Math.floor(at.getTime() / 1000)
526
- }