Currency Rules
SaaS4Builders supports multiple currencies with strict isolation rules. Every monetary value flows through the Money value object, which enforces currency safety at the domain level. There is no currency conversion, no FX rates, and no implicit mixing of currencies — these are deliberate architectural decisions that prevent an entire class of billing errors.
Core Invariants
Five rules govern all money handling in the system. These are enforced by domain exceptions — violating any of them throws an error, not a silent miscalculation.
| # | Invariant | Enforcement |
|---|---|---|
| 1 | All amounts are stored in minor units (cents) | Money value object, database columns (*_cents) |
| 2 | One currency per invoice | All invoice lines must match the invoice currency. CurrencyMismatchException thrown on violation. |
| 3 | Subscription currency is immutable | Set at creation, never changes. Plan changes must use the same currency. |
| 4 | No cross-currency arithmetic | Money::add(), subtract(), greaterThan(), lessThan() all throw CurrencyMismatchException on currency mismatch. |
| 5 | No FX conversion | Each plan price is defined natively per currency. There are no exchange rates, no conversion tables, no implicit rounding. |
Money value object throws exceptions on every violation. You cannot accidentally mix currencies — the code won't let you.The Money Value Object
All monetary values use the Money value object. Raw integers and floats are never used for money operations.
final readonly class Money implements \JsonSerializable
{
private function __construct(
public int $amount, // Always in minor units (cents)
public string $currency, // ISO 4217, always uppercase
) {}
}
Creating Money Instances
// From minor units (cents) — the most common factory
$price = Money::cents(2999, 'EUR'); // €29.99
// Zero amount
$zero = Money::zero('USD'); // $0.00
// From major units (converts automatically using CurrencyRegistry)
$major = Money::fromMajor(29.99, 'EUR'); // €29.99 (2999 cents)
$yen = Money::fromMajor(1000, 'JPY'); // ¥1000 (1000 — no decimals)
Arithmetic with Currency Guards
Every arithmetic operation validates that both operands share the same currency:
$a = Money::cents(2999, 'USD');
$b = Money::cents(500, 'USD');
// Same currency — works
$total = $a->add($b); // Money(3499, 'USD')
$diff = $a->subtract($b); // Money(2499, 'USD')
// Scalar operations — no currency check needed
$half = $a->divide(2); // Money(1500, 'USD') — rounded
$tax = $a->percentage(20); // Money(600, 'USD')
$double = $a->multiply(2); // Money(5998, 'USD')
// Different currencies — throws exception
$euro = Money::cents(500, 'EUR');
$a->add($euro); // throws CurrencyMismatchException
Comparison Methods
$a->equals($b); // true if same amount AND same currency
$a->greaterThan($b); // throws CurrencyMismatchException if different currencies
$a->lessThan($b); // throws CurrencyMismatchException if different currencies
$a->isZero(); // true if amount === 0
$a->isPositive(); // true if amount > 0
$a->isNegative(); // true if amount < 0
Formatting and Serialization
$price = Money::cents(2999, 'EUR');
// Convert to major units
$price->toMajor(); // 29.99
// Format with locale (uses PHP's NumberFormatter)
$price->format('fr_FR'); // "29,99 €"
$price->format('en_US'); // "€29.99"
// JSON serialization (used in API responses)
$price->jsonSerialize();
// { "amount": 2999, "currency": "EUR" }
Currency Database
Currencies are stored in the currencies table with metadata needed for formatting and validation:
Schema::create('currencies', function (Blueprint $table) {
$table->string('code', 3)->primary(); // ISO 4217 (EUR, USD, JPY)
$table->string('name'); // Human-readable name
$table->string('symbol', 10); // Display symbol (€, $, ¥)
$table->unsignedTinyInteger('minor_units')->default(2); // Decimal places
$table->boolean('is_active')->default(true); // Can be used in new transactions
$table->timestamps();
});
Seeded Currencies
The database seeder provides six currencies out of the box:
| Code | Name | Symbol | Minor Units | Active |
|---|---|---|---|---|
| EUR | Euro | € | 2 | Yes |
| USD | US Dollar | $ | 2 | Yes |
| GBP | British Pound | £ | 2 | No |
| CHF | Swiss Franc | CHF | 2 | No |
| JPY | Japanese Yen | ¥ | 0 | No |
| CAD | Canadian Dollar | $ | 2 | No |
To activate additional currencies, update the seeder or toggle the is_active flag directly. Only active currencies can be used for new subscriptions and plan prices.
Zero-Decimal Currencies
Some currencies like JPY (Japanese Yen) have no fractional units — 1000 JPY means 1000, not 10.00. The system handles this transparently via the minor_units field.
How It Works
The CurrencyRegistry service provides currency metadata:
CurrencyRegistry::minorUnits('EUR'); // 2 — two decimal places
CurrencyRegistry::minorUnits('JPY'); // 0 — no decimal places
CurrencyRegistry::symbol('EUR'); // "€"
CurrencyRegistry::symbol('JPY'); // "¥"
The Money value object uses this metadata for conversion between minor and major units:
// EUR: 2 minor units → divide by 10²
Money::cents(2999, 'EUR')->toMajor(); // 29.99
// JPY: 0 minor units → divide by 10⁰
Money::cents(1000, 'JPY')->toMajor(); // 1000.0
// Creating from major units works the same way
Money::fromMajor(29.99, 'EUR'); // 2999 cents
Money::fromMajor(1000, 'JPY'); // 1000 (stored as-is)
Formatting also adapts automatically:
Money::cents(2999, 'EUR')->format('en_US'); // "€29.99"
Money::cents(1000, 'JPY')->format('ja_JP'); // "¥1,000"
CurrencyRegistry caches all currency data in memory after the first query, with a 24-hour cache expiration. This avoids repeated database lookups during request processing.Currency Resolution
When a tenant needs a currency (e.g., for checkout or pricing display), the CurrencyResolver determines which currency to use through a fallback chain:
final class CurrencyResolver
{
/**
* Resolution order:
* 1. Tenant's preferred_currency (if active)
* 2. Settings money.currency (if active)
* 3. Config default (billing.default_currency)
* 4. Config fallback (billing.fallback_currency)
* 5. Hardcoded EUR (always succeeds)
*/
public function resolveForTenant(Tenant $tenant): string
}
| Priority | Source | Example |
|---|---|---|
| 1 | Tenant's preferred_currency | The tenant chose GBP during onboarding |
| 2 | Application settings | money.currency setting configured by admin |
| 3 | Config default | BILLING_DEFAULT_CURRENCY=USD in .env |
| 4 | Config fallback | BILLING_FALLBACK_CURRENCY=USD in .env |
| 5 | Hardcoded | EUR (always succeeds, never fails) |
The resolver checks that each candidate currency is active before using it. If a tenant's preferred currency has been deactivated, the resolver falls to the next level.
BILLING_DEFAULT_CURRENCY=USD
BILLING_FALLBACK_CURRENCY=USD
resolveForTenant() never throws. If the hardcoded fallback is used, a warning is logged for investigation.Multi-Currency Plans
Plans support multiple currencies through the plan_prices table. Each plan can have one price entry per currency:
Schema::create('plan_prices', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('plan_id')->constrained()->cascadeOnDelete();
$table->string('currency', 3);
$table->unsignedInteger('price_cents');
$table->string('stripe_price_id')->nullable()->index();
$table->unique(['plan_id', 'currency']); // One price per currency per plan
$table->foreign('currency')->references('code')->on('currencies');
});
For example, a "Pro" plan might have these prices:
| Currency | Price (cents) | Display |
|---|---|---|
| EUR | 2999 | €29.99/month |
| USD | 3499 | $34.99/month |
| GBP | 2499 | £24.99/month |
Each price is defined natively — there is no "base price" converted to other currencies. Each PlanPrice has its own stripe_price_id linked to the corresponding Stripe Price object.
Currency Availability
When a tenant tries to subscribe to a plan, the system checks that the plan has a price in the tenant's currency. If not, a PlanNotAvailableInCurrencyException is thrown with the list of available currencies:
final class PlanNotAvailableInCurrencyException extends DomainException
{
public function __construct(
public readonly string $planId,
public readonly string $currency,
public readonly array $availableCurrencies = [],
) {}
}
This allows your frontend to display a helpful message like: "This plan is not available in JPY. Available currencies: EUR, USD, GBP."
Currency Immutability on Subscriptions
A subscription's currency is frozen at creation and never changes. This is enforced by the ChangePlan action:
- The target plan must have a
PlanPricein the subscription's currency - If the plan lacks a price in that currency,
PlanNotAvailableInCurrencyExceptionis thrown - There is no mechanism to change a subscription's currency — the user must cancel and resubscribe
This immutability extends to proration calculations. When previewing a plan change, the ProrationCalculation DTO validates that all amounts (credit, charge, net) share the same currency:
final readonly class ProrationCalculation
{
public function __construct(
public Money $credit,
public Money $charge,
public Money $net,
public array $breakdown,
) {
// Validates all amounts share the same currency
}
}
See Subscriptions & Lifecycle for details on plan changes and proration.
Currency Protection
Active currencies that are referenced in billing records cannot be deleted or deactivated. The CurrencyInUseException prevents accidental data integrity issues:
final class CurrencyInUseException extends DomainException
{
public function __construct(
public readonly string $currencyCode,
public readonly array $references, // Where the currency is used
public readonly string $operation = self::OPERATION_DELETE,
) {}
}
The references array identifies which billing records use the currency:
| Reference Type | Description |
|---|---|
plan_prices | Plans that have prices in this currency |
subscriptions | Active subscriptions using this currency |
tenants | Tenants with this as preferred currency |
invoices | Invoices issued in this currency |
To deactivate a currency, you must first migrate all references to a different currency.
API Conventions
JSON Money Format
All API responses represent money as a two-field object:
{
"subtotal": { "amount_cents": 2999, "currency": "USD" },
"tax": { "amount_cents": 600, "currency": "USD" },
"total": { "amount_cents": 3599, "currency": "USD" }
}
This format is used consistently across all endpoints — subscriptions, invoices, plan prices, proration previews, and seat billing.
Frontend Money Schema
The Zod schema validates money fields at runtime:
export const moneySchema = z.object({
amountCents: z.number().int(),
currency: z.string().length(3),
})
Note the camelCase transformation: the API sends amount_cents (snake_case), but the frontend schema expects amountCents (camelCase). The API client's toCamelCase() transformer handles this automatically.
Frontend Money Formatting
The frontend uses the money schema data for display formatting. The BillingInvoiceList and other billing components use a formatMoney() utility that formats amounts based on currency and locale — handling zero-decimal currencies (like JPY) correctly by checking the currency's minor units.
Currency-Related Exceptions
The billing domain defines four currency-specific exceptions:
| Exception | When It's Thrown |
|---|---|
CurrencyMismatchException | Cross-currency arithmetic, plan change to different currency, proration across currencies, invoice line with wrong currency |
PlanNotAvailableInCurrencyException | Plan has no price in the requested currency (includes available currencies for user guidance) |
InvalidCurrencyException | Currency resolution fails (no valid default found) |
CurrencyInUseException | Attempting to delete or deactivate a currency with active references |
All exceptions are in backend/app/Domain/Billing/Exceptions/ and extend DomainException.
What's Next
- Billing Overview — Architecture and philosophy of the billing system
- Pricing Models — How plans define prices in multiple currencies
- Subscriptions & Lifecycle — Currency immutability in practice
- Invoices — How currency rules apply to invoice data
Webhooks
Stripe webhook architecture in SaaS4Builders: signature verification, idempotency, event dispatching, handled events, and how to add new handlers.
Multi-Tenancy Overview
How SaaS4Builders isolates tenant data with a single-database architecture: the Tenant model, user relationships, context helpers, and the middleware chain.