Invoices
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:
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);
}
}
| Status | Meaning | Final? | Can Be Voided? |
|---|---|---|---|
draft | Invoice created but not yet finalized | No | Yes |
open | Finalized and awaiting payment | No | Yes |
paid | Payment received successfully | Yes | No |
void | Canceled before payment | Yes | No |
uncollectible | All payment attempts exhausted | Yes | No |
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
| Field | Type | Description |
|---|---|---|
id | UUID | Internal identifier |
tenant_id | UUID | Owning tenant |
subscription_id | UUID (nullable) | Associated subscription |
stripe_invoice_id | string (nullable) | Stripe's invoice ID for sync |
number | string | Invoice number (format: YYYY-NNNNN) |
status | InvoiceStatus | Current payment state |
currency | string (3) | ISO 4217 currency code |
subtotal_cents | integer | Pre-tax amount in minor units |
tax_cents | integer | Tax amount in minor units |
total_cents | integer | Total amount in minor units |
issue_date | date | When the invoice was issued |
due_date | date (nullable) | Payment deadline |
paid_at | datetime (nullable) | When payment was received |
billing_info | JSON | Snapshot of tenant billing details at invoice time |
pdf_path | string (nullable) | Local path or Stripe URL for PDF |
Invoice Lines
Each invoice contains one or more line items stored in the InvoiceLine model:
| Field | Type | Description |
|---|---|---|
description | string | Human-readable line description |
type | string | One of: subscription, usage, adjustment, proration |
quantity | integer | Number of units |
unit_price_cents | integer | Price per unit in minor units |
amount_cents | integer | Total line amount (quantity * unit_price_cents) |
plan_id | UUID (nullable) | Associated plan (for subscription lines) |
meter_id | UUID (nullable) | Associated meter (for usage lines) |
period_start | datetime (nullable) | Billing period start |
period_end | datetime (nullable) | Billing period end |
Tax Records
Invoices can also have associated TaxRecord entries that capture tax breakdown details:
| Field | Type | Description |
|---|---|---|
tax_type | string | Type of tax (e.g., vat, sales_tax) |
jurisdiction | string | Tax jurisdiction (e.g., FR, DE, CA-QC) |
rate | decimal | Tax rate as a decimal (e.g., 0.200000 for 20%) |
taxable_amount_cents | integer | Amount subject to this tax |
tax_amount_cents | integer | Calculated 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:
| Event | What Happens |
|---|---|
invoice.created | Updates local usage snapshots with Stripe's invoiced amounts for metered line items |
invoice.paid | Creates the local invoice record (if it doesn't exist) and marks it as paid |
invoice.finalized | Triggers usage reconciliation between Stripe amounts and local shadow calculations |
invoice.payment_failed | Dispatches 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.
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:
// 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 Mode | Query Implementation | Data Source |
|---|---|---|
stripe_managed | StripeInvoiceQuery | Live Stripe API |
platform_managed | LocalInvoiceQuery | Local 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.
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:
| Method | Path | Description |
|---|---|---|
GET | /api/v1/tenant/{tenantId}/invoices | List 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}/pdf | Download 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:
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | (all) | Filter by status: draft, open, paid, void, uncollectible |
page | integer | 1 | Page number |
per_page | integer | 25 | Items 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:
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:
| Property | Type | Description |
|---|---|---|
isUnpaid | boolean | true for draft or open status |
isPaid | boolean | true for paid status |
isCancelled | boolean | true for void or uncollectible status |
overdueDays | number | null | Days past due date (for unpaid invoices only) |
statusColor | string | UI color mapping for badges |
statusLabel | string | Localized status label |
Status Colors
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:
| Component | Purpose |
|---|---|
BillingInvoiceList | Paginated table with columns: number, date, total, status, download button |
BillingInvoiceRow | Standalone single invoice row with status badge |
BillingInvoiceStatusBadge | Colored 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:
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_sequencestable 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
| Limitation | Rationale |
|---|---|
| No refunds | Refund accounting (tax adjustments, credit notes) adds significant complexity. Planned for a future milestone. |
| No credit notes | Invoices are immutable legal documents. Credit note support requires additional accounting infrastructure. |
| No manual invoice editing | Invoices cannot be modified after creation. This preserves audit integrity. |
| No partial-period first invoices | First invoice always covers a full billing period. |
| Invoices are immutable after paid | Once status = paid, no fields can be changed. This is enforced at the domain level. |
What's Next
- Tax Configuration — How Stripe Tax calculates taxes on your invoices
- Webhooks — The event-driven architecture that syncs invoice state from Stripe
- Currency Rules — Money representation, multi-currency support, and invariants
- Subscriptions & Lifecycle — How subscriptions generate invoices through their lifecycle
Subscriptions & Lifecycle
Subscription states, transitions, creation flow, cancellation, resumption, plan changes, and proration in SaaS4Builders.
Tax Configuration
Stripe Tax integration in SaaS4Builders: automatic tax calculation, VAT validation, reverse charge detection, and the TaxProvider abstraction.