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¶
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¶
- User calls
POST /api/v1/orders/ - Calculate price (CPU + memory + storage − discount)
- Create Order (status=Pending) + Invoice (kind=Initial, status=Pending)
- If price == 0: auto-call
mark_paidimmediately
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¶
order.status = Active- Create
BillingConfig(cycle_days=30, grace_period_hours=48, auto_renew=true) - Send "order_activated" notification
provision_order_service.provision_order(order_id)— creates K8s resources- For each pending addon: activate it and mark its AddonActivation invoice paid
Resize paid → Resources applied¶
order.apply_pending_resources()— movespending_resources→ current resources- Send "order_resized" notification
reprovision_service.reprovision(order_id)— tears down and recreates K8s resources
Renewal paid → Unsuspend (if needed)¶
- If order is Suspended:
unsuspend_service.unsuspend(order_id)— scales K8s deployment back up - For each Suspended addon: set status = Active
AddonActivation paid → Addon activated¶
activate_addon_service.activate_addon(addon_id)- Creates addon K8s resources per blueprint
addon.status = Active,addon.activated_at = now
Billing Cycle Task¶
File: application/tasks/process_billing_cycles.rs
Runs periodically on all Active orders:
- Suspension check: if unpaid renewal exists and
renewal.created_at + grace_period_hours <= now→ suspend order - New renewal: if no pending renewal and cycle ended → create Renewal invoice
- Auto-renew=false: schedule order cancellation instead of creating renewal
- Addon teardown: addons with
status=Cancellingandcancels_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_paidis the central dispatch point for all post-payment business logic- An invoice with
amount=0is 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