Currencies, intervals and exchange rates
The billing cycles you offer, the currency each price and each report uses, the daily rate sync behind the revenue chart, and the switches that turn the package off.
Every price you create in the admin panel has a country, a currency and one amount per billing interval. This guide covers config/pricing.php, which decides the intervals and the currency reports are expressed in; how a visitor ends up seeing one price rather than another; how revenue in several currencies is brought back to one; and how the whole package is switched off. Creating the prices themselves is in Create and price a plan; keys, flags and webhook in Turn on plans and billing.
Intervals
config/pricing.php ships two:
'intervals' => [
'monthly' => [
'interval' => 'month',
'label' => 'Monthly',
'count' => 1,
'active' => 1,
],
'yearly' => [
'interval' => 'year',
'label' => 'Yearly',
'count' => 1,
'active' => 1,
],
],
| Key | What it is |
|---|---|
The array key (monthly) |
Your internal name. It is stored on each price variation as interval and never sent to Stripe. |
interval |
Stripe's recurring interval, month or year. |
count |
How many of them per cycle: Stripe's interval_count. |
label |
The column heading in the admin form and the pill on the pricing page, passed through __(), so translate it in lang/. |
active |
1 shows the interval on the plan and add-on pages; 0 hides it there while the admin form keeps its column and existing subscribers keep paying. |
The first entry is the default: the public add-on catalogue opens on the first active one, and getPriceFor($country) on a plan or add-on returns its variation for it. The plans page opens on the interval the account already pays, or on the monthly key.
Adding an entry adds a column to the Pricing card of every plan and add-on form, with no code change. Every amount typed there becomes one Stripe price with that interval and interval_count. These are the shapes the file itself suggests:
'quarterly' => ['interval' => 'month', 'label' => 'Quarterly', 'count' => 3, 'active' => 1],
'semiannual' => ['interval' => 'month', 'label' => 'Semiannual', 'count' => 6, 'active' => 1],
'biannual' => ['interval' => 'year', 'label' => 'Biannual', 'count' => 2, 'active' => 1],
Removing an entry does not delete anything: the rows and the Stripe prices stay, they just disappear from the form, and a save skips them. To retire an interval that has subscribers, set active to 0 instead of deleting the key.
A subscriber who moves to a variation with a different interval or count is a change of cycle. The kit compares the Stripe values, not your keys, updates every line of the subscription through the Stripe API with proration_behavior set to always_invoice, so the difference is charged at once, and payment_behavior set to error_if_incomplete, so it fails instead of leaving an unpaid invoice when no card is on file. When the bank asks the customer to authenticate the charge, the change is not refused: it waits for their approval instead, as When your bank asks you to approve a payment describes. Every add-on on the subscription must have a price at the new interval in the same currency, or the change is refused with "Addon ":name" is not available for this billing interval."
Which price a person sees
A price row is a country plus a currency. The country select of the Pricing card offers All, stored as default, and twenty-four countries; the currency select offers twenty currencies, from usd and mxn to jpy and sgd. Once a row has a saved amount both selects lock, because the Stripe prices already carry them.
The country of the person looking is the user's own country_code, then whatever App\Services\CountryDetectionService::detect() finds, then default. On the public home page only detection runs. With that country:
- The plans page takes, for each plan, the variation for that country at the selected interval, and falls back to the
defaultrow. A plan priced only formxis not shown to a visitor from elsewhere. - The add-on catalogue does the same per add-on. In the signed-in catalogue, an add-on with no price at the selected interval shows Not available, unless it can be bought for life, tried or is already owned; the public catalogue does not list it unless it is sold for life.
- On registration, the new user is subscribed to the free plan for their country: the plan variation with amount
0for that country, or fordefault. A variation for their own country wins over one fordefault, and among several the oldest is used, so a second free plan is never handed out at registration; the admin plan list warns when there is more than one. - Opening the plans page subscribes an account with no active subscription to that same free plan, and so does the end of a subscription, on its own. That country is the one saved on the account, never detected from the visitor, and
defaultwhen there is none. A subscriber is never moved: a user on a plan priced for another country keeps it, and their country only decides which prices they are shown.
Stripe charges each subscription in the currency of its prices. The kit never mixes currencies on one subscription: when a plan changes, each add-on line is replaced by its variation in the same currency and interval, and a mass migration between plans only carries add-ons that match the destination's currency.
The amount is displayed with a hard-coded dollar sign whatever the currency: the plan card shows $499 followed by the code, MXN, the add-on card $49.00, and the money_format() helper in app/Helpers/base.php returns '$' . number_format($value, 2) with no currency argument. It is used for the price column in the admin panel and for referral rewards. Change the helper when your currency needs another symbol.
Stripe receives unit_amount as the amount multiplied by 100 and rounded, for every currency. That is right for two-decimal currencies and wrong for zero-decimal ones: jpy and clp are in the list, but a price of 500 would reach Stripe as 50000. Do not price in a zero-decimal currency without changing createPrice() in WeblaborMx\BillingCore\Services\StripeSyncService.
Two currencies that are not the same thing
| Setting | Where | Default | What it decides |
|---|---|---|---|
primary_currency |
config/pricing.php, a committed literal, no env variable |
mxn |
The base of every exchange-rate snapshot and the currency the revenue chart is expressed in. It charges nothing. |
CASHIER_CURRENCY |
vendor/laravel/cashier/config/cashier.php, read from .env |
usd |
Cashier's own currency: the one used for one-off charges through checkoutCharge(), for invoice items, and as the fallback when an amount is formatted with no currency of its own. |
Subscription prices use neither: each Stripe price carries its currency from the admin form, and the invoices listed on /account/billing are formatted in the invoice's own currency. So a project that sells in pesos changes the literal to keep its reports in pesos, and sets CASHIER_CURRENCY=mxn only if it also takes one-off payments, covered in Charge once with Stripe Checkout. .env.example lists neither.
Exchange rates
Revenue arrives in whatever currency each subscriber pays. To add it up, the kit stores one rate per day, per currency, from primary_currency to that currency:
'exchange_rates' => [
'provider' => 'frankfurter',
'endpoint' => 'https://api.frankfurter.app',
'timeout' => 10,
],
frankfurter is the only provider implemented; any other value makes the sync fail with "Unsupported exchange-rate provider". The request is GET {endpoint}/{date}?from=MXN&to=USD,EUR, with the timeout in seconds. A non-200 answer stops the sync with an error and stores nothing. An answer missing one of the requested currencies stores the rates it did bring and reports the missing ones as an error. Every failure is logged and emailed to the super administrators listed in app.sudo.
Rows land in currency_exchange_rates: date, base_currency, quote_currency, rate with eight decimals, provider, provider_date, and a payload with the provider's own date, amount and base. One row per date, base, quote and provider; syncing the same day again updates it. The model is WeblaborMx\BillingCore\Models\CurrencyExchangeRate.
Only currencies that were actually paid are fetched. For a given date the service looks at three kinds of charge settled that day: the invoice.paid webhooks recorded in App\Models\WebhookEvent, with status success, an amount_paid above zero and a subscription; the one-time Checkouts paid that day; and the usage periods of meters charged that day. The distinct currencies of those charges, minus the primary one, are the ones requested. A project whose every subscriber pays in the primary currency never stores a rate and never calls the provider.
The command
php artisan billing:sync-exchange-rates
php artisan billing:sync-exchange-rates --date=2026-08-15
Without options it finds every date that has a charge of those three kinds in a secondary currency and no stored rate for it, and fetches what is missing. It prints "No secondary-currency paid subscription dates found. Nothing to sync." or "All paid subscription exchange rates are already synced." when there is nothing to do, and otherwise one line per rate, MXN → USD: 0.0546, and one "Could not sync" line per date that failed, ending with a failure exit code. With --date it does the same for that one day, which is how you backfill a day the provider was down.
The package schedules it daily at 12:00, in the application timezone, whenever FEATURE_PLANS_ENABLED or FEATURE_ADDONS_ENABLED is on. It runs only if the scheduler runs; see Queues and scheduled work.
Revenue on the dashboard
The admin dashboard at /admin gains a Daily Revenue metric when the package is on, labelled with the primary currency, and Subscriptions Created and Total Accumulated Subscriptions when plans are live. Daily Revenue sums, per day, week or month, the payments in the payment history (billing_payments, recorded from each paid invoice), the paid one-time Checkouts and the charged usage periods of meters, in stacked columns for plans, add-ons and meters. An amount in the primary currency counts as is. Any other is divided by that day's rate, since the rate says how many units of the paid currency one unit of the primary buys. An amount whose day has no rate for its currency is left out of the chart, logged, and named under the chart as "Left out, no exchange rate" with its currency. When that note appears, run the command for that date.

