Integrating a payment API takes one afternoon. Building a billing system that handles mid-cycle plan changes, failed payment recovery, prorated credits, and accurate invoice generation takes months if you design it wrong the first time. These are the patterns Voxire uses for RTYLR's subscription billing.
Integrating a payment API takes one afternoon. Building a billing system that handles mid-cycle plan changes, failed payment recovery, prorated credits, and accurate invoice generation takes months if you design it wrong the first time. Most teams learn this the hard way when the first subscription upgrade request arrives and the system has no concept of proration.
This post covers the billing architecture Voxire built for RTYLR, a SaaS platform serving restaurant and retail businesses across Lebanon and the Gulf. RTYLR handles subscriptions, usage-based fees, and per-location billing across different pricing tiers and currencies.
What the payment API actually handles
Stripe, Paddle, and similar payment processors are excellent at charging cards and moving money. They handle the compliance, fraud detection, and banking relationships that you absolutely do not want to build yourself.
What they do not handle well is your subscription state machine. The processor knows about charges. It does not know about your tenant's feature access, what they are entitled to on their current plan, when they are due for renewal, or whether a failed charge from three days ago should suspend their access.
That business logic lives in your system, not in the payment provider. The provider is a tool for executing charges. Your database is the source of truth for subscription state.
The subscription state machine
Every subscription moves through a set of states. Defining them explicitly prevents the class of bugs where "partially paid" or "grace period" are handled inconsistently across different parts of the codebase.
type SubscriptionStatus string
const (
StatusTrialing SubscriptionStatus = "trialing"
StatusActive SubscriptionStatus = "active"
StatusPastDue SubscriptionStatus = "past_due"
StatusSuspended SubscriptionStatus = "suspended"
StatusCanceled SubscriptionStatus = "canceled"
)
The transitions between states are business decisions. In RTYLR:
trialingmoves toactivewhen the first successful payment is recordedactivemoves topast_duewhen a payment fails and there is a retry windowpast_duemoves tosuspendedafter three failed payment attempts over seven dayssuspendedmoves toactivewhen the tenant provides a valid payment method and the outstanding charge succeedscanceledis terminal
Store the current status and the date of the last status transition. The history of transitions belongs in an events table, not as overwritten fields.
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL UNIQUE,
plan_id UUID NOT NULL REFERENCES plans(id),
status TEXT NOT NULL,
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
trial_ends_at TIMESTAMPTZ,
canceled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE subscription_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subscription_id UUID NOT NULL REFERENCES subscriptions(id),
event_type TEXT NOT NULL,
from_status TEXT,
to_status TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Proration: the math that breaks billing
Proration is what happens when a tenant changes plans mid-cycle. A restaurant on the $50/month Standard plan upgrades to the $120/month Pro plan on day 20 of a 30-day billing cycle. They have used 20/30 of Standard and should be charged 10/30 of Pro for the remainder.
Credit for unused Standard: $50 * (10/30) = $16.67 Charge for remaining Pro: $120 * (10/30) = $40.00 Net charge at upgrade: $40.00 - $16.67 = $23.33
At the next billing date, they are charged the full $120 for Pro.
func CalculateProration(currentPlan, newPlan *Plan, daysRemaining, daysInCycle int) decimal.Decimal {
ratio := decimal.NewFromInt(int64(daysRemaining)).Div(decimal.NewFromInt(int64(daysInCycle)))
creditUnused := currentPlan.Price.Mul(ratio)
chargeNew := newPlan.Price.Mul(ratio)
return chargeNew.Sub(creditUnused)
}
Use a decimal library, not float64, for all monetary calculations. Floating point arithmetic on money causes rounding errors that accumulate into real discrepancies in invoice totals.
Dunning: recovering failed payments
Dunning is the process of retrying failed payments and communicating with customers about payment failures. Most SaaS products lose 5-10% of monthly revenue to failed payments that were never retried correctly.
A basic dunning sequence for RTYLR:
- Payment fails: immediately retry once (many failures are transient card processor errors)
- Day 1: send email notification, set status to
past_due - Day 3: retry payment, send reminder if still failing
- Day 7: final retry, send urgent notification
- Day 8: set status to
suspended, revoke feature access
The dunning schedule lives in a database table, not hardcoded. Different plan tiers can have different dunning windows. Enterprise accounts get longer grace periods.
CREATE TABLE dunning_schedules (
plan_tier TEXT NOT NULL,
attempt_number INT NOT NULL,
days_after_failure INT NOT NULL,
send_notification BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (plan_tier, attempt_number)
);
A background job runs daily and processes tenants in past_due status according to their dunning schedule. Each step is logged in subscription_events.
Invoice generation
Invoices serve two purposes: they are the legal record of a transaction, and they are what your customers print and submit for expense reimbursement or VAT reclaim.
In Lebanon and across the Gulf, invoice requirements include: company name and registration number, VAT number where applicable, line items with unit price and quantity, total before tax, tax amount, and total including tax. Generating invoices that fail these requirements creates real operational pain for customers.
Store invoice data in your own database, not only in the payment provider. Payment providers delete or archive old invoices on their own schedules. Your customers expect invoices to be accessible for at least seven years for tax purposes.
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
subscription_id UUID NOT NULL REFERENCES subscriptions(id),
invoice_number TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
subtotal NUMERIC(10,2) NOT NULL,
tax_rate NUMERIC(5,4) NOT NULL DEFAULT 0,
tax_amount NUMERIC(10,2) NOT NULL,
total NUMERIC(10,2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'USD',
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Generate invoice numbers with a predictable sequential format that includes the year and tenant prefix: VX-2026-00142. Customers reference these in support requests and expense systems.
Usage-based billing
Flat-rate subscriptions are straightforward. Usage-based billing adds a metering layer. RTYLR charges per-location: a restaurant with three branches pays for three location seats.
The metering table records usage events:
CREATE TABLE usage_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
metric TEXT NOT NULL,
quantity INT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
billing_period DATE NOT NULL
);
At billing time, aggregate the usage records for the period and calculate the charge. Keep the records after billing for dispute resolution. Tenants occasionally contest usage charges, and having the raw records enables fast resolution.
Key lessons from RTYLR billing
Three things were more complex than anticipated:
Currency handling for Gulf deployments. RTYLR serves tenants billed in USD, Lebanese pound equivalent, and Gulf currencies. Each requires a separate invoice template and a clear exchange rate policy for the billing period.
Plan change timing. Whether a plan downgrade takes effect immediately or at period end significantly affects customer experience. Immediate downgrades with prorated refunds feel fair. End-of-period downgrades are simpler to implement. RTYLR uses immediate upgrades and end-of-period downgrades.
Support access during suspension. Suspended tenants still need to access the billing section to update their payment method. Restricting all access to a suspended account means they cannot fix the problem. Build a payment-only mode that survives suspension.
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.
Not sure where to start?
If you are building SaaS billing for a product serving Lebanon or MENA markets and need a system that handles real business complexity, Voxire builds production billing systems that go beyond payment API integrations.
https://voxire.com/get-a-quote/



