@meith/plugin-dues 0.16.0 → 0.17.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/README.md CHANGED
@@ -40,27 +40,40 @@ history behind it, and a checkout a visitor can actually go through. See
40
40
  1. **Register the plugin** where plugins are registered:
41
41
 
42
42
  ```ts
43
- // apps/community/community.plugins.ts (this repository's own demo and
44
- // test boards register their versions in community.demo.plugins.ts,
45
- // behind DEMO_MODE and DUES_TEST_BOARD)
43
+ // apps/community/community.plugins.ts
46
44
  import { dues } from '@meith/plugin-dues'
47
45
 
48
- export const INSTALLED_PLUGINS = [
49
- { key: 'dues', plugin: dues({ currency: 'gbp', graceDays: 7 }) },
50
- ]
46
+ export const INSTALLED_PLUGINS = [{ key: 'dues', plugin: dues }]
51
47
  ```
52
48
 
53
- `plans` may also be declared here as **seeds** they populate the plan
54
- table on the board's first run and are ignored once it has rows. After
55
- that, the panel owns the plans.
49
+ That is the whole of it `dues` needs no constructor argument, which is
50
+ what lets a marketplace install register it from the key alone. Every
51
+ board-specific choice is made afterwards, in the browser.
52
+
53
+ A board that registers plugins in code and genuinely needs a code-only
54
+ escape hatch — an extra redirect host for a proxy or a loopback address,
55
+ or code-declared seed plans for a demo or test board — calls `createDues`
56
+ instead: `createDues({ extraRedirectHosts: ['proxy.example'] })`. This
57
+ repository's own demo and test boards do exactly that, in
58
+ `community.demo.plugins.ts`, behind `DEMO_MODE` and `DUES_TEST_BOARD`. A
59
+ seed plan populates the plan table on the board's first run and is
60
+ ignored once it has rows — after that, the panel owns the plans, exactly
61
+ as for a board that never declared any.
56
62
 
57
63
  2. **On the board**: create the group a plan will grant (its permissions,
58
64
  badge and colour are the product), then tick **may be granted by plugins**
59
65
  on its screen under Admin → Groups. Staff, system and power-carrying
60
66
  groups refuse the tick, on purpose.
61
- 3. **Keys**: set `DUES_STRIPE_SECRET_KEY` and `DUES_STRIPE_WEBHOOK_SECRET` in
62
- the environment, or fill them under Admin Plugins Dues. Environment
63
- wins, and the settings screen says which source is in force.
67
+ 3. **Settings**, all under Admin Plugins → Dues:
68
+ - **Currency** and **Grace period** the board's default currency (a
69
+ plan can still be priced in any ISO 4217 code; this is what a new plan
70
+ defaults to, what the ledger shows, and the fallback when a Stripe
71
+ event carries no currency of its own) and the days a lapsed renewal
72
+ keeps access. `DUES_CURRENCY` and `DUES_GRACE_DAYS` override them from
73
+ the environment, on the same rule as the keys below.
74
+ - **`DUES_STRIPE_SECRET_KEY`** and **`DUES_STRIPE_WEBHOOK_SECRET`** — set
75
+ in the environment, or filled in here. Environment wins, and the
76
+ screen says which source is in force.
64
77
  4. **Migrations**: run `community upgrade`.
65
78
  5. **Make the plans** under Admin → Plugins → Dues → plans — see
