Skip to content

Enrollment enhancement#714

Open
Mikearaya wants to merge 12 commits into
masterfrom
enrollment-enhancement
Open

Enrollment enhancement#714
Mikearaya wants to merge 12 commits into
masterfrom
enrollment-enhancement

Conversation

@Mikearaya

Copy link
Copy Markdown
Contributor

Enrollment Enhancement Summary

What changed

Five features were added to the enrollment/subscription system, plus 9 bug fixes in existing logic.

Features

  • Termination terms — Adapters now control when termination takes effect. The licensed adapter enforces a 30-day notice period; termination is scheduled via requestedTerminationDate instead of happening immediately.
  • Fixed-point expiry — Adapters can return an expiryDate() at initialization for non-renewing subscriptions. The system auto-terminates when the date passes.
  • Multi-period pre-generationinitialPeriods() replaces the single nextPeriod() call at initialization, allowing adapters to generate back-dated or future periods in bulk.
  • Suspend/resume — New SUSPENDED status and suspendEnrollment mutation. Unlike PAUSED (system-driven, orders still generated), suspended enrollments are completely frozen until manually resumed.
  • Plan changesupdateEnrollment with a new plan on non-INITIAL enrollments now delegates to transformPlanToNewPlan() instead of throwing. Future periods are replaced; pro-rata credits are left to adapter implementations.

Bug fixes

  • processEnrollment was bound to the order processor (D2)
  • Expiry check was unreachable — only ran for already-terminated enrollments (D1)
  • periods.pop() mutated the in-memory array and ignored pre-set expires (D5)
  • Worker never called processEnrollment, so auto-termination couldn't fire (D3)
  • activateEnrollment didn't guard against redundant ACTIVE→ACTIVE transitions (D7)
  • Seed data used .getTime() numbers instead of Date objects (D9)

Key decisions

  • SUSPENDED is a new status, not a reuse of PAUSED — because PAUSED auto-reactivates and still generates orders, which is wrong for a manual hold.
  • requestedTerminationDate is persisted, not computed live — follows the order system pattern where timestamps are facts of record.
  • Pro-rata credits on plan change are out of scope — the transformPlanToNewPlan adapter hook is where projects implement that logic.
  • All new adapter actions have backward-compatible defaults — existing adapters work unchanged.

What remains

  • Admin UI plan change UX — The useUpdateEnrollmentPlan hook is wired up but there's no UI form yet for selecting a new plan on a running enrollment.
  • Unit tests — The plan called for unit tests alongside source files for addEnrollmentPeriods, removeFuturePeriods, base adapter defaults, and processEnrollment with SUSPENDED/requestedTerminationDate. Only integration tests were added.
  • DateTime scalar inconsistency — GraphQL schema declares scalar DateTime but the runtime resolver registers it as DateTimeISO (from graphql-scalars). Client queries must use DateTimeISO for variable types. Not introduced by this PR but surfaced during testing.

@Mikearaya

Mikearaya commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

1. Status Lifecycle Rules

New Status: SUSPENDED

  • Added to EnrollmentStatus enum in both DB schema and GraphQL schema
  • Color-coded as blue in the admin UI list view

Status Machine — Allowed Transitions

Action Allowed From Target Blocked From
Activate INITIAL, PAUSED, SUSPENDED ACTIVE ACTIVE (throws), TERMINATED (throws)
Suspend ACTIVE, PAUSED SUSPENDED INITIAL, SUSPENDED, TERMINATED (all throw)
Terminate INITIAL, ACTIVE, PAUSED, SUSPENDED TERMINATED or scheduled TERMINATED (throws via API resolver)
Resume SUSPENDED (via activateEnrollment) ACTIVE
Auto-Resume SUSPENDED (via processEnrollment when resumeAt has passed) ACTIVE

Activation / Resume Rules

  • Activating an already ACTIVE enrollment now throws EnrollmentWrongStatusError (previously silently returned)
  • Activating from SUSPENDED is treated as a resume: emits ENROLLMENT_RESUME event, clears requestedTerminationDate and resumeAt, and logs "resumed from suspension"
  • All other activations log "activated manually"

Suspension Rules

  • Emits ENROLLMENT_SUSPEND event
  • Accepts optional resumeAt date for scheduled auto-resume (see Section 2a)
  • Suspended enrollments are frozen: processEnrollment returns the current status immediately without running adapter logic (unless resumeAt has passed, triggering auto-resume)
  • Suspended enrollments are included in openEnrollmentWithProduct queries (alongside ACTIVE and PAUSED)
  • Plan changes are blocked while suspended (EnrollmentWrongStatusError)
  • The order generator worker skips suspended enrollments after processing (no new orders)

