Shop It Docs
Developer Resourcescare-package

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:

  1. Load subscription row by subscriptionId.
  2. Guard: if (subscription.status !== "pending_activation") { log debug; return; } — idempotency.
  3. Validate pricingSnapshot.durationMonths is a positive integer. If invalid: logger.warn() and return (not throw — avoids poisoning the queue).
  4. Calculate dates:
    • startDate = new Date().toISOString().split("T")[0] (today as YYYY-MM-DD)
    • expiryDate = new Date(); expiryDate.setMonth(expiryDate.getMonth() + durationMonths)YYYY-MM-DD
  5. Update subscription: status = "active", startDate, expiryDate, activatedAt = now, updatedAt = now.
  6. For each daysBeforeExpiry in [30, 7]:
    • Compute delay = expiryDateMs - daysBeforeExpiry * 86400000 - Date.now()
    • Skip if delay <= 0 (expiry already passed or too close)
    • Enqueue SEND_EXPIRY_REMINDER with jobId = "care-package-reminder-{subscriptionId}-{daysBeforeExpiry}d" and { delay }
    • Catch duplicate job errors via isDuplicateQueueJobError() — swallow silently

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:

  1. Guard: status !== "pending_activation" → throw CARE_PACKAGE_SUBSCRIPTION_ALREADY_ACTIVE.
  2. Validate pricingSnapshot.durationMonths → throw CARE_PACKAGE_SUBSCRIPTION_ACTIVATE_FAILED if invalid.
  3. Calculate and set dates (same logic as BullMQ processor).
  4. 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 <= today

Single 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():

  1. JOIN carePackageSubscriptionsorderItemsorderscustomers to get customer email.
  2. Skip if subscription not active (status may have changed since scheduling).
  3. Skip with warn if customer email is null.
  4. Enqueue NotificationJob.SEND_EMAIL to QueueName.NOTIFICATIONS:
    • Subject: "Your {carePackageName} expires in {daysBeforeExpiry} days"
    • Body: renewal reminder text.

6. Admin Suspend / Unsuspend

Suspend (POST /api/admin/care-package-subscriptions/:id/suspend):

  • Guard: status !== "active" → throw CARE_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" → throw CARE_PACKAGE_SUBSCRIPTION_UNSUSPEND_INVALID_STATUS
  • Date check (ISO YYYY-MM-DD string 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 reason from payload.

8. Sequence Diagram

9. QA Checklist

  • Activation is idempotent: re-running job on active subscription is a no-op.
  • durationMonths from pricingSnapshot is validated before date calculation.
  • Reminder job IDs are deterministic and survive processor restart.
  • Expiry sweep bulk-updates all eligible subscriptions in one query.
  • unsuspend sets expired when expiryDate is in the past.
  • suspend requires active; unsuspend requires suspended; both enforced at service layer.