Skip to content
SaaS4Builders
Billing

Invoices

Invoice management in SaaS4Builders: Stripe as invoicing authority, invoice sync, status tracking, PDF download, and the useInvoices composable.

In stripe_managed mode, Stripe is the invoicing authority. Stripe generates invoice numbers, calculates line items, produces PDFs, and processes payments. Your application maintains local copies of invoice data synced via webhooks, providing a queryable interface for your users without duplicating Stripe's invoicing logic.


Invoice Statuses

The InvoiceStatus enum defines the five possible invoice states:

backend/app/Domain/Billing/Enums/InvoiceStatus.php
enum InvoiceStatus: string
{
    case Draft = 'draft';
    case Open = 'open';
    case Paid = 'paid';
    case Void = 'void';
    case Uncollectible = 'uncollectible';

    public function isFinal(): bool
    {
        return in_array($this, [self::Paid, self::Void, self::Uncollectible], true);
    }

    public function canBeVoided(): bool
    {
        return in_array($this, [self::Draft, self::Open], true);
    }
}
StatusMeaningFinal?Can Be Voided?
draftInvoice created but not yet finalizedNoYes
openFinalized and awaiting paymentNoYes
paidPayment received successfullyYesNo
voidCanceled before paymentYesNo
uncollectibleAll payment attempts exhaustedYesNo

Once an invoice reaches a final status (paid, void, or uncollectible), it is immutable. No fields can be modified. Only draft and open invoices can be voided.


Invoice Data Model

The Invoice model stores the local copy of each Stripe invoice:

Core Fields

FieldTypeDescription
idUUIDInternal identifier
tenant_idUUIDOwning tenant
subscription_idUUID (nullable)Associated subscription
stripe_invoice_idstring (nullable)Stripe's invoice ID for sync
numberstringInvoice number (format: YYYY-NNNNN)
statusInvoiceStatusCurrent payment state
currencystring (3)ISO 4217 currency code
subtotal_centsintegerPre-tax amount in minor units
tax_centsintegerTax amount in minor units
total_centsintegerTotal amount in minor units
issue_datedateWhen the invoice was issued
due_datedate (nullable)Payment deadline
paid_atdatetime (nullable)When payment was received
billing_infoJSONSnapshot of tenant billing details at invoice time
pdf_pathstring (nullable)Local path or Stripe URL for PDF

Invoice Lines

Each invoice contains one or more line items stored in the InvoiceLine model:

FieldTypeDescription
descriptionstringHuman-readable line description
typestringOne of: subscription, usage, adjustment, proration
quantityintegerNumber of units
unit_price_centsintegerPrice per unit in minor units
amount_centsintegerTotal line amount (quantity * unit_price_cents)
plan_idUUID (nullable)Associated plan (for subscription lines)
meter_idUUID (nullable)Associated meter (for usage lines)
period_startdatetime (nullable)Billing period start
period_enddatetime (nullable)Billing period end

Tax Records

Invoices can also have associated TaxRecord entries that capture tax breakdown details:

FieldTypeDescription
tax_typestringType of tax (e.g., vat, sales_tax)
jurisdictionstringTax jurisdiction (e.g., FR, DE, CA-QC)
ratedecimalTax rate as a decimal (e.g., 0.200000 for 20%)
taxable_amount_centsintegerAmount subject to this tax
tax_amount_centsintegerCalculated tax amount

See Tax Configuration for how tax records are populated.


Invoice Sync from Stripe

In stripe_managed mode, invoices are synced from Stripe to your local database through two mechanisms:

Webhook-Driven Sync (Primary)

Stripe webhook events trigger invoice creation and updates automatically:

