Sell add-ons
Declare a capability in code, price it in the admin panel, and let people switch it on, buy it or receive it with their plan.
An add-on is a capability your project switches on for one account: a module, a paid extra, a quota. The code declares which capabilities exist; the admin panel, or the add-on seeder when you declare the add-on itself in code, decides how each one is sold; the billing account answers whether a given account has it. This guide covers the whole path, from the PlanFeatures method to the button the user clicks.
Turn it on
Add-ons live in the Billing Core package, so the package has to be on. Plans do not: a free add-on works with no plan, no subscription and no Stripe key.
| Option | Env variable | Default | What it changes |
|---|---|---|---|
| Add-ons module | FEATURE_ADDONS_ENABLED |
false |
Off: /account/add-ons is a 404, the admin resource refuses every action, the Add-ons panel disappears from the user's page and nothing is granted or sold. |
| Billing Core | BILLING_CORE_ENABLED |
true |
Off: the package does not load its models, migrations, routes or webhook. |
| User billing | BILLING_USER_ENABLED |
true |
Off: the /account/... billing routes are not registered, add-ons included. |
| Stripe | STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET |
empty | Needed to sell anything. Every save of an add-on or a price talks to Stripe. |
Two questions decide what is shown once the flag is on:
BillingAddon::hasVisibleCatalog()is true when the flag is on and the account of the active billing context can see at least one add-on — one on sale, or one it already holds. It, or the same question onBillingMeter, shows the Add-ons link in the account menu, and only where that context has its own add-ons screen: with user billing off there is no link. Stripe is not asked.BillingAddon::isActive()asks the same plusSTRIPE_SECRET. It decides whether the public home page shows the add-on catalogue to visitors.
The flag also schedules billing:sync-exchange-rates daily at 12:00, the same command plans schedule, plus billing:expire-trials at 08:00 and billing:close-meter-periods at 02:00. Installation of the package, keys and webhook are in Turn on plans and billing.
Declare the capability in code
PlanFeatures declares which capabilities exist. Open app/Classes/PlanFeatures.php and add one public method per capability. The method name is the feature key. The return value says whether the capability is already released in code, so you can sell something in pre-sale that switches nothing on yet.
namespace App\Classes;
class PlanFeatures extends BasePlanFeatures
{
public function electronic_wallet()
{
return true;
}
public function white_label()
{
return false; // sold in pre-sale, switches nothing on yet
}
}
BasePlanFeatures reads the class by reflection:
| Method | Returns |
|---|---|
PlanFeatures::availableFeatures() |
The keys, one per public non-static method. |
PlanFeatures::featureOptions() |
['electronic_wallet' => 'Electronic Wallet', ...], the list the admin form offers. |
PlanFeatures::isReleased('white_label') |
true only when the key is declared and its method returns true. |
The class is wired through config/billing-core.php under user_adapter.plan_features. Weblabor Base ships with no methods, so a clean install shows no Capability field and no catalogue. An add-on can sell a limit without a declared capability. If an add-on has no limit and its stored key is no longer declared in code, the form keeps and flags it with "This capability is not declared in code, so the add-on will not switch anything on"; the add-on can still be sold. If you save an add-on without choosing a capability, the key is derived from the name (Extra Storage becomes extra_storage), with a number added when another add-on already uses it (extra_storage_2). Two add-ons never share a key: the form refuses a key that is taken.
Declaring the capability is enough to sell it from the admin panel. To declare the add-on itself — its name, prices, images and guides — see Declare the add-on in code.
Create it in the admin panel
Go to Plans → Add-ons in the admin panel, at /admin/billing-addons. The index lists name, key and whether it is on sale, and has a Categories button that opens /admin/categories/billing-addons, where you create the categories the catalogue filters by. Create opens /admin/billing-addons/create; edit is /admin/billing-addons/{id}/edit. The form shows a warning when STRIPE_SECRET is not set: set it first, because saving a paid add-on creates or updates its Stripe product.
| Field | Required | What it does |
|---|---|---|
| Name | Yes | Card title. Also the Stripe product name. |
| Categories | No | Multiple, from the billing-addons category type. The catalogue filters by them. |
| Description | Yes | Short text on the card and in Stripe. |
| Capability | No | The declared key this add-on switches on. Only shown when the code declares at least one. |
| Depends on | No | Another add-on the account has to hold already. Until it does, this one cannot be activated, tried or bought. Empty means no dependency. |
| Available from plans | No | The plans an account has to be subscribed to before it can activate, try or buy this add-on; several can be chosen. Empty, shown as Any plan, sells it with any plan. See Sell it only with some plans. |
| Full description | No | Shown on the detail page under "About this add-on". |
| Icon | No | Square image, up to 5 MB, stored in the media library under General/Add-ons. Shown on the card. |
| Screenshots | No | Up to six images, up to 5 MB each, stored in the media library under General/Add-ons; several can be chosen at once. Shown as a gallery on the detail page. |
| Guides | No | Help centre guides that explain the add-on, as many as you need. The catalogue card links to them. Only shown when the help centre has guides. |
| On sale | Yes | On by default. Off removes it from the catalogue for anybody who does not have it, and refuses new purchases. Whoever already holds it keeps seeing it in their catalogue and on its page, and can still cancel. |
| Context | Only with two adapters | Both, User or Team. Filters which billing owner sees it. Hidden when only the user adapter is registered. |
| Sold for life | No | Adds a Lifetime price table: one amount per country and currency, paid once through Stripe Checkout. |
| Free trial days | No | Full access for that many days. No card is asked for and nothing is charged: when it ends, the add-on switches off. Zero offers no trial. Only an add-on not sold per unit nor by tiers offers one. Once per account, forever. |
| Sold per unit | No | The user picks how many; the price and the limits multiply. Needs at least one limit and cannot be combined with tiers. |
| Sold by tiers | No | The user picks one of several options. The subscription prices gain a Units column, one row per option. Needs at least one limit. |
| Tiers add up | No | Shown with Sold by tiers on. The user buys each tier in units, and every unit of every tier adds its quota instead of replacing the tier already held. See Tiers that add up. |
| Subscription prices | No | One entry per country: country, currency, and one amount per interval from config/pricing.php (monthly and yearly by default). Leave empty for a free capability. |
| Limits | No | Limit key from App\Classes\PlanLimits and an integer value. The quota the add-on contributes. The select also lists the keys of the meters on sale, see Sell usage with meters and packages. |
Prices are Stripe prices and Stripe prices are immutable. Each amount you save creates a recurring Stripe price; an existing amount cannot be edited. To change a price, remove the interval and add it again with the new amount. An interval that has subscribers cannot be removed. An add-on that has prices cannot be deleted either, because subscriptions and invoices reference them: switch On sale off instead. Deleting an add-on with no prices archives its Stripe product.
An add-on is only sold in the currency the account already pays in, because Stripe keeps one currency per customer: an account subscribed in pesos is never offered a price in dollars, for a subscription or for life. When your prices leave out the currency of accounts that are already subscribed, the prices table shows Subscribers who cannot buy this add-on with the currency and the country missing, and the plan's edit screen lists the Add-ons its subscribers cannot buy. You can still save; those accounts cannot buy it until a price in their currency covers them.
A paid add-on syncs with Stripe: its first price above zero creates a product with the add-on id in its metadata, updating the add-on updates the name and description, and each price variation creates a price with unit_amount in cents, the currency and the interval and count from config/pricing.php. A free add-on — one with no price above zero — creates nothing in Stripe: it is switched on and off inside the application. A price of zero creates no Stripe price, on a paid add-on too. An add-on that already has its product keeps it, even if its paid prices go away later.
Make one add-on need another
Some add-ons only make sense on top of another one. Pick that other one in Depends on and this add-on becomes obtainable only by accounts that already hold it. Until then its button reads Requires and the name of the missing one, the detail page says the same above the boxes, and nothing can be activated, tried or bought — the refusal is the same whether the button is pressed or the request is replayed.
Whoever already has the add-on is untouched: they can deactivate it, cancel its line or cancel its trial as they always could, even when the add-on it depends on is gone. Making somebody keep paying for something because a requirement lapsed would be a trap, not a rule.
The list offers every add-on that has a capability, except the one you are editing. Add-ons you have taken off sale are on the list on purpose: what is checked is whether the account holds the capability, not whether it is still being sold, so one you stopped selling still counts for everyone who already has it.
Only one step is checked, and nothing stops you from writing a loop. If A needs B and B needs C, an account with B can get A without ever having C; and two add-ons that name each other lock each other out, so do not.
Sell it only with some plans
Pick one or more plans in Available from plans and the add-on can be obtained only by accounts whose active subscription is on one of them. An account with no active subscription has no plan, so it cannot obtain it either. Until the account changes plan, the button reads Requires and the plan names, joined with commas and a final "or" ("Requires Pro or Business plan"), and is disabled; the detail page says "This add-on can be obtained only with the Pro plan. Change your plan first." above the boxes and offers none of them, and the same refusal answers when the request is replayed. A plan that no longer exists is left out of the names; when none of the plans chosen exists any more, nobody can obtain the add-on, and the button reads Not available with your plan.
The plan is checked before Depends on, so an account missing both is told about the plan. As with the dependency, it decides who can obtain the add-on, never who keeps it: an account that already holds it — bought before the list was set, or left holding it after changing plan — can still deactivate it, cancel its line, cancel its trial, lower its quantity or move to a cheaper tier, but cannot raise the quantity or move to a dearer tier. Completing a lifetime purchase already paid is not checked.
The list offers the plans of the add-on's context. Leave it empty to sell the add-on with any plan, which is how every add-on starts.
Declare the add-on in code
Instead of capturing an add-on by hand in every environment, you can describe it once in database/seeders/AddOnSeeder.php. The deploy already runs the seeders (php artisan db:seed --force), so the add-on is created on the first deploy and kept up to date on every one after that. Weblabor Base ships the seeder empty.
Each entry is keyed by its capability, the same key PlanFeatures declares:
$addons = [
'extra_storage' => [
'name' => 'Extra storage',
'description' => 'Short text shown on the catalog card.',
'long_description' => 'What it does, shown on the detail page.',
'prices' => [
['country' => 'default', 'currency' => 'usd', 'units' => null, 'intervals' => ['monthly' => 9.99, 'once' => 49]],
['country' => 'mx', 'currency' => 'mxn', 'units' => null, 'intervals' => ['monthly' => 179]],
],
'icon' => 'resources/images/addons/extra-storage.png',
'screenshots' => ['resources/images/addons/extra-storage-1.png'],
'help_guides' => ['sell-add-ons'],
'defaults' => ['is_active' => true],
],
];
| Key | What it sets |
|---|---|
name, description, long_description |
The card and the detail page. |
prices |
One row per country, currency and units. intervals holds one amount per interval; once is the lifetime price. units is the tier or package size, null for a plain price. |
icon, screenshots |
Paths to images inside your repository, uploaded to the media library under General/Add-ons. An image that did not change is not uploaded again. |
help_guides |
The slugs of the help centre guides to link from the card, in order. |
purchasable_plans |
The slugs of the plans it can be obtained with, the Available from plans field: ['pro', 'business']. Left out or empty, it is sold with any plan. |
limits |
The quota it adds, by limit key: ['disk_space' => 5, 'branches' => 1]. A key must be one the project declares or a meter key, the same list the panel offers. A changed value is updated in place; an empty list removes them all. |
sells_per_unit, sells_per_tier, stacks_tiers |
How the quota is sold, see Per unit and by tiers. By tiers, every subscription price row carries its units: one row per country, currency and tier. stacks_tiers makes the tiers add up and needs sells_per_tier. |
sells_lifetime, trial_days |
Sold for life (it needs a once price) and the trial length in days; 0 offers no trial. |
defaults |
Anything else the form captures, such as is_active. Applied only when the add-on is created; after that it is yours to change in the panel. |
Six storage tiers in pesos and dollars, and a branch sold per unit, look like this:
'extra_storage' => [
'name' => 'Extra storage',
'description' => 'Pick how much extra room you need.',
'sells_per_tier' => true,
'limits' => ['disk_space' => 5],
'prices' => [
['country' => 'mx', 'currency' => 'mxn', 'units' => 5, 'intervals' => ['monthly' => 49]],
// ... one row per tier, up to 1000
['country' => 'default', 'currency' => 'usd', 'units' => 5, 'intervals' => ['monthly' => 3]],
// ... one row per tier, up to 1000
],
],
'extra_branch' => [
'name' => 'Extra branch',
'description' => 'One more branch per unit.',
'sells_per_unit' => true,
'limits' => ['branches' => 1],
'prices' => [
['country' => 'mx', 'currency' => 'mxn', 'units' => null, 'intervals' => ['monthly' => 50]],
],
],
A module sold only by subscription needs neither limits nor a modality: its monthly and yearly prices are enough.
A key you leave out of the entry is left as it is. PlanSeeder works the same way for plans; see Create and price a plan.
What the panel lets you change
An add-on created by the seeder opens with a Managed by code notice. Its name, description, full description, capability, prices, icon, screenshots, guides, available plans, the ways it is sold (for life, trial days, per unit, by tiers) and its limits are read-only there, whether or not its entry declares them, and each deploy puts back what the code says. Everything else — categories, On sale, Depends on — stays editable.
A change to the limits or the way it is sold applies on the next deploy even when the add-on has subscribers, exactly as saving it in the panel would.
Prices follow the code without churning Stripe: an amount that did not change keeps its Stripe price, and a changed amount replaces it exactly as removing it and typing the new one in the panel would. An amount that has subscribers is retired instead of removed: its Stripe price is archived, so new purchases and the cards use the new amount, while whoever already pays the old one keeps paying it.
Release it from code
When you want to manage an add-on by hand from then on, press Release from code in the notice and confirm. Every field becomes editable and the seeder never touches that add-on again, even though its entry is still in the code. This cannot be undone from the panel.
Bring an existing add-on under code
An add-on that already exists with the entry's key is taken over only when everything the entry declares matches what is stored: the texts, the same price rows with no other price stored, the limits, the ways it is sold, the guides, the available plans, and the same icon and screenshots (the same image files). Then it opens with the Managed by code notice from the next deploy on, with its prices, images and subscribers untouched, and defaults is not applied. An image larger than the uploader's maximum size was resized when it was uploaded, so it no longer matches its file; leave icon and screenshots out of the entry to adopt it, and add them afterwards.
When the add-on differs from the entry and you want the code to control it anyway, mark it with a migration in your project; from the next deploy the seeder treats it as its own and puts everything the entry declares in place:
DB::table('billing_addons')
->where('feature_key', 'extra_seats')
->whereNull('released_from_code_at')
->update(['managed_by_code' => true]);
An add-on released from code is skipped on purpose, and a price with subscribers cannot be replaced from code, as with plans. The same recipe for a meter, with its migration in full, is in Bring a meter under code when it differs from the entry.
What the seeder never touches
An add-on that differs in anything the entry declares is never taken over: the seeder leaves it alone and the deploy prints which fields differ. An add-on released from code is not taken back even when it still matches, and one the seeder created and somebody then deleted is not brought back either.
An entry that cannot be sold as written is skipped whole, the add-on stays as it was and the deploy prints the reason and carries on. That happens when it is sold per unit and by tiers at once, sells quota (per unit or by tiers) with no limit, is sold by tiers with a subscription price row that has no units, names a limit the project does not declare and no meter uses, or is sold for life with no once price.
The five ways a user gets an add-on
They can be combined on the same add-on. Access is resolved from the strongest one, in this order:
lifetime > plan > subscription > trial > free
| Source | How it is granted | Lost when | Catalogue flow |
|---|---|---|---|
free |
The user presses Activate | The user switches it off, or the admin uses Remove gift | Yes |
plan |
The plan includes the add-on | The subscription stops being active | Yes |
subscription |
The add-on is a line on the Stripe subscription | The line or the subscription is cancelled | Yes |
trial |
The user presses Try N days, or the admin grants one | ends_at passes, with nothing charged, or the user cancels the trial; the row is kept as expired |
Yes |
lifetime |
The user presses Buy for life and the webhook confirms the payment, or the admin grants it | Never, unless you revoke it | Yes |
Plan inclusion and subscription lines are read live from the plan pivot and the subscription items, so they cannot drift. Free activations, trials and lifetime purchases are rows in billing_entitlements:
| Column | Meaning |
|---|---|
billing_account_id |
The account that holds it. |
feature_key |
The capability. |
source |
free, trial or lifetime. One row per account, key and source. |
status |
active by default; pending while a lifetime payment is being confirmed; expired once a trial ended. Anything but active is ignored. |
quantity |
Units held, default 1. |
starts_at, ends_at |
Both optional. A row counts only between them. |
reference |
Free text for your own bookkeeping, such as a Stripe session id. |
granted_by, granted_at |
The admin who granted it by hand, and when. Empty for anything the user did. |
Free
An add-on with no price above zero is free. The catalogue shows Free and an Activate button. Activation writes a free entitlement and needs no card, no subscription, no plan and no Stripe key. Deactivate deletes only the free row, so a plan or a purchase that also grants the capability keeps granting it.
If you grant a free add-on as a trial from the Add-ons panel of a user page, their catalogue card reads Active until and the date the trial ends, with no button to press, as the detail page says. Once the trial ends the card offers Activate again; granted for life, it reads Owned.
If you put a price on an add-on somebody already activated for free, they keep it and you stop offering it to them: their catalogue card shows Gift instead of the price and its button reads Activated and does nothing, and the detail page drops the subscription and lifetime boxes and shows No cost for you where the price was, next to the line saying they have it through a free activation and that the gift cannot be removed from their account. They can neither buy the paid version on top of it nor switch it off, because Deactivate is only shown on add-ons that are still free. Take it off them from the Add-ons panel of their user page if you need to: Remove gift beside it.
Included in a plan
The plan form has an Included Add-ons multi-select that writes the billing_plan_addon pivot. While the account's default subscription is active, every add-on of its plan is granted with source plan. Its catalogue card and its detail page show Included in your plan where the price was, and the card button says Included and does nothing. Both show the rate underneath only as a reference ("Outside the plan: $5.00 per month") when the add-on has one. The limits of included add-ons are added to the plan's limits.
Subscription
The catalogue shows the price for the interval the account already pays and a Buy button. What happens depends on the account:
- No subscription yet: a Stripe Checkout opens with the free plan for that interval plus the add-on. Exactly one active free plan variation must exist for that interval, or the user sees "No free plan available for :interval billing. Please subscribe to a plan first." If free trials are on in
config/pricing.php, the trial covers plan and add-on. Success returns to the dashboard with?checkout=success; cancel returns to/account/add-ons?checkout=cancel. - A free plan without a card to charge: the page asks for the card alone, under "Add a card to buy the add-on". No Checkout opens and no second subscription is created. Once the card is saved the purchase goes on by itself, as in the next point: the add-on joins the free subscription, today's charge covers only the days up to the next renewal, there are no free days, and the renewal date does not move. Cancelling the form, or a card that cannot be saved, leaves the account as it was.
- A paid subscription, or a free plan with a card: the add-on's price is added to the subscription and invoiced immediately. The page polls every three seconds until the webhook records the new item, then reloads and shows Cancel. With real time switched on (/help/real-time-with-reverb) it does not poll: the webhook tells the page the moment it arrives.
An add-on is only added at the billing period the subscription already has. On a monthly subscription, the yearly price of an add-on is refused with "Your subscription is billed monthly, so this add-on can only be added with its monthly price."
Pressing Cancel removes the price from the subscription without proration. It is refused when the account would be left using more than its quota allows without the add-on; see "When a change would leave the account over its quota" below. The webhooks customer.subscription.updated, invoice.paid and customer.subscription.deleted recompute the access of every add-on with a feature key on that account. That recomputation asks every source, so a webhook that no longer sees an add-on among the items cannot switch off a capability a lifetime purchase or a free activation still grants.
Trial
Set Free trial days on the add-on and the user can press Try N days. No card is asked for and nothing reaches Stripe: the trial starts the moment the button is pressed. The detail page offers it beside Subscribe and the lifetime purchase; the catalogue card shows Try N days beside Buy while the account does not hold the add-on and has not used its trial — side by side when the card has room, one under the other when it does not — and as the only button when nothing is for sale for the billing period on screen. Once the trial is running or spent, the card offers only Buy. Only a simple add-on offers a trial: one sold per unit or by tiers never does, whatever its days say. It writes a trial row with ends_at and the detail page, under the heading Your trial, shows the days left, the date it ends and that nothing is charged. One trial per account and add-on, forever: the row is kept as expired when it ends, and that row refuses a second one. The trial is refused too when the account already holds the capability by any way.
The day before the end the user gets a notice, "Your trial of :addon ends tomorrow", saying nothing is charged automatically and that subscribing or paying once from the add-ons page keeps the add-on. The day it ends, billing:expire-trials switches it off: nothing is charged, the add-on goes back to not bought, and the user gets a notice saying it is no longer available and that nothing was charged. What was created during the trial is kept; an account left over its quota only stops being able to create more. From then on the add-on is obtained only with Subscribe or by paying once, charged like any purchase. The trial is spent and cannot be taken again. See "By expiry" below.
Buying the add-on while the trial runs replaces the trial with no gap in access. The trial row expires right then, with no notice, and the history records that the trial ended before the purchase line: trial started, trial ended, then the purchase.
The subscription history records "The trial of :name started (until :date)." when a trial starts, also one an administrator grants, and "The trial of :name ended." when it ends, is cancelled or is replaced by a purchase — once, whichever happens first.
While the trial runs, the detail page offers Cancel trial. It withdraws the access right away, charges nothing and sends no notice — and it uses the trial up, so it cannot be taken again afterwards.
The trial does not depend on the way the add-on is paid for. An add-on sold only for life can offer one, which is exactly trying it before buying.
Lifetime
Switch Sold for life on and fill the Lifetime price table, and the catalogue shows Buy for life. Pressing it opens a Stripe Checkout in payment mode and records the session before the user leaves, with a lifetime row in status pending. Access is not granted on the way back from Stripe: the detail page shows Payment in progress and polls until the checkout.session.completed webhook confirms the payment and turns the row active. A reload or a second click reuses the pending session, so nothing is paid twice. It is reused only in the language it was opened in: after switching the app's language, the next click opens a new session in that language.
While the payment is in progress, the detail page offers Resume payment, which reopens that Checkout, and Abandon, which closes every pending Checkout of the add-on in Stripe, marks them abandoned and deletes the pending row: nothing is charged, the add-on reads as not bought and the history writes nothing. A session Stripe already completed is not abandoned; its webhook grants the access as usual.
Once the payment is confirmed, the subscription history records "X was bought for life for $…" with the amount paid, marked as done by the account. Bought during a trial, the history reads: the trial started, the trial ended, then the lifetime purchase — each once, even if Stripe repeats its notice. An access an administrator gives with Grant for life does not appear as a purchase.
A lifetime grant wins over every other source, so the catalogue shows Owned, the detail page shows Paid for life where the price was, the admin cannot switch it off and cancelling the plan does not touch it. When the account also pays for the same add-on as a subscription line, the detail page says so and offers Cancel the recurring line; nothing changes until the user presses it. The same Checkout mechanism, used for things that are not add-ons, is the subject of Charge once with Stripe Checkout.
Per unit and by tiers
Both sell quota, so both need at least one limit, and they cannot be combined. Sold per unit is the linear price: with storage_gb = 5 at 10 a month, the detail page shows a quantity selector, the summary recalculates the amount and the quota as it moves, and three units are fifteen gigabytes at 30. Sold by tiers is the non-linear price: the price table gains a Units column and the user picks one option, so ten gigabytes can cost 70 and not 100:
| Country | Currency | Units | Monthly | Yearly |
|---|---|---|---|---|
| default | mxn | 1 | 10.00 | 100.00 |
| default | mxn | 10 | 70.00 | 700.00 |
| default | mxn | 50 | 250.00 | 2,500.00 |
Changing the quantity (Update) or the tier (Change) adjusts the line that already exists; it never adds a second one. The gateway prorates the difference. Lowering the quantity or moving to a smaller tier is refused when the smaller quota would be below what the account already uses: the line stays as it was, and the message names each capability that would be over, with the usage and the new maximum. Raising either is never refused for the quota, but it charges the difference at once. If the account no longer meets the plan or dependency requirement, it can lower or cancel its existing line but cannot raise it: the quantity stops at the contracted amount and the tier selector offers the current tier and cheaper ones. When the card is refused, nothing changes, the line keeps its quantity or its tier, and the page shows the contracted one again. When the subscription has a payment still to be completed, a tier change is answered with that first. With a tier contracted, the detail page's price is that tier's, not the first option's; the catalogue card shows it too on the tab of the interval the account pays, and offers Cancel whichever tier it is. Recurring capacity is paid contracted, not consumed: whoever holds ten gigabytes pays ten whether they use three or all of them. For units that are spent and run out, see Sell usage with meters and packages.
Tiers that add up
With Tiers add up on, an add-on sold by tiers stops being one choice. The user picks a tier and a quantity, and pays the tier's price times the quantity; buying another tier adds it next to the ones already held instead of replacing them, and buying a tier they already hold raises its quantity. Every unit of every tier adds its quota, so two units of the five-gigabyte tier and one of the ten-gigabyte tier give twenty.
On the detail page each tier held is listed with its own quantity, Update and Remove, followed by what they give together, and Add more offers the next purchase. Change is not offered: removing one tier leaves the others as they are. Lowering a quantity or removing a tier is still refused when the account would be left over its quota, as below.
When a change would leave the account over its quota
Every action that shrinks what an add-on gives is checked against what the account already uses, and refused when the usage would be over the new maximum:
- Lowering the quantity or the tier, on the detail page.
- Cancel on a paid add-on, on the detail page or the catalogue card.
- Deactivate on a free add-on, on the detail page or the catalogue card.
- Remove gift in the admin panel.
Nothing changes, and the message names each capability that would be over: "You have exceeded the limit for Disk Space (12 / 10)." Using exactly the new maximum is allowed. To go ahead, the account frees up what is over, or gets the quota another way first.
Only the add-on being cancelled or switched off is taken out of the count. The plan, the add-ons it includes, anything held for life or on trial, and the other add-ons on the subscription keep counting. If that add-on was the only thing giving the capability, the new maximum is 0, not unlimited. An add-on that gives no limit is never refused, and neither is a subscription cancelled automatically for an unpaid charge.
Activate and deactivate
From the catalogue
Free add-ons toggle with Activate and Deactivate. Paid ones with Buy and Cancel. The card also offers Try N days and Buy for life when the add-on has them. The detail page lists every way the add-on offers as its own box, with Subscribe (and the quantity or the tier), Update or Change on a line already held, Cancel, Cancel trial while a trial is running, and Cancel the recurring line when a lifetime purchase and a subscription line coexist.
Cancel asks for confirmation before it removes the add-on, on the card as on the detail page; Deactivate on a free add-on asks too on the detail page. A card shows Cancel for an add-on the account holds whichever tab is open: held monthly and seen on the yearly tab, it still reads Cancel, with the yearly price on that tab. An account that already pays for a plan or for an add-on has no second tab: add-ons join that same subscription, so its catalogue shows only the subscription's period. Everything that charges on the spot — Buy on the card, Subscribe, Update and Change on the detail page — is refused at once when the card is declined: the add-on is not added, the quantity or the tier stays as it was, no unpaid invoice is left behind, and the message says what the bank answered.
A card whose bank asks the buyer to approve the payment (3-D Secure) is not refused. The screen opens Confirm your payment inside the app, and Approve payment shows the bank's own authentication. Nothing is switched on until the payment is approved: approved, the add-on, the quantity or the tier is active at once and the screen reloads to say so; cancelled, rejected or failed, nothing is charged and nothing changes, and the message says the buyer can try again. A buyer who closes the tab with the payment still open finds "You have a payment waiting for your approval" with Confirm payment on the add-on's page and on Billing, until the bank's time runs out (about a day); then it disappears, with nothing charged. While one payment waits, any other purchase or change on the subscription is answered with that same notice. The same window serves a plan upgrade or a change of billing period. On a subscription that costs nothing yet, such as the free plan, the payment to approve is only the part up to the next renewal, charged on its own; once it is approved the add-on, the quantity or the tier joins the subscription without moving its start or renewal date, and the full price is charged from the renewal. If the add-on cannot be added after the payment was approved, the payment is refunded in full and the message says so; nothing changes in the subscription.
Your Stripe webhook needs customer.subscription.pending_update_applied and customer.subscription.pending_update_expired for this; see Turn on plans and billing.
Any of those that works reloads the screen and confirms it with a banner at the top, so whatever the add-on unlocks or withdraws — an entry in the account menu, a screen that was not there before — is in place without the user reloading anything. A purchase that is still waiting for the payment to be confirmed is the exception: that screen stays where it is and announces the outcome when the confirmation arrives.
From the admin panel
A user's page in the admin panel shows an Add-ons panel. It is a read-only list: you cannot switch an add-on on or off for an account from the admin panel. The panel lists the add-ons the account has, each with the way it has it, or "This account has no add-ons." It shows on the user's page, not on the edit form, and saving the form never grants or removes an add-on. A paid add-on the account holds only because it was switched on by hand for free — a gift — has a Remove gift link beside it. The link opens a short form with that add-on already chosen; confirming it takes the gift away and brings you back to the user's page, with "The gift was removed from the account." and the add-on gone from the list, unless the account would be left over its quota, in which case it is refused with the same message the account owner would see and you stay on the form. An add-on held any other way is not a gift and has no link: cancel it where it was bought.
What an administrator granted with Grant for life or Grant a trial shows a Remove access link beside the add-on. It opens a short form with that access already chosen; confirming it removes the access and brings you back to the user's page with "The access was removed from the account.", unless the account would be left over its quota, which is refused with the same message as Remove gift. What the account bought, even for life, never has the link: a refund is made in Stripe. When the account has the same add-on another way — included in its plan, as a subscription line or switched on for free — it keeps it. The removed access stays in the Access history as Removed, with who removed it and when, and a removed trial still counts as the account's trial, so it cannot start another one of the same add-on.
To switch an add-on on for an account, sign in as that account and activate it from its Add-ons screen, or use one of the two actions below.
The same panel has two actions, Grant for life and Grant a trial (with the days, fifteen by default). They grant the add-on as if it had been bought or tried, recording which admin did it and when. A manual trial keeps the once-forever rule. The read-only Access history panel below lists every access the account ever held: add-on, source, status, dates and, when it was by hand, who granted it.
From code
Everything is a method of the billing account. Get it from the owner:
use WeblaborMx\BillingCore\BillingManager;
$account = $user->billingAccount; // relation
$account = app(BillingManager::class)->context($user, 'user')->account;
| Method | What it does |
|---|---|
$account->grantAddOn($key, $source, $attributes = []) |
Creates or updates the entitlement for that key and source, sets status to active, refreshes the cache. $attributes may carry quantity, starts_at, ends_at, reference. |
$account->revokeAddOn($key, $source) |
Deletes a free row, or marks any other source expired, and refreshes the cache. Other sources are untouched. |
$account->startTrial($addon) |
Starts the trial the add-on offers. Returns ['success' => bool, 'message' => ...]; refused when the add-on offers no trial, the trial was already used or the capability is already held. No card is needed. |
$account->grantManually($key, 'lifetime' or 'trial', $days, $admin) |
What the admin actions call. Records granted_by and granted_at. |
$account->confirmLifetime($reference) |
Turns the pending lifetime row with that reference active. Idempotent: an active one answers true and nothing is granted again. |
$account->toggleAddOn($variation) |
Buys or cancels a paid add-on, given a BillingPriceVariation. Returns ['success' => bool, 'message' => ..., 'action' => 'buy' or 'cancel'], or ['success' => true, 'url' => ...] when a Checkout must be opened, or 'action' => 'capture_card' when the account is on a free plan with no card to charge, which is what makes the screen ask for one. |
$user->toggleAddOn($variation) |
The same through the owner. |
By expiry
A trial or any entitlement with ends_at stops counting the moment the date passes, because hasAddOn() reads the rows when it is asked. The command billing:expire-trials, scheduled daily at 08:00 while the module is on, does the rest: it sends the notice for trials ending within a day, once, and for trials whose time is up it marks the row expired, refreshes the features cache described below and sends the notice that the trial ended with nothing charged. It never charges. A trial already replaced by a purchase is expired with no notice; if the purchase did not already end it, the command writes its end in the history, once.
The row ends as expired whichever way it went, including when the user cancelled the trial themselves, which is what keeps one trial per account and add-on forever. Between the moment a trial ends and the next run, hasAddOn() already says no while the features cache described below still says yes.
Check access in your code
Ask the account. It is the only thing that knows every source.
| Method | Returns |
|---|---|
$account->hasAddOn('electronic_wallet') |
true or false. |
$account->addOnSource('electronic_wallet') |
'lifetime', 'plan', 'subscription', 'trial', 'free' or null. |
$account->addOnQuantity('extra_storage') |
Units held: the largest of 1, the subscription item quantity and the entitlement quantity. 0 when not held. |
$account->entitlements() |
The billing_entitlements rows; ->granted() scopes to the ones counting now. |
$user->hasAddOn('electronic_wallet') |
The same answer, asked from the user. |
$user->addOnSource('electronic_wallet') |
The same source, asked from the user. |
$user->hasLockedAddOn('white_label') |
true when the source is anything but free. Padlock display only, see below. |
$user->getIncludedAddons() |
The add-ons of the current plan. |
Ask hasAddOn(), on the user or on the account. There is one way to ask, it reads the five sources as they stand right now, and it is what decides whether a screen, a menu or a guard lets somebody through.
hasLockedAddOn() is not that question. It answers false for a capability granted for free, because it exists to tell the admin panel when to draw a padlock. Gating a screen with it hides the screen from exactly the people who were given the capability.
The features json is only a cache. It lives on billing_accounts.features, and also on the owner's own table when that table has a features column, which is how a product that already stores the flag on its own table keeps that column in step. The users table of Weblabor Base has no such column. It is written by refreshFeatureCache() after every grant, revoke and webhook, so it lags on expiry: a trial that ran out an hour ago still reads as granted there. Never decide from it.
Limits: $user->getLimitTotal('activities') sums the plan limit, the limits of add-ons that are subscription items, the limits of add-ons included in the plan, and the limits of add-ons held as free, trial or lifetime. An add-on already counted as a line or as included is not counted again as a grant, so buying for life what the plan includes counts once. A line sold per unit contributes its limit times the quantity; a line sold by tiers contributes the units of the tier instead of the limit value.
What the user sees
The catalogue is /account/add-ons, reachable from the Add-ons entry of the account menu as soon as one add-on is on sale. It renders the same component the home page uses, so both look alike.
- A heading, "Enhance your plan", and a pill that switches the interval. Only intervals that some add-on actually prices, and that are active in
config/pricing.php, are offered. The interval starts on the one the account already pays, or monthly. An account paying for a plan, or for an add-on on the free plan, gets no pill: its catalogue stays on its subscription's period, and an add-on with no price there reads Not available. On the free plan with nothing paid, or with no plan, both periods stay on offer, and the one chosen when buying sets the period. - A search box over name and description, and a category select that only lists categories with at least one add-on on sale.
- One card per add-on on sale: icon or a puzzle-piece placeholder, name linking to the detail page, up to two categories, the description, the price for the interval, and the button. Prices are picked for the account's country and fall back to the
defaultcountry entry. The price carries its period: "$9.00 / month", "$90.00 / year". The "for life" price shows only on an add-on sold for life. - An add-on with prices but none for the selected interval, and not sold for life, stays in the catalogue when the user switches interval: no price, the button Not available, and the note "This add-on has no price for that billing period yet." An add-on priced only for other countries is not listed at all.
- View guide under the card when the add-on has one guide, going straight to it; View guides when it has several, opening a short list to pick from. Nothing when it has none. The same link shows on the public home page.
- The detail page,
/account/add-ons/{id}, adds the screenshot gallery, the full description, "What it includes", built from the add-on's limits (for an add-on sold per tier, the units of the contracted tier, or of the tier picked in the selector while the account does not hold it), and a side panel with one box per way to get it: free, included, subscription, trial and lifetime, each with its price and condition. It says which way the account already holds it through, the days left on a trial, and Payment in progress while a lifetime purchase waits for its webhook. Once the trial was used and the account does not hold the add-on another way, the trial box reads "The trial of this add-on was already used on this account." and explains that each account can take the trial only once; when the account holds it another way, the box says which instead.
The button says what the account can do:
| Label | When |
|---|---|
| Owned | Source is lifetime, or the account holds it through its subscription or a running trial and the selected interval has no price for it. Disabled. |
| Included | Source is plan. Disabled. |
| Activated | Source is free on an add-on that has a price. Disabled. |
| Coming soon | The capability is declared in code but not released yet, or the add-on has no price and no capability at all. Disabled, with the note "Coming soon. This add-on is not available yet." |
| Payment in progress | A lifetime purchase is waiting for its webhook. Disabled, with the note "Open the add-on to resume the payment or abandon it." |
| Activate / Deactivate | Free add-on, not or already activated. |
| Buy / Cancel | Paid add-on, not or already a subscription item. |
| Buy for life | Sold for life, with no recurring price for the selected interval. |
| Try N days | Offers a trial, not used yet, and the account holds nothing else. |
| Not available | Paid add-on with no price for the selected interval, and not sold for life. Disabled, with a note. |
| Requires ... plan | The add-on is available only from some plans and the account is not on one of them. Disabled, with the reason next to it. |
| Not available with your plan | The add-on is available only from some plans and none of them exists any more. Disabled, with the reason next to it. |
| Requires ... | The account does not have the add-on this one depends on. Disabled, with the reason next to it. |
On the public home page the catalogue appears only when BillingAddon::isActive() is true and the visitor is not on iOS. There it shows the four add-ons held by the most accounts, through a subscription or bought for life, with ties ordered by name. The cards carry no button: a notice above them reads "To get an add-on you need an account." with links to create one and to sign in, and View all under them opens the public add-ons page, /en/add-ons (/es/complementos in Spanish), which lists every add-on on sale in the visitor's country by name, the same way. An add-on with no price for the selected interval is left out rather than shown as not available.
Not built
Refunds, disputes and chargebacks: a lifetime access is not revoked on its own when the payment is reversed later. Prorating an included quota when the plan changes mid-cycle. Units that are spent and run out, prepaid packages and usage billed at the close of the cycle are not add-ons: they are meters, in Sell usage with meters and packages.
