Skip to content
SaaS4Builders
Billing

Currency Rules

Multi-currency architecture in SaaS4Builders: the Money value object, currency invariants, zero-decimal handling, resolution chain, and domain enforcement.

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.

#InvariantEnforcement
1All amounts are stored in minor units (cents)Money value object, database columns (*_cents)
2One currency per invoiceAll invoice lines must match the invoice currency. CurrencyMismatchException thrown on violation.
3Subscription currency is immutableSet at creation, never changes. Plan changes must use the same currency.
4No cross-currency arithmeticMoney::add(), subtract(), greaterThan(), lessThan() all throw CurrencyMismatchException on currency mismatch.
5No FX conversionEach plan price is defined natively per currency. There are no exchange rates, no conversion tables, no implicit rounding.
These invariants are enforced at the domain level, not just by convention. The 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.

backend/app/Domain/Shared/ValueObjects/Money.php
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:

backend/database/migrations/0001_01_01_000005_create_currencies_table.php
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:

CodeNameSymbolMinor UnitsActive
EUREuro2Yes
USDUS Dollar$2Yes
GBPBritish Pound£2No
CHFSwiss FrancCHF2No
JPYJapanese Yen¥0No
CADCanadian Dollar$2No

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:

backend/app/Domain/Billing/Services/CurrencyRegistry.php
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"
The 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:

backend/app/Domain/Billing/Services/CurrencyResolver.php
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
}
PrioritySourceExample
1Tenant's preferred_currencyThe tenant chose GBP during onboarding
2Application settingsmoney.currency setting configured by admin
3Config defaultBILLING_DEFAULT_CURRENCY=USD in .env
4Config fallbackBILLING_FALLBACK_CURRENCY=USD in .env
5HardcodedEUR (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.

backend/.env
BILLING_DEFAULT_CURRENCY=USD
BILLING_FALLBACK_CURRENCY=USD
The resolution always succeeds — the hardcoded EUR fallback ensures that 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:

backend/database/migrations/0001_01_01_000019_create_plan_prices_table.php
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:

CurrencyPrice (cents)Display
EUR2999€29.99/month
USD3499$34.99/month
GBP2499£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:

backend/app/Domain/Billing/Exceptions/PlanNotAvailableInCurrencyException.php
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 PlanPrice in the subscription's currency
  • If the plan lacks a price in that currency, PlanNotAvailableInCurrencyException is 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:

backend/app/Domain/Billing/DTO/ProrationCalculation.php
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:

backend/app/Domain/Billing/Exceptions/CurrencyInUseException.php
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 TypeDescription
plan_pricesPlans that have prices in this currency
subscriptionsActive subscriptions using this currency
tenantsTenants with this as preferred currency
invoicesInvoices 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:

frontend/features/core/docs/billing/schemas.ts
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.


The billing domain defines four currency-specific exceptions:

ExceptionWhen It's Thrown
CurrencyMismatchExceptionCross-currency arithmetic, plan change to different currency, proration across currencies, invoice line with wrong currency
PlanNotAvailableInCurrencyExceptionPlan has no price in the requested currency (includes available currencies for user guidance)
InvalidCurrencyExceptionCurrency resolution fails (no valid default found)
CurrencyInUseExceptionAttempting to delete or deactivate a currency with active references

All exceptions are in backend/app/Domain/Billing/Exceptions/ and extend DomainException.


What's Next