EventWhat Happens
invoice.createdUpdates local usage snapshots with Stripe's invoiced amounts for metered line items
invoice.paidCreates the local invoice record (if it doesn't exist) and marks it as paid
invoice.finalizedTriggers usage reconciliation between Stripe amounts and local shadow calculations
invoice.payment_failedDispatches a PaymentFailed event for your notification system

The invoice.paid handler is the primary invoice creation path. It maps Stripe's invoice data to a local InvoiceDraft, creates the invoice with all line items and tax records, then marks it as paid — all in a single operation.

Webhook sync is idempotent. Handlers check for existing records via stripe_invoice_id before creating duplicates. If Stripe retries a webhook, the handler safely skips already-processed invoices.

Bulk Sync (Administrative)

The StripeInvoiceSyncService provides bulk and incremental sync for administrative use:

backend/app/Infrastructure/Billing/Services/StripeInvoiceSyncService.php
// Full sync — fetches all invoices from Stripe (paginated automatically)
$result = $syncService->syncAll(dryRun: false);

// Incremental sync — only invoices created since a given date
$result = $syncService->syncSince(now()->subDays(7), dryRun: false);

Each invoice sync is tracked as created, updated, skipped, or error. The service validates that the tenant exists and the currency is recognized before creating local records.


Query Resolution by Billing Mode

Invoice queries are resolved differently depending on the billing mode. The InvoiceQueryResolver maps the current mode to the correct implementation:

Billing ModeQuery ImplementationData Source
stripe_managedStripeInvoiceQueryLive Stripe API
platform_managedLocalInvoiceQueryLocal database

In stripe_managed mode, the StripeInvoiceQuery fetches invoice data directly from the Stripe API and returns in-memory Invoice models. These models are not persisted — they exist only for the duration of the request. This means invoice listings always reflect the latest Stripe data without requiring sync.

Because StripeInvoiceQuery reads from Stripe's API, invoice list requests have higher latency than local database queries. The API endpoints handle this transparently — you don't need to change your frontend code when switching billing modes.

API Endpoints

Three endpoints provide invoice access:

MethodPathDescription
GET/api/v1/tenant/{tenantId}/invoicesList invoices (paginated)
GET/api/v1/tenant/{tenantId}/invoices/{invoiceId}Get a single invoice with lines and tax records
GET/api/v1/tenant/{tenantId}/invoices/{invoiceId}/pdfDownload invoice PDF

All endpoints require Sanctum authentication and tenant.member middleware.

List Invoices

GET /api/v1/tenant/{tenantId}/invoices?status=paid&page=1&per_page=25

Query parameters:

ParameterTypeDefaultDescription
statusstring(all)Filter by status: draft, open, paid, void, uncollectible
pageinteger1Page number
per_pageinteger25Items per page (max: 100)

Invoice Response Shape

{
  "id": "9f1a2b3c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
  "tenant_id": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
  "subscription_id": "b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e",
  "stripe_invoice_id": "in_1234567890",
  "stripe_payment_intent_id": "pi_1234567890",
  "number": "2026-00042",
  "status": "paid",
  "subtotal": { "amount_cents": 2999, "currency": "EUR" },
  "tax": { "amount_cents": 600, "currency": "EUR" },
  "total": { "amount_cents": 3599, "currency": "EUR" },
  "issue_date": "2026-03-26",
  "due_date": null,
  "paid_at": "2026-03-26T10:30:00.000000Z",
  "pdf_url": "/api/v1/tenant/{tenantId}/invoices/{invoiceId}/pdf",
  "billing_info": {
    "legal_name": "Acme Corp",
    "address": "123 Main St",
    "city": "Paris",
    "country": "FR"
  },
  "lines": [
    {
      "id": "c3d4e5f6-a7b8-9c0d-1e2f-3a4b5c6d7e8f",
      "invoice_id": "9f1a2b3c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
      "description": "Pro Plan — Monthly",
      "type": "subscription",
      "quantity": 1,
      "unit_price": { "amount_cents": 2999, "currency": "EUR" },
      "amount": { "amount_cents": 2999, "currency": "EUR" },
      "plan_id": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a",
      "meter_id": null,
      "period_start": "2026-03-01T00:00:00.000000Z",
      "period_end": "2026-04-01T00:00:00.000000Z",
      "created_at": "2026-03-26T10:00:00.000000Z",
      "updated_at": "2026-03-26T10:00:00.000000Z"
    }
  ],
  "created_at": "2026-03-26T10:00:00.000000Z",
  "updated_at": "2026-03-26T10:30:00.000000Z"
}

Money fields follow the standard { amount_cents, currency } format. See Currency Rules for details on money representation.

PDF Download

The PDF endpoint streams the invoice PDF directly to the browser:

GET /api/v1/tenant/{tenantId}/invoices/{invoiceId}/pdf

In stripe_managed mode, the PDF is fetched from Stripe's hosted URL and proxied through your application. This avoids CORS issues that would occur if the frontend tried to download directly from Stripe's domain.

In platform_managed mode, the PDF is streamed from local disk storage.

The response is always a StreamedResponse with Content-Type: application/pdf.


Frontend: useInvoices Composable

The useInvoices() composable provides reactive invoice data with pagination, status filtering, and PDF download:

frontend/features/core/docs/billing/composables/useInvoices.ts
const {
  // Data
  invoices,          // InvoiceWithHelpers[] — enriched with computed properties
  meta,              // PaginationMeta (current_page, last_page, per_page, total)
  isEmpty,           // true when no invoices match the filter
  total,             // Total invoice count

  // Pagination
  page,              // Current page number
  goToPage,          // Navigate to a specific page
  nextPage,          // Go to next page
  prevPage,          // Go to previous page
  hasNextPage,       // true if more pages exist
  hasPrevPage,       // true if previous pages exist

  // Loading & error
  isLoading,         // true during fetch
  error,             // Error object (if any)

  // Actions
  downloadPdf,       // Download invoice PDF: downloadPdf(invoiceId, filename?)
  refresh,           // Refetch current page
} = useInvoices({ perPage: 25, status: 'paid' })

Invoice Helpers

Each invoice is enriched with computed properties via InvoiceWithHelpers:

PropertyTypeDescription
isUnpaidbooleantrue for draft or open status
isPaidbooleantrue for paid status
isCancelledbooleantrue for void or uncollectible status
overdueDaysnumber | nullDays past due date (for unpaid invoices only)
statusColorstringUI color mapping for badges
statusLabelstringLocalized status label

Status Colors

frontend/features/core/docs/billing/schemas.ts
export const INVOICE_STATUS_COLORS = {
  draft: 'neutral',
  open: 'info',
  paid: 'success',
  void: 'neutral',
  uncollectible: 'error',
}

Components

The billing feature includes ready-made UI components for invoice display:

ComponentPurpose
BillingInvoiceListPaginated table with columns: number, date, total, status, download button
BillingInvoiceRowStandalone single invoice row with status badge
BillingInvoiceStatusBadgeColored badge component using INVOICE_STATUS_COLORS

The BillingInvoiceList component accepts the data from useInvoices() and renders a complete table with pagination controls, loading states, and empty states — ready to use in your billing dashboard.


Frontend Zod Schemas

Invoice data is validated at runtime using Zod schemas:

frontend/features/core/docs/billing/schemas.ts
export const invoiceStatusSchema = z.enum([
  'draft', 'open', 'paid', 'void', 'uncollectible'
])

export const invoiceLineTypeSchema = z.enum([
  'subscription', 'usage', 'adjustment', 'proration'
])

export const invoiceSchema = z.object({
  id: z.string(),
  tenantId: z.string().uuid(),
  subscriptionId: z.string().uuid().nullable(),
  stripeInvoiceId: z.string().nullable(),
  number: z.string(),
  status: invoiceStatusSchema,
  subtotal: moneySchema,
  tax: moneySchema,
  total: moneySchema,
  issueDate: z.string(),
  dueDate: z.string().nullable(),
  paidAt: z.string().datetime().nullable(),
  pdfUrl: z.string().nullable(),
  billingInfo: z.record(z.unknown()),
  lines: z.array(invoiceLineSchema).optional(),
  createdAt: z.string().datetime().nullable(),
  updatedAt: z.string().datetime().nullable(),
})

export const invoiceFiltersSchema = z.object({
  status: invoiceStatusSchema.optional(),
  page: z.number().int().positive().optional(),
  perPage: z.number().int().positive().max(100).optional(),
})

API responses are automatically transformed from snake_case to camelCase and validated against these schemas before reaching your components. See Validation (Zod) for how the transformation pipeline works.


The Shadow Invoice System

The internal InternalInvoiceGenerator exists alongside Stripe's invoicing for a specific purpose: it provides the infrastructure for the future platform_managed billing mode, where your application generates authoritative invoices for flat and seat-based plans.

In the current stripe_managed mode, this generator is used only for:

  • Thread-safe invoice number generation using the invoice_sequences table with database row locking (format: YYYY-NNNNN)
  • PDF generation via Laravel Blade templates when local invoices are created
  • Validation and reconciliation — comparing Stripe amounts against internal calculations

You don't need to interact with the internal generator directly in stripe_managed mode. It runs as background infrastructure, ensuring data consistency and preparing the codebase for future billing mode transitions.


V1 Limitations

LimitationRationale
No refundsRefund accounting (tax adjustments, credit notes) adds significant complexity. Planned for a future milestone.
No credit notesInvoices are immutable legal documents. Credit note support requires additional accounting infrastructure.
No manual invoice editingInvoices cannot be modified after creation. This preserves audit integrity.
No partial-period first invoicesFirst invoice always covers a full billing period.
Invoices are immutable after paidOnce status = paid, no fields can be changed. This is enforced at the domain level.
These limitations are enforced by domain exceptions. Attempting to modify a paid invoice or void a finalized one will throw an exception — there is no "force" option.

What's Next