@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/LICENSE.md +165 -0
- package/README.md +182 -0
- package/package.json +31 -0
- package/src/codes.ts +47 -0
- package/src/config.ts +153 -0
- package/src/definition.tsx +292 -0
- package/src/demo.ts +526 -0
- package/src/entitlement.ts +371 -0
- package/src/handlers-admin.ts +405 -0
- package/src/handlers.ts +397 -0
- package/src/index.ts +18 -0
- package/src/money.ts +42 -0
- package/src/period.ts +76 -0
- package/src/plans.ts +183 -0
- package/src/schema.ts +153 -0
- package/src/store.ts +966 -0
- package/src/stripe/client.ts +303 -0
- package/src/stripe/events.ts +189 -0
- package/src/stripe/webhook.ts +64 -0
- package/src/tasks.ts +156 -0
- package/src/ui/admin.tsx +925 -0
- package/src/ui/pages.tsx +557 -0
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
|
+
}
|