@meith/plugin-dues 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,371 @@
1
+ import type { PluginGrants, PluginData, PluginNotify } from '@meith/plugin-kit'
2
+
3
+ import type { DuesConfig } from './config'
4
+ import { moneyMatches } from './money'
5
+ import { addBillingInterval, addDays, addPeriod, parsePeriod } from './period'
6
+ import { clampGrantUntil, isLifetime, LIFETIME_END } from './plans'
7
+ import {
8
+ countCodeRedemption,
9
+ extendMembership,
10
+ flagMembership,
11
+ insertLedger,
12
+ insertMembership,
13
+ liveMembership,
14
+ membershipBySubscription,
15
+ orderByPaymentIntent,
16
+ orderBySessionId,
17
+ planRowByKey,
18
+ setMembershipStatus,
19
+ settleOrder,
20
+ type MembershipRow,
21
+ type OrderRow,
22
+ type PlanRow,
23
+ } from './store'
24
+ import type { InternalEvent } from './stripe/events'
25
+
26
+ export interface EntitlementDeps {
27
+ readonly config: DuesConfig
28
+ readonly data: PluginData
29
+ readonly grants: PluginGrants
30
+ readonly notify: PluginNotify
31
+ readonly log: (message: string, detail?: Record<string, unknown>) => void
32
+ readonly now: () => Date
33
+ }
34
+
35
+ async function tryNotify(
36
+ deps: EntitlementDeps,
37
+ input: Parameters<PluginNotify['send']>[0],
38
+ ): Promise<void> {
39
+ try {
40
+ await deps.notify.send(input)
41
+ } catch (error) {
42
+ deps.log('dues: notification not sent', {
43
+ kind: input.kind,
44
+ reason: error instanceof Error ? error.message : String(error),
45
+ })
46
+ }
47
+ }
48
+
49
+ function grantReason(order: OrderRow): string {
50
+ const gift =
51
+ order.buyerUserId === order.recipientUserId
52
+ ? ''
53
+ : `, a gift from user ${order.buyerUserId}`
54
+ return `dues order ${order.id}: ${order.planName}${gift}`
55
+ }
56
+
57
+ export interface SettlementInfo {
58
+ readonly amountTotal: number | null
59
+ readonly currency: string | null
60
+ readonly subscriptionId: string | null
61
+ readonly paymentIntentId: string | null
62
+ }
63
+
64
+ export async function settlePaidOrder(
65
+ deps: EntitlementDeps,
66
+ order: OrderRow,
67
+ info: SettlementInfo,
68
+ ): Promise<string> {
69
+ if (order.status === 'paid') return 'already-settled'
70
+ if (order.status === 'failed' || order.status === 'cancelled') return `order-${order.status}`
71
+
72
+ if (!moneyMatches({ amountMinor: order.amountMinor, currency: order.currency }, {
73
+ amountMinor: info.amountTotal,
74
+ currency: info.currency,
75
+ })) {
76
+ await settleOrder(deps.data, order.id, {
77
+ status: 'pending',
78
+ needsAttention:
79
+ `paid ${String(info.amountTotal)} ${String(info.currency)} against an order for ` +
80
+ `${order.amountMinor} ${order.currency}`,
81
+ })
82
+ deps.log('dues: settlement amount mismatch', { orderId: order.id })
83
+ return 'amount-mismatch'
84
+ }
85
+
86
+ const plan = await planRowByKey(deps.data, order.planKey)
87
+ const now = deps.now()
88
+ const existing = await liveMembership(deps.data, order.recipientUserId, order.groupKey)
89
+
90
+ const periodEnd = settledPeriodEnd(order, plan, existing, now)
91
+ const graceUntil = isLifetime(periodEnd) ? periodEnd : addDays(periodEnd, deps.config.graceDays)
92
+
93
+ let membership: MembershipRow
94
+ if (existing === null) {
95
+ membership = await insertMembership(deps.data, {
96
+ userId: order.recipientUserId,
97
+ groupKey: order.groupKey,
98
+ planKey: order.planKey,
99
+ renewalMode: order.billingMode,
100
+ currentPeriodEnd: periodEnd,
101
+ graceUntil,
102
+ stripeSubscriptionId: info.subscriptionId,
103
+ lastOrderId: order.id,
104
+ })
105
+ } else {
106
+ await extendMembership(deps.data, existing.id, {
107
+ currentPeriodEnd: periodEnd,
108
+ graceUntil,
109
+ planKey: order.planKey,
110
+ ...(order.billingMode === 'lifetime' ? { renewalMode: 'lifetime' as const } : {}),
111
+ lastOrderId: order.id,
112
+ stripeSubscriptionId: info.subscriptionId,
113
+ })
114
+ membership = existing
115
+ }
116
+
117
+ let outcome = 'granted'
118
+ try {
119
+ await deps.grants.grant({
120
+ userId: order.recipientUserId,
121
+ groupKey: order.groupKey,
122
+ until: clampGrantUntil(graceUntil, now),
123
+ reason: grantReason(order),
124
+ })
125
+ } catch (error) {
126
+ const reason = error instanceof Error ? error.message : String(error)
127
+ await flagMembership(deps.data, membership.id, `grant refused: ${reason}`)
128
+ deps.log('dues: grant refused after payment', { orderId: order.id, reason })
129
+ outcome = 'paid-but-grant-refused'
130
+ }
131
+
132
+ const notes = [
133
+ ...(order.buyerUserId === order.recipientUserId
134
+ ? []
135
+ : [`gift for user ${order.recipientUserId}`]),
136
+ ...(order.discountMinor > 0 ? [`code took ${order.discountMinor} off`] : []),
137
+ ]
138
+
139
+ await insertLedger(deps.data, {
140
+ kind: 'charge',
141
+ userId: order.buyerUserId,
142
+ membershipId: membership.id,
143
+ orderId: order.id,
144
+ amountMinor: order.amountMinor,
145
+ currency: order.currency,
146
+ stripeRef: info.paymentIntentId ?? info.subscriptionId,
147
+ note: notes.length === 0 ? null : notes.join('; '),
148
+ })
149
+
150
+ await settleOrder(deps.data, order.id, {
151
+ status: 'paid',
152
+ stripeSubscriptionId: info.subscriptionId,
153
+ stripePaymentIntentId: info.paymentIntentId,
154
+ })
155
+
156
+ if (order.codeId !== null) await countCodeRedemption(deps.data, order.codeId)
157
+
158
+ if (order.buyerUserId !== order.recipientUserId && outcome === 'granted') {
159
+ await tryNotify(deps, {
160
+ userId: order.recipientUserId,
161
+ kind: 'gift_received',
162
+ subject: `Somebody bought you ${order.planName}`,
163
+ body: 'It starts this moment — nothing to claim, nothing to do.',
164
+ href: '/plugins/dues/manage',
165
+ dedupeKey: `gift:${order.id}`,
166
+ })
167
+ }
168
+
169
+ return outcome
170
+ }
171
+
172
+ function settledPeriodEnd(
173
+ order: OrderRow,
174
+ plan: PlanRow | null,
175
+ existing: MembershipRow | null,
176
+ now: Date,
177
+ ): Date {
178
+ if (order.billingMode === 'lifetime') return LIFETIME_END
179
+
180
+ if (order.billingMode === 'fixed') {
181
+ const base = existing !== null && existing.currentPeriodEnd > now ? existing.currentPeriodEnd : now
182
+ if (isLifetime(base)) return LIFETIME_END
183
+ const parsed =
184
+ (plan?.periodSpec !== null && plan?.periodSpec !== undefined
185
+ ? parsePeriod(plan.periodSpec)
186
+ : null) ?? parseStoredPeriod(order)
187
+ return addPeriod(base, parsed)
188
+ }
189
+
190
+ return addBillingInterval(now, plan?.billingInterval ?? 'month')
191
+ }
192
+
193
+ function parseStoredPeriod(order: OrderRow) {
194
+ const fallback = { years: 0, months: 0, weeks: 0, days: 30 }
195
+ if (order.periodSpec === null) return fallback
196
+ const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?$/.exec(order.periodSpec)
197
+ if (match === null) return fallback
198
+ return {
199
+ years: Number(match[1] ?? 0),
200
+ months: Number(match[2] ?? 0),
201
+ weeks: Number(match[3] ?? 0),
202
+ days: Number(match[4] ?? 0),
203
+ }
204
+ }
205
+
206
+ export async function applyInternalEvent(
207
+ deps: EntitlementDeps,
208
+ event: InternalEvent,
209
+ ): Promise<string> {
210
+ switch (event.kind) {
211
+ case 'checkout-settled': {
212
+ const order = await orderBySessionId(deps.data, event.sessionId)
213
+ if (order === null) return 'unmatched-session'
214
+ if (!event.paid) return 'awaiting-async-payment'
215
+ return settlePaidOrder(deps, order, event)
216
+ }
217
+
218
+ case 'checkout-failed': {
219
+ const order = await orderBySessionId(deps.data, event.sessionId)
220
+ if (order === null) return 'unmatched-session'
221
+ if (order.status === 'paid') return 'already-settled'
222
+ await settleOrder(deps.data, order.id, { status: 'failed' })
223
+ return 'order-failed'
224
+ }
225
+
226
+ case 'checkout-expired': {
227
+ const order = await orderBySessionId(deps.data, event.sessionId)
228
+ if (order === null) return 'unmatched-session'
229
+ if (order.status !== 'created' && order.status !== 'pending') return 'already-settled'
230
+ await settleOrder(deps.data, order.id, { status: 'cancelled' })
231
+ return 'order-expired'
232
+ }
233
+
234
+ case 'invoice-paid': {
235
+ if (event.subscriptionId === null) return 'no-subscription'
236
+ const membership = await membershipBySubscription(deps.data, event.subscriptionId)
237
+ if (membership === null) {
238
+ return 'no-membership-yet'
239
+ }
240
+ if (event.billingReason === 'subscription_create') return 'covered-by-checkout'
241
+
242
+ const periodEnd =
243
+ event.periodEnd ?? addBillingInterval(membership.currentPeriodEnd, 'month')
244
+ const graceUntil = addDays(periodEnd, deps.config.graceDays)
245
+
246
+ await extendMembership(deps.data, membership.id, {
247
+ currentPeriodEnd: periodEnd,
248
+ graceUntil,
249
+ })
250
+
251
+ let outcome = 'renewed'
252
+ try {
253
+ await deps.grants.grant({
254
+ userId: membership.userId,
255
+ groupKey: membership.groupKey,
256
+ until: clampGrantUntil(graceUntil, deps.now()),
257
+ reason: `dues renewal of subscription ${event.subscriptionId}`,
258
+ })
259
+ } catch (error) {
260
+ const reason = error instanceof Error ? error.message : String(error)
261
+ await flagMembership(deps.data, membership.id, `renewal grant refused: ${reason}`)
262
+ deps.log('dues: renewal grant refused', { membershipId: membership.id, reason })
263
+ outcome = 'renewed-but-grant-refused'
264
+ }
265
+
266
+ await insertLedger(deps.data, {
267
+ kind: 'charge',
268
+ userId: membership.userId,
269
+ membershipId: membership.id,
270
+ orderId: membership.lastOrderId,
271
+ amountMinor: event.amountPaid,
272
+ currency: event.currency ?? deps.config.currency,
273
+ stripeRef: event.subscriptionId,
274
+ note: 'renewal',
275
+ })
276
+ return outcome
277
+ }
278
+
279
+ case 'invoice-failed': {
280
+ if (event.subscriptionId === null) return 'no-subscription'
281
+ const membership = await membershipBySubscription(deps.data, event.subscriptionId)
282
+ if (membership === null) return 'no-membership'
283
+ if (membership.status === 'active') {
284
+ await setMembershipStatus(deps.data, membership.id, 'grace')
285
+ await tryNotify(deps, {
286
+ userId: membership.userId,
287
+ kind: 'renewal_trouble',
288
+ subject: 'A membership payment did not go through',
289
+ body:
290
+ 'Stripe retries on its own. Your access holds during the grace window, ' +
291
+ 'and updating your card from the manage page usually settles it.',
292
+ href: '/plugins/dues/manage',
293
+ dedupeKey: `grace:${membership.id}`,
294
+ })
295
+ }
296
+ return 'grace'
297
+ }
298
+
299
+ case 'subscription-updated': {
300
+ const membership = await membershipBySubscription(deps.data, event.subscriptionId)
301
+ if (membership === null) return 'no-membership'
302
+
303
+ if (event.periodEnd !== null && membership.status !== 'revoked') {
304
+ await extendMembership(deps.data, membership.id, {
305
+ currentPeriodEnd: event.periodEnd,
306
+ graceUntil: addDays(event.periodEnd, deps.config.graceDays),
307
+ })
308
+ }
309
+ if (event.cancelAtPeriodEnd && membership.status !== 'revoked') {
310
+ await setMembershipStatus(deps.data, membership.id, 'closing')
311
+ return 'closing'
312
+ }
313
+ if (!event.cancelAtPeriodEnd && membership.status === 'closing') {
314
+ await setMembershipStatus(deps.data, membership.id, 'active')
315
+ return 'reopened'
316
+ }
317
+ return 'noted'
318
+ }
319
+
320
+ case 'subscription-deleted': {
321
+ const membership = await membershipBySubscription(deps.data, event.subscriptionId)
322
+ if (membership === null) return 'no-membership'
323
+ if (membership.status === 'active' || membership.status === 'grace') {
324
+ await setMembershipStatus(deps.data, membership.id, 'closing')
325
+ }
326
+ return 'closing'
327
+ }
328
+
329
+ case 'payment-reversed': {
330
+ if (event.paymentIntentId === null) return 'unmatched-reversal'
331
+ const order = await orderByPaymentIntent(deps.data, event.paymentIntentId)
332
+ if (order === null) return 'unmatched-reversal'
333
+
334
+ const membership = await liveMembership(
335
+ deps.data,
336
+ order.recipientUserId,
337
+ order.groupKey,
338
+ )
339
+ if (membership !== null) {
340
+ await setMembershipStatus(deps.data, membership.id, 'revoked')
341
+ try {
342
+ await deps.grants.revoke({
343
+ userId: order.recipientUserId,
344
+ groupKey: order.groupKey,
345
+ reason: `dues ${event.reversal} on order ${order.id}`,
346
+ })
347
+ } catch (error) {
348
+ deps.log('dues: revoke after reversal failed', {
349
+ orderId: order.id,
350
+ reason: error instanceof Error ? error.message : String(error),
351
+ })
352
+ }
353
+ }
354
+
355
+ await insertLedger(deps.data, {
356
+ kind: event.reversal,
357
+ userId: order.buyerUserId,
358
+ membershipId: membership?.id ?? null,
359
+ orderId: order.id,
360
+ amountMinor: -Math.abs(event.amountMinor),
361
+ currency: event.currency ?? order.currency,
362
+ stripeRef: event.stripeRef,
363
+ note: event.reversal === 'chargeback' ? 'dispute opened' : null,
364
+ })
365
+ return `revoked-${event.reversal}`
366
+ }
367
+
368
+ case 'unhandled':
369
+ return `ignored:${event.type}`
370
+ }
371
+ }