Contract: grace:send-mail Scheduled Console Command

Command: php artisan grace:send-mail

Schedule: Registered once daily in admin/app/Console/Kernel.php, alongside the existing
DowngradePlanTrialToFree/SendPaymentRemindMail entries.

Trigger: Time-based only (cron). Not an HTTP-exposed contract; documented here because it is
the other primary “interface” this feature adds (batch interface vs. the webhook interface above).

Behavior contract

For every UserPayment row where status = PAST_DUE:

  1. Resolve the grace anchor: grace_period_started_at if set; otherwise fall back to the related
    UserPlan.billing_use_end_date (logged as a warning — indicates a pre-migration row that never
    got backfilled); otherwise skip the row.
  2. Let day0 = anchor (start of day, Asia/Tokyo).
    • If today >= day0 and grace_mail_1_sent_at is null → send FirstFailureMail, set
      grace_mail_1_sent_at. (Safety net only — the webhook handler already sends this immediately
      in the normal case.)
    • If today >= day0 + 3 days and grace_mail_2_sent_at is null → send ReminderMail, set
      grace_mail_2_sent_at.
    • If today >= day0 + 6 days and grace_mail_3_sent_at is null → send FinalNoticeMail, set
      grace_mail_3_sent_at.
  3. Safety-net cancellation: if today > day0 + 7 days and the row is still PAST_DUE (i.e.,
    the expected customer.subscription.deleted webhook never arrived), query Stripe directly for
    the subscription’s live status; if it is canceled/incomplete_expired upstream, mark
    UserPayment.status = DELETED and run the existing downgrade cleanup — self-healing a missed
    webhook rather than leaving the subscriber stuck in grace forever.

Idempotency guarantees

  • Each of the 3 mail sends is individually guarded by its own sent_at column — safe to run this
    command multiple times a day or re-run after a failure without duplicate mail.
  • A send failure (mail exception) does not set the guard column, so the next run retries; it also
    does not abort processing of other eligible UserPayment rows in the same run.
  • The command performs no automatic payment retries and never calls Stripe to cancel a
    subscription itself
    — cancellation is always Stripe-initiated (dashboard grace-period setting);
    this command only detects and reacts to it.

Exit codes

  • Command::SUCCESS (0) on completion, even if individual rows logged warnings (row-level
    failures must not fail the whole scheduled run).