Contract: `grace:send-mail` Scheduled Console Command
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:
- Resolve the grace anchor:
grace_period_started_atif 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. - Let
day0 = anchor(start of day, Asia/Tokyo).- If
today >= day0andgrace_mail_1_sent_atis null → sendFirstFailureMail, set
grace_mail_1_sent_at. (Safety net only — the webhook handler already sends this immediately
in the normal case.) - If
today >= day0 + 3 daysandgrace_mail_2_sent_atis null → sendReminderMail, set
grace_mail_2_sent_at. - If
today >= day0 + 6 daysandgrace_mail_3_sent_atis null → sendFinalNoticeMail, set
grace_mail_3_sent_at.
- If
- Safety-net cancellation: if
today > day0 + 7 daysand the row is stillPAST_DUE(i.e.,
the expectedcustomer.subscription.deletedwebhook never arrived), query Stripe directly for
the subscription’s live status; if it iscanceled/incomplete_expiredupstream, mark
UserPayment.status = DELETEDand 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_atcolumn — 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 eligibleUserPaymentrows 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).