A payment platform that cannot collect its own fees has a fundamental credibility problem. The billing suspension system is the enforcement layer of 0fee.dev's 0.99% fee model -- it ensures that merchants who use the platform pay for the service, while being fair and predictable about consequences.
The design principle: warn early, suspend late, reactivate instantly.
The Billing Timeline
Every month follows the same pattern, no exceptions:
Day 1 --> Cron: Generate invoices for previous month
Day 1 --> Email: Invoice sent to merchant
Day 5 --> Due date
Day 6 --> Email: "Your invoice is overdue" warning
Day 8 --> Email: "Suspension in 2 days" final warning
Day 10 --> Cron: Check unpaid invoices, suspend accounts
Day 10+ --> 402 Payment Required on all API callsThis ten-day cycle gives merchants ample time to pay. The grace period (days 6-10) exists because we understand that African businesses often deal with delayed bank transfers, mobile money float issues, and varying pay cycles.
Invoice Generation: The 1st-of-Month Cron
The first cron job runs at 00:01 UTC on the 1st of every month. It scans all active applications, aggregates their completed transactions from the previous month, and generates invoices.
pythonfrom datetime import datetime, timedelta
from decimal import Decimal
async def cron_generate_invoices(db: AsyncSession):
"""Monthly invoice generation -- runs on the 1st."""
now = datetime.utcnow()
period_start = (now.replace(day=1) - timedelta(days=1)).replace(day=1)
period_end = now.replace(day=1) - timedelta(days=1)
apps = await db.execute(
select(App).where(App.status == "active")
)
invoices_created = 0
for app in apps.scalars().all():
# Get completed transactions for the period
transactions = await db.execute(
select(Transaction).where(
Transaction.app_id == app.id,
Transaction.status == "completed",
Transaction.completed_at >= period_start,
Transaction.completed_at <= period_end
)
)
tx_list = transactions.scalars().all()
if not tx_list:
continue
total_volume = sum(tx.amount_usd for tx in tx_list)
total_fees = sum(tx.platform_fee_usd for tx in tx_list)
# Account for refund reversals
refunds = [tx for tx in tx_list if tx.status == "refunded"]
refund_credits = sum(tx.platform_fee_usd for tx in refunds)
net_fees = total_fees - refund_credits
if net_fees <= Decimal("0"):
continue
invoice = PlatformInvoice(
app_id=app.id,
user_id=app.user_id,
period_start=period_start.date(),
period_end=period_end.date(),
transaction_count=len(tx_list),
total_transaction_volume_usd=total_volume,
total_fees_usd=net_fees,
status="pending",
due_date=now.replace(day=5).date(),
)
db.add(invoice)
invoices_created += 1
# Send invoice email
await send_invoice_notification(app.user, invoice)
await db.commit()
return {"invoices_created": invoices_created}The platform_invoices Table
The invoice table captures everything needed for billing, auditing, and dispute resolution:
sqlCREATE TABLE platform_invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id),
user_id UUID NOT NULL REFERENCES users(id),
period_start DATE NOT NULL,
period_end DATE NOT NULL,
transaction_count INTEGER NOT NULL DEFAULT 0,
total_transaction_volume_usd DECIMAL(12, 2) NOT NULL DEFAULT 0,
total_fees_usd DECIMAL(10, 2) NOT NULL DEFAULT 0,
refund_credits_usd DECIMAL(10, 2) NOT NULL DEFAULT 0,
net_amount_usd DECIMAL(10, 2) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
due_date DATE NOT NULL,
paid_at TIMESTAMP,
suspended_at TIMESTAMP,
reactivated_at TIMESTAMP,
generated_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT valid_status CHECK (
status IN ('pending', 'paid', 'overdue', 'suspended')
)
);Status transitions follow a strict state machine:
pending --> paid (merchant pays before due date)
pending --> overdue (due date passes without payment)
overdue --> paid (merchant pays during grace period)
overdue --> suspended (10th of month, still unpaid)
suspended --> paid (merchant pays after suspension)Suspension Check: The 10th-of-Month Cron
The second cron job runs at 00:01 UTC on the 10th. It checks all overdue invoices and suspends the associated applications:
pythonasync def cron_check_suspensions(db: AsyncSession):
"""Suspension check -- runs on the 10th."""
overdue_invoices = await db.execute(
select(PlatformInvoice).where(
PlatformInvoice.status == "overdue",
PlatformInvoice.due_date < datetime.utcnow().date()
)
)
suspended_count = 0
for invoice in overdue_invoices.scalars().all():
# Suspend the app
app = await db.get(App, invoice.app_id)
app.status = "suspended"
app.suspended_at = datetime.utcnow()
app.suspension_reason = f"Unpaid invoice: {invoice.id}"
# Update invoice status
invoice.status = "suspended"
invoice.suspended_at = datetime.utcnow()
suspended_count += 1
# Notify merchant
await send_suspension_notification(
user_id=invoice.user_id,
app_name=app.name,
invoice_amount=invoice.net_amount_usd,
invoice_id=invoice.id
)
await db.commit()
return {"suspended": suspended_count}Cron Trigger Security
Cron jobs are triggered via HTTP POST endpoints, secured with a shared secret:
pythonfrom fastapi import Header, HTTPException
CRON_SECRET = os.getenv("CRON_SECRET")
async def verify_cron_secret(x_cron_secret: str = Header(...)):
"""Verify the cron trigger is authorized."""
if x_cron_secret != CRON_SECRET:
raise HTTPException(status_code=403, detail="Invalid cron secret")
@router.post("/api/cron/generate-invoices")
async def trigger_invoice_generation(
_: str = Depends(verify_cron_secret),
db: AsyncSession = Depends(get_db)
):
result = await cron_generate_invoices(db)
return {"status": "completed", **result}
@router.post("/api/cron/check-suspensions")
async def trigger_suspension_check(
_: str = Depends(verify_cron_secret),
db: AsyncSession = Depends(get_db)
):
result = await cron_check_suspensions(db)
return {"status": "completed", **result}The X-Cron-Secret header ensures that only our scheduler (0cron.dev, naturally) can trigger these jobs. External requests without the correct secret receive a 403.
The 402 Payment Required Response
When a suspended app tries to make any API call, the middleware intercepts it and returns HTTP 402:
pythonasync def suspension_middleware(request: Request, call_next):
"""Check if the requesting app is suspended."""
app = request.state.app # Set by auth middleware
if app and app.status == "suspended":
return JSONResponse(
status_code=402,
content={
"error": "payment_required",
"message": "Your account is suspended due to an unpaid invoice. "
"Please pay your outstanding balance to restore access.",
"invoice_url": f"https://app.0fee.dev/billing/invoices",
"support_email": "[email protected]"
}
)
return await call_next(request)HTTP 402 is the semantically correct status code -- "Payment Required." Most developers have never encountered it in the wild, which makes it self-documenting. When a merchant sees a 402, they immediately understand the issue without reading the error message.
JWT-Based Suspension Check
For performance, we embed the suspension status in the JWT token. This avoids a database query on every request:
pythondef create_access_token(app: App) -> str:
"""Create a JWT with embedded suspension status."""
payload = {
"app_id": str(app.id),
"user_id": str(app.user_id),
"status": app.status, # "active" or "suspended"
"exp": datetime.utcnow() + timedelta(hours=24)
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
async def verify_token(token: str) -> dict:
"""Verify JWT and check suspension status."""
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
if payload.get("status") == "suspended":
raise HTTPException(
status_code=402,
detail="Account suspended -- unpaid invoice"
)
return payloadThere is a subtlety here: when an account is suspended (or reactivated), existing JWTs still carry the old status until they expire. We handle this with a two-layer check:
- JWT layer (fast): catches most cases without a DB hit.
- Middleware layer (authoritative): for edge cases where the JWT status is stale, the middleware checks the app's current status from the database.
pythonasync def full_suspension_check(request: Request):
"""Two-layer suspension verification."""
token_payload = request.state.token_payload
# Layer 1: JWT status (fast path)
if token_payload.get("status") == "suspended":
raise HTTPException(status_code=402, detail="Account suspended")
# Layer 2: DB status (authoritative, only if JWT says active)
app = await get_app(token_payload["app_id"])
if app.status == "suspended":
raise HTTPException(status_code=402, detail="Account suspended")The SuspensionBanner Component
On the frontend, suspended merchants see a prominent banner across every dashboard page:
svelte<script lang="ts">
let { invoice } = $props<{ invoice: PlatformInvoice | null }>();
let daysOverdue = $derived(() => {
if (!invoice?.due_date) return 0;
const due = new Date(invoice.due_date);
const now = new Date();
return Math.floor((now.getTime() - due.getTime()) / (1000 * 60 * 60 * 24));
});
</script>
{#if invoice?.status === 'suspended'}
<div class="bg-red-600 text-white px-4 py-3 text-center">
<p class="font-semibold">
Your account is suspended due to an unpaid invoice of
${invoice.net_amount_usd.toFixed(2)}.
</p>
<p class="text-sm mt-1">
API calls are returning 402 Payment Required.
{daysOverdue()} days overdue.
</p>
<a
href="/billing/pay/{invoice.id}"
class="inline-block mt-2 bg-white text-red-600 px-4 py-1.5 rounded font-medium
hover:bg-red-50 transition-colors"
>
Pay Now
</a>
</div>
{:else if invoice?.status === 'overdue'}
<div class="bg-yellow-500 text-yellow-900 px-4 py-3 text-center">
<p class="font-semibold">
Your invoice of ${invoice.net_amount_usd.toFixed(2)} is overdue.
</p>
<p class="text-sm mt-1">
Your account will be suspended on the 10th if payment is not received.
</p>
<a
href="/billing/pay/{invoice.id}"
class="inline-block mt-2 bg-yellow-900 text-white px-4 py-1.5 rounded
font-medium hover:bg-yellow-800 transition-colors"
>
Pay Now
</a>
</div>
{/if}The banner is intentionally intrusive. A subtle notification would be ignored. A full-width red bar with the exact amount owed and a "Pay Now" button gets action.
Auto-Reactivation
When a suspended merchant pays their invoice, reactivation is instant and automatic:
pythonasync def process_invoice_payment(invoice_id: str, db: AsyncSession):
"""Process payment and auto-reactivate if suspended."""
invoice = await db.get(PlatformInvoice, invoice_id)
if not invoice:
raise HTTPException(status_code=404)
# Mark invoice as paid
invoice.status = "paid"
invoice.paid_at = datetime.utcnow()
# Check if app is suspended
app = await db.get(App, invoice.app_id)
if app.status == "suspended":
app.status = "active"
app.suspended_at = None
app.suspension_reason = None
invoice.reactivated_at = datetime.utcnow()
# Notify merchant
await send_reactivation_notification(
user_id=invoice.user_id,
app_name=app.name
)
# Check if there are other unpaid invoices
other_unpaid = await db.execute(
select(PlatformInvoice).where(
PlatformInvoice.app_id == app.id,
PlatformInvoice.status.in_(["overdue", "suspended"]),
PlatformInvoice.id != invoice.id
)
)
if other_unpaid.scalars().first():
# Cannot reactivate -- other invoices still unpaid
app.status = "suspended"
app.suspension_reason = "Multiple unpaid invoices"
await db.commit()The key detail: reactivation only happens if all outstanding invoices are paid. A merchant cannot pay the latest invoice and ignore older ones.
Grace Period Notifications
Between the due date (5th) and suspension date (10th), we send escalating notifications:
pythonGRACE_PERIOD_EMAILS = [
{
"day": 6,
"subject": "Your 0fee.dev invoice is overdue",
"tone": "informational",
"message": "Your invoice of {amount} was due on the 5th. "
"Please pay within 4 days to avoid service interruption."
},
{
"day": 8,
"subject": "Action required: Account suspension in 2 days",
"tone": "urgent",
"message": "Your 0fee.dev account will be suspended on the 10th "
"if payment of {amount} is not received. "
"All API calls will return 402 Payment Required."
}
]We deliberately limit ourselves to two emails during the grace period. More would be spam. Fewer would be negligent. Two emails -- one informational, one urgent -- strikes the right balance.
Edge Cases We Handle
Merchant Pays on the 10th (Race Condition)
If a payment arrives on the same day as the suspension cron, we process whichever happens first. The cron checks the invoice status before suspending:
python# In cron_check_suspensions:
if invoice.status == "paid":
continue # Already paid, skipPartial Payment
We do not support partial payments. The invoice must be paid in full. This simplifies the state machine and avoids confusion about remaining balances.
Disputed Invoices
Merchants can dispute an invoice within the grace period. Disputed invoices are excluded from the suspension cron:
pythonif invoice.status == "disputed":
continue # Under review, do not suspendMultiple Apps, One User
A user with multiple apps can have one app suspended while others remain active. Suspension is per-app, not per-user. Each app has its own invoice and its own suspension status.
Monitoring and Alerts
We track billing health metrics:
pythonBILLING_METRICS = {
"invoices_generated": Counter("Total invoices generated"),
"invoices_paid_on_time": Counter("Invoices paid before due date"),
"invoices_paid_grace": Counter("Invoices paid during grace period"),
"accounts_suspended": Counter("Accounts suspended for non-payment"),
"accounts_reactivated": Counter("Accounts reactivated after payment"),
"average_payment_days": Histogram("Days from invoice to payment"),
}The suspension rate is our most watched metric. A spike in suspensions could indicate a billing bug, a UX problem in the payment flow, or an economic event affecting our merchant base.
Why This Design Works
The billing suspension system embodies our approach to building 0fee.dev:
- Automated end-to-end. No human intervention needed for the entire billing cycle -- generation, notification, suspension, reactivation.
- Fair and predictable. Merchants know exactly what happens and when. No surprises.
- Recoverable. Suspension is a pause, not a termination. Pay the invoice, and service resumes immediately.
- Secure. Cron endpoints are secret-protected. JWT embedding reduces database load. Two-layer checks prevent stale state issues.
With zero human engineers, we cannot afford to manually chase invoices. The system must handle itself, and it does.
This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.