@meith/plugin-dues 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/plugin-dues",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Membership dues for a Meith board: Stripe-backed subscriptions as a contained plugin.",
5
5
  "license": "LGPL-3.0-or-later",
6
6
  "repository": {
@@ -20,7 +20,8 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "@meith/plugin-kit": "^0.7.0"
23
+ "@meith/plugin-kit": "^0.8.0",
24
+ "@meith/theme-kit": "^0.8.0"
24
25
  },
25
26
  "peerDependencies": {
26
27
  "react": "^19.2.0"
package/src/config.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isCurrencyCode, isValidMinorAmount } from './money'
2
- import { parsePeriod, periodCeilingDays, type Period } from './period'
2
+ import { type Period, parsePeriod, periodCeilingDays } from './period'
3
3
 
4
4
  export interface DuesPlanInput {
5
5
  readonly key: string
@@ -114,9 +114,7 @@ export function parseDuesConfig(input: DuesConfigInput): DuesConfig {
114
114
 
115
115
  const parsed = parsePeriod(plan.billing.period)
116
116
  if (parsed === null) {
117
- refuse(
118
- `${where}: "${plan.billing.period}" is not an ISO-8601 period like P90D, P1M or P1Y.`,
119
- )
117
+ refuse(`${where}: "${plan.billing.period}" is not an ISO-8601 period like P90D, P1M or P1Y.`)
120
118
  }
121
119
  if (periodCeilingDays(parsed) + graceDays > MAX_GRANTABLE_DAYS) {
122
120
  refuse(
@@ -1,14 +1,20 @@
1
- import { definePlugin, type PluginDefinition, type PluginRequest, type PluginResponse, type PluginRuntimeContext } from '@meith/plugin-kit'
1
+ import {
2
+ definePlugin,
3
+ type PluginDefinition,
4
+ type PluginRequest,
5
+ type PluginResponse,
6
+ type PluginRuntimeContext,
7
+ } from '@meith/plugin-kit'
2
8
 
3
- import { parseDuesConfig, type DuesConfigInput } from './config'
9
+ import { type DuesConfigInput, parseDuesConfig } from './config'
4
10
  import {
5
11
  buildServices,
12
+ type DuesServices,
6
13
  entitlementDeps,
7
14
  handleCancel,
8
15
  handleCheckout,
9
16
  handlePortal,
10
17
  handleWebhook,
11
- type DuesServices,
12
18
  } from './handlers'
13
19
  import {
14
20
  handleAdminCancel,
@@ -21,6 +27,7 @@ import {
21
27
  handleAdminPlanUpdate,
22
28
  handleAdminRevoke,
23
29
  } from './handlers-admin'
30
+ import en from './messages/en.json'
24
31
  import { DUES_MIGRATIONS } from './schema'
25
32
  import { runReconcile, runSweep } from './tasks'
26
33
  import { CodesPage, LedgerPage, MembersPage, PlansAdminPage, StatusPage } from './ui/admin'
@@ -39,49 +46,53 @@ export function dues(input: DuesConfigInput): PluginDefinition {
39
46
  return definePlugin({
40
47
  key: 'dues',
41
48
  name: 'Dues',
42
- version: '0.7.0',
43
- description:
44
- `Sells ${config.label.toLowerCase()} — time-limited membership of a group — ` +
45
- 'through Stripe, for yourself or as a gift.',
49
+ version: '0.8.0',
50
+ description: en['dues.definition.description'].replace('{label}', config.label.toLowerCase()),
51
+ descriptionKey: 'dues.definition.description',
52
+ descriptionArgs: { label: config.label.toLowerCase() },
46
53
  apiVersion: '0',
47
54
 
48
55
  settings: [
49
56
  {
50
57
  key: 'stripe_secret_key',
51
- label: 'Stripe secret key',
58
+ label: en['dues.definition.setting.secret.label'],
59
+ labelKey: 'dues.definition.setting.secret.label',
52
60
  type: 'secret',
53
61
  env: 'DUES_STRIPE_SECRET_KEY',
54
62
  required: true,
55
63
  default: '',
56
- description: 'The sk_live_… or sk_test_… key from the Stripe dashboard.',
64
+ description: en['dues.definition.setting.secret.description'],
65
+ descriptionKey: 'dues.definition.setting.secret.description',
57
66
  },
58
67
  {
59
68
  key: 'stripe_webhook_secret',
60
- label: 'Webhook signing secret',
69
+ label: en['dues.definition.setting.webhook.label'],
70
+ labelKey: 'dues.definition.setting.webhook.label',
61
71
  type: 'secret',
62
72
  env: 'DUES_STRIPE_WEBHOOK_SECRET',
63
73
  required: true,
64
74
  default: '',
65
- description:
66
- 'The whsec_… secret of the webhook endpoint. Without it, payments are taken ' +
67
- 'but can never confirm.',
75
+ description: en['dues.definition.setting.webhook.description'],
76
+ descriptionKey: 'dues.definition.setting.webhook.description',
68
77
  },
69
78
  {
70
79
  key: 'stripe_api_version',
71
- label: 'Stripe API version',
80
+ label: en['dues.definition.setting.apiVersion.label'],
81
+ labelKey: 'dues.definition.setting.apiVersion.label',
72
82
  default: '2024-12-18.acacia',
73
83
  advanced: true,
74
- description:
75
- 'Pinned so Stripe’s payload shapes change on a deploy, not on a Tuesday. ' +
76
- 'Change it only alongside a plugin upgrade that expects the new shapes.',
84
+ description: en['dues.definition.setting.apiVersion.description'],
85
+ descriptionKey: 'dues.definition.setting.apiVersion.description',
77
86
  },
78
87
  {
79
88
  key: 'stripe_api_base',
80
- label: 'Stripe API address',
89
+ label: en['dues.definition.setting.apiBase.label'],
90
+ labelKey: 'dues.definition.setting.apiBase.label',
81
91
  env: 'DUES_STRIPE_API_BASE',
82
92
  default: 'https://api.stripe.com',
83
93
  advanced: true,
84
- description: 'Only a stripe-mock or a test double ever changes this.',
94
+ description: en['dues.definition.setting.apiBase.description'],
95
+ descriptionKey: 'dues.definition.setting.apiBase.description',
85
96
  },
86
97
  ],
87
98
 
@@ -95,7 +106,10 @@ export function dues(input: DuesConfigInput): PluginDefinition {
95
106
  const services = buildServices(config, context)
96
107
  const result = await runReconcile(entitlementDeps(services), services.stripe)
97
108
  if (
98
- result.ordersSettled + result.ordersClosed + result.eventsReplayed + result.subscriptionsCorrected >
109
+ result.ordersSettled +
110
+ result.ordersClosed +
111
+ result.eventsReplayed +
112
+ result.subscriptionsCorrected >
99
113
  0
100
114
  ) {
101
115
  context.logger.info('dues: reconciled', { ...result })
@@ -116,13 +130,17 @@ export function dues(input: DuesConfigInput): PluginDefinition {
116
130
  notifications: [
117
131
  {
118
132
  key: 'gift_received',
119
- title: 'Somebody gifts you a membership',
120
- description: 'A member bought a membership in your name. It starts the moment the payment confirms.',
133
+ title: en['dues.definition.notification.gift.title'],
134
+ titleKey: 'dues.definition.notification.gift.title',
135
+ description: en['dues.definition.notification.gift.description'],
136
+ descriptionKey: 'dues.definition.notification.gift.description',
121
137
  },
122
138
  {
123
139
  key: 'renewal_trouble',
124
- title: 'A membership payment fails',
125
- description: 'A renewal did not go through. Access holds during the grace window while Stripe retries.',
140
+ title: en['dues.definition.notification.renewal.title'],
141
+ titleKey: 'dues.definition.notification.renewal.title',
142
+ description: en['dues.definition.notification.renewal.description'],
143
+ descriptionKey: 'dues.definition.notification.renewal.description',
126
144
  },
127
145
  ],
128
146
 
@@ -221,19 +239,23 @@ export function dues(input: DuesConfigInput): PluginDefinition {
221
239
  },
222
240
  {
223
241
  path: 'return',
224
- title: 'Confirming your payment',
242
+ title: en['dues.definition.page.return'],
243
+ titleKey: 'dues.definition.page.return',
225
244
  access: 'member',
226
245
  render: (context) => ReturnPage({ config, context }),
227
246
  },
228
247
  {
229
248
  path: 'manage',
230
- title: `Your ${config.label.toLowerCase()}`,
249
+ title: en['dues.definition.manage'].replace('{label}', config.label.toLowerCase()),
250
+ titleKey: 'dues.definition.manage',
251
+ titleArgs: { label: config.label.toLowerCase() },
231
252
  access: 'member',
232
253
  render: (context) => ManagePage({ config, context }),
233
254
  },
234
255
  {
235
256
  path: 'go',
236
- title: 'Over to Stripe',
257
+ title: en['dues.definition.go'],
258
+ titleKey: 'dues.definition.go',
237
259
  access: 'member',
238
260
  render: (context) =>
239
261
  GoPage({
@@ -251,27 +273,32 @@ export function dues(input: DuesConfigInput): PluginDefinition {
251
273
  adminPages: [
252
274
  {
253
275
  path: 'status',
254
- title: 'Status',
276
+ title: en['dues.definition.page.status'],
277
+ titleKey: 'dues.definition.page.status',
255
278
  render: (context) => StatusPage({ config, context }),
256
279
  },
257
280
  {
258
281
  path: 'plans',
259
- title: 'Plans',
282
+ title: en['dues.definition.page.plans'],
283
+ titleKey: 'dues.definition.page.plans',
260
284
  render: (context) => PlansAdminPage({ config, context }),
261
285
  },
262
286
  {
263
287
  path: 'members',
264
- title: 'Memberships',
288
+ title: en['dues.definition.page.members'],
289
+ titleKey: 'dues.definition.page.members',
265
290
  render: (context) => MembersPage({ context }),
266
291
  },
267
292
  {
268
293
  path: 'codes',
269
- title: 'Discount codes',
294
+ title: en['dues.definition.page.codes'],
295
+ titleKey: 'dues.definition.page.codes',
270
296
  render: (context) => CodesPage({ config, context }),
271
297
  },
272
298
  {
273
299
  path: 'ledger',
274
- title: 'Ledger',
300
+ title: en['dues.definition.page.ledger'],
301
+ titleKey: 'dues.definition.page.ledger',
275
302
  render: (context) => LedgerPage({ config, context }),
276
303
  },
277
304
  ],
package/src/demo.ts CHANGED
@@ -1,26 +1,26 @@
1
1
  import type { PluginData, PluginGrants, PluginNotify } from '@meith/plugin-kit'
2
2
 
3
3
  import { discountedPrice } from './codes'
4
- import { parseDuesConfig, type DuesConfig } from './config'
5
- import { applyInternalEvent, settlePaidOrder, type EntitlementDeps } from './entitlement'
4
+ import { type DuesConfig, parseDuesConfig } from './config'
5
+ import { applyInternalEvent, type EntitlementDeps, settlePaidOrder } from './entitlement'
6
6
  import { addDays } from './period'
7
7
  import {
8
8
  attachCheckoutSession,
9
+ type CodeRow,
9
10
  insertCode,
10
11
  insertOrder,
11
12
  insertPlan,
12
13
  markEventProcessed,
14
+ type NewPlan,
15
+ type OrderRow,
16
+ type PlanRow,
13
17
  planRowByKey,
14
18
  recordEvent,
15
19
  setCodeDisabled,
16
20
  setPlanArchived,
17
21
  settleOrder,
18
- type CodeRow,
19
- type NewPlan,
20
- type OrderRow,
21
- type PlanRow,
22
22
  } from './store'
23
- import { toInternalEvent, type StripeEventEnvelope } from './stripe/events'
23
+ import { type StripeEventEnvelope, toInternalEvent } from './stripe/events'
24
24
 
25
25
  export const DUES_DEMO_GROUP = 'supporters'
26
26
 
@@ -1,4 +1,4 @@
1
- import type { PluginGrants, PluginData, PluginNotify } from '@meith/plugin-kit'
1
+ import type { PluginData, PluginGrants, PluginNotify } from '@meith/plugin-kit'
2
2
 
3
3
  import type { DuesConfig } from './config'
4
4
  import { moneyMatches } from './money'
@@ -11,15 +11,15 @@ import {
11
11
  insertLedger,
12
12
  insertMembership,
13
13
  liveMembership,
14
+ type MembershipRow,
14
15
  membershipBySubscription,
16
+ type OrderRow,
15
17
  orderByPaymentIntent,
16
18
  orderBySessionId,
19
+ type PlanRow,
17
20
  planRowByKey,
18
21
  setMembershipStatus,
19
22
  settleOrder,
20
- type MembershipRow,
21
- type OrderRow,
22
- type PlanRow,
23
23
  } from './store'
24
24
  import type { InternalEvent } from './stripe/events'
25
25
 
@@ -48,9 +48,7 @@ async function tryNotify(
48
48
 
49
49
  function grantReason(order: OrderRow): string {
50
50
  const gift =
51
- order.buyerUserId === order.recipientUserId
52
- ? ''
53
- : `, a gift from user ${order.buyerUserId}`
51
+ order.buyerUserId === order.recipientUserId ? '' : `, a gift from user ${order.buyerUserId}`
54
52
  return `dues order ${order.id}: ${order.planName}${gift}`
55
53
  }
56
54
 
@@ -69,10 +67,15 @@ export async function settlePaidOrder(
69
67
  if (order.status === 'paid') return 'already-settled'
70
68
  if (order.status === 'failed' || order.status === 'cancelled') return `order-${order.status}`
71
69
 
72
- if (!moneyMatches({ amountMinor: order.amountMinor, currency: order.currency }, {
73
- amountMinor: info.amountTotal,
74
- currency: info.currency,
75
- })) {
70
+ if (
71
+ !moneyMatches(
72
+ { amountMinor: order.amountMinor, currency: order.currency },
73
+ {
74
+ amountMinor: info.amountTotal,
75
+ currency: info.currency,
76
+ },
77
+ )
78
+ ) {
76
79
  await settleOrder(deps.data, order.id, {
77
80
  status: 'pending',
78
81
  needsAttention:
@@ -160,8 +163,9 @@ export async function settlePaidOrder(
160
163
  await tryNotify(deps, {
161
164
  userId: order.recipientUserId,
162
165
  kind: 'gift_received',
163
- subject: `Somebody bought you ${order.planName}`,
164
- body: 'It starts this moment — nothing to claim, nothing to do.',
166
+ subjectKey: 'dues.notification.gift.subject',
167
+ subjectArgs: { plan: order.planName },
168
+ bodyKey: 'dues.notification.gift.body',
165
169
  href: '/plugins/dues/manage',
166
170
  dedupeKey: `gift:${order.id}`,
167
171
  })
@@ -179,7 +183,8 @@ function settledPeriodEnd(
179
183
  if (order.billingMode === 'lifetime') return LIFETIME_END
180
184
 
181
185
  if (order.billingMode === 'fixed') {
182
- const base = existing !== null && existing.currentPeriodEnd > now ? existing.currentPeriodEnd : now
186
+ const base =
187
+ existing !== null && existing.currentPeriodEnd > now ? existing.currentPeriodEnd : now
183
188
  if (isLifetime(base)) return LIFETIME_END
184
189
  const parsed =
185
190
  (plan?.periodSpec !== null && plan?.periodSpec !== undefined
@@ -240,8 +245,7 @@ export async function applyInternalEvent(
240
245
  }
241
246
  if (event.billingReason === 'subscription_create') return 'covered-by-checkout'
242
247
 
243
- const periodEnd =
244
- event.periodEnd ?? addBillingInterval(membership.currentPeriodEnd, 'month')
248
+ const periodEnd = event.periodEnd ?? addBillingInterval(membership.currentPeriodEnd, 'month')
245
249
  const graceUntil = addDays(periodEnd, deps.config.graceDays)
246
250
 
247
251
  await extendMembership(deps.data, membership.id, {
@@ -287,10 +291,8 @@ export async function applyInternalEvent(
287
291
  await tryNotify(deps, {
288
292
  userId: membership.userId,
289
293
  kind: 'renewal_trouble',
290
- subject: 'A membership payment did not go through',
291
- body:
292
- 'Stripe retries on its own. Your access holds during the grace window, ' +
293
- 'and updating your card from the manage page usually settles it.',
294
+ subjectKey: 'dues.notification.renewal.subject',
295
+ bodyKey: 'dues.notification.renewal.body',
294
296
  href: '/plugins/dues/manage',
295
297
  dedupeKey: `grace:${membership.id}`,
296
298
  })
@@ -333,11 +335,7 @@ export async function applyInternalEvent(
333
335
  const order = await orderByPaymentIntent(deps.data, event.paymentIntentId)
334
336
  if (order === null) return 'unmatched-reversal'
335
337
 
336
- const membership = await liveMembership(
337
- deps.data,
338
- order.recipientUserId,
339
- order.groupKey,
340
- )
338
+ const membership = await liveMembership(deps.data, order.recipientUserId, order.groupKey)
341
339
  if (membership !== null) {
342
340
  await setMembershipStatus(deps.data, membership.id, 'revoked')
343
341
  try {
@@ -1,9 +1,9 @@
1
1
  import type { PluginRequest, PluginResponse } from '@meith/plugin-kit'
2
2
 
3
3
  import { generateCode, normalizeCode, validCodeShape } from './codes'
4
- import { anyPlanByKey, clampGrantUntil, isLifetime, parsePlanForm } from './plans'
5
- import { addDays } from './period'
6
4
  import type { DuesServices } from './handlers'
5
+ import { addDays } from './period'
6
+ import { anyPlanByKey, clampGrantUntil, isLifetime, parsePlanForm } from './plans'
7
7
  import {
8
8
  clearMembershipAttention,
9
9
  clearOrderAttention,
@@ -12,6 +12,7 @@ import {
12
12
  flagMembership,
13
13
  insertCode,
14
14
  insertPlan,
15
+ type MembershipRow,
15
16
  membershipById,
16
17
  orderById,
17
18
  planRowById,
@@ -20,7 +21,6 @@ import {
20
21
  setPlanArchived,
21
22
  setPlanStripePrice,
22
23
  updatePlan,
23
- type MembershipRow,
24
24
  } from './store'
25
25
 
26
26
  function toAdmin(
package/src/handlers.ts CHANGED
@@ -1,29 +1,25 @@
1
- import type {
2
- PluginRequest,
3
- PluginResponse,
4
- PluginRuntimeContext,
5
- } from '@meith/plugin-kit'
1
+ import type { PluginRequest, PluginResponse, PluginRuntimeContext } from '@meith/plugin-kit'
6
2
 
7
3
  import { codeProblem, discountedPrice, normalizeCode } from './codes'
8
4
  import type { DuesConfig } from './config'
5
+ import { applyInternalEvent, type EntitlementDeps, settlePaidOrder } from './entitlement'
9
6
  import { isLifetime, sellablePlanByKey } from './plans'
10
- import { applyInternalEvent, settlePaidOrder, type EntitlementDeps } from './entitlement'
11
7
  import {
12
8
  attachCheckoutSession,
9
+ type CodeRow,
13
10
  codeByCode,
14
11
  findStripeCustomer,
15
12
  insertOrder,
16
13
  liveMembership,
17
- membershipById,
18
- recordEvent,
19
14
  markEventFailed,
20
15
  markEventProcessed,
16
+ membershipById,
17
+ recordEvent,
21
18
  saveCodeCoupon,
22
19
  saveStripeCustomer,
23
20
  setMembershipStatus,
24
- type CodeRow,
25
21
  } from './store'
26
- import { createStripeClient, StripeError, type StripeClient } from './stripe/client'
22
+ import { createStripeClient, type StripeClient, StripeError } from './stripe/client'
27
23
  import { parseEventEnvelope, toInternalEvent } from './stripe/events'
28
24
  import { verifyStripeSignature } from './stripe/webhook'
29
25
 
@@ -151,8 +147,7 @@ export async function handleCheckout(
151
147
  if (problem !== null) return back({ error: `code-${problem}`, ...bounce })
152
148
  }
153
149
 
154
- const charge =
155
- code === null ? plan.priceMinor : discountedPrice(plan.priceMinor, code.percentOff)
150
+ const charge = code === null ? plan.priceMinor : discountedPrice(plan.priceMinor, code.percentOff)
156
151
 
157
152
  if (services.stripe === null && (plan.mode === 'auto' || charge > 0)) {
158
153
  return back({ error: 'unconfigured' })
package/src/index.ts CHANGED
@@ -1,9 +1,5 @@
1
- export { dues } from './definition'
2
1
  export type { DuesConfigInput, DuesPlanInput } from './config'
3
-
4
- export { SUBSCRIBED_EVENT_TYPES } from './stripe/events'
5
- export { signStripePayload } from './stripe/webhook'
6
-
2
+ export { dues } from './definition'
7
3
  export {
8
4
  DUES_DEMO_CODES,
9
5
  DUES_DEMO_CURRENCY,
@@ -11,8 +7,11 @@ export {
11
7
  DUES_DEMO_GROUP,
12
8
  DUES_DEMO_PLANS,
13
9
  DUES_DEMO_PRICES,
14
- seedDuesDemo,
15
10
  type DuesDemoCast,
16
11
  type DuesDemoDeps,
17
12
  type DuesDemoSummary,
13
+ seedDuesDemo,
18
14
  } from './demo'
15
+ export { duesMessages } from './messages'
16
+ export { SUBSCRIBED_EVENT_TYPES } from './stripe/events'
17
+ export { signStripePayload } from './stripe/webhook'