@meith/plugin-dues 0.17.0 → 0.17.2

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
@@ -70,7 +70,9 @@ history behind it, and a checkout a visitor can actually go through. See
70
70
  defaults to, what the ledger shows, and the fallback when a Stripe
71
71
  event carries no currency of its own) and the days a lapsed renewal
72
72
  keeps access. `DUES_CURRENCY` and `DUES_GRACE_DAYS` override them from
73
- the environment, on the same rule as the keys below.
73
+ the environment, on the same rule as the keys below. `DUES_CURRENCY` is
74
+ matched case-insensitively and trimmed — `EUR` and `eur` both select
75
+ the same option — but write it lower-case.
74
76
  - **`DUES_STRIPE_SECRET_KEY`** and **`DUES_STRIPE_WEBHOOK_SECRET`** — set
75
77
  in the environment, or filled in here. Environment wins, and the
76
78
  screen says which source is in force.
@@ -101,9 +103,12 @@ A plan is made and edited in the panel. It has a permanent key, a name, a
101
103
  description, the group it grants, a price in minor units, its own three-letter
102
104
  currency, and one of three billing shapes:
103
105
 
104
- - **A pass** — one payment for a fixed stretch, one day to two years
105
- (including the grace window; the board caps a plugin grant at two years,
106
- and a pass respects the cap rather than pretending otherwise).
106
+ - **A pass** — one payment for a fixed stretch, one day to two years minus
107
+ the longest possible grace window: the board caps a plugin grant at two
108
+ years, checked against the grace setting's maximum (30 days) rather than
109
+ whatever it is set to today, since grace can be raised after the plan
110
+ already exists — and a pass respects the cap rather than pretending
111
+ otherwise.
107
112
  - **A subscription** — renews monthly or yearly until cancelled. It bills
108
113
  against a real Stripe price: leave the box empty and the plugin mints a
109
114
  product and price to match the form, or paste a `price_…` id made in the
@@ -124,6 +129,13 @@ sees the new number. Plans are never deleted — **archiving** takes one off
124
129
  sale while everyone who holds it keeps it. The plan key is permanent because
125
130
  it is how orders, memberships and the ledger refer to the plan forever.
126
131
 
132
+ A pass's length is part of that snapshot, and settlement reads it from the
133
+ order, not from the plan row — an edit made while a buyer is mid-checkout
134
+ grants what they paid for, never the edited length. Money already worked
135
+ this way (the exact-match rule above); the length just needed to match it.
136
+ The plan's own length is a fallback only, for an order that genuinely
137
+ predates carrying one of its own.
138
+
127
139
  ## How it decides things
128
140
 