2. Termination Rules

Adapter-Driven Termination Date

  • Termination is no longer always immediate. The adapter's terminationDate({ referenceDate }) determines when it takes effect
  • If adapter returns null → termination is rejected (EnrollmentTerminationNotAllowedError)
  • If adapter returns a future date → termination is scheduled: requestedTerminationDate is persisted, status stays unchanged. No updateStatus call is made — only updateRequestedTerminationDate is called, which emits ENROLLMENT_UPDATE with field requestedTerminationDate
  • If adapter returns now or past → termination is immediate (original behavior)

Cancellation Reason and Comment

  • terminateEnrollment accepts optional reason (enum EnrollmentTerminationReason) and comment (string)
  • Reason values: USER_REQUESTED, PAYMENT_FAILED, EXPIRED, ADMIN_ACTION, OTHER
  • Persisted via updateCancellation before scheduling/executing termination
  • Stored as cancellationReason and cancellationComment on the enrollment document
  • Exposed in GraphQL as cancellationReason: EnrollmentTerminationReason and cancellationComment: String

Cancel at Period End

  • updateEnrollment accepts optional cancelAtPeriodEnd: Boolean
  • When true: finds the current active period (where start <= now <= end), sets requestedTerminationDate to that period's end. If no current period is found, sets requestedTerminationDate to now
  • When false: clears requestedTerminationDate (set to null), effectively undoing the cancellation
  • This is syntactic sugar — the enrollment is terminated by the normal processEnrollment flow when the date passes

Licensed Adapter Termination Policy

  • No periods → immediate termination (returns referenceDate)
  • Has periods → earliest termination = currentActivePeriod.endDate

Base Adapter Termination Policy

  • Returns referenceDate directly (immediate by default)

Automatic Termination via processEnrollment

  • Checks requestedTerminationDate first — if now >= requestedTerminationDate, forces TERMINATED regardless of other conditions
  • Then checks isExpired — if expired, forces TERMINATED
  • Both checks run before any adapter-driven status evaluation

Expiry Preservation on Termination

  • When transitioning to TERMINATED, the expires field is only set if not already present (prevents overwriting explicit expiry)
  • Uses reduce to find the latest period end date (safe for unsorted arrays, replacing the old pop())

2a. Suspend with Scheduled Resume

resumeAt Field

  • Added to Enrollment TypeScript type and MongoDB document
  • Exposed in GraphQL as resumeAt: DateTime
  • Set via suspendEnrollment(enrollmentId: ID!, resumeAt: DateTime) mutation
  • Persisted via updateResumeAt module method

Auto-Resume via processEnrollment

  • processEnrollmentfindNextStatus: for SUSPENDED enrollments, if resumeAt is set and now >= resumeAt, returns ACTIVE
  • After the status transition is applied via updateStatus, processEnrollment clears resumeAt by calling updateResumeAt(enrollmentId, null)
  • This prevents stale resumeAt values from persisting after auto-resume

Manual Resume Clears resumeAt

  • activateEnrollment (resume path): clears both requestedTerminationDate and resumeAt before updating status

Worker Integration

  • The enrollment order generator worker queries SUSPENDED enrollments alongside ACTIVE and PAUSED
  • It calls processEnrollment first, which handles auto-resume for suspended enrollments with past resumeAt
  • After processing, still-suspended enrollments are skipped (no order generation)

3. New Field: requestedTerminationDate

  • Added to the Enrollment TypeScript type and MongoDB schema
  • Exposed in GraphQL as requestedTerminationDate: DateTime
  • Set when termination is scheduled for a future date
  • Cleared (set to null) when a suspended enrollment is resumed via activateEnrollment
  • Admin UI shows an amber warning banner when set and enrollment is not yet terminated: "Termination scheduled for {date}"
  • The enrollment status email template includes a "Scheduled Termination" section when this field is present, including cancellation reason and comment if available

4. Period Management Rules

Multi-Period Initialization

  • initializeEnrollment now calls director.initialPeriods({ referenceDate }) instead of a single nextPeriod()
  • The first period receives orderIdForFirstPeriod; remaining periods are bulk-added via addEnrollmentPeriods

Expiry Date on Initialization

  • After creating initial periods, calls director.expiryDate() — if non-null, sets enrollment.expires

