From dd4300a0f2a389c06351d18ecd5951ed36c68f13 Mon Sep 17 00:00:00 2001 From: Jeff Erickson <16201464+jee7s@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:59:13 -0500 Subject: [PATCH] feat(membership): cascade lapse/revocation to program enrollment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a household's org membership lapses (year boundary passes without a completed renewal) or is revoked, its program enrollments now cascade: flagged on the ops surface, members blocked from check-in and new enrollment, household + board notified once, and — after a configurable grace window — pending enrollments auto-withdrawn. - Lapsed-ness is DERIVED live from OrgMembership (isMembershipLapsed): REVOKED/ DENIED, or ACTIVE with a renewal overdue past the year boundary. NONE/never- member is never lapsed. The check-in (scan) and new-enrollment guards and the cron all use the same predicate, so a renewal lifts the block instantly. - New daily cron GET /api/cron/membership-lapse-cascade (withCron + CRON_SECRET, thin route -> runLapseCascadeSweep): flags newly-lapsed households, stamps OrgMembership.lapseFlaggedAt purely for the grace clock + notification dedup, auto-withdraws past-grace PENDING enrollments one row at a time through the shared withdrawAndReleaseHold (so scholarship holds are released +1 exactly once - never a bulk deleteMany), and clears the stamp when a household renews. - BoardSettings.membershipLapseGraceDays Int? (NULL = auto-withdraw OFF; flag/ block/notify stay on), mirroring scholarshipDenialGraceDays; settings PUT + UI. - Board force-enroll (external-admin override) still bypasses the block. - Migration 20260709010000: two additive nullable columns (expand step). - Design: docs/designs/MEMBERSHIP_LAPSE_CASCADE.md (incl. reconciliation with PR #958's NotificationLedger). Tests: unit (isMembershipLapsed / isPastGrace / cron auth gate) + integration (flag -> notify-once/dedup -> grace auto-withdraw with hold +1 per row -> renewal clears; enrollment 4xx block + board override). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RsD1zGYyqQFZaHT2wfkSB7 --- AGENTS.md | 1 + .../programSignupIntegration.test.ts | 8 + .../docs/designs/MEMBERSHIP_LAPSE_CASCADE.md | 194 ++++++++++++ .../migration.sql | 12 + checkin-app/prisma/schema.prisma | 20 ++ ...MembershipLapseCascade.integration.test.ts | 250 +++++++++++++++ .../__tests__/route.test.ts | 64 ++++ .../cron/membership-lapse-cascade/route.ts | 22 ++ .../api/programs/[id]/participants/route.ts | 10 + checkin-app/src/app/api/scan/route.ts | 8 + .../src/app/api/settings/membership/route.ts | 13 +- .../membership-ops/households/[id]/page.tsx | 22 +- .../src/app/settings/membership/page.tsx | 28 +- .../lib/membership/__tests__/lapse.test.ts | 65 ++++ checkin-app/src/lib/membership/lapse.ts | 293 ++++++++++++++++++ .../src/security/generated/classifications.ts | 2 + 16 files changed, 999 insertions(+), 13 deletions(-) create mode 100644 checkin-app/docs/designs/MEMBERSHIP_LAPSE_CASCADE.md create mode 100644 checkin-app/prisma/migrations/20260709010000_membership_lapse_cascade/migration.sql create mode 100644 checkin-app/src/app/__tests__/cronMembershipLapseCascade.integration.test.ts create mode 100644 checkin-app/src/app/api/cron/membership-lapse-cascade/__tests__/route.test.ts create mode 100644 checkin-app/src/app/api/cron/membership-lapse-cascade/route.ts create mode 100644 checkin-app/src/lib/membership/__tests__/lapse.test.ts create mode 100644 checkin-app/src/lib/membership/lapse.ts diff --git a/AGENTS.md b/AGENTS.md index c3120c0e3..7d05a69f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,6 +100,7 @@ Read these before changing the relevant area — start here, then follow links. - `DEV_INSTANCE_DESIGN.md` — the `CHECKIN_ENV` prod/dev/local model + persona-mint/impersonation (read before touching auth/env). - `DEV_DASHBOARD_DESIGN.md` — dev dashboard + seed/reset macros. - `PRODUCTION_PLAN.md`, `implementation_plan.md`, `MY_PROGRAMS_SCOPING.md`, `ARCHITECT_IDEAS_*.md` — roadmap/scoping notes. +- `MEMBERSHIP_LAPSE_CASCADE.md` — membership lapse/revocation → program-enrollment cascade (flag/block/notify + grace auto-withdraw via the daily cron). **Security** (`docs/security/`) - `SECURITY-POLICY.md` — the response-stripper / `@sensitivity` registry rules (read before adding API responses or schema fields). diff --git a/checkin-app/__tests__/programSignupIntegration.test.ts b/checkin-app/__tests__/programSignupIntegration.test.ts index 68daabb24..db3114968 100644 --- a/checkin-app/__tests__/programSignupIntegration.test.ts +++ b/checkin-app/__tests__/programSignupIntegration.test.ts @@ -60,6 +60,14 @@ jest.mock('@/lib/prisma', () => { create: jest.fn(), deleteMany: jest.fn(), }, + // Enroll route now derives lapse state via householdMembershipLapsed; + // default (undefined) reads as "no membership row" → not lapsed. + orgMembership: { + findUnique: jest.fn(), + }, + boardSettings: { + findUnique: jest.fn(), + }, programParticipant: { create: jest.fn(), findUnique: jest.fn(), diff --git a/checkin-app/docs/designs/MEMBERSHIP_LAPSE_CASCADE.md b/checkin-app/docs/designs/MEMBERSHIP_LAPSE_CASCADE.md new file mode 100644 index 000000000..c6f6455e8 --- /dev/null +++ b/checkin-app/docs/designs/MEMBERSHIP_LAPSE_CASCADE.md @@ -0,0 +1,194 @@ +# Membership Lapse / Revocation → Program-Enrollment Cascade + +**Status:** built on `feat/membership-lapse-cascade` (post-first-release). +**Interview decision:** "grace then auto-withdraw" (2026-07-07). +**Supersedes the manual board job** on the household detail page (#915) for the +detection/flag/block/withdraw arc; the manual revoke button (household ops POST) +stays as the human trigger for a revocation. + +## 1. Problem + +When a household's org membership lapses (the membership year boundary passes +without a completed renewal) or is revoked by the board, nothing today cascades +to that household's program enrollments. Members keep checking in, keep enrolling +in new programs, and keep held/pending program seats they're no longer entitled +to. Today the only lever is a board member manually revoking on the household +detail page (#915) — and even that doesn't touch program enrollments. + +## 2. Decision (from the product interview) + +1. **On lapse or revocation** a household's program enrollments are **flagged** + (visible on the ops surface), its members are **blocked** from check-in and + from **new** enrollment, and **both the household and the board are notified** + (once — not re-emailed daily). +2. **After a configurable grace window** (`BoardSettings.membershipLapseGraceDays`, + `Int?`) the household's **pending** enrollments are **auto-withdrawn**. + `NULL = auto-withdraw OFF` — flag/block/notify stay on, enrollments are only + ever withdrawn manually. This mirrors `scholarshipDenialGraceDays`' NULL-is-off + semantics exactly (never guess a default). +3. **Withdrawal routes through the shared `withdrawAndReleaseHold`** + (`lib/program/capacity.ts`) so every scholarship inventory hold is released + `+1` exactly once and Shopify seat accounting stays correct + (see `docs/PROGRAM_CAPACITY_AND_SCHOLARSHIPS.md`). History/audit is preserved; + **renewal before the deadline clears the flag** and blocks nothing further. + +## 3. What counts as "lapsed" — a derivation, not a status + +`OrgMembershipStatus` has no `LAPSED` value, and the renewal flow deliberately +does **not** auto-revoke (`lib/membership/renewal.ts`: "No auto-revoke — manual +admin action"). So lapsed-ness is **derived live** from `OrgMembership` +(`isMembershipLapsed`, `lib/membership/lapse.ts`): + +> A household is **lapsed** when its membership status is **REVOKED** or +> **DENIED**, **or** its status is **ACTIVE** but it has an **incomplete RENEWAL +> process past the membership-year boundary** (the "year boundary passed without +> renewal" case). + +- **REVOKED / DENIED** — a board act; `isActiveOrgMember` is already false. DENIED + additionally blocks login (so the check-in block is moot for them), but their + enrollments still cascade — losing membership is losing membership. +- **ACTIVE + overdue renewal** — a renewal opens `RENEWAL_LEAD_MONTHS` before the + boundary `B`, so `nextBoundary(boundary, process.createdAt) === B`; once + `now > B` and the renewal is still in an incomplete status + (`PENDING_RENEWAL` / `RENEWAL_PENDING_BG` / `PENDING_PAYMENT`) the household has + lapsed. Completing the renewal moves the process out of those statuses, so the + derivation flips false the instant they renew. +- **NONE / no membership** is **never** lapsed — those are legitimate non-members + whose (non-member-priced) program enrollments must not be swept. + +### Why derive, and stamp only for grace/dedup + +Every place that *gates behavior* — the check-in guard, the new-enrollment guard, +and the cron — calls the same live predicate, so a renewal or reactivation lifts +the block **immediately**, with no denormalized flag to keep in sync. We stamp +exactly one field, and only for the two things a derivation can't do by itself: + +- **`OrgMembership.lapseFlaggedAt DateTime?`** (household-level, via the 1:1 + membership). Chosen over a per-`ProgramParticipant.membershipLapsedAt` stamp + because lapsed-ness is a **household** property, not a per-enrollment one: one + stamp drives the **grace clock** (one deadline for the whole household) and the + **notification dedup** (one household + board notice), with zero per-row + denormalization to write or reconcile. It is **never** read to decide "is this + household blocked right now?" — that stays derived — so a stale stamp can never + block a renewed member. `@sensitivity:public`, matching the rest of + `OrgMembership`; it never enters a member-facing response. + +## 4. Data model + +| Field | Type | Meaning | +|---|---|---| +| `OrgMembership.lapseFlaggedAt` | `DateTime?` | When the cron first flagged this membership as lapsed. Grace-clock + dedup only. `NULL` = not currently flagged. | +| `BoardSettings.membershipLapseGraceDays` | `Int?` | Grace days before pending enrollments auto-withdraw. `NULL` = auto-withdraw OFF. | + +Migration `20260709010000_membership_lapse_cascade`: two additive nullable +columns (expand step; safe on populated tables — no backfill, no default). + +## 5. Flows + +### Detection cron — `GET /api/cron/membership-lapse-cascade` (daily) + +`withCron` + `CRON_SECRET`, mirroring the other crons. Thin route → delegates to +`runLapseCascadeSweep(now)` (`lib/membership/lapse.ts`) so the sweep is unit- and +integration-testable directly (the trusted-adult-expiry pattern). The route file +touches no `prisma` and reads no edge model, so it needs no `EDGE_INCLUDE_ALLOWLIST` +entry. Scheduling is an **infra follow-up** (add alongside the existing cron +schedules). + +Each run: + +1. **Find lapsed** memberships (candidates = REVOKED/DENIED, or ACTIVE with an + incomplete renewal; then filtered by the live predicate). +2. **Flag + notify the newly-lapsed** (those with `lapseFlaggedAt == null`): stamp + `lapseFlaggedAt = now`, audit (`reason: "membership_lapsed"`), email the + household's leads once, and collect them for **one** board digest email. An + already-flagged household is skipped → **no daily re-email**. +3. **Auto-withdraw** (only if `membershipLapseGraceDays` is set): for every lapsed + household flagged longer ago than the grace window, withdraw its **PENDING** + enrollments **one row at a time** through `withdrawAndReleaseHold`, each with a + `reason: "membership_lapse_withdrawn"` audit row. +4. **Clear stale flags**: any membership with `lapseFlaggedAt` set that is no + longer lapsed (renewed / reactivated) has the stamp cleared + (`reason: "membership_lapse_cleared"`), so a future re-lapse notifies again. + +### Why PENDING-only auto-withdraw + +`withdrawAndReleaseHold` deletes the row and fires the compensating `+1` **only if +`inventoryHeldAt` was set**. That's correct for **PENDING** rows (awaiting payment +or holding a scholarship seat) — reclaiming a not-yet-paid seat on lapse is right, +and the hold ledger stays balanced. An **ACTIVE** row is a paid/comped completed +transaction: deleting it restores **no** seat (the sale already decremented +Shopify and there's no outstanding hold to release), so auto-withdrawing it would +destroy paid value and drift capacity. So the sweep touches PENDING only; a member +who **pays** a pending enrollment during grace **rescues** it (it becomes ACTIVE +and is no longer swept). The board can still manually remove an ACTIVE enrollment +if policy requires. The all-enrollment *flag/block* is broader than the +PENDING-only *withdraw* — deliberately. + +### Blocking guards (live predicate, `householdMembershipLapsed`) + +- **Check-in** (`POST /api/scan`): a lapsed household's member gets a `403` + ("membership has lapsed … renew to check in"). **No override** — check-in is a + facility-access decision, and DENIED already can't log in. +- **New enrollment** (`POST /api/programs/[id]/participants`): a lapsed household's + member gets a `403` inside the `enforceLimits` block. A **board/sysadmin + force-enroll from outside the household still works** — that path sets `override` + and skips `enforceLimits` entirely (`isExternalAdmin`), so it never reaches the + guard. A household enrolling *itself* has no self-override — it must renew. + +Both guards derive live, so a renewal/reactivation lifts the block instantly, +regardless of when the cron next runs. + +### Ops visibility + +The board household-detail page (`/membership-ops/households/[id]`, the #915 +surface) shows a **"Membership lapsed — enrollments flagged"** badge when +`orgMembership.lapseFlaggedAt` is set (the field flows through the existing +admin-only, hand-shaped `orgMembership: true` include — public tier, no registry +change). Broader per-roster badges on the program-ops rosters are deferred (§8); +the `householdMembershipLapsed` / `isMembershipLapsed` helpers are the reusable +derivation for them. + +## 6. Notifications & dedup — and reconciliation with PR #958 + +Per the standalone-on-main decision, this PR ships its **own minimal** dedup +plumbing rather than depending on the open **PR #958 (staleness framework / +`NotificationLedger`)**. The dedup key here is simply **`lapseFlaggedAt` being +null vs. set**: notify on the `null → set` transition, never again while set, +reset on clear. Emails go through `lib/emailRecipients` (`emailHouseholdLeads`, +`emailBoardMembers`) → `lib/email.ts` `sendEmail` only. + +**When #958 merges**, the reconciliation is: replace the `lapseFlaggedAt`-as-dedup +role with a `NotificationLedger` entry keyed by `(household, "membership_lapsed", +cycle)`, and keep `lapseFlaggedAt` for its **other** job — the grace clock — since +the ledger dedups *notifications*, not the *auto-withdraw deadline*. The board +digest becomes one ledger-backed batch. No schema conflict is expected: `#958` +adds its own ledger table; this PR only adds `lapseFlaggedAt` + the grace knob. +If #958 lands first, this feature's notify calls should be ported to the ledger in +the merge; if this lands first, #958 subsumes the ad-hoc dedup. Either order is a +localized change in `lib/membership/lapse.ts`. + +## 7. Prod safety + +- **Migration** is additive + nullable (expand); no backfill, safe on the + populated prod tables (per `docs/DEPLOY_MIGRATION_ORDER_OF_OPERATIONS.md`). +- **`membershipLapseGraceDays` defaults to NULL** on the singleton, so **shipping + this changes no behavior until the board configures a grace period** — until + then the cascade flags/blocks/notifies but never auto-withdraws. +- **Best-effort external calls**: `withdrawAndReleaseHold`'s Shopify `+1` already + logs + emails on failure and never throws; each withdraw and each notify is + isolated per row/household so one failure doesn't abort the sweep. Emails never + fail the sweep. +- **Idempotent**: re-running the cron re-flags nobody (dedup), re-withdraws + nothing already gone (a re-deleted row hits Prisma P2025, caught per row), and + re-clears nothing. + +## 8. Deliberately deferred + +- **Cron schedule wiring** — infra follow-up (this PR ships the route + auth + gate; the schedule entry lands with the other cron schedules). +- **Per-roster lapse badges** on the program-ops surfaces — the derivation helper + is shipped; wiring badges into every roster is display-only follow-up. +- **Auto-withdrawing ACTIVE (paid) enrollments** — intentionally out of scope + (would destroy paid value / drift Shopify; see §5). Manual board removal covers + the rare case. +- **`NotificationLedger` integration** — pending PR #958 (§6). diff --git a/checkin-app/prisma/migrations/20260709010000_membership_lapse_cascade/migration.sql b/checkin-app/prisma/migrations/20260709010000_membership_lapse_cascade/migration.sql new file mode 100644 index 000000000..575d33945 --- /dev/null +++ b/checkin-app/prisma/migrations/20260709010000_membership_lapse_cascade/migration.sql @@ -0,0 +1,12 @@ +-- Membership lapse/revocation cascade to program enrollment. +-- Additive + nullable only (expand step): safe to apply to a populated table. + +-- Grace-clock + notification-dedup stamp for the lapse cascade. NULL = not +-- currently flagged. Lapsed-ness itself is derived live from OrgMembership; this +-- only times the grace window and dedups the one-time notification. +ALTER TABLE "OrgMembership" ADD COLUMN "lapseFlaggedAt" TIMESTAMP(3); + +-- Days a lapsed household keeps its flagged enrollments before auto-withdraw. +-- NULL = auto-withdraw OFF (flag/block/notify stay on). Mirrors +-- scholarshipDenialGraceDays' NULL-is-off semantics. +ALTER TABLE "BoardSettings" ADD COLUMN "membershipLapseGraceDays" INTEGER; diff --git a/checkin-app/prisma/schema.prisma b/checkin-app/prisma/schema.prisma index 61bf49d0d..f752d1618 100644 --- a/checkin-app/prisma/schema.prisma +++ b/checkin-app/prisma/schema.prisma @@ -343,6 +343,18 @@ model OrgMembership { /// @sensitivity:public isVolunteer Boolean @default(false) + /// When the daily membership-lapse cascade cron first flagged this membership as + /// lapsed (status REVOKED/DENIED, or ACTIVE with a renewal overdue past the year + /// boundary). Purely a grace-clock + notification-dedup stamp — "is this household + /// currently lapsed?" is DERIVED live (lib/membership/lapse.ts isMembershipLapsed), + /// never read off this field, so a stale stamp never blocks a renewed member. The + /// cron sets it once (dedup: one household + board notice), auto-withdraws the + /// household's PENDING enrollments once BoardSettings.membershipLapseGraceDays has + /// elapsed since it, and clears it back to null when the household is no longer + /// lapsed. NULL = not currently flagged. + /// @sensitivity:public + lapseFlaggedAt DateTime? + /// @sensitivity:public householdId Int @unique household Household @relation(fields: [householdId], references: [id]) @@ -497,6 +509,14 @@ model BoardSettings { /// withdrawal or payment; it never auto-expires). /// @sensitivity:public scholarshipDenialGraceDays Int? + /// Grace period (days) a lapsed/revoked household keeps its FLAGGED program + /// enrollments before the membership-lapse cascade cron auto-withdraws its PENDING + /// enrollments (releasing any scholarship hold +1 via withdrawAndReleaseHold). + /// NULL = auto-withdraw OFF — flagging, check-in/enrollment blocking, and the + /// household+board notification stay on; enrollments are only ever withdrawn + /// manually. Mirrors scholarshipDenialGraceDays' NULL-is-off semantics. + /// @sensitivity:public + membershipLapseGraceDays Int? /// @sensitivity:internal updatedAt DateTime @updatedAt } diff --git a/checkin-app/src/app/__tests__/cronMembershipLapseCascade.integration.test.ts b/checkin-app/src/app/__tests__/cronMembershipLapseCascade.integration.test.ts new file mode 100644 index 000000000..31f03bde9 --- /dev/null +++ b/checkin-app/src/app/__tests__/cronMembershipLapseCascade.integration.test.ts @@ -0,0 +1,250 @@ +/** + * @jest-environment node + */ +/** + * Integration tests for the membership-lapse cascade (design: + * docs/designs/MEMBERSHIP_LAPSE_CASCADE.md). Drives runLapseCascadeSweep against a + * real Postgres and the enrollment POST guard, covering the whole arc: + * lapse → flag + notify (once, deduped) → grace expiry → auto-withdraw with + * hold-ledger +1 (per row, NOT a bulk deleteMany) → renewal clears the flag, + * plus the check-in/enrollment block (4xx) and the board force-enroll override. + */ +import { runLapseCascadeSweep, householdMembershipLapsed } from '@/lib/membership/lapse'; +import { POST } from '@/app/api/programs/[id]/participants/route'; +import prisma from '@/lib/prisma'; +import { getServerSession } from 'next-auth/next'; + +// Assert notification dedup by mocking the fan-out helpers. +jest.mock('@/lib/emailRecipients', () => ({ + emailHouseholdLeads: jest.fn(), + emailBoardMembers: jest.fn(), +})); +jest.mock('next-auth/next', () => ({ getServerSession: jest.fn() })); +jest.mock('@/lib/notifications', () => ({ sendNotification: jest.fn() })); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { emailHouseholdLeads, emailBoardMembers } = require('@/lib/emailRecipients'); + +const DAY = 24 * 60 * 60 * 1000; +const daysAgo = (d: number) => new Date(Date.now() - d * DAY - DAY / 2); +const TAG = 'lapse-cascade-test'; + +const params = (id: number) => ({ params: Promise.resolve({ id: String(id) }) }) as unknown as never; +// CHECKIN_ENV=local (set for the Shopify mock) makes authenticateRequest treat a +// COOKIELESS request as a kiosk; a cookie header forces the session path instead. +const enrollReq = (programId: number, body: object) => + new Request(`http://localhost/api/programs/${programId}/participants`, { + method: 'POST', headers: { cookie: 'session=test' }, body: JSON.stringify(body), + }) as never; + +describe('Membership-lapse cascade integration', () => { + const person: Record = {}; + const household: Record = {}; + const membership: Record = {}; + let programHeld: number; + let programBlock: number; + let prevBoundary: Date | null; + let prevGrace: number | null; + let prevEnv: string | undefined; + + const mkHousehold = async (key: string, status: 'ACTIVE' | 'REVOKED' | 'DENIED' | 'NONE') => { + const hh = await prisma.household.create({ data: { name: `${TAG} ${key}` } }); + household[key] = hh.id; + const m = await prisma.orgMembership.create({ data: { householdId: hh.id, status } }); + membership[key] = m.id; + return hh.id; + }; + const mkPerson = async (key: string, householdId: number, lead = false) => { + const p = await prisma.person.create({ + data: { email: `${key}-${TAG}@example.com`, name: `${key} ${TAG}`, isHouseholdLead: lead, householdId }, + }); + person[key] = p.id; + return p.id; + }; + + beforeAll(async () => { + // Clean any leaked state from a prior run. + const leaked = await prisma.person.findMany({ where: { email: { contains: TAG } }, select: { id: true, householdId: true } }); + const pids = leaked.map((p) => p.id); + await prisma.programParticipant.deleteMany({ where: { personId: { in: pids } } }); + await prisma.auditLog.deleteMany({ where: { actorId: { in: pids } } }); + await prisma.person.deleteMany({ where: { id: { in: pids } } }); + const oldHh = await prisma.household.findMany({ where: { name: { contains: TAG } }, select: { id: true } }); + const hids = oldHh.map((h) => h.id); + await prisma.orgMembershipProcess.deleteMany({ where: { orgMembership: { householdId: { in: hids } } } }); + await prisma.orgMembership.deleteMany({ where: { householdId: { in: hids } } }); + await prisma.household.deleteMany({ where: { id: { in: hids } } }); + await prisma.program.deleteMany({ where: { name: { contains: TAG } } }); + + prevEnv = process.env.CHECKIN_ENV; + process.env.CHECKIN_ENV = 'local'; // makes adjustProgramInventory mock-log the +1 + + const s = await prisma.boardSettings.findUnique({ where: { id: 1 } }); + prevBoundary = s?.orgMembershipYearBoundary ?? null; + prevGrace = s?.membershipLapseGraceDays ?? null; + await prisma.boardSettings.upsert({ + where: { id: 1 }, + create: { id: 1, orgMembershipYearBoundary: new Date(Date.UTC(2020, 0, 1)), membershipLapseGraceDays: 7 }, + update: { orgMembershipYearBoundary: new Date(Date.UTC(2020, 0, 1)), membershipLapseGraceDays: 7 }, + }); + + const mkProgram = async (variant: string) => + (await prisma.program.create({ + data: { + name: `${TAG} ${variant}`, phase: 'UPCOMING', enrollmentStatus: 'OPEN', + shopifyVariantId: variant, orgMemberPriceCents: 1000, nonOrgMemberPriceCents: 2000, + }, + })).id; + programHeld = await mkProgram('dev-mock-variant-lapse'); + programBlock = await mkProgram('dev-mock-variant-lapse-2'); + + // R = REVOKED. rHeld holds a scholarship seat (PENDING + inventoryHeldAt); + // rPaid is a completed ACTIVE enrollment (must never be auto-withdrawn). + const rId = await mkHousehold('R', 'REVOKED'); + await mkPerson('rHeld', rId, true); + await mkPerson('rPaid', rId); + await prisma.programParticipant.create({ data: { programId: programHeld, personId: person.rHeld, status: 'PENDING', inventoryHeldAt: new Date() } }); + await prisma.programParticipant.create({ data: { programId: programHeld, personId: person.rPaid, status: 'ACTIVE' } }); + + // O = ACTIVE but a renewal opened long ago is overdue past the boundary. + const oId = await mkHousehold('O', 'ACTIVE'); + await mkPerson('oM', oId, true); + await prisma.orgMembershipProcess.create({ data: { orgMembershipId: membership.O, kind: 'RENEWAL', status: 'PENDING_RENEWAL', createdAt: daysAgo(400) } }); + await prisma.programParticipant.create({ data: { programId: programHeld, personId: person.oM, status: 'PENDING' } }); + + // A = healthy ACTIVE member (no in-flight renewal). Control. + const aId = await mkHousehold('A', 'ACTIVE'); + await mkPerson('aM', aId, true); + await prisma.programParticipant.create({ data: { programId: programHeld, personId: person.aM, status: 'PENDING' } }); + + // N = never a member. Control (its non-member enrollment must be left alone). + const nId = await mkHousehold('N', 'NONE'); + await mkPerson('nM', nId, true); + await prisma.programParticipant.create({ data: { programId: programHeld, personId: person.nM, status: 'PENDING' } }); + + // Board sysadmin (own household) for the force-enroll override. + const bId = await mkHousehold('B', 'ACTIVE'); + const b = await prisma.person.create({ data: { email: `bAdmin-${TAG}@example.com`, name: `bAdmin ${TAG}`, isSysadmin: true, householdId: bId } }); + person.bAdmin = b.id; + }); + + afterAll(async () => { + const pids = Object.values(person); + const hids = Object.values(household); + await prisma.programParticipant.deleteMany({ where: { personId: { in: pids } } }); + await prisma.auditLog.deleteMany({ where: { OR: [{ actorId: { in: [...pids, 0] }, tableName: { in: ['ProgramParticipant', 'OrgMembership'] } }] } }); + await prisma.orgMembershipProcess.deleteMany({ where: { orgMembershipId: { in: Object.values(membership) } } }); + await prisma.person.deleteMany({ where: { id: { in: pids } } }); + await prisma.orgMembership.deleteMany({ where: { householdId: { in: hids } } }); + await prisma.household.deleteMany({ where: { id: { in: hids } } }); + await prisma.program.deleteMany({ where: { id: { in: [programHeld, programBlock] } } }); + await prisma.boardSettings.update({ where: { id: 1 }, data: { orgMembershipYearBoundary: prevBoundary, membershipLapseGraceDays: prevGrace } }); + if (prevEnv === undefined) delete process.env.CHECKIN_ENV; else process.env.CHECKIN_ENV = prevEnv; + }); + + const flaggedAt = async (key: string) => + (await prisma.orgMembership.findUnique({ where: { id: membership[key] }, select: { lapseFlaggedAt: true } }))?.lapseFlaggedAt ?? null; + + it('flags REVOKED and overdue-ACTIVE households, notifies once, leaves healthy members + non-members alone', async () => { + jest.clearAllMocks(); + const res = await runLapseCascadeSweep(new Date()); + + expect(res.lapsed).toBe(2); // R + O + expect(res.newlyFlagged).toBe(2); + expect(await flaggedAt('R')).not.toBeNull(); + expect(await flaggedAt('O')).not.toBeNull(); + expect(await flaggedAt('A')).toBeNull(); + expect(await flaggedAt('N')).toBeNull(); + + // One household notice per newly-flagged household, one board digest for the run. + expect(emailHouseholdLeads).toHaveBeenCalledTimes(2); + expect(emailBoardMembers).toHaveBeenCalledTimes(1); + + // Nothing withdrawn yet — flagged this run, still inside the 7-day grace. + expect(res.withdrawn).toBe(0); + const audit = await prisma.auditLog.findFirst({ where: { tableName: 'OrgMembership', affectedEntityId: membership.R } }); + expect((audit?.newData as { reason?: string })?.reason).toBe('membership_lapsed'); + }); + + it('dedups — a second run re-flags nobody and sends no further email', async () => { + jest.clearAllMocks(); + const before = await flaggedAt('R'); + const res = await runLapseCascadeSweep(new Date()); + + expect(res.newlyFlagged).toBe(0); + expect(emailHouseholdLeads).not.toHaveBeenCalled(); + expect(emailBoardMembers).not.toHaveBeenCalled(); + expect((await flaggedAt('R'))?.getTime()).toBe(before?.getTime()); // stamp untouched + }); + + it('auto-withdraws PENDING enrollments past grace, releasing each hold +1 per row (not a bulk deleteMany), sparing ACTIVE rows', async () => { + // Age R's flag past the 7-day grace window. + await prisma.orgMembership.update({ where: { id: membership.R }, data: { lapseFlaggedAt: daysAgo(10) } }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.clearAllMocks(); + try { + const res = await runLapseCascadeSweep(new Date()); + expect(res.withdrawn).toBe(1); // only rHeld's PENDING row + + // rHeld's held PENDING row: gone, and its seat released +1 (per-row, via + // withdrawAndReleaseHold — a bulk deleteMany would emit ZERO of these). + const held = await prisma.programParticipant.findUnique({ where: { programId_personId: { programId: programHeld, personId: person.rHeld } } }); + expect(held).toBeNull(); + const releaseLogs = logSpy.mock.calls.filter((c) => String(c[0]).includes('Would adjust inventory by 1 for variants: dev-mock-variant-lapse')); + expect(releaseLogs).toHaveLength(1); + + // Per-row audit (a deleteMany produces none). + const audit = await prisma.auditLog.findFirst({ where: { tableName: 'ProgramParticipant', affectedEntityId: person.rHeld, secondaryAffectedEntity: programHeld } }); + expect((audit?.newData as { reason?: string })?.reason).toBe('membership_lapse_withdrawn'); + + // rPaid's ACTIVE (paid) row is spared. + const paid = await prisma.programParticipant.findUnique({ where: { programId_personId: { programId: programHeld, personId: person.rPaid } } }); + expect(paid?.status).toBe('ACTIVE'); + // O flagged this-run-ago (not past grace) — its pending row untouched. + const oRow = await prisma.programParticipant.findUnique({ where: { programId_personId: { programId: programHeld, personId: person.oM } } }); + expect(oRow).not.toBeNull(); + } finally { + logSpy.mockRestore(); + } + }); + + it('renewal before the deadline clears the flag and blocks nothing further', async () => { + // O completes its renewal → the incomplete RENEWAL process leaves the open set. + await prisma.orgMembershipProcess.updateMany({ where: { orgMembershipId: membership.O, kind: 'RENEWAL' }, data: { status: 'ACTIVE' } }); + const res = await runLapseCascadeSweep(new Date()); + + expect(res.cleared).toBeGreaterThanOrEqual(1); + expect(await flaggedAt('O')).toBeNull(); + expect(await householdMembershipLapsed(household.O)).toBe(false); + const cleared = await prisma.auditLog.findFirst({ where: { tableName: 'OrgMembership', affectedEntityId: membership.O, newData: { path: ['reason'], equals: 'membership_lapse_cleared' } } }); + expect(cleared).not.toBeNull(); + }); + + it('householdMembershipLapsed gates lapsed vs healthy households (the shared check-in/enrollment guard)', async () => { + expect(await householdMembershipLapsed(household.R)).toBe(true); // REVOKED + expect(await householdMembershipLapsed(household.A)).toBe(false); // healthy ACTIVE + expect(await householdMembershipLapsed(household.N)).toBe(false); // never a member + }); + + describe('enrollment block (4xx) + board override', () => { + it('403s a lapsed household member self-enrolling in a new program', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { id: person.rHeld } }); + const res = await POST(enrollReq(programBlock, { participantId: person.rHeld }), params(programBlock)); + expect(res.status).toBe(403); + expect((await res.json()).error).toMatch(/membership has lapsed/i); + }); + + it('lets a healthy member enroll (control)', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { id: person.aM } }); + const res = await POST(enrollReq(programBlock, { participantId: person.aM }), params(programBlock)); + expect(res.status).toBe(200); + }); + + it('still lets a board member force-enroll a lapsed member (override bypasses the block)', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { id: person.bAdmin, isSysadmin: true } }); + const res = await POST(enrollReq(programBlock, { participantId: person.rHeld, override: true }), params(programBlock)); + expect(res.status).toBe(200); + expect((await res.json()).success).toBe(true); + }); + }); +}); diff --git a/checkin-app/src/app/api/cron/membership-lapse-cascade/__tests__/route.test.ts b/checkin-app/src/app/api/cron/membership-lapse-cascade/__tests__/route.test.ts new file mode 100644 index 000000000..d258f4fb4 --- /dev/null +++ b/checkin-app/src/app/api/cron/membership-lapse-cascade/__tests__/route.test.ts @@ -0,0 +1,64 @@ +/** + * @jest-environment node + */ +/** + * Route-level auth-gate test for GET /api/cron/membership-lapse-cascade. The + * sweep (runLapseCascadeSweep) is integration-tested against a real DB elsewhere; + * here the sweep is mocked so this is a pure unit test of the withCron gate and + * the success envelope. + */ +import { GET } from '../route'; + +jest.mock('@/lib/membership/lapse', () => ({ + runLapseCascadeSweep: jest.fn(), +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { runLapseCascadeSweep } = require('@/lib/membership/lapse'); + +const SECRET = 'cron-secret-under-test'; + +function req(authHeader?: string) { + return new Request('http://localhost/api/cron/membership-lapse-cascade', { + method: 'GET', + ...(authHeader ? { headers: { authorization: authHeader } } : {}), + }); +} + +describe('GET /api/cron/membership-lapse-cascade — auth gate', () => { + const prev = process.env.CRON_SECRET; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.CRON_SECRET = SECRET; + }); + + afterAll(() => { + if (prev === undefined) delete process.env.CRON_SECRET; + else process.env.CRON_SECRET = prev; + }); + + it('401 when the Authorization header is missing', async () => { + const res = await GET(req()); + expect(res.status).toBe(401); + expect(runLapseCascadeSweep).not.toHaveBeenCalled(); + }); + + it('401 when the bearer secret is wrong', async () => { + const res = await GET(req('Bearer not-the-secret')); + expect(res.status).toBe(401); + expect(runLapseCascadeSweep).not.toHaveBeenCalled(); + }); + + it('200 with the success envelope when the secret is correct', async () => { + (runLapseCascadeSweep as jest.Mock).mockResolvedValue({ + candidates: 3, lapsed: 2, newlyFlagged: 2, withdrawn: 1, cleared: 0, autoWithdrawEnabled: true, + }); + + const res = await GET(req(`Bearer ${SECRET}`)); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ success: true, candidates: 3, lapsed: 2, newlyFlagged: 2, withdrawn: 1, cleared: 0, autoWithdrawEnabled: true }); + expect(runLapseCascadeSweep).toHaveBeenCalledTimes(1); + }); +}); diff --git a/checkin-app/src/app/api/cron/membership-lapse-cascade/route.ts b/checkin-app/src/app/api/cron/membership-lapse-cascade/route.ts new file mode 100644 index 000000000..e0db8995d --- /dev/null +++ b/checkin-app/src/app/api/cron/membership-lapse-cascade/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { withCron } from "@/lib/cronAuth"; +import { logger } from "@/lib/logger"; +import { runLapseCascadeSweep } from "@/lib/membership/lapse"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/cron/membership-lapse-cascade — daily. Flags households whose org + * membership has lapsed (revoked/denied, or a renewal overdue past the year + * boundary), notifies them and the board once, and — once + * BoardSettings.membershipLapseGraceDays has elapsed (NULL = auto-withdraw off) — + * auto-withdraws their PENDING program enrollments via withdrawAndReleaseHold. + * See docs/designs/MEMBERSHIP_LAPSE_CASCADE.md. Scheduling is an infra follow-up + * (add alongside the other cron schedules). Authorized by + * `Authorization: Bearer $CRON_SECRET` (see lib/cronAuth.ts). + */ +export const GET = withCron(async () => { + const result = await runLapseCascadeSweep(new Date()); + logger.info("[CRON] membership-lapse cascade sweep:", result); + return NextResponse.json({ success: true, ...result }); +}); diff --git a/checkin-app/src/app/api/programs/[id]/participants/route.ts b/checkin-app/src/app/api/programs/[id]/participants/route.ts index 0e869b9f0..612b2d680 100644 --- a/checkin-app/src/app/api/programs/[id]/participants/route.ts +++ b/checkin-app/src/app/api/programs/[id]/participants/route.ts @@ -5,6 +5,7 @@ import prisma from "@/lib/prisma"; import { sendNotification } from "@/lib/notifications"; import { lockProgramAndCheckCapacity, ProgramCapacityError, withdrawAndReleaseHold } from "@/lib/program/capacity"; import { checkProgramAge } from "@/lib/programAge"; +import { householdMembershipLapsed } from "@/lib/membership/lapse"; import { apiError } from "@/lib/api-response"; export const POST = withAuth({}, async (req, auth, { params }: { params: Promise<{ id: string }> }) => { @@ -108,6 +109,15 @@ export const POST = withAuth({}, async (req, auth, { params }: { params: Promise : "Participant is outside this program's age range."; return NextResponse.json({ error, requiresOverride: true }, { status: 400 }); } + + // Membership-lapse cascade: a member of a lapsed/revoked household is + // blocked from NEW enrollment. This is a hard block for the household + // itself (no self-override — they must renew); a board/sysadmin + // force-enroll from OUTSIDE the household still works because that path + // sets override and skips enforceLimits entirely (see isExternalAdmin). + if (await householdMembershipLapsed(participantData?.householdId)) { + return apiError("This participant's household Treehouse membership has lapsed. Renew to enroll (or a board member can force-enroll).", 403); + } } const isFree = currentProgram.orgMemberPriceCents === null && currentProgram.nonOrgMemberPriceCents === null; diff --git a/checkin-app/src/app/api/scan/route.ts b/checkin-app/src/app/api/scan/route.ts index 9568210e3..d3bb21bc9 100644 --- a/checkin-app/src/app/api/scan/route.ts +++ b/checkin-app/src/app/api/scan/route.ts @@ -4,6 +4,7 @@ import { apiError } from "@/lib/api-response"; import { processCheckin, processCheckout, finalizeFacilityClose } from "@/lib/scan-service"; import { config } from "@/lib/config"; import { withKiosk } from "@/lib/kioskAuth"; +import { householdMembershipLapsed } from "@/lib/membership/lapse"; // High cap: kiosks burst and a whole facility may share one NAT IP. withKiosk // reads the raw body, authenticates it (kiosk signature OR session), rejects @@ -58,6 +59,13 @@ export const POST = withKiosk( } } + // Membership-lapse cascade: a member of a lapsed/revoked household can't + // check in until the membership is renewed (derived live — a renewal lifts + // this instantly). No admin override on check-in (unlike enrollment). + if (await householdMembershipLapsed(participant.householdId)) { + return apiError("Check-in is blocked: this household's Treehouse membership has lapsed. Please renew to check in.", 403); + } + // Steps 4–6 (debounce read → record event → find visit → check-in/out) // are a read-modify-write on this participant's visit state. Without // serialization, two near-simultaneous scans for the same participant diff --git a/checkin-app/src/app/api/settings/membership/route.ts b/checkin-app/src/app/api/settings/membership/route.ts index 19cb517d0..a7c4b00ce 100644 --- a/checkin-app/src/app/api/settings/membership/route.ts +++ b/checkin-app/src/app/api/settings/membership/route.ts @@ -18,7 +18,8 @@ export const GET = withAuth({ roles: ["isSysadmin", "isBoardMember"] }, async () * PUT /api/settings/membership — update board settings. * Body may include: normalDuesCents, volunteerDuesCents, orgMembershipYearBoundary (ISO|null), * orgMembershipVariantId (string|null), volunteerDiscountCode (string|null), - * scholarshipDenialGraceDays (positive int|null — null disables the grace-period expiry cron). + * scholarshipDenialGraceDays (positive int|null — null disables the grace-period expiry cron), + * membershipLapseGraceDays (positive int|null — null disables lapse-cascade auto-withdraw). * Dues must be finite and >= 0; an invalid value rejects the whole update (400) so the * previous value survives rather than silently collapsing to zero. (The Averity consent * link is an env var, not a board setting. Email sender identity lives in /api/settings/email.) @@ -34,6 +35,7 @@ export const PUT = withAuth({ roles: ["isSysadmin", "isBoardMember"] }, async (r bgRecheckMonths?: number; devSigningTarget?: string | null; scholarshipDenialGraceDays?: number | null; + membershipLapseGraceDays?: number | null; }; try { body = await req.json(); @@ -84,6 +86,15 @@ export const PUT = withAuth({ roles: ["isSysadmin", "isBoardMember"] }, async (r } data.scholarshipDenialGraceDays = body.scholarshipDenialGraceDays; } + // membershipLapseGraceDays: null = auto-withdraw off (flag/block/notify stay on); otherwise a positive integer. + if (body.membershipLapseGraceDays !== undefined) { + if (body.membershipLapseGraceDays !== null) { + if (!Number.isFinite(body.membershipLapseGraceDays) || !Number.isInteger(body.membershipLapseGraceDays) || body.membershipLapseGraceDays <= 0) { + return apiError("membershipLapseGraceDays must be a positive whole number of days, or null", 400); + } + } + data.membershipLapseGraceDays = body.membershipLapseGraceDays; + } const settings = await prisma.boardSettings.upsert({ where: { id: 1 }, diff --git a/checkin-app/src/app/membership-ops/households/[id]/page.tsx b/checkin-app/src/app/membership-ops/households/[id]/page.tsx index eb67d1cc1..a1ee62705 100644 --- a/checkin-app/src/app/membership-ops/households/[id]/page.tsx +++ b/checkin-app/src/app/membership-ops/households/[id]/page.tsx @@ -20,7 +20,7 @@ type Member = { type Household = { id: number; name?: string | null; - orgMembership?: { status: string } | null; + orgMembership?: { status: string; lapseFlaggedAt?: string | null } | null; householdMembers?: Member[] | null; }; @@ -69,6 +69,7 @@ export default function HouseholdDetailPage({ params }: { params: Promise<{ id: } const status = household.orgMembership?.status; + const lapsed = !!household.orgMembership?.lapseFlaggedAt; const members = household.householdMembers ?? []; return ( @@ -79,13 +80,18 @@ export default function HouseholdDetailPage({ params }: { params: Promise<{ id: {household.name || `Household #${household.id}`} - {status === "DENIED" ? ( - Denied - ) : status === "ACTIVE" ? ( - Member - ) : ( - Not a member - )} + + {/* Lapse cascade: members blocked from check-in + new enrollment; pending + enrollments auto-withdraw after the grace window. */} + {lapsed && Membership lapsed — enrollments flagged} + {status === "DENIED" ? ( + Denied + ) : status === "ACTIVE" ? ( + Member + ) : ( + Not a member + )} + {members.length === 0 ? ( diff --git a/checkin-app/src/app/settings/membership/page.tsx b/checkin-app/src/app/settings/membership/page.tsx index 5d4e0afa3..cdc66a4af 100644 --- a/checkin-app/src/app/settings/membership/page.tsx +++ b/checkin-app/src/app/settings/membership/page.tsx @@ -18,6 +18,7 @@ interface Settings { bgRecheckMonths: number; devSigningTarget: string | null; scholarshipDenialGraceDays: number | null; + membershipLapseGraceDays: number | null; } const dollars = (cents: number) => (cents / 100).toFixed(2); @@ -38,6 +39,7 @@ export default function MembershipSettingsPage() { const [volunteerDues, setVolunteerDues] = useState("0"); const [bgRecheckMonths, setBgRecheckMonths] = useState("0"); const [scholarshipGraceDays, setScholarshipGraceDays] = useState(""); + const [lapseGraceDays, setLapseGraceDays] = useState(""); const [boundary, setBoundary] = useState(""); const [boundaryUnlocked, setBoundaryUnlocked] = useState(false); const [variantId, setVariantId] = useState(""); @@ -57,7 +59,7 @@ export default function MembershipSettingsPage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [fieldErrors, setFieldErrors] = useState<{ normalDues?: string; volunteerDues?: string; variantId?: string; scholarshipGraceDays?: string }>({}); + const [fieldErrors, setFieldErrors] = useState<{ normalDues?: string; volunteerDues?: string; variantId?: string; scholarshipGraceDays?: string; lapseGraceDays?: string }>({}); const [saveNotice, setSaveNotice] = useState<{ text: string; err: boolean } | null>(null); const [renewalNotice, setRenewalNotice] = useState<{ text: string; err: boolean } | null>(null); @@ -72,6 +74,7 @@ export default function MembershipSettingsPage() { volunteerDues: dollars(settings.volunteerDuesCents), bgRecheckMonths: String(settings.bgRecheckMonths ?? 0), scholarshipGraceDays: settings.scholarshipDenialGraceDays != null ? String(settings.scholarshipDenialGraceDays) : "", + lapseGraceDays: settings.membershipLapseGraceDays != null ? String(settings.membershipLapseGraceDays) : "", boundary: settings.orgMembershipYearBoundary ? settings.orgMembershipYearBoundary.slice(0, 10) : "", variantId: settings.orgMembershipVariantId ?? "", discountCode: settings.volunteerDiscountCode ?? "", @@ -81,6 +84,7 @@ export default function MembershipSettingsPage() { setVolunteerDues(snap.volunteerDues); setBgRecheckMonths(snap.bgRecheckMonths); setScholarshipGraceDays(snap.scholarshipGraceDays); + setLapseGraceDays(snap.lapseGraceDays); setBoundary(snap.boundary); setVariantId(snap.variantId); setDiscountCode(snap.discountCode); @@ -101,7 +105,7 @@ export default function MembershipSettingsPage() { const saveSettings = async () => { setSaveNotice(null); setFieldErrors({}); - const fe: { normalDues?: string; volunteerDues?: string; variantId?: string; scholarshipGraceDays?: string } = {}; + const fe: { normalDues?: string; volunteerDues?: string; variantId?: string; scholarshipGraceDays?: string; lapseGraceDays?: string } = {}; const nd = parseFloat(normalDues); if (normalDues.trim() === "" || isNaN(nd) || nd < 0) fe.normalDues = "Enter a dollar amount of 0 or more."; const vd = parseFloat(volunteerDues); if (volunteerDues.trim() === "" || isNaN(vd) || vd < 0) fe.volunteerDues = "Enter a dollar amount of 0 or more."; if (variantId.trim() !== "" && !/^\d+$/.test(variantId.trim())) fe.variantId = "Must be a numeric Shopify variant ID."; @@ -109,7 +113,11 @@ export default function MembershipSettingsPage() { const g = parseInt(scholarshipGraceDays.trim(), 10); if (!Number.isInteger(g) || g <= 0 || String(g) !== scholarshipGraceDays.trim()) fe.scholarshipGraceDays = "Enter a positive whole number of days, or leave blank to disable."; } - if (fe.normalDues || fe.volunteerDues || fe.variantId || fe.scholarshipGraceDays) { setFieldErrors(fe); return; } + if (lapseGraceDays.trim() !== "") { + const g = parseInt(lapseGraceDays.trim(), 10); + if (!Number.isInteger(g) || g <= 0 || String(g) !== lapseGraceDays.trim()) fe.lapseGraceDays = "Enter a positive whole number of days, or leave blank to disable."; + } + if (fe.normalDues || fe.volunteerDues || fe.variantId || fe.scholarshipGraceDays || fe.lapseGraceDays) { setFieldErrors(fe); return; } setSaving(true); try { const res = await fetch("/api/settings/membership", { @@ -122,6 +130,7 @@ export default function MembershipSettingsPage() { volunteerDiscountCode: discountCode.trim() || null, bgRecheckMonths: Math.round(parseInt(bgRecheckMonths || "0", 10)), scholarshipDenialGraceDays: scholarshipGraceDays.trim() === "" ? null : parseInt(scholarshipGraceDays.trim(), 10), + membershipLapseGraceDays: lapseGraceDays.trim() === "" ? null : parseInt(lapseGraceDays.trim(), 10), // Dev instances only — the API rejects it elsewhere. ...(isDev ? { devSigningTarget: signingTarget } : {}), // Send the boundary when the unlock is checked, or when it was never set (no @@ -154,7 +163,7 @@ export default function MembershipSettingsPage() { const isDirty = !!initial && - !shallowEqual(initial, { normalDues, volunteerDues, bgRecheckMonths, scholarshipGraceDays, boundary, variantId, discountCode, signingTarget }); + !shallowEqual(initial, { normalDues, volunteerDues, bgRecheckMonths, scholarshipGraceDays, lapseGraceDays, boundary, variantId, discountCode, signingTarget }); useUnsavedGuard(isDirty); return ( @@ -208,6 +217,17 @@ export default function MembershipSettingsPage() { value={scholarshipGraceDays} onChange={(e) => { setScholarshipGraceDays(e.currentTarget.value); setFieldErrors((f) => ({ ...f, scholarshipGraceDays: undefined })); }} /> + days} + rightSectionWidth={44} + inputMode="numeric" + w={220} + error={fieldErrors.lapseGraceDays} + value={lapseGraceDays} + onChange={(e) => { setLapseGraceDays(e.currentTarget.value); setFieldErrors((f) => ({ ...f, lapseGraceDays: undefined })); }} + /> {(!bgRecheckMonths || parseInt(bgRecheckMonths, 10) <= 0) && ( diff --git a/checkin-app/src/lib/membership/__tests__/lapse.test.ts b/checkin-app/src/lib/membership/__tests__/lapse.test.ts new file mode 100644 index 000000000..7aff2eeec --- /dev/null +++ b/checkin-app/src/lib/membership/__tests__/lapse.test.ts @@ -0,0 +1,65 @@ +import { isMembershipLapsed, isPastGrace } from "@/lib/membership/lapse"; + +const D = (y: number, m: number, d: number) => new Date(Date.UTC(y, m, d)); +const DAY = 24 * 60 * 60 * 1000; + +describe("isMembershipLapsed — the lapse derivation", () => { + // A renewal opened ~2mo before a Jan-1 boundary; the boundary it targets is + // Jan 1 2024. `now` decides whether that boundary has passed. + const boundary = D(2020, 0, 1); // Jan 1 (month/day only) + const renewalOpened = D(2023, 10, 1); // Nov 1 2023 → targets Jan 1 2024 + + it("REVOKED is always lapsed (no boundary needed)", () => { + expect(isMembershipLapsed({ status: "REVOKED", renewalProcesses: [] }, null, D(2024, 5, 1))).toBe(true); + }); + + it("DENIED is always lapsed", () => { + expect(isMembershipLapsed({ status: "DENIED", renewalProcesses: [] }, boundary, D(2024, 5, 1))).toBe(true); + }); + + it("NONE / never-a-member is never lapsed", () => { + expect(isMembershipLapsed({ status: "NONE", renewalProcesses: [{ createdAt: renewalOpened }] }, boundary, D(2024, 5, 1))).toBe(false); + }); + + it("ACTIVE with a renewal overdue past the boundary IS lapsed", () => { + // now = Mar 1 2024 > Jan 1 2024 boundary → overdue. + expect(isMembershipLapsed({ status: "ACTIVE", renewalProcesses: [{ createdAt: renewalOpened }] }, boundary, D(2024, 2, 1))).toBe(true); + }); + + it("ACTIVE with a renewal still before its boundary is NOT lapsed", () => { + // now = Dec 15 2023 < Jan 1 2024 boundary → not yet overdue. + expect(isMembershipLapsed({ status: "ACTIVE", renewalProcesses: [{ createdAt: renewalOpened }] }, boundary, D(2023, 11, 15))).toBe(false); + }); + + it("ACTIVE with no in-flight renewal is NOT lapsed", () => { + expect(isMembershipLapsed({ status: "ACTIVE", renewalProcesses: [] }, boundary, D(2024, 5, 1))).toBe(false); + }); + + it("ACTIVE + overdue renewal but no boundary configured is NOT lapsed (never guess)", () => { + expect(isMembershipLapsed({ status: "ACTIVE", renewalProcesses: [{ createdAt: renewalOpened }] }, null, D(2024, 5, 1))).toBe(false); + }); +}); + +describe("isPastGrace — the grace-window math", () => { + const now = D(2024, 5, 15); + + it("flagged now with 0 grace days is immediately past grace", () => { + expect(isPastGrace(now, 0, now)).toBe(true); + }); + + it("flagged now with a positive grace window is NOT yet past", () => { + expect(isPastGrace(now, 7, now)).toBe(false); + }); + + it("flagged 3 days ago is still inside a 7-day window", () => { + expect(isPastGrace(new Date(now.getTime() - 3 * DAY), 7, now)).toBe(false); + }); + + it("flagged 10 days ago is past a 7-day window", () => { + expect(isPastGrace(new Date(now.getTime() - 10 * DAY), 7, now)).toBe(true); + }); + + it("is exact at the boundary (flagged exactly graceDays ago counts as past)", () => { + expect(isPastGrace(new Date(now.getTime() - 7 * DAY), 7, now)).toBe(true); + }); +}); diff --git a/checkin-app/src/lib/membership/lapse.ts b/checkin-app/src/lib/membership/lapse.ts new file mode 100644 index 000000000..d97b0a638 --- /dev/null +++ b/checkin-app/src/lib/membership/lapse.ts @@ -0,0 +1,293 @@ +import type { OrgMembershipStatus, OrgMembershipProcessStatus } from "@/generated/prisma/client"; +import prisma from "@/lib/prisma"; +import { logger } from "@/lib/logger"; +import { config } from "@/lib/config"; +import { escapeHtml } from "@/lib/email-templates/base"; +import { emailHouseholdLeads, emailBoardMembers } from "@/lib/emailRecipients"; +import { withdrawAndReleaseHold } from "@/lib/program/capacity"; +import { nextBoundary } from "@/lib/membership/renewal"; + +/** + * Membership lapse/revocation → program-enrollment cascade. + * (Design: docs/designs/MEMBERSHIP_LAPSE_CASCADE.md; interview decision + * "grace then auto-withdraw", 2026-07-07.) + * + * A household's membership "lapses" when it loses a membership it held: + * - status REVOKED or DENIED (a board act), or + * - status ACTIVE but a RENEWAL process is still incomplete past the membership + * year boundary (the "year boundary passed without renewal" case). + * NONE / no-membership never lapses — those are legitimate non-members, whose + * (non-member-priced) program enrollments must not be swept. + * + * Lapsed-ness is DERIVED live from OrgMembership (isMembershipLapsed) everywhere + * it gates behavior — the check-in and enrollment guards and this cron — so a + * renewal clears the block the instant status/renewal state changes, with no + * stamp to keep in sync. OrgMembership.lapseFlaggedAt is only a grace-clock + + * notification-dedup stamp maintained by the cron, never the source of truth for + * "is this household blocked right now?". + */ + +const SYSTEM_ACTOR = 0; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** RENEWAL process statuses that mean "renewal is still in flight" (not yet + * completed/abandoned). Mirrors the set renewal.ts treats as an open renewal. */ +export const RENEWAL_INCOMPLETE: readonly OrgMembershipProcessStatus[] = [ + "PENDING_RENEWAL", + "RENEWAL_PENDING_BG", + "PENDING_PAYMENT", +]; + +/** Pure grace-window math: has `flaggedAt` aged past `graceDays` as of `now`? + * graceDays 0 = no grace (withdraw the run it's flagged); the caller only invokes + * this when a grace period is configured (NULL grace = auto-withdraw off). */ +export function isPastGrace(flaggedAt: Date, graceDays: number, now: Date): boolean { + return flaggedAt.getTime() <= now.getTime() - graceDays * DAY_MS; +} + +/** Minimal loaded shape the pure predicate needs. */ +export interface LapseInput { + status: OrgMembershipStatus; + /** Incomplete RENEWAL processes for this membership (createdAt only). */ + renewalProcesses: { createdAt: Date }[]; +} + +/** + * Pure predicate: is this membership currently lapsed? Derives entirely from the + * membership's status + its incomplete renewal processes + the global year + * boundary — no dependence on lapseFlaggedAt (that's grace/dedup only). + * + * A renewal opens ~RENEWAL_LEAD_MONTHS before the boundary B, so + * nextBoundary(boundaryMonthDay, process.createdAt) === B; once now > B the + * renewal is overdue and the household has lapsed. (Late-opened renewals resolve + * to the next boundary — the derivation flags conservatively, never early.) + */ +export function isMembershipLapsed( + m: LapseInput, + boundaryMonthDay: Date | null | undefined, + now: Date, +): boolean { + if (m.status === "REVOKED" || m.status === "DENIED") return true; + if (m.status !== "ACTIVE") return false; // NONE / never a member + if (!boundaryMonthDay) return false; // no boundary configured → nothing is overdue + return m.renewalProcesses.some( + (p) => now.getTime() > nextBoundary(boundaryMonthDay, p.createdAt).getTime(), + ); +} + +/** + * Guard helper for the check-in (scan) and new-enrollment routes: does this + * person's household have a currently-lapsed membership? Derives live. Cheap for + * the common case — REVOKED/DENIED short-circuits with no boundary lookup, and an + * ACTIVE membership with no in-flight renewal returns before fetching settings. + */ +export async function householdMembershipLapsed(householdId: number | null | undefined): Promise { + if (!householdId) return false; + const m = await prisma.orgMembership.findUnique({ + where: { householdId }, + select: { + status: true, + processes: { + where: { kind: "RENEWAL", status: { in: [...RENEWAL_INCOMPLETE] } }, + select: { createdAt: true }, + }, + }, + }); + if (!m) return false; + if (m.status === "REVOKED" || m.status === "DENIED") return true; + if (m.status !== "ACTIVE" || m.processes.length === 0) return false; + const settings = await prisma.boardSettings.findUnique({ + where: { id: 1 }, + select: { orgMembershipYearBoundary: true }, + }); + return isMembershipLapsed({ status: m.status, renewalProcesses: m.processes }, settings?.orgMembershipYearBoundary, new Date()); +} + +/** One household notice to the household's leads. Best-effort (errors swallowed). */ +async function notifyLapsedHousehold(householdId: number, graceDays: number | null): Promise { + const base = config.baseUrl(); + const graceLine = + graceDays !== null + ? `

If your membership isn't renewed within ${graceDays} day(s), those enrollments will be automatically withdrawn.

` + : ""; + await emailHouseholdLeads( + householdId, + "Your Treehouse membership has lapsed", + `

Your household's Treehouse membership has lapsed. While it is lapsed, household members can't check in or enroll in new programs, and existing program enrollments are flagged.

${graceLine}

Renew here: ${base}/membership

`, + "Membership-lapse household notice failed:", + ); +} + +/** Single board digest listing every household newly flagged this run. */ +async function notifyBoardOfLapses(households: { householdId: number; name: string | null }[]): Promise { + if (households.length === 0) return; + const base = config.baseUrl(); + const rows = households + .map( + (h) => + `
  • ${escapeHtml(h.name || `Household #${h.householdId}`)}
  • `, + ) + .join(""); + await emailBoardMembers( + `Membership lapses: ${households.length} household(s) flagged`, + `

    The following household(s) lapsed and had their program enrollments flagged (members blocked from check-in and new enrollment):

      ${rows}
    `, + "Membership-lapse board digest failed:", + ); +} + +/** + * Auto-withdraw a lapsed household's PENDING program enrollments, one row at a + * time through withdrawAndReleaseHold so every scholarship hold is released +1 + * exactly once (a bulk deleteMany would delete the rows but skip the Shopify + * seat restore — the correctness point tested in the sweep integration test). + * + * Only PENDING (awaiting payment / scholarship-held) rows are swept. ACTIVE rows + * are paid/comped completed transactions: deleting one restores no seat (the sale + * already decremented Shopify and withdrawAndReleaseHold only +1s a held seat), + * so auto-withdrawing it would destroy paid value and drift capacity. Paying a + * PENDING enrollment during grace therefore "rescues" it — it becomes ACTIVE and + * is no longer swept. Returns the number of rows withdrawn. + */ +async function withdrawHouseholdPendingEnrollments(householdId: number, graceDays: number): Promise { + const enrollments = await prisma.programParticipant.findMany({ + where: { status: "PENDING", person: { householdId } }, + include: { + program: { + select: { + id: true, + name: true, + shopifyVariantId: true, + shopifyOrgMemberVariantId: true, + shopifyNonOrgMemberVariantId: true, + }, + }, + person: { select: { name: true } }, + }, + }); + + let withdrawn = 0; + for (const e of enrollments) { + try { + await withdrawAndReleaseHold(e.programId, e.personId, e.program); + await prisma.auditLog.create({ + data: { + actorId: SYSTEM_ACTOR, + action: "DELETE", + tableName: "ProgramParticipant", + affectedEntityId: e.personId, + secondaryAffectedEntity: e.programId, + newData: { reason: "membership_lapse_withdrawn", graceDays }, + }, + }); + withdrawn++; + logger.info(`[CRON] Auto-withdrew ${e.person.name} from ${e.program.name} — membership lapsed past ${graceDays}d grace.`); + } catch (err) { + // Isolate one bad row from the rest of the sweep. + logger.error(`[CRON] Failed to withdraw participant ${e.personId} from program ${e.programId} (lapse cascade):`, err); + } + } + return withdrawn; +} + +/** + * The daily membership-lapse cascade sweep (body of GET /api/cron/membership-lapse-cascade). + * + * 1. Find currently-lapsed memberships (derived). + * 2. Newly-lapsed (lapseFlaggedAt null) → stamp lapseFlaggedAt, audit, notify the + * household once, and collect for one board digest (dedup: an already-flagged + * household is never re-notified). + * 3. If BoardSettings.membershipLapseGraceDays is set (NULL = auto-withdraw off), + * auto-withdraw the PENDING enrollments of every lapsed household flagged longer + * ago than the grace window. + * 4. Clear lapseFlaggedAt on memberships that were flagged but are no longer lapsed + * (renewed / reactivated) — so a future re-lapse notifies again. + */ +export async function runLapseCascadeSweep(now: Date = new Date()) { + const settings = await prisma.boardSettings.findUnique({ where: { id: 1 } }); + const boundary = settings?.orgMembershipYearBoundary ?? null; + const graceDays = settings?.membershipLapseGraceDays ?? null; // null = auto-withdraw OFF + + const candidates = await prisma.orgMembership.findMany({ + where: { + OR: [ + { status: { in: ["REVOKED", "DENIED"] } }, + { status: "ACTIVE", processes: { some: { kind: "RENEWAL", status: { in: [...RENEWAL_INCOMPLETE] } } } }, + ], + }, + select: { + id: true, + householdId: true, + status: true, + lapseFlaggedAt: true, + household: { select: { name: true } }, + processes: { + where: { kind: "RENEWAL", status: { in: [...RENEWAL_INCOMPLETE] } }, + select: { createdAt: true }, + }, + }, + }); + + const lapsed = candidates.filter((m) => + isMembershipLapsed({ status: m.status, renewalProcesses: m.processes }, boundary, now), + ); + + // 2. Flag + notify the newly-lapsed (dedup on lapseFlaggedAt == null). + // ponytail: sequential per-household await, like the other crons — fine at org + // scale (bounded by lapsed households/day); parallelize if a run ever gets slow. + const newlyFlagged = lapsed.filter((m) => m.lapseFlaggedAt === null); + for (const m of newlyFlagged) { + await prisma.orgMembership.update({ where: { id: m.id }, data: { lapseFlaggedAt: now } }); + await prisma.auditLog.create({ + data: { + actorId: SYSTEM_ACTOR, + action: "EDIT", + tableName: "OrgMembership", + affectedEntityId: m.id, + secondaryAffectedEntity: m.householdId, + newData: { reason: "membership_lapsed", status: m.status }, + }, + }); + await notifyLapsedHousehold(m.householdId, graceDays); + } + await notifyBoardOfLapses(newlyFlagged.map((m) => ({ householdId: m.householdId, name: m.household.name }))); + + // 3. Auto-withdraw past-grace lapsed households' PENDING enrollments. + let withdrawn = 0; + if (graceDays !== null) { + for (const m of lapsed) { + // Just-flagged rows use `now` as their flag time (graceDays 0 => withdraw same run). + if (isPastGrace(m.lapseFlaggedAt ?? now, graceDays, now)) { + withdrawn += await withdrawHouseholdPendingEnrollments(m.householdId, graceDays); + } + } + } + + // 4. Clear stale flags — flagged memberships that are no longer lapsed. + const lapsedIds = lapsed.map((m) => m.id); + const stale = await prisma.orgMembership.findMany({ + where: { lapseFlaggedAt: { not: null }, id: { notIn: lapsedIds } }, + select: { id: true, householdId: true }, + }); + for (const m of stale) { + await prisma.orgMembership.update({ where: { id: m.id }, data: { lapseFlaggedAt: null } }); + await prisma.auditLog.create({ + data: { + actorId: SYSTEM_ACTOR, + action: "EDIT", + tableName: "OrgMembership", + affectedEntityId: m.id, + secondaryAffectedEntity: m.householdId, + newData: { reason: "membership_lapse_cleared" }, + }, + }); + } + + return { + candidates: candidates.length, + lapsed: lapsed.length, + newlyFlagged: newlyFlagged.length, + withdrawn, + cleared: stale.length, + autoWithdrawEnabled: graceDays !== null, + }; +} diff --git a/checkin-app/src/security/generated/classifications.ts b/checkin-app/src/security/generated/classifications.ts index 3c0f6fb52..192954be9 100644 --- a/checkin-app/src/security/generated/classifications.ts +++ b/checkin-app/src/security/generated/classifications.ts @@ -65,6 +65,7 @@ export const classifications = { memberSince: 'public', status: 'public', isVolunteer: 'public', + lapseFlaggedAt: 'public', householdId: 'public', }, OrgMembershipProcess: { @@ -118,6 +119,7 @@ export const classifications = { shopifyVolunteerVariantId: 'internal', shopifyPriceSyncedAt: 'internal', scholarshipDenialGraceDays: 'public', + membershipLapseGraceDays: 'public', updatedAt: 'internal', }, AppSettings: {