feat(membership): cascade lapse/revocation to program enrollment#965
Open
jee7s wants to merge 1 commit into
Open
feat(membership): cascade lapse/revocation to program enrollment#965jee7s wants to merge 1 commit into
jee7s wants to merge 1 commit into
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RsD1zGYyqQFZaHT2wfkSB7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Today, when a household's org membership lapses (the membership-year boundary passes without a completed renewal) or is revoked, nothing cascades to its program enrollments — members keep checking in, keep enrolling, and keep held/pending seats. The only lever is the manual board revoke on the household detail page (#915), which doesn't touch enrollments.
This PR implements the interview decision "grace then auto-withdraw": on lapse/revocation a household's enrollments are flagged, its members are blocked from check-in and new enrollment, the household and board are notified once, and after a configurable grace window its pending enrollments are auto-withdrawn through the shared hold-ledger path.
Full rationale, data model, flows, and prod-safety:
checkin-app/docs/designs/MEMBERSHIP_LAPSE_CASCADE.md.Key decisions
OrgMembership(isMembershipLapsed,lib/membership/lapse.ts): statusREVOKED/DENIED, orACTIVEwith a renewal overdue past the year boundary.NONE/never-a-member is never lapsed (their non-member-priced enrollments are left alone). The check-in guard, the new-enrollment guard, and the cron all use the same predicate, so a renewal lifts the block instantly — no denormalized state to keep in sync.OrgMembership.lapseFlaggedAt(DateTime?) is stamped by the cron only as the grace clock + notification-dedup key — never read to decide "blocked right now?". Chosen over a per-ProgramParticipantstamp because lapse is a household property: one stamp drives one grace deadline and one household+board notice.GET /api/cron/membership-lapse-cascade(withCron+CRON_SECRET, thin route →runLapseCascadeSweep): flags newly-lapsed households, notifies once (deduped), auto-withdraws past-grace PENDING enrollments one row at a time via the sharedwithdrawAndReleaseHoldso each scholarship hold is released+1exactly once (never a bulkdeleteMany), and clears the stamp when a household renews/reactivates. Scheduling is an infra follow-up.BoardSettings.membershipLapseGraceDays Int?(NULL= auto-withdraw OFF; flag/block/notify stay on) mirrorsscholarshipDenialGraceDays' NULL-is-off semantics; wired intoPUT /api/settings/membership+ the membership settings UI.overridepath skipsenforceLimitsentirely, so it bypasses the lapse block (documented).NotificationLedgerwhen both merge.20260709010000_membership_lapse_cascade: two additive nullable columns (expand step; safe on populated tables). Regeneratedclassifications.tscommitted.Testing
lib/membership/__tests__/lapse.test.ts, cronroute.test.ts):isMembershipLapsedacross every case (REVOKED/DENIED/overdue-ACTIVE/not-overdue/NONE/no-boundary),isPastGracegrace-window math, and the cron auth gate.cronMembershipLapseCascade.integration.test.ts, 8 tests): the full arc — flag + notify-once → dedup (second run re-flags/re-emails nobody) → grace expiry → auto-withdraw with hold+1per row (asserts the mock inventory release fired once, per-row audit, ACTIVE spared → the deleteMany-vs-per-row correctness) → renewal clears the flag → the enrollment 403 block + healthy-member control + board force-enroll override bypass.tsc --noEmitclean;eslint --max-warnings 0clean; scan + participants integration suites re-run with no regressions.🤖 Generated with Claude Code
https://claude.ai/code/session_01RsD1zGYyqQFZaHT2wfkSB7