Base Adapter Defaults

  • initialPeriods delegates to nextPeriod, returning a single-element array (or empty if null)
  • expiryDate returns null by default

nextPeriod Enhancements

  • Accepts optional { referenceDate } parameter (defaults to new Date()) for deterministic calculation
  • Expiry guard: if enrollment has expires and the computed period's start >= expires, returns null (no more periods)

Future Period Removal (removeFuturePeriods)

  • Only removes periods where start >= afterDate AND orderId is null
  • Periods already linked to orders are preserved (never removed)
  • Uses as mongodb.UpdateFilter<Enrollment> type assertion (replacing as any)

Bulk Period Addition (addEnrollmentPeriods)

  • New method to push multiple periods at once via $push/$each
  • Emits ENROLLMENT_ADD_PERIOD event

5. Plan Change Rules

Guard Rails

  • Target product must exist (ProductNotFoundError)
  • Target product must be ACTIVE status (ProductWrongStatusError)
  • Target product must be of type PLAN_PRODUCT (ProductWrongTypeError)
  • Plan changes blocked on TERMINATED enrollments (EnrollmentWrongStatusError)
  • Plan changes blocked on SUSPENDED enrollments (EnrollmentWrongStatusError)

Initial vs Non-Initial Enrollment

  • INITIAL enrollment: simple updatePlan + re-run initializeEnrollment
  • Non-initial enrollment: delegates to updateEnrollmentPlan service, which calls the adapter's transformPlanToNewPlan

transformPlanToNewPlan Adapter Hook

  • Base adapter returns null (plan changes not supported by default → EnrollmentPlanChangeNotSupportedError)
  • Licensed adapter: computes effectiveDate as the latest period end date (or referenceDate if no periods), meaning plan changes take effect at end of current billing cycle

Plan Change Process (non-initial)

  1. Current adapter calls transformPlanToNewPlan({ plan, referenceDate }) → returns { plan, effectiveDate } or null
  2. Future unfulfilled periods after effectiveDate are removed (preserving order-linked periods)
  3. Plan is updated on the enrollment document
  4. New director (for the new product) computes initialPeriods({ referenceDate: effectiveDate })
  5. New periods are bulk-added
  6. Enrollment is reprocessed
  7. ENROLLMENT_PLAN_CHANGE event is emitted
  8. A plan_change message is sent to the user

6. Status Update & Processing Rules

Audit Trail Idempotency

  • updateStatus always writes the audit trail ($push to log) and always emits ENROLLMENT_UPDATE
  • Status-specific side effects (enrollment number assignment, expiry calculation) only execute when statusChanged === true

Processing Guard

  • processEnrollment only calls updateStatus when the computed status differs from the current status, avoiding no-op writes
  • After transitioning a SUSPENDED enrollment to ACTIVE (auto-resume), processEnrollment clears the stale resumeAt field

7. Order Generator Worker Rules

  • Queries enrollments with status ACTIVE, PAUSED, or SUSPENDED
  • Calls processEnrollment before generating orders (catches expired/terminated/suspended first, handles auto-resume)
  • Skips terminated enrollments after processing (returns null)
  • Skips suspended enrollments after processing (returns null)
  • Expiry guard: if enrollment has expires and next period's start >= expires, no order is generated
  • Trial ending notification: when a trial period ends within 3 days (daysUntilEnd <= 3 && daysUntilEnd > 0), emits ENROLLMENT_TRIAL_ENDING event with { enrollment, trialEnd } payload

8. Email Template Rules

The resolveEnrollmentStatusTemplate was fully rewritten:

  • Reason-aware subjects: status_change → "Subscription Status Update", plan_change → "Subscription Plan Changed", updated_plan → "Subscription Plan Updated"
  • Status descriptions: each status (INITIAL, ACTIVE, PAUSED, SUSPENDED, TERMINATED) has a human-readable explanation in the email body
  • Locale-aware date formatting: uses Intl locale from the user for date display
  • Conditional sections:
    • Current period details (if a period spans now)
    • Scheduled termination notice (if requestedTerminationDate is set), including cancellationReason and cancellationComment if present
    • Scheduled resume notice (if resumeAt is set): "Your subscription will automatically resume on {date}"
    • Expiry date (if expires is set)
    • Plan change section (if reason === 'plan_change')
    • Billing address (if present)
  • Sender format: changed from bare email to "SiteName <email>" format

9. GraphQL API Changes

