Skip to content

Invoice System

Domain Entity

File: domain/orders/invoices.rs

pub struct Invoice {
    id: InvoiceId,                              // UUID
    pub order_id: OrderId,
    pub amount: Decimal,                        // EUR
    pub currency: String,                       // "EUR"
    pub status: InvoiceStatus,
    pub kind: InvoiceKind,
    pub payment_method: Option<String>,
    pub external_id: Option<String>,            // reserved for payment gateway
    pub external_metadata: Option<Value>,       // reserved for payment gateway
    pub discount_code_id: Option<DiscountCodeId>,
    pub addon_id: Option<OrderAddonId>,         // set for addon-specific invoices
    pub created_at: DateTime<Utc>,
    pub paid_at: Option<DateTime<Utc>>,
    pub failed_at: Option<DateTime<Utc>>,
    pub refunded_at: Option<DateTime<Utc>>,
    pub failure_reason: Option<String>,
}

InvoiceStatus

Pending → Paid
Pending → Failed
Pending → Cancelled
Paid    → Refunded

InvoiceKind

Kind Trigger Amount
Initial Order created Full monthly cost
Resize Order upsized Price difference only
Renewal Billing cycle ended (task) Same as last paid invoice + addon costs
AddonActivation Addon added to Active order Addon monthly cost
AddonRenewal Billing cycle (addon) Addon monthly cost
AddonResize Addon upsized Price difference only
AdminResize Admin-forced resize amount=0, is_synthetic=true

Repository

find_by_id(id)
find_by_order_id(order_id)  Vec<Invoice>
find_last_paid_for_order(order_id)
find_first_paid_for_order(order_id)
list_all(page, status: Option<InvoiceStatus>)  Page<Invoice>
save(invoice)

Database Schema

Initial migration (2026-02-21-233244_catalog):

CREATE TABLE invoices (
    id UUID PRIMARY KEY,
    order_id UUID NOT NULL REFERENCES orders(id),
    amount NUMERIC NOT NULL,
    currency TEXT NOT NULL DEFAULT 'EUR',
    status TEXT NOT NULL DEFAULT 'pending',  -- InvoiceStatus via strum
    kind TEXT NOT NULL,                      -- InvoiceKind via strum
    payment_method TEXT,
    external_id TEXT,
    external_metadata JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    paid_at TIMESTAMPTZ,
    failed_at TIMESTAMPTZ,
    refunded_at TIMESTAMPTZ,
    failure_reason TEXT
);

Later migrations: - discount_code_tracking: ADD COLUMN discount_code_id UUID REFERENCES discount_codes(id) - invoices_addon_fields: ADD COLUMN addon_id UUID REFERENCES order_addons(id) - invoice_is_synthetic: ADD COLUMN is_synthetic BOOLEAN NOT NULL DEFAULT false - invoice_tax_fields: adds tax_rate_id, tax_rate, tax_label, tax_amount

Invoice Creation

Invoices are never created directly by an admin. They are side effects of domain actions:

Initial — CreateOrderService

  1. User calls POST /api/v1/orders/
  2. Calculate price (CPU + memory + storage − discount)
  3. Create Order (status=Pending) + Invoice (kind=Initial, status=Pending)
  4. If price == 0: auto-call mark_paid immediately

Resize — ResizeOrderService

  • Upsize only: create Invoice (kind=Resize, amount=price_difference)
  • Downsize: no invoice; resources stored as pending_resources, applied on next billing cycle

AddonActivation — CreateOrderAddonService

  • If order is Active: create Invoice (kind=AddonActivation, addon_id=addon.id)
  • If order is Pending (checkout flow): no separate invoice; addon cost is part of the Initial invoice

When the Initial invoice is paid, any pending addons are activated and their AddonActivation invoices are auto-marked paid with payment_method = "included_in_checkout".

Renewal — ProcessBillingCyclesTask

Scheduled task: 1. Check last_paid.paid_at + billing_cycle_days <= now 2. If no pending renewal exists: create Invoice (kind=Renewal, amount=last_paid.amount + current addon costs)

Mark Invoice Paid

Service: MarkInvoicePaidService HTTP: POST /api/v1/invoices/{id}/mark-paid Auth: ManageInvoices permission (application token or user with SystemPermission::ManageInvoices)

Guards: rejects if status is already Paid, Failed, Refunded, or Cancelled.

Side Effects on Payment

All side effects are best-effort (errors are logged, payment is recorded regardless).

Initial paid → Order activated

  1. order.status = Active
  2. Create BillingConfig (cycle_days=30, grace_period_hours=48, auto_renew=true)
  3. Send "order_activated" notification
  4. provision_order_service.provision_order(order_id) — creates K8s resources
  5. For each pending addon: activate it and mark its AddonActivation invoice paid

Resize paid → Resources applied

  1. order.apply_pending_resources() — moves pending_resources → current resources
  2. Send "order_resized" notification
  3. reprovision_service.reprovision(order_id) — tears down and recreates K8s resources

Renewal paid → Unsuspend (if needed)

  1. If order is Suspended: unsuspend_service.unsuspend(order_id) — scales K8s deployment back up
  2. For each Suspended addon: set status = Active

AddonActivation paid → Addon activated

  1. activate_addon_service.activate_addon(addon_id)
  2. Creates addon K8s resources per blueprint
  3. addon.status = Active, addon.activated_at = now

Billing Cycle Task

File: application/tasks/process_billing_cycles.rs

Runs periodically on all Active orders:

  1. Suspension check: if unpaid renewal exists and renewal.created_at + grace_period_hours <= now → suspend order
  2. New renewal: if no pending renewal and cycle ended → create Renewal invoice
  3. Auto-renew=false: schedule order cancellation instead of creating renewal
  4. Addon teardown: addons with status=Cancelling and cancels_at <= now → delete K8s resources, release port allocations, soft-delete provisions, set status=Cancelled

BillingConfig fields (per order): - billing_cycle_days — default 30 - grace_period_hours — default 48 - auto_renew — default true

HTTP Endpoints

Method Path Auth Description
GET /api/v1/invoices/ ManageInvoices List all invoices (paginated, filterable by status)
GET /api/v1/invoices/{id} ReadOrder on linked order Get invoice detail
POST /api/v1/invoices/{id}/mark-paid ManageInvoices Mark invoice as paid
GET /api/v1/orders/{id}/invoices ReadOrder + ManageBillingAndResize List invoices for an order

HTTP DTO

pub struct InvoiceView {
    pub id: Uuid,
    pub order_id: Uuid,
    pub amount: String,          // "XX.XX"
    pub currency: String,
    pub status: InvoiceStatusView,
    pub kind: String,
    pub payment_method: Option<String>,
    pub discount_code_id: Option<Uuid>,
    pub addon_id: Option<Uuid>,
    pub tax_amount: Option<String>,
    pub tax_rate: Option<String>,
    pub tax_label: Option<String>,
    pub total_amount: String,
    pub created_at: DateTime<Utc>,
    pub paid_at: Option<DateTime<Utc>>,
    pub failed_at: Option<DateTime<Utc>>,
    pub refunded_at: Option<DateTime<Utc>>,
    pub failure_reason: Option<String>,
}

Key Design Notes

  • Invoices are immutable after creation except for status transitions via mark_paid, mark_failed, mark_cancelled
  • mark_paid is the central dispatch point for all post-payment business logic
  • An invoice with amount=0 is auto-paid at creation time (free tier or full-discount orders)
  • Renewal invoice amounts are recalculated fresh each cycle (last_paid.amount + active addon costs)
  • Discount codes are recorded on invoices but only applied at Initial invoice creation; renewals do not re-apply discounts