How billing & renewals work
Agreement states, automatic renewals, payment failures, grace and restriction — and why price and entitlements are deliberately separate.
How billing & renewals work
Commercial billing is the quiet machinery behind every workspace: an agreement is signed, a mandate is authorized with a payment provider, invoices are raised automatically on each renewal, and if a payment fails the workspace degrades gracefully — never instantly. This page follows that lifecycle and explains the one invariant that keeps everything sane: price and entitlements are separate.
The lifecycle in plain language
- Agreement. You agree commercial terms (price, billing cycle, what is included). The agreement moves through approval and payment-authorization states.
- Mandate. You authorize the payment provider (Razorpay or Cashfree) to debit on schedule. Once authorized, the agreement is ACTIVE.
- Renewal. On each billing date the CRM raises the renewal invoice, and the provider debits using the mandate.
- Failure path. A failed debit never locks you out immediately: the agreement moves to a grace period (default 7 days) with full access, then PAST_DUE, then RESTRICTED (premium features paused; data is never deleted; lead capture and read access continue), and only after 7 more days SUSPENDED (all access denied).
- Recovery. A successful payment at any point in the failure path returns the agreement to ACTIVE.
stateDiagram-v2
[*] --> DRAFT
DRAFT --> PENDING_CLIENT_APPROVAL
PENDING_CLIENT_APPROVAL --> PENDING_PAYMENT_AUTHORIZATION
PENDING_PAYMENT_AUTHORIZATION --> ACTIVE : mandate authorized
ACTIVE --> PAYMENT_FAILED : debit failed
PAYMENT_FAILED --> GRACE_PERIOD : automatic
PAYMENT_FAILED --> ACTIVE : payment recovered
GRACE_PERIOD --> PAST_DUE : grace expired
GRACE_PERIOD --> ACTIVE : payment recovered
PAST_DUE --> RESTRICTED : grace period elapsed
PAST_DUE --> ACTIVE : payment recovered
RESTRICTED --> SUSPENDED : 7 further days
RESTRICTED --> ACTIVE : payment recovered
SUSPENDED --> ACTIVE : payment recovered
ACTIVE --> CANCELLED
ACTIVE --> EXPIRED
What each degraded state allows
| State | Effect on the workspace |
|---|---|
| GRACE_PERIOD | Full access while you fix the payment — nothing is paused |
| PAST_DUE | Still working; the restriction clock is running |
| RESTRICTED | Premium features paused. Allowed: crm, lead_management, followups, reports. Data is never deleted |
| SUSPENDED | All access denied until payment is recovered |
The invariant: price ≠ entitlements What you pay and what your workspace is allowed to use are stored and applied separately. Changing your price never silently changes your entitlements, and restoring access after a failure never requires renegotiating price. That separation is what makes the grace → restriction → suspension ladder predictable.
The automatic schedule
| Job | When | What it does |
|---|---|---|
CreateRenewalInvoice |
Daily 06:30 | Raises the renewal invoice for ACTIVE agreements whose billing date has arrived |
CheckUpcomingRenewals |
Daily 07:00 | Flags renewals approaching so they can be actioned |
CheckPaymentFailures |
Hourly | Detects failed debits and elapsed deadlines |
ApplyGracePeriod |
Daily 07:15 | Moves PAYMENT_FAILED → GRACE_PERIOD and notifies the client |
ApplyRestriction |
Daily 07:30 | Moves PAST_DUE → RESTRICTED (after the grace period) and RESTRICTED → SUSPENDED (after 7 further days) |
ReconcileProviderStatus + SyncAppliedChangeRequests |
Every 30 min | Reconciles provider state and applies approved change requests |
It all rides on cron These jobs are part of php artisan schedule:run — the single cron entry every install must have. A broken cron silently stops renewals, restrictions and recoveries. See Cron & queue workers.
Payments and mandates
- Providers: Razorpay and Cashfree, configured by the platform at Super Admin → Billing → Providers & Settings (secrets encrypted).
- Webhooks:
/api/webhooks/{razorpay,cashfree}— signature-verified and idempotent (a replayed webhook cannot double-apply a payment). - Debit equals invoice: the provider debit, the renewal invoice, and the agreement's recurring gross amount (
grossRecurringAmount()) are the same number by design — no drift between what is invoiced and what is debited. - Mandate changes: some changes (for example altering the mandate amount) require the client to authorize again — the change request honestly reports
REQUIRES_NEW_AUTHORIZATIONrather than pretending it applied. - Client surfaces:
/admin/billing/agreement,/admin/billing/invoices,/admin/billing/paymentsshow the agreement, invoices and payment history.
Troubleshooting notes
| Symptom | Likely cause | Fix |
|---|---|---|
| Workspace restricted while a payment "went through" | The webhook was never delivered (URL/secret), or cron is not running to recover state | Check the provider webhook configuration and cron; reconciliation runs every 30 minutes |
| Renewal invoice missing | Cron not running, or the agreement left ACTIVE | Cron & queue workers; check agreement status |
| Client paid but still suspended | Payment not matched to the invoice | Reconciliation (ReconcileProviderStatus) usually fixes it within 30 minutes; check the payment reference |
| Change request stuck | It requires new authorization from the client | The request shows REQUIRES_NEW_AUTHORIZATION — the client must re-authorize |
| Grace period seems wrong | Configurable platform setting | Default is 7 days; platform admins can adjust it |
Technical details
- Tables: commercial tables (
commercial_*) plusbilling_invoices(notinvoices); services inApp\Services\CommercialBilling\*; providers inapp/Billing/Commercial/*. - State machine:
App\Services\CommercialBilling\AgreementStateMachine— allowed transitions are an explicit map; terminal states areCANCELLEDandEXPIRED. - Entitlements:
App\Services\CommercialBilling\EntitlementService—RESTRICTEDallowscrm,lead_management,followups,reports;SUSPENDEDdenies all; restriction application is best-effort after each transition (the state itself is the source of truth). - Jobs:
App\Jobs\Billing\CreateRenewalInvoice,CheckUpcomingRenewals,CheckPaymentFailures,ApplyGracePeriod,ApplyRestriction(constantSUSPEND_AFTER_DAYS = 7),ReconcileProviderStatus,SyncAppliedChangeRequests— scheduled inroutes/console.php. - Notification:
BillingNotifiersends client-facing emails/in-app notices at grace, restriction and suspension.
Related guides
Was this page helpful?
Thanks — noted. ✦