@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,397 @@
1
+ import type {
2
+ PluginRequest,
3
+ PluginResponse,
4
+ PluginRuntimeContext,
5
+ } from '@meith/plugin-kit'
6
+
7
+ import { codeProblem, discountedPrice, normalizeCode } from './codes'
8
+ import type { DuesConfig } from './config'
9
+ import { isLifetime, sellablePlanByKey } from './plans'
10
+ import { applyInternalEvent, settlePaidOrder, type EntitlementDeps } from './entitlement'
11
+ import {
12
+ attachCheckoutSession,
13
+ codeByCode,
14
+ findStripeCustomer,
15
+ insertOrder,
16
+ liveMembership,
17
+ membershipById,
18
+ recordEvent,
19
+ markEventFailed,
20
+ markEventProcessed,
21
+ saveCodeCoupon,
22
+ saveStripeCustomer,
23
+ setMembershipStatus,
24
+ type CodeRow,
25
+ } from './store'
26
+ import { createStripeClient, StripeError, type StripeClient } from './stripe/client'
27
+ import { parseEventEnvelope, toInternalEvent } from './stripe/events'
28
+ import { verifyStripeSignature } from './stripe/webhook'
29
+
30
+ export interface DuesServices {
31
+ readonly config: DuesConfig
32
+ readonly context: PluginRuntimeContext
33
+ readonly stripe: StripeClient | null
34
+ readonly webhookSecret: string
35
+ readonly now: () => Date
36
+ }
37
+
38
+ export function buildServices(
39
+ config: DuesConfig,
40
+ context: PluginRuntimeContext,
41
+ overrides: { readonly stripe?: StripeClient | null; readonly now?: () => Date } = {},
42
+ ): DuesServices {
43
+ const secretKey = String(context.settings.stripe_secret_key ?? '')
44
+ const stripe =
45
+ overrides.stripe !== undefined
46
+ ? overrides.stripe
47
+ : secretKey === ''
48
+ ? null
49
+ : createStripeClient({
50
+ secretKey,
51
+ apiVersion: String(context.settings.stripe_api_version ?? ''),
52
+ ...(String(context.settings.stripe_api_base ?? '') === ''
53
+ ? {}
54
+ : { apiBase: String(context.settings.stripe_api_base) }),
55
+ })
56
+
57
+ return {
58
+ config,
59
+ context,
60
+ stripe,
61
+ webhookSecret: String(context.settings.stripe_webhook_secret ?? ''),
62
+ now: overrides.now ?? (() => new Date()),
63
+ }
64
+ }
65
+
66
+ export function entitlementDeps(services: DuesServices): EntitlementDeps {
67
+ return {
68
+ config: services.config,
69
+ data: services.context.data,
70
+ grants: services.context.grants,
71
+ notify: services.context.notify,
72
+ log: (message, detail) => services.context.logger.warn(message, detail),
73
+ now: services.now,
74
+ }
75
+ }
76
+
77
+ export function requestOrigin(request: PluginRequest): string {
78
+ if (request.boardUrl !== '') return request.boardUrl
79
+ const host = request.headers['x-forwarded-host'] ?? request.headers.host ?? 'localhost'
80
+ const loopback = host.startsWith('127.0.0.1') || host.startsWith('localhost')
81
+ const proto = request.headers['x-forwarded-proto'] ?? (loopback ? 'http' : 'https')
82
+ return `${proto}://${host}`
83
+ }
84
+
85
+ function back(query: Record<string, string>): PluginResponse {
86
+ const params = new URLSearchParams(query).toString()
87
+ return { kind: 'redirect', to: `/plugins/dues${params === '' ? '' : `?${params}`}` }
88
+ }
89
+
90
+ function offsite(to: string): PluginResponse {
91
+ return { kind: 'redirect', to: `/plugins/dues/go?to=${encodeURIComponent(to)}` }
92
+ }
93
+
94
+ function backToManage(query: Record<string, string>): PluginResponse {
95
+ const params = new URLSearchParams(query).toString()
96
+ return { kind: 'redirect', to: `/plugins/dues/manage${params === '' ? '' : `?${params}`}` }
97
+ }
98
+
99
+ const REUSABLE_CHECKOUT_MS = 25 * 60 * 1000
100
+
101
+ export async function handleCheckout(
102
+ services: DuesServices,
103
+ request: PluginRequest,
104
+ ): Promise<PluginResponse> {
105
+ const buyerId = request.viewer.userId
106
+ if (buyerId === null) return back({ error: 'sign-in' })
107
+
108
+ const planKey = request.form?.plan ?? ''
109
+ const plan = await sellablePlanByKey(services.context.data, services.config, planKey)
110
+ if (plan === null || plan.hidden) return back({ error: 'unknown-plan' })
111
+
112
+ if (plan.mode === 'auto' && plan.stripePriceId === null) {
113
+ return back({ error: 'plan-not-ready', plan: plan.key })
114
+ }
115
+
116
+ const recipientInput = (request.form?.recipient ?? '').trim()
117
+ let recipientId = buyerId
118
+ let recipientName: string | null = null
119
+ if (recipientInput !== '') {
120
+ const found = await services.context.users.byUsername(recipientInput)
121
+ if (found === null) {
122
+ return back({ error: 'unknown-recipient', plan: plan.key, recipient: recipientInput })
123
+ }
124
+ recipientId = found.userId
125
+ recipientName = found.username
126
+ }
127
+ const isGift = recipientId !== buyerId
128
+
129
+ if (isGift && !plan.giftable) return back({ error: 'gift-not-allowed', plan: plan.key })
130
+
131
+ const held = await liveMembership(services.context.data, recipientId, plan.groupKey)
132
+ if (held !== null) {
133
+ if (isLifetime(held.currentPeriodEnd)) {
134
+ return back({ error: 'already-forever', plan: plan.key })
135
+ }
136
+ if (plan.mode === 'auto') return back({ error: 'already-member', plan: plan.key })
137
+ if (plan.mode === 'lifetime' && held.renewalMode === 'auto') {
138
+ return back({ error: 'cancel-first', plan: plan.key })
139
+ }
140
+ }
141
+
142
+ const now = services.now()
143
+
144
+ const codeInput = normalizeCode(request.form?.code ?? '')
145
+ let code: CodeRow | null = null
146
+ if (codeInput !== '') {
147
+ const bounce = { plan: plan.key, code: codeInput, recipient: recipientInput }
148
+ code = await codeByCode(services.context.data, codeInput)
149
+ if (code === null) return back({ error: 'unknown-code', ...bounce })
150
+ const problem = codeProblem(code, plan.key, now)
151
+ if (problem !== null) return back({ error: `code-${problem}`, ...bounce })
152
+ }
153
+
154
+ const charge =
155
+ code === null ? plan.priceMinor : discountedPrice(plan.priceMinor, code.percentOff)
156
+
157
+ if (services.stripe === null && (plan.mode === 'auto' || charge > 0)) {
158
+ return back({ error: 'unconfigured' })
159
+ }
160
+
161
+ const minuteBucket = Math.floor(now.getTime() / 60_000)
162
+ const newOrder = (idempotencyKey: string) =>
163
+ insertOrder(services.context.data, {
164
+ buyerUserId: buyerId,
165
+ recipientUserId: recipientId,
166
+ planKey: plan.key,
167
+ planName: plan.name,
168
+ groupKey: plan.groupKey,
169
+ amountMinor: charge,
170
+ currency: plan.currency,
171
+ billingMode: plan.mode,
172
+ periodSpec: plan.mode === 'fixed' ? plan.periodSpec : null,
173
+ idempotencyKey,
174
+ codeId: code?.id ?? null,
175
+ discountMinor: plan.priceMinor - charge,
176
+ })
177
+
178
+ const baseKey = `co:${buyerId}:${plan.key}:${recipientId}:${code?.id ?? 0}:${minuteBucket}`
179
+ let { order, created } = await newOrder(baseKey)
180
+
181
+ if (!created && order.status !== 'created' && order.status !== 'pending') {
182
+ ;({ order, created } = await newOrder(`${baseKey}:${order.id}`))
183
+ }
184
+
185
+ if (!created) {
186
+ if (
187
+ order.checkoutUrl !== null &&
188
+ now.getTime() - order.createdAt.getTime() < REUSABLE_CHECKOUT_MS
189
+ ) {
190
+ return offsite(order.checkoutUrl)
191
+ }
192
+ return back({ error: 'try-again', plan: plan.key })
193
+ }
194
+
195
+ if (charge === 0 && plan.mode !== 'auto') {
196
+ await settlePaidOrder(entitlementDeps(services), order, {
197
+ amountTotal: 0,
198
+ currency: plan.currency,
199
+ subscriptionId: null,
200
+ paymentIntentId: null,
201
+ })
202
+ return { kind: 'redirect', to: `/plugins/dues/return?order=${order.id}` }
203
+ }
204
+
205
+ if (services.stripe === null) return back({ error: 'unconfigured' })
206
+ const stripe = services.stripe
207
+
208
+ try {
209
+ let customerId = await findStripeCustomer(services.context.data, buyerId)
210
+ if (customerId === null) {
211
+ const customer = await stripe.createCustomer({
212
+ metadata: { meith_user_id: String(buyerId) },
213
+ })
214
+ await saveStripeCustomer(services.context.data, buyerId, customer.id)
215
+ customerId = customer.id
216
+ }
217
+
218
+ const origin = requestOrigin(request)
219
+ const productName =
220
+ recipientName === null ? plan.name : `${plan.name} — a gift for ${recipientName}`
221
+
222
+ let couponId: string | null = null
223
+ if (code !== null && plan.mode === 'auto') {
224
+ couponId = code.stripeCouponId
225
+ if (couponId === null) {
226
+ const coupon = await stripe.createCoupon({
227
+ percentOff: code.percentOff,
228
+ name: `Dues code ${code.code}`,
229
+ reference: String(code.id),
230
+ })
231
+ await saveCodeCoupon(services.context.data, code.id, coupon.id)
232
+ couponId = coupon.id
233
+ }
234
+ }
235
+
236
+ const session = await stripe.createCheckoutSession({
237
+ mode: plan.mode === 'auto' ? 'subscription' : 'payment',
238
+ customer: customerId,
239
+ client_reference_id: String(order.id),
240
+ line_items: [
241
+ plan.mode === 'auto'
242
+ ? { price: plan.stripePriceId, quantity: 1 }
243
+ : {
244
+ quantity: 1,
245
+ price_data: {
246
+ currency: plan.currency,
247
+ unit_amount: charge,
248
+ product_data: { name: productName },
249
+ },
250
+ },
251
+ ],
252
+ metadata: { dues_order_id: String(order.id) },
253
+ ...(plan.mode === 'auto'
254
+ ? { subscription_data: { metadata: { dues_order_id: String(order.id) } } }
255
+ : {}),
256
+ ...(couponId === null ? {} : { discounts: [{ coupon: couponId }] }),
257
+ success_url: `${origin}/plugins/dues/return?order=${order.id}`,
258
+ cancel_url: `${origin}/plugins/dues?cancelled=1`,
259
+ })
260
+
261
+ await attachCheckoutSession(services.context.data, order.id, {
262
+ id: session.id,
263
+ url: session.url,
264
+ })
265
+
266
+ if (session.url === null) return back({ error: 'stripe-error' })
267
+ return offsite(session.url)
268
+ } catch (error) {
269
+ services.context.logger.error('dues: could not start checkout', {
270
+ orderId: order.id,
271
+ message: error instanceof Error ? error.message : String(error),
272
+ ...(error instanceof StripeError ? { code: error.code, status: error.status } : {}),
273
+ })
274
+ return back({ error: 'stripe-error', plan: plan.key })
275
+ }
276
+ }
277
+
278
+ export async function handleWebhook(
279
+ services: DuesServices,
280
+ request: PluginRequest,
281
+ ): Promise<PluginResponse> {
282
+ if (services.webhookSecret === '') {
283
+ return {
284
+ kind: 'json',
285
+ status: 503,
286
+ body: { error: 'the webhook signing secret is not configured' },
287
+ }
288
+ }
289
+ if (request.rawBody === null) {
290
+ return { kind: 'json', status: 400, body: { error: 'no body' } }
291
+ }
292
+
293
+ const verdict = verifyStripeSignature(
294
+ request.rawBody,
295
+ request.headers['stripe-signature'],
296
+ services.webhookSecret,
297
+ services.now(),
298
+ )
299
+ if (!verdict.ok) {
300
+ return { kind: 'json', status: 400, body: { error: `signature ${verdict.reason}` } }
301
+ }
302
+
303
+ let payload: unknown
304
+ try {
305
+ payload = JSON.parse(new TextDecoder().decode(request.rawBody))
306
+ } catch {
307
+ return { kind: 'json', status: 400, body: { error: 'unparseable body' } }
308
+ }
309
+
310
+ const envelope = parseEventEnvelope(payload)
311
+ if (envelope === null) {
312
+ return { kind: 'json', status: 400, body: { error: 'not an event envelope' } }
313
+ }
314
+
315
+ const recorded = await recordEvent(services.context.data, {
316
+ stripeEventId: envelope.id,
317
+ type: envelope.type,
318
+ payload,
319
+ })
320
+ if (!recorded.first) {
321
+ return { kind: 'json', status: 200, body: { received: true, outcome: 'replay' } }
322
+ }
323
+
324
+ try {
325
+ const outcome = await applyInternalEvent(entitlementDeps(services), toInternalEvent(envelope))
326
+ await markEventProcessed(services.context.data, recorded.id, outcome)
327
+ return { kind: 'json', status: 200, body: { received: true, outcome } }
328
+ } catch (error) {
329
+ const message = error instanceof Error ? error.message : String(error)
330
+ await markEventFailed(services.context.data, recorded.id, `failed: ${message}`)
331
+ services.context.logger.error('dues: webhook processing failed', {
332
+ eventId: envelope.id,
333
+ message,
334
+ })
335
+ return { kind: 'json', status: 200, body: { received: true, outcome: 'deferred' } }
336
+ }
337
+ }
338
+
339
+ export async function handlePortal(
340
+ services: DuesServices,
341
+ request: PluginRequest,
342
+ ): Promise<PluginResponse> {
343
+ const userId = request.viewer.userId
344
+ if (userId === null) return backToManage({ error: 'sign-in' })
345
+ if (services.stripe === null) return backToManage({ error: 'unconfigured' })
346
+
347
+ const customerId = await findStripeCustomer(services.context.data, userId)
348
+ if (customerId === null) return backToManage({ error: 'no-customer' })
349
+
350
+ try {
351
+ const portal = await services.stripe.createBillingPortalSession({
352
+ customer: customerId,
353
+ returnUrl: `${requestOrigin(request)}/plugins/dues/manage`,
354
+ })
355
+ return offsite(portal.url)
356
+ } catch (error) {
357
+ services.context.logger.error('dues: could not open the billing portal', {
358
+ message: error instanceof Error ? error.message : String(error),
359
+ })
360
+ return backToManage({ error: 'stripe-error' })
361
+ }
362
+ }
363
+
364
+ export async function handleCancel(
365
+ services: DuesServices,
366
+ request: PluginRequest,
367
+ ): Promise<PluginResponse> {
368
+ const userId = request.viewer.userId
369
+ if (userId === null) return backToManage({ error: 'sign-in' })
370
+
371
+ const membershipId = Number(request.form?.membership ?? '')
372
+ if (!Number.isSafeInteger(membershipId) || membershipId <= 0) {
373
+ return backToManage({ error: 'cancel-failed' })
374
+ }
375
+
376
+ const membership = await membershipById(services.context.data, membershipId)
377
+ if (membership === null || membership.userId !== userId) {
378
+ return backToManage({ error: 'cancel-failed' })
379
+ }
380
+ if (membership.renewalMode !== 'auto' || membership.stripeSubscriptionId === null) {
381
+ return backToManage({ error: 'cancel-failed' })
382
+ }
383
+ if (services.stripe === null) return backToManage({ error: 'unconfigured' })
384
+
385
+ try {
386
+ await services.stripe.setCancelAtPeriodEnd(membership.stripeSubscriptionId, true)
387
+ } catch (error) {
388
+ services.context.logger.error('dues: cancel at period end failed', {
389
+ membershipId,
390
+ message: error instanceof Error ? error.message : String(error),
391
+ })
392
+ return backToManage({ error: 'stripe-error' })
393
+ }
394
+
395
+ await setMembershipStatus(services.context.data, membership.id, 'closing')
396
+ return backToManage({ cancelled: '1' })
397
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export { dues } from './definition'
2
+ export type { DuesConfigInput, DuesPlanInput } from './config'
3
+
4
+ export { SUBSCRIBED_EVENT_TYPES } from './stripe/events'
5
+ export { signStripePayload } from './stripe/webhook'
6
+
7
+ export {
8
+ DUES_DEMO_CODES,
9
+ DUES_DEMO_CURRENCY,
10
+ DUES_DEMO_GRACE_DAYS,
11
+ DUES_DEMO_GROUP,
12
+ DUES_DEMO_PLANS,
13
+ DUES_DEMO_PRICES,
14
+ seedDuesDemo,
15
+ type DuesDemoCast,
16
+ type DuesDemoDeps,
17
+ type DuesDemoSummary,
18
+ } from './demo'
package/src/money.ts ADDED
@@ -0,0 +1,42 @@
1
+
2
+ const ZERO_DECIMAL = new Set([
3
+ 'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga',
4
+ 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
5
+ ])
6
+
7
+ export function isZeroDecimal(currency: string): boolean {
8
+ return ZERO_DECIMAL.has(currency.toLowerCase())
9
+ }
10
+
11
+ export function isCurrencyCode(value: string): boolean {
12
+ return /^[A-Za-z]{3}$/.test(value)
13
+ }
14
+
15
+ export function isValidMinorAmount(value: unknown): value is number {
16
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
17
+ }
18
+
19
+ export function formatMinor(amountMinor: number, currency: string): string {
20
+ const upper = currency.toUpperCase()
21
+ const amount = isZeroDecimal(currency) ? amountMinor : amountMinor / 100
22
+
23
+ try {
24
+ return new Intl.NumberFormat('en', {
25
+ style: 'currency',
26
+ currency: upper,
27
+ minimumFractionDigits: isZeroDecimal(currency) ? 0 : 2,
28
+ }).format(amount)
29
+ } catch {
30
+ return `${amount.toFixed(isZeroDecimal(currency) ? 0 : 2)} ${upper}`
31
+ }
32
+ }
33
+
34
+ export function moneyMatches(
35
+ expected: { readonly amountMinor: number; readonly currency: string },
36
+ actual: { readonly amountMinor: number | null; readonly currency: string | null },
37
+ ): boolean {
38
+ return (
39
+ actual.amountMinor === expected.amountMinor &&
40
+ (actual.currency ?? '').toLowerCase() === expected.currency.toLowerCase()
41
+ )
42
+ }
package/src/period.ts ADDED
@@ -0,0 +1,76 @@
1
+
2
+ export interface Period {
3
+ readonly years: number
4
+ readonly months: number
5
+ readonly weeks: number
6
+ readonly days: number
7
+ }
8
+
9
+ const PATTERN = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?$/
10
+
11
+ export function parsePeriod(spec: string): Period | null {
12
+ const match = PATTERN.exec(spec)
13
+ if (match === null) return null
14
+
15
+ const [, years, months, weeks, days] = match
16
+ const period = {
17
+ years: Number(years ?? 0),
18
+ months: Number(months ?? 0),
19
+ weeks: Number(weeks ?? 0),
20
+ days: Number(days ?? 0),
21
+ }
22
+
23
+ const total = period.years + period.months + period.weeks + period.days
24
+ return total === 0 ? null : period
25
+ }
26
+
27
+ export function periodCeilingDays(period: Period): number {
28
+ return period.years * 366 + period.months * 31 + period.weeks * 7 + period.days
29
+ }
30
+
31
+ const DAY_MS = 86_400_000
32
+
33
+ export function addPeriod(from: Date, period: Period): Date {
34
+ let out = new Date(from.getTime())
35
+
36
+ const months = period.years * 12 + period.months
37
+ if (months > 0) {
38
+ const dayOfMonth = out.getUTCDate()
39
+ out.setUTCDate(1)
40
+ out.setUTCMonth(out.getUTCMonth() + months)
41
+ const lastDay = new Date(
42
+ Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0),
43
+ ).getUTCDate()
44
+ out.setUTCDate(Math.min(dayOfMonth, lastDay))
45
+ }
46
+
47
+ const days = period.weeks * 7 + period.days
48
+ if (days > 0) out = new Date(out.getTime() + days * DAY_MS)
49
+
50
+ return out
51
+ }
52
+
53
+ export function addBillingInterval(from: Date, interval: 'month' | 'year'): Date {
54
+ return addPeriod(from, {
55
+ years: interval === 'year' ? 1 : 0,
56
+ months: interval === 'month' ? 1 : 0,
57
+ weeks: 0,
58
+ days: 0,
59
+ })
60
+ }
61
+
62
+ export function addDays(from: Date, days: number): Date {
63
+ return new Date(from.getTime() + days * DAY_MS)
64
+ }
65
+
66
+ export function describePeriod(period: Period): string {
67
+ const parts: string[] = []
68
+ const piece = (count: number, word: string) => {
69
+ if (count > 0) parts.push(`${count} ${word}${count === 1 ? '' : 's'}`)
70
+ }
71
+ piece(period.years, 'year')
72
+ piece(period.months, 'month')
73
+ piece(period.weeks, 'week')
74
+ piece(period.days, 'day')
75
+ return parts.join(', ')
76
+ }