Enrollment enhancement#714
Open
Mikearaya wants to merge 12 commits into
Open
Conversation
This was
linked to
issues
Jun 12, 2026
Contributor
Author
1. Status Lifecycle RulesNew Status:
|
| 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
ACTIVEenrollment now throwsEnrollmentWrongStatusError(previously silently returned) - Activating from
SUSPENDEDis treated as a resume: emitsENROLLMENT_RESUMEevent, clearsrequestedTerminationDateandresumeAt, and logs"resumed from suspension" - All other activations log
"activated manually"
Suspension Rules
- Emits
ENROLLMENT_SUSPENDevent - Accepts optional
resumeAtdate for scheduled auto-resume (see Section 2a) - Suspended enrollments are frozen:
processEnrollmentreturns the current status immediately without running adapter logic (unlessresumeAthas passed, triggering auto-resume) - Suspended enrollments are included in
openEnrollmentWithProductqueries (alongsideACTIVEandPAUSED) - 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:
requestedTerminationDateis persisted, status stays unchanged. NoupdateStatuscall is made — onlyupdateRequestedTerminationDateis called, which emitsENROLLMENT_UPDATEwith fieldrequestedTerminationDate - If adapter returns now or past → termination is immediate (original behavior)
Cancellation Reason and Comment
terminateEnrollmentaccepts optionalreason(enumEnrollmentTerminationReason) andcomment(string)- Reason values:
USER_REQUESTED,PAYMENT_FAILED,EXPIRED,ADMIN_ACTION,OTHER - Persisted via
updateCancellationbefore scheduling/executing termination - Stored as
cancellationReasonandcancellationCommenton the enrollment document - Exposed in GraphQL as
cancellationReason: EnrollmentTerminationReasonandcancellationComment: String
Cancel at Period End
updateEnrollmentaccepts optionalcancelAtPeriodEnd: Boolean- When
true: finds the current active period (wherestart <= now <= end), setsrequestedTerminationDateto that period'send. If no current period is found, setsrequestedTerminationDatetonow - When
false: clearsrequestedTerminationDate(set tonull), effectively undoing the cancellation - This is syntactic sugar — the enrollment is terminated by the normal
processEnrollmentflow 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
referenceDatedirectly (immediate by default)
Automatic Termination via processEnrollment
- Checks
requestedTerminationDatefirst — ifnow >= requestedTerminationDate, forcesTERMINATEDregardless of other conditions - Then checks
isExpired— if expired, forcesTERMINATED - Both checks run before any adapter-driven status evaluation
Expiry Preservation on Termination
- When transitioning to
TERMINATED, theexpiresfield is only set if not already present (prevents overwriting explicit expiry) - Uses
reduceto find the latest period end date (safe for unsorted arrays, replacing the oldpop())
2a. Suspend with Scheduled Resume
resumeAt Field
- Added to
EnrollmentTypeScript type and MongoDB document - Exposed in GraphQL as
resumeAt: DateTime - Set via
suspendEnrollment(enrollmentId: ID!, resumeAt: DateTime)mutation - Persisted via
updateResumeAtmodule method
Auto-Resume via processEnrollment
processEnrollment→findNextStatus: forSUSPENDEDenrollments, ifresumeAtis set andnow >= resumeAt, returnsACTIVE- After the status transition is applied via
updateStatus,processEnrollmentclearsresumeAtby callingupdateResumeAt(enrollmentId, null) - This prevents stale
resumeAtvalues from persisting after auto-resume
Manual Resume Clears resumeAt
activateEnrollment(resume path): clears bothrequestedTerminationDateandresumeAtbefore updating status
Worker Integration
- The enrollment order generator worker queries
SUSPENDEDenrollments alongsideACTIVEandPAUSED - It calls
processEnrollmentfirst, which handles auto-resume for suspended enrollments with pastresumeAt - After processing, still-suspended enrollments are skipped (no order generation)
3. New Field: requestedTerminationDate
- Added to the
EnrollmentTypeScript 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 viaactivateEnrollment - 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
initializeEnrollmentnow callsdirector.initialPeriods({ referenceDate })instead of a singlenextPeriod()- The first period receives
orderIdForFirstPeriod; remaining periods are bulk-added viaaddEnrollmentPeriods
Expiry Date on Initialization
- After creating initial periods, calls
director.expiryDate()— if non-null, setsenrollment.expires
Base Adapter Defaults
initialPeriodsdelegates tonextPeriod, returning a single-element array (or empty if null)expiryDatereturnsnullby default
nextPeriod Enhancements
- Accepts optional
{ referenceDate }parameter (defaults tonew Date()) for deterministic calculation - Expiry guard: if enrollment has
expiresand the computed period'sstart >= expires, returnsnull(no more periods)
Future Period Removal (removeFuturePeriods)
- Only removes periods where
start >= afterDateANDorderIdisnull - Periods already linked to orders are preserved (never removed)
- Uses
as mongodb.UpdateFilter<Enrollment>type assertion (replacingas any)
Bulk Period Addition (addEnrollmentPeriods)
- New method to push multiple periods at once via
$push/$each - Emits
ENROLLMENT_ADD_PERIODevent
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
TERMINATEDenrollments (EnrollmentWrongStatusError) - Plan changes blocked on
SUSPENDEDenrollments (EnrollmentWrongStatusError)
Initial vs Non-Initial Enrollment
INITIALenrollment: simpleupdatePlan+ re-runinitializeEnrollment- Non-initial enrollment: delegates to
updateEnrollmentPlanservice, which calls the adapter'stransformPlanToNewPlan
transformPlanToNewPlan Adapter Hook
- Base adapter returns
null(plan changes not supported by default →EnrollmentPlanChangeNotSupportedError) - Licensed adapter: computes
effectiveDateas the latest period end date (orreferenceDateif no periods), meaning plan changes take effect at end of current billing cycle
Plan Change Process (non-initial)
- Current adapter calls
transformPlanToNewPlan({ plan, referenceDate })→ returns{ plan, effectiveDate }ornull - Future unfulfilled periods after
effectiveDateare removed (preserving order-linked periods) - Plan is updated on the enrollment document
- New director (for the new product) computes
initialPeriods({ referenceDate: effectiveDate }) - New periods are bulk-added
- Enrollment is reprocessed
ENROLLMENT_PLAN_CHANGEevent is emitted- A
plan_changemessage is sent to the user
6. Status Update & Processing Rules
Audit Trail Idempotency
updateStatusalways writes the audit trail ($pushtolog) and always emitsENROLLMENT_UPDATE- Status-specific side effects (enrollment number assignment, expiry calculation) only execute when
statusChanged === true
Processing Guard
processEnrollmentonly callsupdateStatuswhen the computed status differs from the current status, avoiding no-op writes- After transitioning a
SUSPENDEDenrollment toACTIVE(auto-resume),processEnrollmentclears the staleresumeAtfield
7. Order Generator Worker Rules
- Queries enrollments with status
ACTIVE,PAUSED, orSUSPENDED - Calls
processEnrollmentbefore 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
expiresand next period'sstart >= expires, no order is generated - Trial ending notification: when a trial period ends within 3 days (
daysUntilEnd <= 3 && daysUntilEnd > 0), emitsENROLLMENT_TRIAL_ENDINGevent 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
Intllocale from the user for date display - Conditional sections:
- Current period details (if a period spans
now) - Scheduled termination notice (if
requestedTerminationDateis set), includingcancellationReasonandcancellationCommentif present - Scheduled resume notice (if
resumeAtis set): "Your subscription will automatically resume on {date}" - Expiry date (if
expiresis set) - Plan change section (if
reason === 'plan_change') - Billing address (if present)
- Current period details (if a period spans
- 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
suspendEnrollmentuses the same ACL action asupdateEnrollment(actions.updateEnrollment)
10. Product Type Filtering (Supporting Feature)
buildFindSelectorinconfigureProductsModulesupports newtypesfilter- Single type → direct equality match; multiple types →
$inquery - Used by admin UI to show only
PLAN_PRODUCTproducts in the plan change dropdown
11. Admin UI Rules
- Suspend button: shown on
ACTIVEenrollments only, with confirmation dialog - Resume button: shown on
SUSPENDEDenrollments, callsactivateEnrollment - Activate button: shown on
PAUSEDenrollments (unchanged) - Terminate button: shown on all non-
TERMINATEDenrollments (unchanged) - Plan change dropdown: visible when enrollment is not
INITIAL, notTERMINATED, notSUSPENDED, and user hasUpdateEnrollmentrole. Filters products toPLAN_PRODUCTtype only. - Scheduled termination banner: amber warning shown when
requestedTerminationDateis set and status is not yetTERMINATED - Status progress timeline now includes
SUSPENDEDas step 3 (betweenACTIVEandPAUSED)
12. DateTime Scalar Fix
- The
DateTimeGraphQL scalar was renamed fromDateTimeISOtoDateTimeto match the schema declaration. This fixes serialization/parsing for date fields including the newexpiresparameter andrequestedTerminationDate.
13. Bug Fix: Service Binding
processEnrollmentin the services index was incorrectly bound toprocessOrderService— fixed to bind toprocessEnrollmentService
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
reasonandcomment→ verifiescancellationReasonandcancellationCommentfields returned suspendEnrollmentwithresumeAt→ verifies the date is stored and returnedactivateEnrollmenton suspended enrollment → verifiesresumeAtis cleared tonullcancelAtPeriodEnd: true→ verifiesrequestedTerminationDateis set to current period endcancelAtPeriodEnd: false→ verifiesrequestedTerminationDateis cleared tonull- Query cancellation fields on previously terminated enrollment → verifies persistence
Data Consistency
- All period
start/endandexpiresvalues converted from.getTime()numbers to properDateobjects for consistent typing across the test suite
8603632 to
af3f1b0
Compare
…nd multi-period support
- Add plan product selector to enrollment detail page for changing plans on active enrollments - Add unit tests for EnrollmentAdapter base actions (8 tests) and SUSPENDED status in buildFindSelector (2 tests) - Fix DateTime scalar inconsistency: wrap GraphQLDateTimeISO with name DateTime to match schema declaration - Update integration test to use DateTime instead of DateTimeISO
…prove test coverage
…rial ending event
af3f1b0 to
1f497d0
Compare
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.
Enrollment Enhancement Summary
What changed
Five features were added to the enrollment/subscription system, plus 9 bug fixes in existing logic.
Features
requestedTerminationDateinstead of happening immediately.expiryDate()at initialization for non-renewing subscriptions. The system auto-terminates when the date passes.initialPeriods()replaces the singlenextPeriod()call at initialization, allowing adapters to generate back-dated or future periods in bulk.SUSPENDEDstatus andsuspendEnrollmentmutation. UnlikePAUSED(system-driven, orders still generated), suspended enrollments are completely frozen until manually resumed.updateEnrollmentwith a new plan on non-INITIAL enrollments now delegates totransformPlanToNewPlan()instead of throwing. Future periods are replaced; pro-rata credits are left to adapter implementations.Bug fixes
processEnrollmentwas bound to the order processor (D2)periods.pop()mutated the in-memory array and ignored pre-setexpires(D5)processEnrollment, so auto-termination couldn't fire (D3)activateEnrollmentdidn't guard against redundant ACTIVE→ACTIVE transitions (D7).getTime()numbers instead ofDateobjects (D9)Key decisions
requestedTerminationDateis persisted, not computed live — follows the order system pattern where timestamps are facts of record.transformPlanToNewPlanadapter hook is where projects implement that logic.What remains
useUpdateEnrollmentPlanhook is wired up but there's no UI form yet for selecting a new plan on a running enrollment.addEnrollmentPeriods,removeFuturePeriods, base adapter defaults, andprocessEnrollmentwith SUSPENDED/requestedTerminationDate. Only integration tests were added.scalar DateTimebut the runtime resolver registers it asDateTimeISO(from graphql-scalars). Client queries must useDateTimeISOfor variable types. Not introduced by this PR but surfaced during testing.