billing: payment-failure dunning + 7d grace + auto-cancel (with NR dashboard + alerts) - #66
Merged
Merged
Conversation
Wires the dunning state machine on the api side: Razorpay subscription.charged_failed opens a 7-day grace row (payment_grace_periods), subscription.charged during the grace window flips it to recovered. Emits four new audit kinds (grace_started, grace_reminder, grace_recovered, grace_terminated) consumed by the Brevo forwarder for the dunning email sequence. Idempotency comes from a partial-unique index on team_id WHERE status='active' — Razorpay webhook redeliveries hit the constraint and silently no-op so customers don't get duplicate reminder emails. Cross-team isolation + fail-open audit emit are covered. The worker-side reminder cadence (every 6h, up to 28 over 7d) and the destructive terminator job (Razorpay cancel + resource soft-delete) ship in a separate worker PR — this PR delivers the trigger + state machine + recovery path. NR dashboard + two alerts ship in the same PR (billing-dunning.json, payment-failure-spike.json for Razorpay-outage detection, dunning-recovery-rate-low.json for unhealthy dunning UX). bake_test.sh updated; 42/42 schema-shape assertions green. Test counts: 12 model tests + 6 handler tests = 18 new tests. make test-unit green across all 20 packages. TestAgentActionContract green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 of 6 tasks
mastermanas805
added a commit
that referenced
this pull request
May 14, 2026
…obby_plus copy (#107) Wave FIX-H wraps up the BugBash B36 backup-correctness items so customer- facing restore stops being one accidental retry away from a destroyed database. #57/#Q45 — restore replay guard Adds models.HasInflightRestore; a second POST /restore for the same resource while a prior row is pending/running returns 409 restore_in_progress + AgentActionRestoreInflight. Fail-CLOSED on DB error (concurrent pg_restore --clean races itself). #58/#A2 — restore to a new DB Accepts optional target_resource_id. Worker restores into the target (same team only); audit row carries both source + target ids. Skips the destructive-ack ceremony because the agent already opted into a fresh database. #59 — backup integrity Migration 043 adds a nullable sha256 TEXT column to resource_backups. Worker stamps the digest at finalize; restore handler verifies before pg_restore. NULL on legacy rows is logged + accepted (fail-open on pre-043 data). #64/#Q46 — cross-tenant 404 GetBackupByIDForTeam joins resources to scope by team; cross-tenant backup_id guess now returns 404 backup_not_found instead of 400 backup_resource_mismatch. Matches FIX-B tenant-isolation posture. #65/#Q47 — refund quota on failure New internal endpoint POST /internal/teams/:id/backup-quota/refund (WORKER_INTERNAL_JWT_SECRET HS256, fail-closed when unset). The worker calls this when a MANUAL backup fails terminally so the team's daily manual-backups counter is credited back. #66/#Q48 — hobby agent_action points to Hobby Plus AgentActionRestoreRequiresHobbyPlus added. The Hobby-tier restore 402 now nudges Hobby Plus ($19/mo, restore enabled) instead of skipping the customer past the cheapest restore-enabled plan onto Pro ($49). #67/#Q49 — destructive ack required for in-place restore In-place restore (no target_resource_id) now requires destructive_acknowledgment: true in the body. pg_restore --clean drops every table — refusing without an explicit ack prevents an agent testing a backup from wiping a live customer DB. #Q50 — RPO/RTO on /capabilities Adds rpo_minutes + rto_minutes per tier in plans.yaml (anonymous/free = 0/0, hobby/hobby_plus = 1440/30, pro/team = 60/15) wired into the /api/v1/capabilities matrix via plans.Registry.RPOMinutes/RTOMinutes (added in instant.dev/common/plans#13). Tests - +5 backup_test.go cases: ReplayBlocked, TargetNewDB, RequiresDestructiveAck, HobbyAgentActionPointsToHobbyPlus, CrossTenantBackupID_404 - All prior backup tests updated to include destructive_acknowledgment - Contract test enforces the four new agent_action constants DO NOT TOUCH list respected — no edits to email/, dpop.go, circuit/. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mastermanas805
added a commit
that referenced
this pull request
May 21, 2026
…obby_plus copy Wave FIX-H wraps up the BugBash B36 backup-correctness items so customer- facing restore stops being one accidental retry away from a destroyed database. #57/#Q45 — restore replay guard Adds models.HasInflightRestore; a second POST /restore for the same resource while a prior row is pending/running returns 409 restore_in_progress + AgentActionRestoreInflight. Fail-CLOSED on DB error (concurrent pg_restore --clean races itself). #58/#A2 — restore to a new DB Accepts optional target_resource_id. Worker restores into the target (same team only); audit row carries both source + target ids. Skips the destructive-ack ceremony because the agent already opted into a fresh database. #59 — backup integrity Migration 043 adds a nullable sha256 TEXT column to resource_backups. Worker stamps the digest at finalize; restore handler verifies before pg_restore. NULL on legacy rows is logged + accepted (fail-open on pre-043 data). #64/#Q46 — cross-tenant 404 GetBackupByIDForTeam joins resources to scope by team; cross-tenant backup_id guess now returns 404 backup_not_found instead of 400 backup_resource_mismatch. Matches FIX-B tenant-isolation posture. #65/#Q47 — refund quota on failure New internal endpoint POST /internal/teams/:id/backup-quota/refund (WORKER_INTERNAL_JWT_SECRET HS256, fail-closed when unset). The worker calls this when a MANUAL backup fails terminally so the team's daily manual-backups counter is credited back. #66/#Q48 — hobby agent_action points to Hobby Plus AgentActionRestoreRequiresHobbyPlus added. The Hobby-tier restore 402 now nudges Hobby Plus ($19/mo, restore enabled) instead of skipping the customer past the cheapest restore-enabled plan onto Pro ($49). #67/#Q49 — destructive ack required for in-place restore In-place restore (no target_resource_id) now requires destructive_acknowledgment: true in the body. pg_restore --clean drops every table — refusing without an explicit ack prevents an agent testing a backup from wiping a live customer DB. #Q50 — RPO/RTO on /capabilities Adds rpo_minutes + rto_minutes per tier in plans.yaml (anonymous/free = 0/0, hobby/hobby_plus = 1440/30, pro/team = 60/15) wired into the /api/v1/capabilities matrix via plans.Registry.RPOMinutes/RTOMinutes (added in instant.dev/common/plans#13). Tests - +5 backup_test.go cases: ReplayBlocked, TargetNewDB, RequiresDestructiveAck, HobbyAgentActionPointsToHobbyPlus, CrossTenantBackupID_404 - All prior backup tests updated to include destructive_acknowledgment - Contract test enforces the four new agent_action constants DO NOT TOUCH list respected — no edits to email/, dpop.go, circuit/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Summary
subscription.charged_failedopens a 7-day grace row;subscription.chargedduring grace flips it to recovered. Four new audit kinds (payment.grace_started,payment.grace_reminder,payment.grace_recovered,payment.grace_terminated) feed the Brevo email forwarder.payment_grace_periodswith a partial-unique index onteam_id WHERE status='active'. Razorpay webhook redeliveries hit the constraint and silently no-op so customers don't get duplicate reminder emails.What ships in this PR (api side)
internal/db/migrations/027_payment_dunning.sql— table + two indexes (status/expires_at for worker sweeps, partial-unique team_id WHERE active for idempotency).internal/models/payment_grace_periods.go—Create(returnsErrPaymentGraceAlreadyActiveon duplicate-active),GetActive,MarkRecovered,MarkTerminated. State transitions enforced by the application; the partial-unique index is the concurrency guard.internal/handlers/billing.go— extended Razorpay webhook switch: new case forsubscription.charged_failed, recovery path tacked ontosubscription.charged. All audit emits fail-open.internal/models/audit_kinds.go— 4 new constants with full docstrings.internal/handlers/openapi.go— webhook description updated.internal/testhelpers/testhelpers.go— table mirror so handler tests work without depending on the SQL-migration step.What ships in a separate follow-up PR (worker side)
payment_grace_reminderRiver job — every-30-min sweep that finds active grace rows withlast_reminder_at < now() - interval '6 hours', emitspayment.grace_reminderaudit row (Brevo forwarder picks it up), bumpsreminders_sent+last_reminder_at. Up to 28 reminders over the 7-day window.payment_grace_terminatorRiver job — hourly sweep overstatus='active' AND expires_at < now(). Calls Razorpay subscription cancel (best-effort, log+audit on failure), soft-deletes all team resources (transactionally), emitspayment.grace_terminated. This is the destructive half — kept out of api per brief.Pushback / confirmation: the worker-side jobs are flagged as follow-ups as the brief required. Iron rule honoured — this PR only ships the trigger + grace state + recovery + reminder-emit-via-audit-kind, no destructive work.
Audit kinds + Brevo templates
payment.grace_startedinstanode-payment-grace-started-v1subscription.charged_failedpayment.grace_reminderinstanode-payment-grace-reminder-v1payment.grace_recoveredinstanode-payment-grace-recovered-v1subscription.chargedduring active gracepayment.grace_terminatedinstanode-payment-grace-terminated-v1NR observability (same PR per memory rule)
infra/newrelic/lives in this repo, so dashboards + alerts ship here:dashboards/billing-dunning.json— customers in active grace, reminders/day, recovery-rate billboard, auto-terminations/week, webhook p95 latency.alerts/payment-failure-spike.json— critical if >10grace_startedin 1h (Razorpay outage or webhook double-fire).alerts/dunning-recovery-rate-low.json— critical if 7d rolling recovery rate < 30% (unhealthy dunning UX).bake_test.shupdated to include the new files — 42/42 schema-shape assertions green.Test plan
make test-unitgreen across all 20 packagescharge_failedopens grace + emits audit, redelivery is no-op, charged-during-grace flips to recovered, charged-without-grace emits no recovery audit, cross-team isolation, fail-open audit-miss does not roll back grace row)TestAgentActionContractgreenbake_test.sh42/42 NR schema assertions pass🤖 Generated with Claude Code