v0.13.0
AlpineClubBookingsNZ v0.13.0
Release target: v0.13.0 on 2026-07-21. This minor public reference release
brings all changes landed after v0.12.2 into one supported tag. It touches
subscription billing — the annual-subscription billing epic (#2151) changes how
the annual membership fee is deduped, voided, re-billed, and suppressed, and it
drops the old role-based subscription exemption in favour of DB-backed membership
types. An operator reading only this page must know the money paths change
this release; read the 0.13.0 changelog section and docs/UPGRADING.md before
deploying.
Unlike v0.12.2 (which carried two breaking contract migrations), this release
is not breaking: its four migrations are all expand / metadata-only and all
old_code_compatible=yes, so the blue/green validator passes with no
ALLOW_BREAKING_BLUE_GREEN_MIGRATIONS override and a normal deploy window is
enough. The canonical detailed entries remain in CHANGELOG.md; this page is the
release/operator overview.
Highlights
Annual-subscription billing epic (#2151)
- Double-billing fix with void / re-bill (#2147). The billing preview and the
in-transaction confirm now treat a seasonMemberSubscriptionas already billed
when itsstatus = PAIDor it carries a non-nullxeroInvoiceId
(manually-marked-paid members with no invoice stay skipped), and coverage-based
dedup counts only active (unreleased) claims. When the Xero sync observes a
charge's invoice VOIDED/DELETED it atomically releases the coverage claim
(releasedAtset, row kept for audit), marks the chargeVOIDED(row kept),
bumpsMemberSubscription.voidGeneration, and clears the subscription's invoice
link toNOT_INVOICED— the member becomes re-billable, and a post-void confirm
derives a new idempotency key that folds invoidGeneration(byte-identical
for never-voided subscriptions). A collapsed-by-default "Already invoiced"
section in the preview shows the count and each suppressed member's Xero invoice
number and status. Documented semantics change: a voided invoice previously read
asUNPAID(booking lockout) and now reads asNOT_INVOICED(re-billable). - Billing-exception resolution provenance (#2148). A new nullable
MembershipBillingException.resolvedViacolumn, backed by a
MembershipBillingExceptionResolutionenum (CONFIRM | PREVIEW_RECONCILE),
records how an exception reachedRESOLVEDso a preview-reconciliation
resolution is distinguishable from a confirm-run one. Existing and legacy
resolved rows, and every open row, stayNULL— the "resolved before this
column existed / not yet resolved" state. - Membership type is the sole subscription authority (#2149) — behaviour
change. The role-based subscription exemption is removed from every
derivation: membership type (subscriptionBehavior, plus age tier where the
type isBASED_ON_AGE_TIER) is now the only authority on whether a member owes
a subscription, and the loginRoleenum is a pure permission concept again. A
fee-paying member who happens to holdrole=ADMINnow shows their real
subscription status everywhere. Five divergent derivations (booking gate,
profile/subscription-status API, admin members list and its SQL filter variants,
subscriptions list, CSV export, and the Xero-sync status check) are consolidated
onto shared primitives. A data-only migration seeds two built-in types so the
dropped exemption keeps a DB-backedNOT_REQUIREDfallback: ADMIN
(NOT_REQUIRED,BLOCK_BOOKING) and LODGE (NOT_REQUIRED,MEMBER_RATE,
so kiosk accounts still book on behalf of members). - Operator "already invoiced" family marker and refined family suppression
(#2161 / #2167). ForPER_FAMILYbilling, a live legacy invoice suppresses a
family's charge only when its holder's own resolved billing basis is
PER_FAMILY; aPER_MEMBER-billed member's personal invoice no longer blocks
the family fee (that member stays skipped per-member). To close the ambiguous
legacy shapes that refinement re-opens, a newFamilyGroupSeasonInvoiceMarker
table lets an operator mark a family "already invoiced":finance:edit-gated
MARK/UNMARK actions on the billing route, an active marker suppressing the family
regardless of basis, unmark releasing the row (releasedAt) while keeping it for
audit, and a partial unique index enforcing one active marker per
(familyGroupId, seasonYear). Suppression is fail-closed: it lifts only on a
provenPER_MEMBERholder basis, with unresolvable bases carrying an
"Unresolved basis" badge in the audit panel.
Xero and finance surfaces
VOIDEDcharges are fenced everywhere they could otherwise re-enqueue or
re-invoice — enqueue/RETRY_CHARGE, invoice creation, the outbox failure handler,
and the panel (no Retry button) — so a voided-then-retained charge cannot cause a
second Xero write.- Four dependency security advisories cleared (#2173).
npm auditbegan
flagging one moderate and three high-severity advisories against two transitive
packages (anaxios/brace-expansiondenial-of-service and prototype-pollution
set), turning the requiredverifyjob red onmainand every PR. The exact
overridespins were bumped (axios1.16.1→1.18.1,brace-expansion
5.0.6→5.0.7);xero-nodestill resolves^1.7.7and did not move, and no
application code changed. The hardened axios form-serialization and body-limit
paths are not on the Xero call path.
Admin UX
- View-only banner rollout (#2142 → #2160 → #2168). The per-button "you cannot
change this" explanation that reached almost nobody (a greyed-out button is
skipped by the keyboard and shows no hover tooltip) is replaced by a short
section-level banner shown once at the top of a read-only area — the pattern
Booking Policies adopted in #2142, now rolled across most of the admin tree
(#2160) and the member detail page as one page-level banner (#2168). About seven
of every eight gated buttons are now explained by a banner instead of
individually. Nothing about who can do what changed — every button is gated
exactly as before. - Shared
useSectionEditStatehook (#2136). The canonical admin
settings-section pattern (load read-only, per-section Edit reveals Save/Cancel,
nothing auto-persists on toggle, Cancel reverts to the saved snapshot, Save
persists once) is centralised into one hook, so Cancel restores every field and
Save re-seeds both draft and snapshot from the save callback's return. Refactor
only, adopted by the group discount, password policy, email sign-in link, and
Google sign-in sections. - Quote-timing cards open with Edit (#2166), and the indicative-pricing toggle
is staged behind Save (#2162). On Booking Policies → Public Booking Requests,
the Quote Response Window / School Attendee Confirmation cards and the "Show
indicative pricing" checkbox no longer write on the first keystroke or click —
each now has its own Edit → change → Save → Cancel cycle, matching the rest of
the area, and each save re-reads the stored settings and sends back only the
fields it owns so it cannot clobber another admin's concurrent change.
Theming and print
- Categorical teal on tokens, and a themed
/financebody (#2137). The last
literal-Tailwind categorical teal (waitlist-offered chip, audit-logfamily
badge, family-groupGROUP_CREATEbadge) now reaches its hue through the
--hue-*tokens (value-identical), the audit category badge map is de-duplicated
into one shared module, and the/financedashboard body now uses semantic
surface tokens so it follows a strongly non-default club theme in light mode as
well as dark. - Print / PDF dark mode no longer produces a blank page (#2146). Downloading a
PDF or printing/financeor/admin/reportswhile browsing in dark mode used
to render near-white ink on the forced-white print background. Print and PDF now
always render the light colour scheme regardless of the browsing theme, across
every printable surface (reports, chore roster, induction sign-off, lodge
instructions). - Secondary text actually looks secondary (#2145), and bed-allocation labels
no longer clip (#2150): the muted-foreground token is corrected so secondary
text reads as secondary (falling back to identical-to-normal only where contrast
requires it), and the bed-allocation board's label column gets its own 14rem
width with two-line wrapping and atitlefallback for long bed names.
Config transfer and hut fees
- Config-transfer legacy-bundle compatibility window closed (#2131). One
release after the E13 contraction, the importer rejects the legacy boolean-keyed
bundle shapes (isMembercolumns, the pre-#1931ENTRANCE_FEEcategory) at
dry-run with a clear row-named error, and aHUT_FEErow with a blank
membershipTypeKeyis now a blocking error. v0.12.2 was the last release that
could import the legacy shape — re-export archived bundles before upgrading
(seedocs/UPGRADING.md). - Public
{{hut-fees}}embed re-sourced onto the authoritative rates (#2129,
step 1). The embed was the last application-runtime reader of the frozen
member/non-memberSeasonRatetable; it now readsMembershipTypeSeasonRate—
the same rows that price a booking — and renders a real per-lodge × season table
with one column per active, publicly listed membership type. No schema change
this release;SeasonRatestill has one reader and two writers in seed code
outsidesrc/, so its DROP is deferred to a later release. - Blue/green runtime-prep for the legacy Xero column drops (#2130). This is
step-1 prep only, with no schema change and no migration: every mutation on
XeroItemCodeMappingandAgeTierSettingis narrowed with an explicitselect
(completing the read-path narrowingv0.12.2began) so the deployed Prisma
client stops emitting an implicitRETURNINGover the doomedisMember/
xeroContactGroup*columns, and a static source-scan guard fails CI on any
future call site that forgets itsselect. Only after this release has itself
deployed are those columns drop-eligible, by the next release's contract
migration (public #2152 pending) — never the same release as this prep.
Compatibility and operator impact
- Runtime requirements remain Node.js 24 LTS, npm 11+, and PostgreSQL 16.
- The release contains four migrations, all expand / metadata-only and all
old_code_compatible=yes. None is breaking, so no
ALLOW_BREAKING_BLUE_GREEN_MIGRATIONSoverride is required and a normal deploy
window is sufficient. (If the validator demands the override, the checkout is
wrong — stop and re-check the commit.)20260720130000_subscription_invoice_dedup_void_release(#2147, expand)
adds theVOIDEDcharge-status enum value, aMemberSubscription.voidGeneration
integer (constant default 0), a nullable
MembershipSubscriptionChargeCoverage.releasedAt, and swaps the coverage
subscriptionIdfull UNIQUE for a partial UNIQUE over active
(releasedAt IS NULL) claims. Metadata-only on cold membership-billing tables.
During drain the old colour never reads the new columns and cannot create a
second coverage row per subscription, so it never needs the dropped full-unique
constraint. Forward-only: once a subscription accrues a released + active
coverage pair after a void→re-bill, the old full UNIQUE cannot be recreated —
recovery is roll-forward.20260720140000_billing_exception_resolution_provenance(#2148, expand)
creates theMembershipBillingExceptionResolutionenum and adds a nullable
MembershipBillingException.resolvedVia(no default). Metadata-only ADD COLUMN
on a cold table; the old colour never names the enum or the column, and its
confirm-time update continues to set onlystatus/resolvedAt(leaving
resolvedViaNULL, which the new colour treats as legacy provenance).20260720180000_seed_admin_lodge_membership_types(#2149, metadata-only data
seed) seeds the built-in ADMIN and LODGE membership types on the cold,
admin-onlyMembershipType/MembershipTypeAgeTierconfig tables. No DDL, no
schema change. The old colour resolves ADMIN/LODGE via the old role exemption
and never reads these rows for a subscription decision, so seeding changes no
old-colour output; the new colour reads them via the role→default-type fallback
after cutover. The seed create-if-missing and reconciles the
behaviour/isBuiltIn/isActivecolumns of any hand-created ADMIN/LODGE row,
preserving an admin-edited name and description.20260721100000_family_season_invoice_marker(#2161, expand) creates the
new, emptyFamilyGroupSeasonInvoiceMarkertable with its indexes, foreign
keys, and one partial UNIQUE over active markers per(familyGroupId, seasonYear). Purely additive; the old colour has no model for it. Drain-window
edge: for the marker's documented use case the old colour's #2147 suppression
predicate already suppresses the same family, so no old-colour confirm mints a
second charge; the one residual edge is a purely manual marker with no
DB-detectable invoice or coverage anywhere in the group, mitigated by running
the annual-billing confirm in a quiet admin window across the brief overlap.
- The one behaviour change is #2149: from cutover, a member's subscription
requirement is derived from membership type alone, so a fee-paying member holding
role=ADMINshows their real status, and a bare ADMIN service account can no
longer book as itself (its seeded fallback type isBLOCK_BOOKING). Real
fee-paying admins are assigned a real membership type and are unaffected; LODGE
kiosk accounts still book (MEMBER_RATE) and never owe a subscription. - Opt-in / no behaviour change until used: the family marker (#2161) only
suppresses a family once an operator marks it; the exception provenance (#2148)
only stamps new resolutions; the indicative-pricing staging (#2162) and the
config/hut-fees items (#2131/#2129) are config surfaces. The #2130 runtime-prep
changes no schema and no behaviour. - No migration makes a Xero, Stripe, or SES call, and no member is re-grouped in
Xero by any migration.
Upgrade and verification
Follow docs/UPGRADING.md from v0.12.2 to v0.13.0. In summary: take and
restore-test a fresh backup, review the four pending migrations against the safety
ledger (all expand/metadata-only, no override needed), run the annual-billing
confirm in a quiet admin window across cutover, and complete the post-upgrade
actions — chiefly the #2149 subscription-derivation behaviour change and the
still-current #2131/#2129 config/hut-fee steps.
After cutover, verify at minimum:
- Subscription derivation (#2149) — a fee-paying member holding
role=ADMIN
shows their real subscription status; a bare ADMIN/LODGE account is excluded
from the billing preview (noMISSING_MEMBERSHIP_ASSIGNMENT) and a LODGE kiosk
still books across a season rollover. - Billing dedup and void/re-bill (#2147) — the preview's "Already invoiced"
section lists members with a live invoice; voiding an invoice in Xero flips the
subscription toNOT_INVOICEDand it re-bills as a fresh charge. - Family marker (#2161) — a
finance:editoperator can mark and unmark a
family "already invoiced", the marker suppresses the family regardless of basis,
and unmark restores billing (the audit row is retained). - Admin view-only banners (#2160/#2168) — read-only areas show one banner at
the top; every gated button is still gated exactly as before. - Print / PDF in dark mode (#2146) — exporting a report while browsing dark
produces dark ink on a light page. - Public hut-fee table (#2129) — if you use the
{{hut-fees}}embed, confirm
the publicly-listed membership types produce the expected columns.
This release does not publish an npm package (package.json remains private).
The supported artefacts are the Git tag/GitHub release and the repository's
container-image release workflow.