Get a quote

Building a SaaS Billing System in Go: What the Payment API Does Not Handle

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:

  • trialing moves to active when the first successful payment is recorded
  • active moves to past_due when a payment fails and there is a retry window
  • past_due moves to suspended after three failed payment attempts over seven days
  • suspended moves to active when the tenant provides a valid payment method and the outstanding charge succeeds
  • canceled is 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:

  1. Payment fails: immediately retry once (many failures are transient card processor errors)
  2. Day 1: send email notification, set status to past_due
  3. Day 3: retry payment, send reminder if still failing
  4. Day 7: final retry, send urgent notification
  5. 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.

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

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/

Back to blog
Chat on WhatsApp