Care Package Subscription Lifecycle
Subscription status state machine, activation engine, expiry sweep, reminder jobs, and admin manual operations.
Care Package Subscription Lifecycle
1. Status State Machine
Subscription is created with status = "pending_activation" at order creation time (before payment). This ensures the subscription record is created atomically with the order — if activation fails, the admin can manually re-trigger.
2. Activation Engine
Trigger: OrderProcessor dispatches care_package.activate_subscription after order.payment_success job completes. One job per subscription in the order.
Processor: CarePackageProcessor.processActivation() in apps/api/src/modules/care-package/workers/care-package.processor.ts
Step-by-step:
- Load subscription row by
subscriptionId. - Guard:
if (subscription.status !== "pending_activation") { log debug; return; }— idempotency. - Validate
pricingSnapshot.durationMonthsis a positive integer. If invalid:logger.warn()and return (not throw — avoids poisoning the queue). - Calculate dates:
startDate = new Date().toISOString().split("T")[0](today asYYYY-MM-DD)expiryDate = new Date(); expiryDate.setMonth(expiryDate.getMonth() + durationMonths)→YYYY-MM-DD
- Update subscription:
status = "active",startDate,expiryDate,activatedAt = now,updatedAt = now. - For each
daysBeforeExpiryin[30, 7]:- Compute
delay = expiryDateMs - daysBeforeExpiry * 86400000 - Date.now() - Skip if
delay <= 0(expiry already passed or too close) - Enqueue
SEND_EXPIRY_REMINDERwithjobId = "care-package-reminder-{subscriptionId}-{daysBeforeExpiry}d"and{ delay } - Catch duplicate job errors via
isDuplicateQueueJobError()— swallow silently
- Compute
isDuplicateQueueJobError
private isDuplicateQueueJobError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const message = error.message.toLowerCase();
return message.includes("jobid") && (message.includes("already") || message.includes("exists"));
}3. Manual Activate (Admin)
CarePackageSubscriptionsAdminService.manualActivate() in care-package-subscriptions-admin.service.ts:
- Guard:
status !== "pending_activation"→ throwCARE_PACKAGE_SUBSCRIPTION_ALREADY_ACTIVE. - Validate
pricingSnapshot.durationMonths→ throwCARE_PACKAGE_SUBSCRIPTION_ACTIVATE_FAILEDif invalid. - Calculate and set dates (same logic as BullMQ processor).
- Schedule 30d + 7d reminder jobs (best-effort — errors are logged as warnings, not thrown).
4. Expiry Engine
Scheduler: CarePackageWorkersScheduler.runExpirySweep() runs on cron schedule CARE_PACKAGE_EXPIRY_SWEEP_CRON.
Daily job ID: care-package-expire-sweep-{YYYY-MM-DD} — idempotent per day.
Processor: processExpireSubscriptions():
UPDATE care_package_subscriptions
SET status = 'expired', updated_at = now()
WHERE status = 'active' AND expiry_date <= todaySingle bulk update — not per-subscription. Logs count of expired subscriptions.
5. Expiry Reminder Notifications
Job: care_package.send_expiry_reminder with payload { subscriptionId, daysBeforeExpiry }.
Processor processExpiryReminder():
- JOIN
carePackageSubscriptions←orderItems←orders←customersto get customeremail. - Skip if subscription not
active(status may have changed since scheduling). - Skip with
warnif customeremailis null. - Enqueue
NotificationJob.SEND_EMAILtoQueueName.NOTIFICATIONS:- Subject:
"Your {carePackageName} expires in {daysBeforeExpiry} days" - Body: renewal reminder text.
- Subject:
6. Admin Suspend / Unsuspend
Suspend (POST /api/admin/care-package-subscriptions/:id/suspend):
- Guard:
status !== "active"→ throwCARE_PACKAGE_SUBSCRIPTION_SUSPEND_INVALID_STATUS - Sets:
status = "suspended",suspendedAt = now,suspensionReason = dto.reason ?? null,updatedAt = now
Unsuspend (POST /api/admin/care-package-subscriptions/:id/unsuspend):
- Guard:
status !== "suspended"→ throwCARE_PACKAGE_SUBSCRIPTION_UNSUSPEND_INVALID_STATUS - Date check (ISO
YYYY-MM-DDstring comparison is lexicographically safe):const today = new Date().toISOString().split("T")[0]; const newStatus = sub.expiryDate && sub.expiryDate <= today ? "expired" : "active"; - Clears:
suspendedAt = null,suspensionReason = null
7. Cancel Job
processCancellation():
- Guard:
if (status === "cancelled" || status === "expired") return— idempotent - Sets:
status = "cancelled",cancelledAt = now,updatedAt = now - Logs cancellation with
reasonfrom payload.
8. Sequence Diagram
9. QA Checklist
- Activation is idempotent: re-running job on
activesubscription is a no-op. -
durationMonthsfrompricingSnapshotis validated before date calculation. - Reminder job IDs are deterministic and survive processor restart.
- Expiry sweep bulk-updates all eligible subscriptions in one query.
-
unsuspendsetsexpiredwhenexpiryDateis in the past. -
suspendrequiresactive;unsuspendrequiressuspended; both enforced at service layer.