@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
|
@@ -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
|
+
}
|