129
141
  - **A pass stacks.** Buying a fixed pass while holding one adds to the end —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/plugin-dues",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
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.17.0",
24
- "@meith/theme-kit": "^0.17.0"
23
+ "@meith/plugin-kit": "^0.17.2",
24
+ "@meith/theme-kit": "^0.17.2"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "react": "^19.2.0"
@@ -50,9 +50,11 @@ import { GoPage, ManagePage, PlansPage, ReturnPage } from './ui/pages'
50
50
  export function createDues(input: DuesConfigInput = {}): PluginDefinition {
51
51
  const staticConfig = parseDuesConfig(input)
52
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.
53
+ /**
54
+ * Resolved fresh per call: `currency` and `graceDays` are settings, so a
55
+ * request made after an operator edits them must see the new value, not
56
+ * one baked in when the plugin was registered.
57
+ */
56
58
  const configFor = (context: PluginRuntimeContext): DuesConfig =>
57
59
  resolveDuesConfig(staticConfig, context.settings)
58
60
 
@@ -66,7 +68,7 @@ export function createDues(input: DuesConfigInput = {}): PluginDefinition {
66
68
  return definePlugin({
67
69
  key: 'dues',
68
70
  name: 'Dues',
69
- version: '0.17.0',
71
+ version: '0.17.2',
70
72
  description: en['dues.definition.description'].replace(
71
73
  '{label}',
72
74
  staticConfig.label.toLowerCase(),
@@ -94,10 +96,6 @@ export function createDues(input: DuesConfigInput = {}): PluginDefinition {
94
96
  type: 'number',
95
97
  env: 'DUES_GRACE_DAYS',
96
98
  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
99
  description: en['dues.definition.setting.graceDays.description'],
102
100
  descriptionKey: 'dues.definition.setting.graceDays.description',
103
101
  },
@@ -187,26 +187,22 @@ function settledPeriodEnd(
187
187
  existing !== null && existing.currentPeriodEnd > now ? existing.currentPeriodEnd : now
188
188
  if (isLifetime(base)) return LIFETIME_END
189
189
  const parsed =
190
+ parseStoredPeriod(order) ??
190
191
  (plan?.periodSpec !== null && plan?.periodSpec !== undefined
191
192
  ? parsePeriod(plan.periodSpec)
192
- : null) ?? parseStoredPeriod(order)
193
+ : null) ??
194
+ FALLBACK_PERIOD
193
195
  return addPeriod(base, parsed)
194
196
  }
195
197
 
196
198
  return addBillingInterval(now, plan?.billingInterval ?? 'month')
197
199
  }
198
200
 
201
+ /** @see plugins/dues/README.md#changing-and-retiring-one */
202
+ const FALLBACK_PERIOD = { years: 0, months: 0, weeks: 0, days: 30 }
203
+
199
204
  function parseStoredPeriod(order: OrderRow) {
200
- const fallback = { years: 0, months: 0, weeks: 0, days: 30 }
201
- if (order.periodSpec === null) return fallback
202
- const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?$/.exec(order.periodSpec)
203
- if (match === null) return fallback
204
- return {
205
- years: Number(match[1] ?? 0),
206
- months: Number(match[2] ?? 0),
207
- weeks: Number(match[3] ?? 0),
208
- days: Number(match[4] ?? 0),
209
- }
205
+ return order.periodSpec === null ? null : parsePeriod(order.periodSpec)
210
206
  }
211
207
 
212
208
  export async function applyInternalEvent(
@@ -275,7 +275,7 @@ export async function handleAdminPlanCreate(
275
275
  services: DuesServices,
276
276
  request: PluginRequest,
277
277
  ): Promise<PluginResponse> {
278
- const parsed = parsePlanForm(request.form ?? {}, services.config.graceDays)
278
+ const parsed = parsePlanForm(request.form ?? {})
279
279
  if (!parsed.ok) return toAdmin('plans', { error: parsed.error })
280
280
  const plan = parsed.plan
281
281
 
@@ -321,10 +321,7 @@ export async function handleAdminPlanUpdate(
321
321
  const existing = id === null ? null : await planRowById(services.context.data, id)
322
322
  if (existing === null) return toAdmin('plans', { error: 'no-such-plan' })
323
323
 
324
- const parsed = parsePlanForm(
325
- { ...request.form, key: existing.key, mode: existing.mode },
326
- services.config.graceDays,
327
- )
324
+ const parsed = parsePlanForm({ ...request.form, key: existing.key, mode: existing.mode })
328
325
  if (!parsed.ok) return toAdmin('plans', { error: parsed.error })
329
326
  const plan = parsed.plan
330
327
 
package/src/handlers.ts CHANGED
@@ -70,12 +70,24 @@ export function entitlementDeps(services: DuesServices): EntitlementDeps {
70
70
  }
71
71
  }
72
72
 
73
+ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]'])
74
+
75
+ /** docs/membership-guide.md#origin-for-stripe-redirects */
76
+ function isLoopbackHost(host: string): boolean {
77
+ let parsed: URL
78
+ try {
79
+ parsed = new URL(`http://${host}`)
80
+ } catch {
81
+ return false
82
+ }
83
+ return LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase())
84
+ }
85
+
86
+ /** docs/membership-guide.md#origin-for-stripe-redirects */
73
87
  export function requestOrigin(request: PluginRequest): string {
74
88
  if (request.boardUrl !== '') return request.boardUrl
75
- const host = request.headers['x-forwarded-host'] ?? request.headers.host ?? 'localhost'
76
- const loopback = host.startsWith('127.0.0.1') || host.startsWith('localhost')
77
- const proto = request.headers['x-forwarded-proto'] ?? (loopback ? 'http' : 'https')
78
- return `${proto}://${host}`
89
+ const host = request.headers.host ?? ''
90
+ return isLoopbackHost(host) ? `http://${host}` : ''
79
91
  }
80
92
 
81
93
  function back(query: Record<string, string>): PluginResponse {
@@ -200,6 +212,9 @@ export async function handleCheckout(
200
212
  if (services.stripe === null) return back({ error: 'unconfigured' })
201
213
  const stripe = services.stripe
202
214
 
215
+ const origin = requestOrigin(request)
216
+ if (origin === '') return back({ error: 'unconfigured' })
217
+
203
218
  try {
204
219
  let customerId = await findStripeCustomer(services.context.data, buyerId)
205
220
  if (customerId === null) {
@@ -210,7 +225,6 @@ export async function handleCheckout(
210
225
  customerId = customer.id
211
226
  }
212
227
 
213
- const origin = requestOrigin(request)
214
228
  const productName =
215
229
  recipientName === null ? plan.name : `${plan.name} — a gift for ${recipientName}`
216
230
 
@@ -339,13 +353,16 @@ export async function handlePortal(
339
353
  if (userId === null) return backToManage({ error: 'sign-in' })
340
354
  if (services.stripe === null) return backToManage({ error: 'unconfigured' })
341
355
 
356
+ const origin = requestOrigin(request)
357
+ if (origin === '') return backToManage({ error: 'unconfigured' })
358
+
342
359
  const customerId = await findStripeCustomer(services.context.data, userId)
343
360
  if (customerId === null) return backToManage({ error: 'no-customer' })
344
361
 
345
362
  try {
346
363
  const portal = await services.stripe.createBillingPortalSession({
347
364
  customer: customerId,
348
- returnUrl: `${requestOrigin(request)}/plugins/dues/manage`,
365
+ returnUrl: `${origin}/plugins/dues/manage`,
349
366
  })
350
367
  return offsite(portal.url)
351
368
  } catch (error) {
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
+ /**
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
+ */
1
7
  export type { DuesConfigInput, DuesPlanInput } from './config'
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
8
  export { createDues, dues, dues as plugin } from './definition'
7
9
  export {
8
10
  DUES_DEMO_CODES,
@@ -123,7 +123,7 @@
123
123
  "dues.admin.plans.subscription": "a subscription — renews itself",
124
124
  "dues.admin.plans.takeOffSale": "Take it off sale",
125
125
  "dues.admin.plans.thePlans": "The plans",
126
- "dues.admin.plans.tooLong": "A pass plus its grace window cannot reach past two years ({days} days) — that is the board’s cap on a plugin grant. Sell lifetime instead.",
126
+ "dues.admin.plans.tooLong": "A pass plus the longest possible grace window ({days} days) cannot reach past two years — that is the board’s cap on a plugin grant, checked against the grace setting’s maximum, not whatever it is set to today. Sell lifetime instead.",
127
127
  "dues.admin.plans.unit": "…counted in",
128
128
  "dues.admin.plans.updated": "{key} is updated. Existing memberships and running subscriptions keep what they bought; the change is for the next buyer.",
129
129
  "dues.admin.plans.weeks": "weeks",
package/src/plans.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { PluginData } from '@meith/plugin-kit'
2
2
 
3
- import type { DuesConfig } from './config'
3
+ import { type DuesConfig, MAX_GRACE_DAYS } from './config'
4
4
  import { isCurrencyCode, isValidMinorAmount } from './money'
5
5
  import { addDays, type Period, parsePeriod, periodCeilingDays } from './period'
6
6
  import {
@@ -117,7 +117,13 @@ function bad(error: string): PlanParse {
117
117
  return { ok: false, error }
118
118
  }
119
119
 
120
- export function parsePlanForm(form: PlanFormInput, graceDays: number): PlanParse {
120
+ /**
121
+ * Checks a fixed plan's length against `MAX_GRACE_DAYS`, not the live
122
+ * `grace_days` setting — mirroring the seed-path cap in `parseDuesConfig`
123
+ * (`config.ts`), for the same reason: `grace_days` is a runtime setting an
124
+ * operator can raise after the plan already exists.
125
+ */
126
+ export function parsePlanForm(form: PlanFormInput): PlanParse {
121
127
  const key = (form.key ?? '').trim().toLowerCase()
122
128
  if (!PLAN_KEY.test(key)) return bad('bad-key')
123
129
 
@@ -157,7 +163,7 @@ export function parsePlanForm(form: PlanFormInput, graceDays: number): PlanParse
157
163
  periodSpec = `P${length}${letter}`
158
164
  const parsed = parsePeriod(periodSpec)
159
165
  if (parsed === null) return bad('bad-length')
160
- if (periodCeilingDays(parsed) + graceDays > MAX_PLAN_DAYS) return bad('too-long')
166
+ if (periodCeilingDays(parsed) + MAX_GRACE_DAYS > MAX_PLAN_DAYS) return bad('too-long')
161
167
  }
162
168
 
163
169
  if (mode === 'auto') {
package/src/ui/admin.tsx CHANGED
@@ -2,9 +2,9 @@ import type { ReactNode } from 'react'
2
2
 
3
3
  import type { PluginAdminPageContext } from '@meith/plugin-kit'
4
4
 
5
- import type { DuesConfig } from '../config'
5
+ import { type DuesConfig, MAX_GRACE_DAYS } from '../config'
6
6
  import { formatMinor } from '../money'
7
- import { describeBilling, isLifetime, loadPlans, MAX_PLAN_DAYS } from '../plans'
7
+ import { describeBilling, isLifetime, loadPlans } from '../plans'
8
8
  import {
9
9
  allMemberships,
10
10
  attentionCount,
@@ -571,7 +571,7 @@ export async function CodesPage({
571
571
  <BadNotice>
572
572
  {context.t.t(
573
573
  error,
574
- context.query.error === 'too-long' ? { days: MAX_PLAN_DAYS } : undefined,
574
+ context.query.error === 'too-long' ? { days: MAX_GRACE_DAYS } : undefined,
575
575
  )}
576
576
  </BadNotice>
577
577
  )}