66
79
  [Plans](#plans) below.
@@ -70,6 +83,18 @@ history behind it, and a checkout a visitor can actually go through. See
70
83
  7. **Prove it**: the status page (Admin → Plugins → Dues → status) should read
71
84
  green; buy a pass yourself in test mode before turning the live key on.
72
85
 
86
+ ### What a marketplace install cannot reach
87
+
88
+ `allowedRedirectHosts` — the hosts a route's redirect may point an absolute
89
+ URL at — is declared on the plugin definition itself, and the host reads it
90
+ before any setting resolves. There is no way for a setting to feed it, so
91
+ the zero-argument `dues` export carries only Stripe's own two hosts
92
+ (`checkout.stripe.com`, `billing.stripe.com`), which is everything the
93
+ checkout and billing-portal flows need. A board that must add another host
94
+ registers `createDues({ extraRedirectHosts: [...] })` in code instead — the
95
+ one piece of Dues configuration that stays code-only because the plugin API
96
+ has no other way to express it.
97
+
73
98
  ## Plans
74
99
 
75
100
  A plan is made and edited in the panel. It has a permanent key, a name, a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/plugin-dues",
3
- "version": "0.16.0",
3
+ "version": "0.17.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,8 +20,8 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "@meith/plugin-kit": "^0.16.0",
24
- "@meith/theme-kit": "^0.16.0"
23
+ "@meith/plugin-kit": "^0.17.0",
24
+ "@meith/theme-kit": "^0.17.0"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "react": "^19.2.0"
package/src/config.ts CHANGED
@@ -14,9 +14,13 @@ export interface DuesPlanInput {
14
14
  readonly hidden?: boolean
15
15
  }
16
16
 
17
+ /**
18
+ * What a board still configures in code, when it registers the plugin with
19
+ * arguments rather than taking the zero-argument export. `currency` and
20
+ * `graceDays` moved to plugin settings — see `resolveDuesConfig` — because a
21
+ * marketplace install can only supply a key, never a constructor argument.
22
+ */
17
23
  export interface DuesConfigInput {
18
- readonly currency: string
19
- readonly graceDays?: number
20
24
  readonly label?: string
21
25
  readonly plans?: readonly DuesPlanInput[]
22
26
  readonly extraRedirectHosts?: readonly string[]
@@ -35,34 +39,72 @@ export interface DuesPlan {
35
39
  readonly hidden: boolean
36
40
  }
37
41
 
38
- export interface DuesConfig {
39
- readonly currency: string
40
- readonly graceDays: number
42
+ /** The half of the configuration fixed at plugin registration. */
43
+ export interface DuesStaticConfig {
41
44
  readonly label: string
42
45
  readonly seedPlans: readonly DuesPlan[]
43
46
  readonly extraRedirectHosts: readonly string[]
44
47
  }
45
48
 
49
+ /** The static half plus the settings an operator edits in the panel. */
50
+ export interface DuesConfig extends DuesStaticConfig {
51
+ readonly currency: string
52
+ readonly graceDays: number
53
+ }
54
+
46
55
  const PLAN_KEY = /^[a-z][a-z0-9-]{0,39}$/
47
56
  const MAX_GRANTABLE_DAYS = 2 * 366
48
57
 
58
+ export const DEFAULT_CURRENCY = 'usd'
59
+ export const DEFAULT_GRACE_DAYS = 7
60
+ export const MIN_GRACE_DAYS = 0
61
+ export const MAX_GRACE_DAYS = 30
62
+
63
+ /**
64
+ * The board-wide currency is a `select` setting, and a select needs a fixed
65
+ * list of options — unlike a plan's own currency (still any ISO 4217 code,
66
+ * typed into the plan form), this is the curated set an operator picks a
67
+ * default from. Labels are bare codes, on purpose: they need no translation
68
+ * and cost nothing in the message catalog.
69
+ */
70
+ export const DUES_CURRENCY_OPTIONS: readonly { readonly value: string; readonly label: string }[] =
71
+ [
72
+ { value: 'usd', label: 'USD' },
73
+ { value: 'eur', label: 'EUR' },
74
+ { value: 'gbp', label: 'GBP' },
75
+ { value: 'cad', label: 'CAD' },
76
+ { value: 'aud', label: 'AUD' },
77
+ { value: 'nzd', label: 'NZD' },
78
+ { value: 'chf', label: 'CHF' },
79
+ { value: 'jpy', label: 'JPY' },
80
+ { value: 'sek', label: 'SEK' },
81
+ { value: 'nok', label: 'NOK' },
82
+ { value: 'dkk', label: 'DKK' },
83
+ { value: 'pln', label: 'PLN' },
84
+ { value: 'czk', label: 'CZK' },
85
+ { value: 'sgd', label: 'SGD' },
86
+ { value: 'hkd', label: 'HKD' },
87
+ { value: 'inr', label: 'INR' },
88
+ { value: 'brl', label: 'BRL' },
89
+ { value: 'mxn', label: 'MXN' },
90
+ { value: 'zar', label: 'ZAR' },
91
+ ]
92
+
49
93
  function refuse(message: string): never {
50
94
  throw new Error(`dues plugin configuration: ${message}`)
51
95
  }
52
96
 
53
- export function parseDuesConfig(input: DuesConfigInput): DuesConfig {
54
- if (!isCurrencyCode(input.currency)) {
55
- refuse(`"${input.currency}" is not a three-letter ISO 4217 currency code.`)
56
- }
57
-
58
- const graceDays = input.graceDays ?? 7
59
- if (!Number.isInteger(graceDays) || graceDays < 0 || graceDays > 30) {
60
- refuse(
61
- `graceDays is ${String(input.graceDays)}. It is the window a lapsed payment keeps ` +
62
- 'access for, 0 to 30 days.',
63
- )
64
- }
65
-
97
+ /**
98
+ * Validates the code-configured half of the plugin — a plan's `currency` is
99
+ * still supplied per-plan on the admin form, but a code-declared *seed* plan
100
+ * has none of its own, so it always seeds under whatever the `currency`
101
+ * setting resolves to when the board's first request seeds it. That means
102
+ * the period-plus-grace cap below cannot know the actual `graceDays` in
103
+ * force (a setting, resolved per request) it checks against the worst
104
+ * case, `MAX_GRACE_DAYS`, so no seed can exceed the board's two-year grant
105
+ * cap no matter how the setting is later changed.
106
+ */
107
+ export function parseDuesConfig(input: DuesConfigInput = {}): DuesStaticConfig {
66
108
  const label = (input.label ?? 'Membership').trim()
67
109
  if (label === '') refuse('label must not be empty.')
68
110
 
@@ -116,10 +158,10 @@ export function parseDuesConfig(input: DuesConfigInput): DuesConfig {
116
158
  if (parsed === null) {
117
159
  refuse(`${where}: "${plan.billing.period}" is not an ISO-8601 period like P90D, P1M or P1Y.`)
118
160
  }
119
- if (periodCeilingDays(parsed) + graceDays > MAX_GRANTABLE_DAYS) {
161
+ if (periodCeilingDays(parsed) + MAX_GRACE_DAYS > MAX_GRANTABLE_DAYS) {
120
162
  refuse(
121
- `${where}: the period plus grace can reach past two years, and the board caps a ` +
122
- 'plugin grant at two years. Sell a shorter pass.',
163
+ `${where}: the period plus the longest possible grace window can reach past two ` +
164
+ 'years, and the board caps a plugin grant at two years. Sell a shorter pass.',
123
165
  )
124
166
  }
125
167
 
@@ -142,10 +184,38 @@ export function parseDuesConfig(input: DuesConfigInput): DuesConfig {
142
184
  }
143
185
 
144
186
  return {
145
- currency: input.currency.toLowerCase(),
146
- graceDays,
147
187
  label,
148
188
  seedPlans: plans,
149
189
  extraRedirectHosts: input.extraRedirectHosts ?? [],
150
190
  }
151
191
  }
192
+
193
+ function clampGraceDays(value: number): number {
194
+ if (!Number.isFinite(value)) return DEFAULT_GRACE_DAYS
195
+ return Math.min(MAX_GRACE_DAYS, Math.max(MIN_GRACE_DAYS, Math.round(value)))
196
+ }
197
+
198
+ /**
199
+ * Merges the static, code-declared half of the configuration with the two
200
+ * settings an operator edits in the panel. Settings have no refusal path —
201
+ * unlike `parseDuesConfig`, a bad value here is repaired rather than thrown:
202
+ * an unrecognised currency falls back to the default, and an out-of-range
203
+ * grace period is clamped to 0–30 days. `resolvePluginSettings` already
204
+ * guarantees a `select` setting's stored value is one of its declared
205
+ * options, so the currency check below is a second, cheap line of defence
206
+ * rather than the one this depends on.
207
+ */
208
+ export function resolveDuesConfig(
209
+ staticConfig: DuesStaticConfig,
210
+ settings: Readonly<Record<string, string | number | boolean>>,
211
+ ): DuesConfig {
212
+ const rawCurrency = String(settings.currency ?? DEFAULT_CURRENCY).toLowerCase()
213
+ const currency = isCurrencyCode(rawCurrency) ? rawCurrency : DEFAULT_CURRENCY
214
+
215
+ const rawGraceDays = settings.grace_days
216
+ const graceDays = clampGraceDays(
217
+ typeof rawGraceDays === 'number' ? rawGraceDays : DEFAULT_GRACE_DAYS,
218
+ )
219
+
220
+ return { ...staticConfig, currency, graceDays }
221
+ }
@@ -6,7 +6,15 @@ import {
6
6
  type PluginRuntimeContext,
7
7
  } from '@meith/plugin-kit'
8
8
 
9
- import { type DuesConfigInput, parseDuesConfig } from './config'
9
+ import {
10
+ DEFAULT_CURRENCY,
11
+ DEFAULT_GRACE_DAYS,
12
+ DUES_CURRENCY_OPTIONS,
13
+ type DuesConfig,
14
+ type DuesConfigInput,
15
+ parseDuesConfig,
16
+ resolveDuesConfig,
17
+ } from './config'
10
18
  import {
11
19
  buildServices,
12
20
  type DuesServices,
@@ -33,26 +41,66 @@ import { runReconcile, runSweep } from './tasks'
33
41
  import { CodesPage, LedgerPage, MembersPage, PlansAdminPage, StatusPage } from './ui/admin'
34
42
  import { GoPage, ManagePage, PlansPage, ReturnPage } from './ui/pages'
35
43
 
36
- export function dues(input: DuesConfigInput): PluginDefinition {
37
- const config = parseDuesConfig(input)
44
+ /**
45
+ * The code-configured path: still takes constructor arguments, for a board
46
+ * that registers Dues directly in `community.plugins.ts` rather than
47
+ * through a marketplace install. `dues`, below, is this called with none —
48
+ * the zero-argument export a marketplace install actually uses.
49
+ */
50
+ export function createDues(input: DuesConfigInput = {}): PluginDefinition {
51
+ const staticConfig = parseDuesConfig(input)
52
+
53
+ // Resolved fresh per call: `currency` and `graceDays` are settings, so a
54
+ // request made after an operator edits them must see the new value, not
55
+ // one baked in when the plugin was registered.
56
+ const configFor = (context: PluginRuntimeContext): DuesConfig =>
57
+ resolveDuesConfig(staticConfig, context.settings)
38
58
 
39
59
  const route =
40
60
  (
41
61
  fn: (services: DuesServices, request: PluginRequest) => Promise<PluginResponse>,
42
62
  ): ((request: PluginRequest, context: PluginRuntimeContext) => Promise<PluginResponse>) =>
43
63
  (request, context) =>
44
- fn(buildServices(config, context), request)
64
+ fn(buildServices(configFor(context), context), request)
45
65
 
46
66
  return definePlugin({
47
67
  key: 'dues',
48
68
  name: 'Dues',
49
- version: '0.16.0',
50
- description: en['dues.definition.description'].replace('{label}', config.label.toLowerCase()),
69
+ version: '0.17.0',
70
+ description: en['dues.definition.description'].replace(
71
+ '{label}',
72
+ staticConfig.label.toLowerCase(),
73
+ ),
51
74
  descriptionKey: 'dues.definition.description',
52
- descriptionArgs: { label: config.label.toLowerCase() },
75
+ descriptionArgs: { label: staticConfig.label.toLowerCase() },
53
76
  apiVersion: '0',
54
77
 
55
78
  settings: [
79
+ {
80
+ key: 'currency',
81
+ label: en['dues.definition.setting.currency.label'],
82
+ labelKey: 'dues.definition.setting.currency.label',
83
+ type: 'select',
84
+ options: DUES_CURRENCY_OPTIONS,
85
+ env: 'DUES_CURRENCY',
86
+ default: DEFAULT_CURRENCY,
87
+ description: en['dues.definition.setting.currency.description'],
88
+ descriptionKey: 'dues.definition.setting.currency.description',
89
+ },
90
+ {
91
+ key: 'grace_days',
92
+ label: en['dues.definition.setting.graceDays.label'],
93
+ labelKey: 'dues.definition.setting.graceDays.label',
94
+ type: 'number',
95
+ env: 'DUES_GRACE_DAYS',
96
+ default: DEFAULT_GRACE_DAYS,
97
+ // PluginSetting has no descriptionArgs — unlike the definition's own
98
+ // description, a setting's is translated with no interpolation — so
99
+ // the range is spelled out in the catalog text itself, and MIN/MAX
100
+ // below only need to stay in sync with the words there by hand.
101
+ description: en['dues.definition.setting.graceDays.description'],
102
+ descriptionKey: 'dues.definition.setting.graceDays.description',
103
+ },
56
104
  {
57
105
  key: 'stripe_secret_key',
58
106
  label: en['dues.definition.setting.secret.label'],
@@ -103,7 +151,7 @@ export function dues(input: DuesConfigInput): PluginDefinition {
103
151
  id: 'reconcile',
104
152
  intervalSeconds: 300,
105
153
  run: async (context) => {
106
- const services = buildServices(config, context)
154
+ const services = buildServices(configFor(context), context)
107
155
  const result = await runReconcile(entitlementDeps(services), services.stripe)
108
156
  if (
109
157
  result.ordersSettled +
@@ -120,7 +168,7 @@ export function dues(input: DuesConfigInput): PluginDefinition {
120
168
  id: 'sweep',
121
169
  intervalSeconds: 3600,
122
170
  run: async (context) => {
123
- const services = buildServices(config, context)
171
+ const services = buildServices(configFor(context), context)
124
172
  const expired = await runSweep(entitlementDeps(services))
125
173
  if (expired > 0) context.logger.info('dues: memberships expired', { expired })
126
174
  },
@@ -233,24 +281,24 @@ export function dues(input: DuesConfigInput): PluginDefinition {
233
281
  pages: [
234
282
  {
235
283
  path: '',
236
- title: config.label,
284
+ title: staticConfig.label,
237
285
  access: 'anonymous',
238
- render: (context) => PlansPage({ config, context }),
286
+ render: (context) => PlansPage({ config: configFor(context), context }),
239
287
  },
240
288
  {
241
289
  path: 'return',
242
290
  title: en['dues.definition.page.return'],
243
291
  titleKey: 'dues.definition.page.return',
244
292
  access: 'member',
245
- render: (context) => ReturnPage({ config, context }),
293
+ render: (context) => ReturnPage({ config: configFor(context), context }),
246
294
  },
247
295
  {
248
296
  path: 'manage',
249
- title: en['dues.definition.manage'].replace('{label}', config.label.toLowerCase()),
297
+ title: en['dues.definition.manage'].replace('{label}', staticConfig.label.toLowerCase()),
250
298
  titleKey: 'dues.definition.manage',
251
- titleArgs: { label: config.label.toLowerCase() },
299
+ titleArgs: { label: staticConfig.label.toLowerCase() },
252
300
  access: 'member',
253
- render: (context) => ManagePage({ config, context }),
301
+ render: (context) => ManagePage({ config: configFor(context), context }),
254
302
  },
255
303
  {
256
304
  path: 'go',
@@ -259,12 +307,12 @@ export function dues(input: DuesConfigInput): PluginDefinition {
259
307
  access: 'member',
260
308
  render: (context) =>
261
309
  GoPage({
262
- config,
310
+ config: configFor(context),
263
311
  context,
264
312
  allowedHosts: [
265
313
  'checkout.stripe.com',
266
314
  'billing.stripe.com',
267
- ...config.extraRedirectHosts,
315
+ ...staticConfig.extraRedirectHosts,
268
316
  ],
269
317
  }),
270
318
  },
@@ -275,13 +323,13 @@ export function dues(input: DuesConfigInput): PluginDefinition {
275
323
  path: 'status',
276
324
  title: en['dues.definition.page.status'],
277
325
  titleKey: 'dues.definition.page.status',
278
- render: (context) => StatusPage({ config, context }),
326
+ render: (context) => StatusPage({ config: configFor(context), context }),
279
327
  },
280
328
  {
281
329
  path: 'plans',
282
330
  title: en['dues.definition.page.plans'],
283
331
  titleKey: 'dues.definition.page.plans',
284
- render: (context) => PlansAdminPage({ config, context }),
332
+ render: (context) => PlansAdminPage({ config: configFor(context), context }),
285
333
  },
286
334
  {
287
335
  path: 'members',
@@ -293,23 +341,23 @@ export function dues(input: DuesConfigInput): PluginDefinition {
293
341
  path: 'codes',
294
342
  title: en['dues.definition.page.codes'],
295
343
  titleKey: 'dues.definition.page.codes',
296
- render: (context) => CodesPage({ config, context }),
344
+ render: (context) => CodesPage({ config: configFor(context), context }),
297
345
  },
298
346
  {
299
347
  path: 'ledger',
300
348
  title: en['dues.definition.page.ledger'],
301
349
  titleKey: 'dues.definition.page.ledger',
302
- render: (context) => LedgerPage({ config, context }),
350
+ render: (context) => LedgerPage({ config: configFor(context), context }),
303
351
  },
304
352
  ],
305
353
 
306
354
  navigation: [
307
- { key: 'plans', label: config.label, path: '', audience: 'members' },
355
+ { key: 'plans', label: staticConfig.label, path: '', audience: 'members' },
308
356
  {
309
357
  key: 'manage',
310
- label: en['dues.definition.manage'].replace('{label}', config.label.toLowerCase()),
358
+ label: en['dues.definition.manage'].replace('{label}', staticConfig.label.toLowerCase()),
311
359
  labelKey: 'dues.definition.manage',
312
- labelArgs: { label: config.label.toLowerCase() },
360
+ labelArgs: { label: staticConfig.label.toLowerCase() },
313
361
  path: 'manage',
314
362
  audience: 'members',
315
363
  under: 'plans',
@@ -319,7 +367,19 @@ export function dues(input: DuesConfigInput): PluginDefinition {
319
367
  allowedRedirectHosts: [
320
368
  'checkout.stripe.com',
321
369
  'billing.stripe.com',
322
- ...config.extraRedirectHosts,
370
+ ...staticConfig.extraRedirectHosts,
323
371
  ],
324
372
  })
325
373
  }
374
+
375
+ /**
376
+ * The zero-argument export: what a marketplace install actually registers.
377
+ * `allowedRedirectHosts` carries only Stripe's own hosts — a marketplace
378
+ * install cannot express a constructor argument, and `allowedRedirectHosts`
379
+ * is fixed on the definition at this call, before any settings resolve, so
380
+ * there is no later point where a board-configured host could be added. A
381
+ * board that genuinely needs another redirect host (a proxy, a loopback
382
+ * address for a test double) registers `createDues({ extraRedirectHosts })`
383
+ * directly instead.
384
+ */
385
+ export const dues: PluginDefinition = createDues()
package/src/demo.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { PluginData, PluginGrants, PluginNotify } from '@meith/plugin-kit'
2
2
 
3
3
  import { discountedPrice } from './codes'
4
- import { type DuesConfig, parseDuesConfig } from './config'
4
+ import { type DuesConfig, parseDuesConfig, resolveDuesConfig } from './config'
5
5
  import { applyInternalEvent, type EntitlementDeps, settlePaidOrder } from './entitlement'
6
6
  import { addDays } from './period'
7
7
  import {
@@ -103,9 +103,9 @@ class DemoSeed {
103
103
  private counts = { plans: 0, codes: 0, orders: 0, memberships: 0, events: 0 }
104
104
 
105
105
  constructor(private readonly deps: DuesDemoDeps) {
106
- this.config = parseDuesConfig({
106
+ this.config = resolveDuesConfig(parseDuesConfig({}), {
107
107
  currency: DUES_DEMO_CURRENCY,
108
- graceDays: DUES_DEMO_GRACE_DAYS,
108
+ grace_days: DUES_DEMO_GRACE_DAYS,
109
109
  })
110
110
  this.clock = deps.now
111
111
  this.grants = deps.grants(() => this.clock)
package/src/index.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export type { DuesConfigInput, DuesPlanInput } from './config'
2
- export { dues } from './definition'
2
+ // `plugin` and `messages` (below) are the manifest-installable convention
3
+ // board.plugins.json generation relies on (scripts/board-plugins-gen.mjs) —
4
+ // re-exports, not a second definition, so `dues` stays the name everyone
5
+ // reads in code that names it directly.
6
+ export { createDues, dues, dues as plugin } from './definition'
3
7
  export {
4
8
  DUES_DEMO_CODES,
5
9
  DUES_DEMO_CURRENCY,
@@ -12,6 +16,6 @@ export {
12
16
  type DuesDemoSummary,
13
17
  seedDuesDemo,
14
18
  } from './demo'
15
- export { duesMessages } from './messages'
19
+ export { duesMessages, duesMessages as messages } from './messages'
16
20
  export { SUBSCRIBED_EVENT_TYPES } from './stripe/events'
17
21
  export { signStripePayload } from './stripe/webhook'
@@ -178,6 +178,10 @@
178
178
  "dues.definition.setting.apiBase.label": "Stripe API address",
179
179
  "dues.definition.setting.apiVersion.description": "Pinned so Stripe’s payload shapes change on a deploy, not on a Tuesday. Change it only alongside a plugin upgrade that expects the new shapes.",
180
180
  "dues.definition.setting.apiVersion.label": "Stripe API version",
181
+ "dues.definition.setting.currency.description": "The board’s default currency — shown on the ledger, and the fallback when a Stripe event carries none of its own. Each plan still records whatever currency its own price is in.",
182
+ "dues.definition.setting.currency.label": "Currency",
183
+ "dues.definition.setting.graceDays.description": "Days a lapsed renewal keeps access before the membership lapses — 0 to 30. A value outside that range is clamped rather than refused.",
184
+ "dues.definition.setting.graceDays.label": "Grace period, in days",
181
185
  "dues.definition.setting.secret.description": "The sk_live_… or sk_test_… key from the Stripe dashboard.",
182
186
  "dues.definition.setting.secret.label": "Stripe secret key",
183
187
  "dues.definition.setting.webhook.description": "The whsec_… secret of the webhook endpoint. Without it, payments are taken but can never confirm.",