Skip to content

v0.13.0

Choose a tag to compare

@thatskiff33 thatskiff33 released this 21 Jul 12:32
0e83763

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 season MemberSubscription as already billed
    when its status = PAID or it carries a non-null xeroInvoiceId
    (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
    (releasedAt set, row kept for audit), marks the charge VOIDED (row kept),
    bumps MemberSubscription.voidGeneration, and clears the subscription's invoice
    link to NOT_INVOICED — the member becomes re-billable, and a post-void confirm
    derives a new idempotency key that folds in voidGeneration (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
    as UNPAID (booking lockout) and now reads as NOT_INVOICED (re-billable).
  • Billing-exception resolution provenance (#2148). A new nullable
    MembershipBillingException.resolvedVia column, backed by a
    MembershipBillingExceptionResolution enum (CONFIRM | PREVIEW_RECONCILE),
    records how an exception reached RESOLVED so a preview-reconciliation
    resolution is distinguishable from a confirm-run one. Existing and legacy
    resolved rows, and every open row, stay NULL — 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 is BASED_ON_AGE_TIER) is now the only authority on whether a member owes
    a subscription, and the login Role enum is a pure permission concept again. A
    fee-paying member who happens to hold role=ADMIN now 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-backed NOT_REQUIRED fallback: 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).
    For PER_FAMILY billing, a live legacy invoice suppresses a
    family's charge only when its holder's own resolved billing basis is
    PER_FAMILY; a PER_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 new FamilyGroupSeasonInvoiceMarker
    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
    proven PER_MEMBER holder basis, with unresolvable bases carrying an
    "Unresolved basis" badge in the audit panel.

Xero and finance surfaces

  • VOIDED charges 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 audit began
    flagging one moderate and three high-severity advisories against two transitive
    packages (an axios/brace-expansion denial-of-service and prototype-pollution
    set), turning the required verify job red on main and every PR. The exact
    overrides pins were bumped (axios 1.16.11.18.1, brace-expansion
    5.0.65.0.7); xero-node still resolves ^1.7.7 and 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 useSectionEditState hook (#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 /finance body (#2137). The last
    literal-Tailwind categorical teal (waitlist-offered chip, audit-log family
    badge, family-group GROUP_CREATE badge) now reaches its hue through the
    --hue-* tokens (value-identical), the audit category badge map is de-duplicated
    into one shared module, and the /finance dashboard 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 /finance or /admin/reports while 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 a title fallback 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 (isMember columns, the pre-#1931 ENTRANCE_FEE category) at
    dry-run with a clear row-named error, and a HUT_FEE row with a blank
    membershipTypeKey is now a blocking error. v0.12.2 was the last release that
    could import the legacy shape
    — re-export archived bundles before upgrading
    (see docs/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-member SeasonRate table; it now reads MembershipTypeSeasonRate
    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; SeasonRate still has one reader and two writers in seed code
    outside src/, 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
    XeroItemCodeMapping and AgeTierSetting is narrowed with an explicit select
    (completing the read-path narrowing v0.12.2 began) so the deployed Prisma
    client stops emitting an implicit RETURNING over the doomed isMember /
    xeroContactGroup* columns, and a static source-scan guard fails CI on any
    future call site that forgets its select. 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_MIGRATIONS override 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 the VOIDED charge-status enum value, a MemberSubscription.voidGeneration
      integer (constant default 0), a nullable
      MembershipSubscriptionChargeCoverage.releasedAt, and swaps the coverage
      subscriptionId full 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 the MembershipBillingExceptionResolution enum 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 only status/resolvedAt (leaving
      resolvedVia NULL, 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-only MembershipType / MembershipTypeAgeTier config 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/isActive columns of any hand-created ADMIN/LODGE row,
      preserving an admin-edited name and description.
    • 20260721100000_family_season_invoice_marker (#2161, expand) creates the
      new, empty FamilyGroupSeasonInvoiceMarker table 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=ADMIN shows their real status, and a bare ADMIN service account can no
    longer book as itself (its seeded fallback type is BLOCK_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:

  1. 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 (no MISSING_MEMBERSHIP_ASSIGNMENT) and a LODGE kiosk
    still books across a season rollover.
  2. 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 to NOT_INVOICED and it re-bills as a fresh charge.
  3. Family marker (#2161) — a finance:edit operator 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).
  4. Admin view-only banners (#2160/#2168) — read-only areas show one banner at
    the top; every gated button is still gated exactly as before.
  5. Print / PDF in dark mode (#2146) — exporting a report while browsing dark
    produces dark ink on a light page.
  6. 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.