@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/store.ts ADDED
@@ -0,0 +1,966 @@
1
+ import type { PluginData } from '@meith/plugin-kit'
2
+
3
+ export type OrderStatus = 'created' | 'pending' | 'paid' | 'failed' | 'cancelled'
4
+ export type MembershipStatus = 'active' | 'grace' | 'closing' | 'expired' | 'revoked'
5
+ export type RenewalMode = 'auto' | 'fixed' | 'lifetime'
6
+ export type PlanMode = RenewalMode
7
+
8
+ export interface OrderRow {
9
+ readonly id: number
10
+ readonly buyerUserId: number
11
+ readonly recipientUserId: number
12
+ readonly planKey: string
13
+ readonly planName: string
14
+ readonly groupKey: string
15
+ readonly amountMinor: number
16
+ readonly currency: string
17
+ readonly billingMode: RenewalMode
18
+ readonly periodSpec: string | null
19
+ readonly status: OrderStatus
20
+ readonly needsAttention: string | null
21
+ readonly codeId: number | null
22
+ readonly discountMinor: number
23
+ readonly stripeSessionId: string | null
24
+ readonly stripeSubscriptionId: string | null
25
+ readonly stripePaymentIntentId: string | null
26
+ readonly checkoutUrl: string | null
27
+ readonly createdAt: Date
28
+ readonly settledAt: Date | null
29
+ }
30
+
31
+ export interface MembershipRow {
32
+ readonly id: number
33
+ readonly userId: number
34
+ readonly groupKey: string
35
+ readonly planKey: string
36
+ readonly status: MembershipStatus
37
+ readonly renewalMode: RenewalMode
38
+ readonly currentPeriodEnd: Date
39
+ readonly graceUntil: Date
40
+ readonly needsAttention: string | null
41
+ readonly stripeSubscriptionId: string | null
42
+ readonly lastOrderId: number | null
43
+ }
44
+
45
+ export interface EventRow {
46
+ readonly id: number
47
+ readonly stripeEventId: string
48
+ readonly type: string
49
+ readonly payload: unknown
50
+ readonly receivedAt: Date
51
+ readonly processedAt: Date | null
52
+ readonly outcome: string | null
53
+ }
54
+
55
+ export interface LedgerRow {
56
+ readonly id: number
57
+ readonly occurredAt: Date
58
+ readonly kind: 'charge' | 'refund' | 'chargeback'
59
+ readonly userId: number
60
+ readonly membershipId: number | null
61
+ readonly orderId: number | null
62
+ readonly amountMinor: number
63
+ readonly currency: string
64
+ readonly stripeRef: string | null
65
+ readonly note: string | null
66
+ }
67
+
68
+ function asDate(value: unknown): Date {
69
+ return value instanceof Date ? value : new Date(String(value))
70
+ }
71
+
72
+ function asNullableDate(value: unknown): Date | null {
73
+ return value === null || value === undefined ? null : asDate(value)
74
+ }
75
+
76
+ function asNullableString(value: unknown): string | null {
77
+ return value === null || value === undefined ? null : String(value)
78
+ }
79
+
80
+ function orderRow(row: Record<string, unknown>): OrderRow {
81
+ return {
82
+ id: Number(row.id),
83
+ buyerUserId: Number(row.buyer_user_id),
84
+ recipientUserId: Number(row.recipient_user_id),
85
+ planKey: String(row.plan_key),
86
+ planName: String(row.plan_name),
87
+ groupKey: String(row.group_key),
88
+ amountMinor: Number(row.amount_minor),
89
+ currency: String(row.currency),
90
+ billingMode: String(row.billing_mode) as RenewalMode,
91
+ periodSpec: asNullableString(row.period_spec),
92
+ status: String(row.status) as OrderStatus,
93
+ needsAttention: asNullableString(row.needs_attention),
94
+ codeId: row.code_id === null || row.code_id === undefined ? null : Number(row.code_id),
95
+ discountMinor: Number(row.discount_minor ?? 0),
96
+ stripeSessionId: asNullableString(row.stripe_session_id),
97
+ stripeSubscriptionId: asNullableString(row.stripe_subscription_id),
98
+ stripePaymentIntentId: asNullableString(row.stripe_payment_intent_id),
99
+ checkoutUrl: asNullableString(row.checkout_url),
100
+ createdAt: asDate(row.created_at),
101
+ settledAt: asNullableDate(row.settled_at),
102
+ }
103
+ }
104
+
105
+ function membershipRow(row: Record<string, unknown>): MembershipRow {
106
+ return {
107
+ id: Number(row.id),
108
+ userId: Number(row.user_id),
109
+ groupKey: String(row.group_key),
110
+ planKey: String(row.plan_key),
111
+ status: String(row.status) as MembershipStatus,
112
+ renewalMode: String(row.renewal_mode) as RenewalMode,
113
+ currentPeriodEnd: asDate(row.current_period_end),
114
+ graceUntil: asDate(row.grace_until),
115
+ needsAttention: asNullableString(row.needs_attention),
116
+ stripeSubscriptionId: asNullableString(row.stripe_subscription_id),
117
+ lastOrderId: row.last_order_id === null || row.last_order_id === undefined ? null : Number(row.last_order_id),
118
+ }
119
+ }
120
+
121
+ function eventRow(row: Record<string, unknown>): EventRow {
122
+ return {
123
+ id: Number(row.id),
124
+ stripeEventId: String(row.stripe_event_id),
125
+ type: String(row.type),
126
+ payload: row.payload,
127
+ receivedAt: asDate(row.received_at),
128
+ processedAt: asNullableDate(row.processed_at),
129
+ outcome: asNullableString(row.outcome),
130
+ }
131
+ }
132
+
133
+ function ledgerRow(row: Record<string, unknown>): LedgerRow {
134
+ return {
135
+ id: Number(row.id),
136
+ occurredAt: asDate(row.occurred_at),
137
+ kind: String(row.kind) as LedgerRow['kind'],
138
+ userId: Number(row.user_id),
139
+ membershipId:
140
+ row.membership_id === null || row.membership_id === undefined ? null : Number(row.membership_id),
141
+ orderId: row.order_id === null || row.order_id === undefined ? null : Number(row.order_id),
142
+ amountMinor: Number(row.amount_minor),
143
+ currency: String(row.currency),
144
+ stripeRef: asNullableString(row.stripe_ref),
145
+ note: asNullableString(row.note),
146
+ }
147
+ }
148
+
149
+ export async function findStripeCustomer(data: PluginData, userId: number): Promise<string | null> {
150
+ const row = await data.one(
151
+ 'select stripe_customer_id from plugin_dues_customer where user_id = $1',
152
+ [userId],
153
+ )
154
+ return row === null ? null : String(row.stripe_customer_id)
155
+ }
156
+
157
+ export async function saveStripeCustomer(
158
+ data: PluginData,
159
+ userId: number,
160
+ stripeCustomerId: string,
161
+ ): Promise<void> {
162
+ await data.query(
163
+ `insert into plugin_dues_customer (user_id, stripe_customer_id)
164
+ values ($1, $2)
165
+ on conflict (user_id) do nothing`,
166
+ [userId, stripeCustomerId],
167
+ )
168
+ }
169
+
170
+ export interface NewOrder {
171
+ readonly buyerUserId: number
172
+ readonly recipientUserId: number
173
+ readonly planKey: string
174
+ readonly planName: string
175
+ readonly groupKey: string
176
+ readonly amountMinor: number
177
+ readonly currency: string
178
+ readonly billingMode: RenewalMode
179
+ readonly periodSpec: string | null
180
+ readonly idempotencyKey: string
181
+ readonly codeId?: number | null
182
+ readonly discountMinor?: number
183
+ }
184
+
185
+ export async function insertOrder(
186
+ data: PluginData,
187
+ order: NewOrder,
188
+ ): Promise<{ readonly order: OrderRow; readonly created: boolean }> {
189
+ const inserted = await data.one(
190
+ `insert into plugin_dues_order
191
+ (buyer_user_id, recipient_user_id, plan_key, plan_name, group_key,
192
+ amount_minor, currency, billing_mode, period_spec, idempotency_key,
193
+ code_id, discount_minor)
194
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
195
+ on conflict (idempotency_key) do nothing
196
+ returning *`,
197
+ [
198
+ order.buyerUserId,
199
+ order.recipientUserId,
200
+ order.planKey,
201
+ order.planName,
202
+ order.groupKey,
203
+ order.amountMinor,
204
+ order.currency,
205
+ order.billingMode,
206
+ order.periodSpec,
207
+ order.idempotencyKey,
208
+ order.codeId ?? null,
209
+ order.discountMinor ?? 0,
210
+ ],
211
+ )
212
+ if (inserted !== null) return { order: orderRow(inserted), created: true }
213
+
214
+ const existing = await data.one('select * from plugin_dues_order where idempotency_key = $1', [
215
+ order.idempotencyKey,
216
+ ])
217
+ if (existing === null) throw new Error('order insert lost a race it cannot lose')
218
+ return { order: orderRow(existing), created: false }
219
+ }
220
+
221
+ export async function attachCheckoutSession(
222
+ data: PluginData,
223
+ orderId: number,
224
+ session: { readonly id: string; readonly url: string | null },
225
+ ): Promise<void> {
226
+ await data.query(
227
+ `update plugin_dues_order
228
+ set status = 'pending', stripe_session_id = $2, checkout_url = $3
229
+ where id = $1 and status in ('created', 'pending')`,
230
+ [orderId, session.id, session.url],
231
+ )
232
+ }
233
+
234
+ export async function orderById(data: PluginData, id: number): Promise<OrderRow | null> {
235
+ const row = await data.one('select * from plugin_dues_order where id = $1', [id])
236
+ return row === null ? null : orderRow(row)
237
+ }
238
+
239
+ export async function orderBySessionId(
240
+ data: PluginData,
241
+ sessionId: string,
242
+ ): Promise<OrderRow | null> {
243
+ const row = await data.one('select * from plugin_dues_order where stripe_session_id = $1', [
244
+ sessionId,
245
+ ])
246
+ return row === null ? null : orderRow(row)
247
+ }
248
+
249
+ export async function orderByPaymentIntent(
250
+ data: PluginData,
251
+ paymentIntentId: string,
252
+ ): Promise<OrderRow | null> {
253
+ const row = await data.one(
254
+ 'select * from plugin_dues_order where stripe_payment_intent_id = $1',
255
+ [paymentIntentId],
256
+ )
257
+ return row === null ? null : orderRow(row)
258
+ }
259
+
260
+ export async function settleOrder(
261
+ data: PluginData,
262
+ orderId: number,
263
+ outcome: {
264
+ readonly status: OrderStatus
265
+ readonly stripeSubscriptionId?: string | null
266
+ readonly stripePaymentIntentId?: string | null
267
+ readonly needsAttention?: string | null
268
+ },
269
+ ): Promise<void> {
270
+ await data.query(
271
+ `update plugin_dues_order
272
+ set status = $2,
273
+ settled_at = now(),
274
+ stripe_subscription_id = coalesce($3, stripe_subscription_id),
275
+ stripe_payment_intent_id = coalesce($4, stripe_payment_intent_id),
276
+ needs_attention = $5
277
+ where id = $1`,
278
+ [
279
+ orderId,
280
+ outcome.status,
281
+ outcome.stripeSubscriptionId ?? null,
282
+ outcome.stripePaymentIntentId ?? null,
283
+ outcome.needsAttention ?? null,
284
+ ],
285
+ )
286
+ }
287
+
288
+ export async function pendingOrdersOlderThan(
289
+ data: PluginData,
290
+ minutes: number,
291
+ limit: number,
292
+ ): Promise<readonly OrderRow[]> {
293
+ const rows = await data.query(
294
+ `select * from plugin_dues_order
295
+ where status in ('created', 'pending')
296
+ and created_at < now() - ($1 * interval '1 minute')
297
+ order by created_at
298
+ limit $2`,
299
+ [minutes, limit],
300
+ )
301
+ return rows.map(orderRow)
302
+ }
303
+
304
+ export async function ordersBoughtBy(
305
+ data: PluginData,
306
+ buyerUserId: number,
307
+ limit = 20,
308
+ ): Promise<readonly OrderRow[]> {
309
+ const rows = await data.query(
310
+ `select * from plugin_dues_order
311
+ where buyer_user_id = $1
312
+ order by created_at desc
313
+ limit $2`,
314
+ [buyerUserId, limit],
315
+ )
316
+ return rows.map(orderRow)
317
+ }
318
+
319
+ export async function liveMembership(
320
+ data: PluginData,
321
+ userId: number,
322
+ groupKey: string,
323
+ ): Promise<MembershipRow | null> {
324
+ const row = await data.one(
325
+ `select * from plugin_dues_membership
326
+ where user_id = $1 and group_key = $2 and status in ('active', 'grace', 'closing')`,
327
+ [userId, groupKey],
328
+ )
329
+ return row === null ? null : membershipRow(row)
330
+ }
331
+
332
+ export async function membershipById(
333
+ data: PluginData,
334
+ id: number,
335
+ ): Promise<MembershipRow | null> {
336
+ const row = await data.one('select * from plugin_dues_membership where id = $1', [id])
337
+ return row === null ? null : membershipRow(row)
338
+ }
339
+
340
+ export async function membershipBySubscription(
341
+ data: PluginData,
342
+ stripeSubscriptionId: string,
343
+ ): Promise<MembershipRow | null> {
344
+ const row = await data.one(
345
+ `select * from plugin_dues_membership
346
+ where stripe_subscription_id = $1
347
+ order by created_at desc
348
+ limit 1`,
349
+ [stripeSubscriptionId],
350
+ )
351
+ return row === null ? null : membershipRow(row)
352
+ }
353
+
354
+ export async function membershipsFor(
355
+ data: PluginData,
356
+ userId: number,
357
+ ): Promise<readonly MembershipRow[]> {
358
+ const rows = await data.query(
359
+ `select * from plugin_dues_membership
360
+ where user_id = $1
361
+ order by status in ('active', 'grace', 'closing') desc, updated_at desc
362
+ limit 50`,
363
+ [userId],
364
+ )
365
+ return rows.map(membershipRow)
366
+ }
367
+
368
+ export interface NewMembership {
369
+ readonly userId: number
370
+ readonly groupKey: string
371
+ readonly planKey: string
372
+ readonly renewalMode: RenewalMode
373
+ readonly currentPeriodEnd: Date
374
+ readonly graceUntil: Date
375
+ readonly stripeSubscriptionId: string | null
376
+ readonly lastOrderId: number | null
377
+ }
378
+
379
+ export async function insertMembership(
380
+ data: PluginData,
381
+ membership: NewMembership,
382
+ ): Promise<MembershipRow> {
383
+ const row = await data.one(
384
+ `insert into plugin_dues_membership
385
+ (user_id, group_key, plan_key, status, renewal_mode,
386
+ current_period_end, grace_until, stripe_subscription_id, last_order_id)
387
+ values ($1, $2, $3, 'active', $4, $5, $6, $7, $8)
388
+ returning *`,
389
+ [
390
+ membership.userId,
391
+ membership.groupKey,
392
+ membership.planKey,
393
+ membership.renewalMode,
394
+ membership.currentPeriodEnd,
395
+ membership.graceUntil,
396
+ membership.stripeSubscriptionId,
397
+ membership.lastOrderId,
398
+ ],
399
+ )
400
+ if (row === null) throw new Error('membership insert returned nothing')
401
+ return membershipRow(row)
402
+ }
403
+
404
+ export async function extendMembership(
405
+ data: PluginData,
406
+ id: number,
407
+ update: {
408
+ readonly currentPeriodEnd: Date
409
+ readonly graceUntil: Date
410
+ readonly planKey?: string
411
+ readonly renewalMode?: RenewalMode
412
+ readonly lastOrderId?: number | null
413
+ readonly stripeSubscriptionId?: string | null
414
+ },
415
+ ): Promise<void> {
416
+ await data.query(
417
+ `update plugin_dues_membership
418
+ set status = 'active',
419
+ current_period_end = greatest(current_period_end, $2),
420
+ grace_until = greatest(grace_until, $3),
421
+ plan_key = coalesce($4, plan_key),
422
+ renewal_mode = coalesce($5, renewal_mode),
423
+ last_order_id = coalesce($6, last_order_id),
424
+ stripe_subscription_id = coalesce($7, stripe_subscription_id),
425
+ needs_attention = null,
426
+ updated_at = now()
427
+ where id = $1`,
428
+ [
429
+ id,
430
+ update.currentPeriodEnd,
431
+ update.graceUntil,
432
+ update.planKey ?? null,
433
+ update.renewalMode ?? null,
434
+ update.lastOrderId ?? null,
435
+ update.stripeSubscriptionId ?? null,
436
+ ],
437
+ )
438
+ }
439
+
440
+ export async function setMembershipStatus(
441
+ data: PluginData,
442
+ id: number,
443
+ status: MembershipStatus,
444
+ ): Promise<void> {
445
+ await data.query(
446
+ `update plugin_dues_membership set status = $2, updated_at = now() where id = $1`,
447
+ [id, status],
448
+ )
449
+ }
450
+
451
+ export async function flagMembership(
452
+ data: PluginData,
453
+ id: number,
454
+ reason: string,
455
+ ): Promise<void> {
456
+ await data.query(
457
+ `update plugin_dues_membership set needs_attention = $2, updated_at = now() where id = $1`,
458
+ [id, reason],
459
+ )
460
+ }
461
+
462
+ export async function expireDueMemberships(data: PluginData, limit: number): Promise<number> {
463
+ const rows = await data.query(
464
+ `update plugin_dues_membership
465
+ set status = 'expired', updated_at = now()
466
+ where id in (
467
+ select id from plugin_dues_membership
468
+ where (status in ('active', 'grace') and grace_until <= now())
469
+ or (status = 'closing' and current_period_end <= now())
470
+ limit $1
471
+ )
472
+ returning id`,
473
+ [limit],
474
+ )
475
+ return rows.length
476
+ }
477
+
478
+ export async function allMemberships(
479
+ data: PluginData,
480
+ limit = 200,
481
+ ): Promise<readonly MembershipRow[]> {
482
+ const rows = await data.query(
483
+ `select * from plugin_dues_membership
484
+ order by needs_attention is not null desc,
485
+ status in ('active', 'grace', 'closing') desc,
486
+ updated_at desc
487
+ limit $1`,
488
+ [limit],
489
+ )
490
+ return rows.map(membershipRow)
491
+ }
492
+
493
+ export async function membershipsPastPeriod(
494
+ data: PluginData,
495
+ limit: number,
496
+ ): Promise<readonly MembershipRow[]> {
497
+ const rows = await data.query(
498
+ `select * from plugin_dues_membership
499
+ where status = 'active'
500
+ and renewal_mode = 'auto'
501
+ and stripe_subscription_id is not null
502
+ and current_period_end <= now()
503
+ limit $1`,
504
+ [limit],
505
+ )
506
+ return rows.map(membershipRow)
507
+ }
508
+
509
+ export async function recordEvent(
510
+ data: PluginData,
511
+ event: { readonly stripeEventId: string; readonly type: string; readonly payload: unknown },
512
+ ): Promise<{ readonly id: number; readonly first: boolean }> {
513
+ const inserted = await data.one(
514
+ `insert into plugin_dues_event (stripe_event_id, type, payload)
515
+ values ($1, $2, $3::jsonb)
516
+ on conflict (stripe_event_id) do nothing
517
+ returning id`,
518
+ [event.stripeEventId, event.type, JSON.stringify(event.payload ?? null)],
519
+ )
520
+ if (inserted !== null) return { id: Number(inserted.id), first: true }
521
+
522
+ const existing = await data.one(
523
+ 'select id from plugin_dues_event where stripe_event_id = $1',
524
+ [event.stripeEventId],
525
+ )
526
+ if (existing === null) throw new Error('event insert lost a race it cannot lose')
527
+ return { id: Number(existing.id), first: false }
528
+ }
529
+
530
+ export async function markEventProcessed(
531
+ data: PluginData,
532
+ id: number,
533
+ outcome: string,
534
+ ): Promise<void> {
535
+ await data.query(
536
+ `update plugin_dues_event set processed_at = now(), outcome = $2 where id = $1`,
537
+ [id, outcome],
538
+ )
539
+ }
540
+
541
+ export async function markEventFailed(
542
+ data: PluginData,
543
+ id: number,
544
+ outcome: string,
545
+ ): Promise<void> {
546
+ await data.query(`update plugin_dues_event set outcome = $2 where id = $1`, [id, outcome])
547
+ }
548
+
549
+ export async function unprocessedEvents(
550
+ data: PluginData,
551
+ limit: number,
552
+ ): Promise<readonly EventRow[]> {
553
+ const rows = await data.query(
554
+ `select * from plugin_dues_event
555
+ where processed_at is null
556
+ order by received_at
557
+ limit $1`,
558
+ [limit],
559
+ )
560
+ return rows.map(eventRow)
561
+ }
562
+
563
+ export async function recentEvents(data: PluginData, limit = 20): Promise<readonly EventRow[]> {
564
+ const rows = await data.query(
565
+ `select * from plugin_dues_event order by received_at desc limit $1`,
566
+ [limit],
567
+ )
568
+ return rows.map(eventRow)
569
+ }
570
+
571
+ export interface NewLedgerEntry {
572
+ readonly kind: LedgerRow['kind']
573
+ readonly userId: number
574
+ readonly membershipId: number | null
575
+ readonly orderId: number | null
576
+ readonly amountMinor: number
577
+ readonly currency: string
578
+ readonly stripeRef: string | null
579
+ readonly note: string | null
580
+ }
581
+
582
+ export async function insertLedger(data: PluginData, entry: NewLedgerEntry): Promise<void> {
583
+ await data.query(
584
+ `insert into plugin_dues_ledger
585
+ (kind, user_id, membership_id, order_id, amount_minor, currency, stripe_ref, note)
586
+ values ($1, $2, $3, $4, $5, $6, $7, $8)`,
587
+ [
588
+ entry.kind,
589
+ entry.userId,
590
+ entry.membershipId,
591
+ entry.orderId,
592
+ entry.amountMinor,
593
+ entry.currency,
594
+ entry.stripeRef,
595
+ entry.note,
596
+ ],
597
+ )
598
+ }
599
+
600
+ export async function recentLedger(data: PluginData, limit = 50): Promise<readonly LedgerRow[]> {
601
+ const rows = await data.query(
602
+ `select * from plugin_dues_ledger order by occurred_at desc limit $1`,
603
+ [limit],
604
+ )
605
+ return rows.map(ledgerRow)
606
+ }
607
+
608
+ export interface MonthlyTotal {
609
+ readonly month: string
610
+ readonly currency: string
611
+ readonly grossMinor: number
612
+ readonly refundedMinor: number
613
+ readonly charges: number
614
+ }
615
+
616
+ export async function monthlyTotals(data: PluginData, months = 12): Promise<readonly MonthlyTotal[]> {
617
+ const rows = await data.query(
618
+ `select to_char(date_trunc('month', occurred_at), 'YYYY-MM') as month,
619
+ currency,
620
+ coalesce(sum(amount_minor) filter (where kind = 'charge'), 0)::bigint as gross_minor,
621
+ coalesce(-sum(amount_minor) filter (where kind in ('refund', 'chargeback')), 0)::bigint as refunded_minor,
622
+ count(*) filter (where kind = 'charge')::int as charges
623
+ from plugin_dues_ledger
624
+ group by 1, 2
625
+ order by 1 desc
626
+ limit $1`,
627
+ [months],
628
+ )
629
+ return rows.map((row) => ({
630
+ month: String(row.month),
631
+ currency: String(row.currency),
632
+ grossMinor: Number(row.gross_minor),
633
+ refundedMinor: Number(row.refunded_minor),
634
+ charges: Number(row.charges),
635
+ }))
636
+ }
637
+
638
+ export async function attentionCount(data: PluginData): Promise<number> {
639
+ const row = await data.one(
640
+ `select
641
+ (select count(*) from plugin_dues_membership where needs_attention is not null)::int
642
+ + (select count(*) from plugin_dues_order where needs_attention is not null)::int
643
+ as total`,
644
+ )
645
+ return row === null ? 0 : Number(row.total)
646
+ }
647
+
648
+ export async function ordersNeedingAttention(
649
+ data: PluginData,
650
+ limit = 50,
651
+ ): Promise<readonly OrderRow[]> {
652
+ const rows = await data.query(
653
+ `select * from plugin_dues_order
654
+ where needs_attention is not null
655
+ order by created_at desc
656
+ limit $1`,
657
+ [limit],
658
+ )
659
+ return rows.map(orderRow)
660
+ }
661
+
662
+ export async function clearMembershipAttention(data: PluginData, id: number): Promise<void> {
663
+ await data.query(
664
+ `update plugin_dues_membership set needs_attention = null, updated_at = now() where id = $1`,
665
+ [id],
666
+ )
667
+ }
668
+
669
+ export async function clearOrderAttention(data: PluginData, id: number): Promise<void> {
670
+ await data.query(`update plugin_dues_order set needs_attention = null where id = $1`, [id])
671
+ }
672
+
673
+ export interface CodeRow {
674
+ readonly id: number
675
+ readonly code: string
676
+ readonly percentOff: number
677
+ readonly planKey: string | null
678
+ readonly maxRedemptions: number | null
679
+ readonly redeemedCount: number
680
+ readonly expiresAt: Date | null
681
+ readonly disabled: boolean
682
+ readonly createdByUserId: number
683
+ readonly stripeCouponId: string | null
684
+ readonly createdAt: Date
685
+ }
686
+
687
+ function codeRow(row: Record<string, unknown>): CodeRow {
688
+ return {
689
+ id: Number(row.id),
690
+ code: String(row.code),
691
+ percentOff: Number(row.percent_off),
692
+ planKey: asNullableString(row.plan_key),
693
+ maxRedemptions:
694
+ row.max_redemptions === null || row.max_redemptions === undefined
695
+ ? null
696
+ : Number(row.max_redemptions),
697
+ redeemedCount: Number(row.redeemed_count),
698
+ expiresAt: asNullableDate(row.expires_at),
699
+ disabled: row.disabled === true,
700
+ createdByUserId: Number(row.created_by_user_id),
701
+ stripeCouponId: asNullableString(row.stripe_coupon_id),
702
+ createdAt: asDate(row.created_at),
703
+ }
704
+ }
705
+
706
+ export interface NewCode {
707
+ readonly code: string
708
+ readonly percentOff: number
709
+ readonly planKey: string | null
710
+ readonly maxRedemptions: number | null
711
+ readonly expiresAt: Date | null
712
+ readonly createdByUserId: number
713
+ }
714
+
715
+ export async function insertCode(
716
+ data: PluginData,
717
+ code: NewCode,
718
+ ): Promise<CodeRow | null> {
719
+ const row = await data.one(
720
+ `insert into plugin_dues_code
721
+ (code, percent_off, plan_key, max_redemptions, expires_at, created_by_user_id)
722
+ values ($1, $2, $3, $4, $5, $6)
723
+ on conflict ((lower(code))) do nothing
724
+ returning *`,
725
+ [
726
+ code.code,
727
+ code.percentOff,
728
+ code.planKey,
729
+ code.maxRedemptions,
730
+ code.expiresAt,
731
+ code.createdByUserId,
732
+ ],
733
+ )
734
+ return row === null ? null : codeRow(row)
735
+ }
736
+
737
+ export async function codeByCode(data: PluginData, code: string): Promise<CodeRow | null> {
738
+ const row = await data.one(
739
+ 'select * from plugin_dues_code where lower(code) = lower($1)',
740
+ [code],
741
+ )
742
+ return row === null ? null : codeRow(row)
743
+ }
744
+
745
+ export async function codeById(data: PluginData, id: number): Promise<CodeRow | null> {
746
+ const row = await data.one('select * from plugin_dues_code where id = $1', [id])
747
+ return row === null ? null : codeRow(row)
748
+ }
749
+
750
+ export async function listCodes(data: PluginData, limit = 100): Promise<readonly CodeRow[]> {
751
+ const rows = await data.query(
752
+ `select * from plugin_dues_code order by created_at desc limit $1`,
753
+ [limit],
754
+ )
755
+ return rows.map(codeRow)
756
+ }
757
+
758
+ export async function setCodeDisabled(
759
+ data: PluginData,
760
+ id: number,
761
+ disabled: boolean,
762
+ ): Promise<void> {
763
+ await data.query(`update plugin_dues_code set disabled = $2 where id = $1`, [id, disabled])
764
+ }
765
+
766
+ export async function saveCodeCoupon(
767
+ data: PluginData,
768
+ id: number,
769
+ stripeCouponId: string,
770
+ ): Promise<void> {
771
+ await data.query(
772
+ `update plugin_dues_code set stripe_coupon_id = $2 where id = $1 and stripe_coupon_id is null`,
773
+ [id, stripeCouponId],
774
+ )
775
+ }
776
+
777
+ export async function countCodeRedemption(data: PluginData, id: number): Promise<void> {
778
+ await data.query(
779
+ `update plugin_dues_code set redeemed_count = redeemed_count + 1 where id = $1`,
780
+ [id],
781
+ )
782
+ }
783
+
784
+ export interface PlanRow {
785
+ readonly id: number
786
+ readonly key: string
787
+ readonly name: string
788
+ readonly description: string | null
789
+ readonly groupKey: string
790
+ readonly priceMinor: number
791
+ readonly currency: string
792
+ readonly mode: PlanMode
793
+ readonly periodSpec: string | null
794
+ readonly billingInterval: 'month' | 'year' | null
795
+ readonly stripePriceId: string | null
796
+ readonly stripeProductId: string | null
797
+ readonly giftable: boolean
798
+ readonly hidden: boolean
799
+ readonly archived: boolean
800
+ readonly createdAt: Date
801
+ }
802
+
803
+ function planRow(row: Record<string, unknown>): PlanRow {
804
+ return {
805
+ id: Number(row.id),
806
+ key: String(row.plan_key),
807
+ name: String(row.name),
808
+ description: asNullableString(row.description),
809
+ groupKey: String(row.group_key),
810
+ priceMinor: Number(row.price_minor),
811
+ currency: String(row.currency),
812
+ mode: String(row.mode) as PlanMode,
813
+ periodSpec: asNullableString(row.period_spec),
814
+ billingInterval: asNullableString(row.billing_interval) as 'month' | 'year' | null,
815
+ stripePriceId: asNullableString(row.stripe_price_id),
816
+ stripeProductId: asNullableString(row.stripe_product_id),
817
+ giftable: row.giftable === true,
818
+ hidden: row.hidden === true,
819
+ archived: row.archived === true,
820
+ createdAt: asDate(row.created_at),
821
+ }
822
+ }
823
+
824
+ export interface NewPlan {
825
+ readonly planKey: string
826
+ readonly name: string
827
+ readonly description: string | null
828
+ readonly groupKey: string
829
+ readonly priceMinor: number
830
+ readonly currency: string
831
+ readonly mode: PlanMode
832
+ readonly periodSpec: string | null
833
+ readonly billingInterval: 'month' | 'year' | null
834
+ readonly stripePriceId?: string | null
835
+ readonly stripeProductId?: string | null
836
+ readonly giftable: boolean
837
+ readonly hidden: boolean
838
+ }
839
+
840
+ export async function insertPlan(data: PluginData, plan: NewPlan): Promise<PlanRow | null> {
841
+ const row = await data.one(
842
+ `insert into plugin_dues_plan
843
+ (plan_key, name, description, group_key, price_minor, currency, mode,
844
+ period_spec, billing_interval, stripe_price_id, stripe_product_id,
845
+ giftable, hidden)
846
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
847
+ on conflict (plan_key) do nothing
848
+ returning *`,
849
+ [
850
+ plan.planKey,
851
+ plan.name,
852
+ plan.description,
853
+ plan.groupKey,
854
+ plan.priceMinor,
855
+ plan.currency,
856
+ plan.mode,
857
+ plan.periodSpec,
858
+ plan.billingInterval,
859
+ plan.stripePriceId ?? null,
860
+ plan.stripeProductId ?? null,
861
+ plan.giftable,
862
+ plan.hidden,
863
+ ],
864
+ )
865
+ return row === null ? null : planRow(row)
866
+ }
867
+
868
+ export async function updatePlan(
869
+ data: PluginData,
870
+ id: number,
871
+ update: {
872
+ readonly name: string
873
+ readonly description: string | null
874
+ readonly groupKey: string
875
+ readonly priceMinor: number
876
+ readonly currency: string
877
+ readonly periodSpec: string | null
878
+ readonly billingInterval: 'month' | 'year' | null
879
+ readonly giftable: boolean
880
+ readonly hidden: boolean
881
+ },
882
+ ): Promise<void> {
883
+ await data.query(
884
+ `update plugin_dues_plan
885
+ set name = $2, description = $3, group_key = $4, price_minor = $5,
886
+ currency = $6, period_spec = $7, billing_interval = $8,
887
+ giftable = $9, hidden = $10, updated_at = now()
888
+ where id = $1`,
889
+ [
890
+ id,
891
+ update.name,
892
+ update.description,
893
+ update.groupKey,
894
+ update.priceMinor,
895
+ update.currency,
896
+ update.periodSpec,
897
+ update.billingInterval,
898
+ update.giftable,
899
+ update.hidden,
900
+ ],
901
+ )
902
+ }
903
+
904
+ export async function setPlanStripePrice(
905
+ data: PluginData,
906
+ id: number,
907
+ stripePriceId: string,
908
+ stripeProductId: string | null,
909
+ ): Promise<void> {
910
+ await data.query(
911
+ `update plugin_dues_plan
912
+ set stripe_price_id = $2,
913
+ stripe_product_id = coalesce($3, stripe_product_id),
914
+ updated_at = now()
915
+ where id = $1`,
916
+ [id, stripePriceId, stripeProductId],
917
+ )
918
+ }
919
+
920
+ export async function setPlanArchived(
921
+ data: PluginData,
922
+ id: number,
923
+ archived: boolean,
924
+ ): Promise<void> {
925
+ await data.query(
926
+ `update plugin_dues_plan set archived = $2, updated_at = now() where id = $1`,
927
+ [id, archived],
928
+ )
929
+ }
930
+
931
+ export async function listPlans(data: PluginData): Promise<readonly PlanRow[]> {
932
+ const rows = await data.query(
933
+ `select * from plugin_dues_plan order by archived, created_at, id`,
934
+ )
935
+ return rows.map(planRow)
936
+ }
937
+
938
+ export async function countPlans(data: PluginData): Promise<number> {
939
+ const row = await data.one('select count(*)::int as total from plugin_dues_plan')
940
+ return row === null ? 0 : Number(row.total)
941
+ }
942
+
943
+ export async function planRowByKey(data: PluginData, key: string): Promise<PlanRow | null> {
944
+ const row = await data.one('select * from plugin_dues_plan where plan_key = $1', [key])
945
+ return row === null ? null : planRow(row)
946
+ }
947
+
948
+ export async function planRowById(data: PluginData, id: number): Promise<PlanRow | null> {
949
+ const row = await data.one('select * from plugin_dues_plan where id = $1', [id])
950
+ return row === null ? null : planRow(row)
951
+ }
952
+
953
+ export async function longLiveMemberships(
954
+ data: PluginData,
955
+ horizon: Date,
956
+ limit = 200,
957
+ ): Promise<readonly MembershipRow[]> {
958
+ const rows = await data.query(
959
+ `select * from plugin_dues_membership
960
+ where status in ('active', 'grace', 'closing')
961
+ and grace_until > $1
962
+ limit $2`,
963
+ [horizon, limit],
964
+ )
965
+ return rows.map(membershipRow)
966
+ }