Skip to content

feat(membership): cascade lapse/revocation to program enrollment#965

Open
jee7s wants to merge 1 commit into
mainfrom
feat/membership-lapse-cascade
Open

feat(membership): cascade lapse/revocation to program enrollment#965
jee7s wants to merge 1 commit into
mainfrom
feat/membership-lapse-cascade

Conversation

@jee7s

@jee7s jee7s commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

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

  • Lapsed-ness is derived live from OrgMembership (isMembershipLapsed, lib/membership/lapse.ts): status REVOKED/DENIED, or ACTIVE with 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-ProgramParticipant stamp because lapse is a household property: one stamp drives one grace deadline and one household+board notice.
  • New daily cron 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 shared withdrawAndReleaseHold so each scholarship hold is released +1 exactly once (never a bulk deleteMany), and clears the stamp when a household renews/reactivates. Scheduling is an infra follow-up.
  • PENDING-only auto-withdraw: an ACTIVE/paid enrollment is a completed transaction — deleting it restores no Shopify seat and would destroy paid value, so it's flagged/blocked but not auto-withdrawn (see design §5). Paying a pending enrollment during grace "rescues" it.
  • BoardSettings.membershipLapseGraceDays Int? (NULL = auto-withdraw OFF; flag/block/notify stay on) mirrors scholarshipDenialGraceDays' NULL-is-off semantics; wired into PUT /api/settings/membership + the membership settings UI.
  • Board force-enroll still works: the external-admin override path skips enforceLimits entirely, so it bypasses the lapse block (documented).
  • Standalone dedup per the on-main decision; the design doc's §6 documents how it reconciles with PR feat: auto-notifications as things go stale #958's NotificationLedger when both merge.
  • Migration 20260709010000_membership_lapse_cascade: two additive nullable columns (expand step; safe on populated tables). Regenerated classifications.ts committed.

Testing

  • Unit (lib/membership/__tests__/lapse.test.ts, cron route.test.ts): isMembershipLapsed across every case (REVOKED/DENIED/overdue-ACTIVE/not-overdue/NONE/no-boundary), isPastGrace grace-window math, and the cron auth gate.
  • Integration (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 +1 per 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.
  • Full unit suite (1722) green; tsc --noEmit clean; eslint --max-warnings 0 clean; scan + participants integration suites re-run with no regressions.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RsD1zGYyqQFZaHT2wfkSB7

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
@jee7s jee7s added the post-first-release Deferred until after the first production release label Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

post-first-release Deferred until after the first production release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant