@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/src/demo.ts ADDED
@@ -0,0 +1,526 @@
1
+ import type { PluginData, PluginGrants, PluginNotify } from '@meith/plugin-kit'
2
+
3
+ import { discountedPrice } from './codes'
4
+ import { parseDuesConfig, type DuesConfig } from './config'
5
+ import { applyInternalEvent, settlePaidOrder, type EntitlementDeps } from './entitlement'
6
+ import { addDays } from './period'
7
+ import {
8
+ attachCheckoutSession,
9
+ insertCode,
10
+ insertOrder,
11
+ insertPlan,
12
+ markEventProcessed,
13
+ planRowByKey,
14
+ recordEvent,
15
+ setCodeDisabled,
16
+ setPlanArchived,
17
+ settleOrder,
18
+ type CodeRow,
19
+ type NewPlan,
20
+ type OrderRow,
21
+ type PlanRow,
22
+ } from './store'
23
+ import { toInternalEvent, type StripeEventEnvelope } 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 = parseDuesConfig({
107
+ currency: DUES_DEMO_CURRENCY,
108
+ graceDays: 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
+ }