Switching the package off
| Variable | Config key | Default | What false does |
|---|---|---|---|
BILLING_CORE_ENABLED |
billing-core.enabled |
true |
The provider stops at boot: no Cashier configuration, no models observed, no admin resources, views, migrations, command or schedule, no routes, no Livewire components, no policies, no adapters. |
BILLING_USER_ENABLED |
billing-core.user_adapter.enabled |
true |
Only the user adapter goes: the /account/* billing routes, the listener that subscribes a new user to the free plan, and the ensure.subscribed middleware. Tables and admin resources stay for another adapter. |
BILLING_SUITE_ENABLED |
billing-core.adapters.suite.enabled |
not read | The commented example under adapters in config/billing-core.php. Nothing reads it until you uncomment the entry and write the class it names. |
The host never imports a package class directly. app/Helpers/base.php provides the bridges, and each one answers null or false when the package is off or absent:
| Helper | Answers |
|---|---|
billingCoreAvailable() |
true when the provider class exists and billing-core.enabled is on. Before the configuration is loaded it reads BILLING_CORE_ENABLED directly. |
billingUserBillingAvailable() |
The same plus user_adapter.enabled, or BILLING_USER_ENABLED before config. |
billingCoreModel('plan') |
The model class for a models key of config/billing-core.php, or null. |
billingCoreManager() |
The BillingManager instance, or null. |
billingUser() |
The user billing resource the admin user form uses, or null. |
billingDashboardMetrics() |
The dashboard metrics service, or null, which is what removes the revenue metric. |
bootstrap/app.php adds ensure.subscribed to the /app route group, and registers the alias at all, only when billingUserBillingAvailable() is true. routes/api.php keeps POST /stripe/webhook registered and answers 204 No Content when billingCoreAvailable() is false, so Stripe stops retrying. The HasPlans trait on the user returns empty relations and null results for the same reason.
To prove the application boots without the package:
composer test-without-billing-core
It copies the repository to a temporary directory, points it at a fresh SQLite file, removes weblabormx/billing-core and its path repository there, deletes the package directory, and then runs php artisan about, checks that no auth.billing, billing-plans or billing-addons route survives, migrates, seeds and runs two non-billing tests. It never touches your database or your working copy. Removing the package for real is the same sequence, run in place: composer remove weblabormx/billing-core, delete the packages/billing-core entry from repositories in composer.json, delete the directory, then composer dump-autoload and php artisan optimize:clear. Billing tables and data are not dropped.
What the package borrows from the host
The host block of config/billing-core.php names the classes of your application the package uses instead of shipping its own:
| Key | Default | Used for |
|---|---|---|
webhook_event |
App\Models\WebhookEvent |
The table of received webhooks. The exchange-rate sync reads paid subscription invoices from it. |
webhook_success_status |
success |
The status value that marks a webhook as processed. Only the migration that built the payment history from past invoice.paid webhooks reads it; the exchange-rate sync filters on success through the model itself. |
select_filter |
App\Front\Filters\SelectFilter |
The filter class behind the Plan filter on the admin users list. |
category_model |
App\Models\Category |
Available but not used by any feature of the kit: add-on categories go through the HasCategories trait, which does not read this key. |
array_cast |
App\Casts\ArrayCast |
The cast for extra_data on price variations. Falls back to array when the class is missing. |
You change these only when you rename the host class they point at.