Addition Description
suspendEnrollment(enrollmentId: ID!, resumeAt: DateTime): Enrollment! New mutation — optionally schedules auto-resume
terminateEnrollment(enrollmentId: ID!, reason: EnrollmentTerminationReason, comment: String): Enrollment! Updated mutation — accepts optional cancellation reason and comment
updateEnrollment(... expires: DateTime, cancelAtPeriodEnd: Boolean) New optional parameters
Enrollment.requestedTerminationDate: DateTime New field
Enrollment.resumeAt: DateTime New field
Enrollment.cancellationReason: EnrollmentTerminationReason New field
Enrollment.cancellationComment: String New field
EnrollmentTerminationReason New enum (USER_REQUESTED, PAYMENT_FAILED, EXPIRED, ADMIN_ACTION, OTHER)
EnrollmentStatus.SUSPENDED New enum value
products(... types: [ProductType!]) New filter parameter (used by plan change UI)
productsCount(... types: [ProductType!]) New filter parameter

Permissions

  • suspendEnrollment uses the same ACL action as updateEnrollment (actions.updateEnrollment)

10. Product Type Filtering (Supporting Feature)

  • buildFindSelector in configureProductsModule supports new types filter
  • Single type → direct equality match; multiple types → $in query
  • Used by admin UI to show only PLAN_PRODUCT products in the plan change dropdown

11. Admin UI Rules

  • Suspend button: shown on ACTIVE enrollments only, with confirmation dialog
  • Resume button: shown on SUSPENDED enrollments, calls activateEnrollment
  • Activate button: shown on PAUSED enrollments (unchanged)
  • Terminate button: shown on all non-TERMINATED enrollments (unchanged)
  • Plan change dropdown: visible when enrollment is not INITIAL, not TERMINATED, not SUSPENDED, and user has UpdateEnrollment role. Filters products to PLAN_PRODUCT type only.
  • Scheduled termination banner: amber warning shown when requestedTerminationDate is set and status is not yet TERMINATED
  • Status progress timeline now includes SUSPENDED as step 3 (between ACTIVE and PAUSED)

12. DateTime Scalar Fix

  • The DateTime GraphQL scalar was renamed from DateTimeISO to DateTime to match the schema declaration. This fixes serialization/parsing for date fields including the new expires parameter and requestedTerminationDate.

13. Bug Fix: Service Binding

  • processEnrollment in the services index was incorrectly bound to processOrderService — fixed to bind to processEnrollmentService

14. New Events

Event Emitted When
ENROLLMENT_SUSPEND Enrollment transitions to SUSPENDED
ENROLLMENT_RESUME Enrollment transitions from SUSPENDED to ACTIVE (manual resume)
ENROLLMENT_ADD_PERIOD Periods are added (single or bulk via addEnrollmentPeriods)
ENROLLMENT_PLAN_CHANGE Enrollment plan is changed via updateEnrollmentPlan service
ENROLLMENT_TRIAL_ENDING Trial period ends within 3 days (emitted by order generator worker)

All five events are registered in the ENROLLMENT_EVENTS array in configureEnrollmentsModule.


15. Seed Data & Tests

New Seed Enrollments

Seed Purpose
ActiveEnrollmentForCancelAtPeriodEnd ACTIVE enrollment with a long-running period (2024–2030), used for termination with reason/comment and cancelAtPeriodEnd tests
SuspendedWithResumeAtEnrollment SUSPENDED enrollment with resumeAt: new Date('2020/01/01') (past date), used for suspend-with-resumeAt and resume-clears-resumeAt tests

Integration Test Coverage

  • Termination with reason and comment → verifies cancellationReason and cancellationComment fields returned
  • suspendEnrollment with resumeAt → verifies the date is stored and returned
  • activateEnrollment on suspended enrollment → verifies resumeAt is cleared to null
  • cancelAtPeriodEnd: true → verifies requestedTerminationDate is set to current period end
  • cancelAtPeriodEnd: false → verifies requestedTerminationDate is cleared to null
  • Query cancellation fields on previously terminated enrollment → verifies persistence

Data Consistency

  • All period start/end and expires values converted from .getTime() numbers to proper Date objects for consistent typing across the test suite

@Mikearaya Mikearaya marked this pull request as ready for review June 13, 2026 17:52
@Mikearaya Mikearaya requested a review from pozylon June 13, 2026 17:52
@Mikearaya Mikearaya force-pushed the enrollment-enhancement branch from 8603632 to af3f1b0 Compare July 8, 2026 19:52
@Mikearaya Mikearaya force-pushed the enrollment-enhancement branch from af3f1b0 to 1f497d0 Compare July 8, 2026 21:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant