Skip to the content.

ADR 010 — Billing Architecture: Dual Processor, Isolated App, Plan-Gated Access

Date: April 2026 Status: Accepted — build triggered on first paying tenant Author: Ezinna (Founder)


Context

AgriOps is a multi-tenant SaaS platform moving toward its first paying tenant. The platform serves two distinct customer profiles with different payment realities:

A three-tier subscription model (Starter / Growth / Enterprise) has been designed with feature gates that map to real upgrade triggers: farm scale for Starter→Growth, and US/non-EU buyer market access (neutral traceability certificates) for Growth→Enterprise.

The architecture must stay consistent with existing codebase patterns — particularly the RBAC mixin + template guard discipline — and must not allow billing logic to leak into core domain apps.


Decision Drivers


Options Considered

Option 1 — Single processor (Stripe only), NGN via manual invoicing

Pros:

Cons:

Option 2 — Dual processor: Paystack (NGN) + Stripe (USD) behind a shared service layer ✅ Chosen

Pros:

Cons:

Option 3 — Usage-based billing (per farm, per report)

Pros:

Cons:


Decision

1. Isolated billing app

All billing code lives in apps/billing/. No other app imports from it. Billing reads Company — that is the only dependency direction.

apps/billing/
├── models.py       # Subscription, Invoice
├── mixins.py       # PlanRequiredMixin
├── services.py     # BillingService — abstracts Stripe + Paystack
├── webhooks.py     # Idempotent webhook handlers for both processors
├── templatetags/   # {% plan_gate 'enterprise' %} template tag
└── urls.py         # /billing/ + /webhooks/stripe/ + /webhooks/paystack/

2. Plan tier on Company model

Four fields added to Company:

plan_tier = CharField(choices=['starter', 'growth', 'enterprise'], default='starter')
subscription_status = CharField(choices=['trial', 'active', 'suspended', 'cancelled'])
billing_currency = CharField(choices=['ngn', 'usd'])
subscription_expires_at = DateTimeField(null=True)

billing_currency is set at company creation from registration country and never changed by the tenant. Nigerian-registered companies = NGN/Paystack. All others = USD/Stripe.

3. Feature gating follows existing RBAC discipline

PlanRequiredMixin mirrors ManagerRequiredMixin. Backend gate and template guard must always be in sync — the same permission sync checklist applies.

class PlanRequiredMixin:
    required_plan = 'growth'

    def dispatch(self, request, *args, **kwargs):
        if not request.user.company.meets_plan(self.required_plan):
            return redirect('billing:upgrade')
        return super().dispatch(request, *args, **kwargs)

Company.meets_plan(tier) evaluates tier hierarchy: enterprise ≥ growth ≥ starter.

Template guard:

{% if request.user.company.meets_plan 'enterprise' %}
  ...feature...
{% else %}
  ...upgrade nudge...
{% endif %}

4. BillingService abstracts processor selection

class BillingService:
    @staticmethod
    def get_processor(company):
        if company.billing_currency == 'ngn':
            return PaystackProcessor()
        return StripeProcessor()

Views and models call BillingService only. Stripe and Paystack SDKs are never imported outside apps/billing/.

5. Webhooks are idempotent

Both processors retry failed webhooks. Event IDs are stored on first receipt; duplicate events are discarded without side effects.

6. Ops dashboard integration

The ops dashboard gains a subscription management panel per tenant:


Consequences