TL;DR
- Commercial rules — how many free seats a plan gets, whether you can buy seats in bulk, how tax is applied — drift constantly. Hardcoding them means a deploy every time Sales changes their mind.
- I moved them behind a
BillingProvidercontract plus config/settings: the number is data, the behaviour is a swappable driver (manual vs Stripe). - Takeaway: if a value is a business decision, not an engineering one, it belongs in config or settings — not an
ifstatement.
A "free tier of 3 seats" looks innocent until someone asks for 5. Then you grep the codebase, find the 3 in four places, miss one, and ship a billing bug. Today's work was about making sure that number — and a few friends — never live in code again.
The seam: one contract, two drivers
Billing has two modes: invoice-by-hand (early/enterprise customers) and Stripe (self-serve). Callers shouldn't care which. So everything goes through a contract:
interface BillingProvider
{
public function freeSeatDefault(): int;
public function purchaseSeats(Account $account, int $quantity): SeatPurchase;
public function appliesTax(): bool;
// ...
}
ManualBillingProvider and StripeBillingProvider implement it. The entitlement gate that asks "does this account have a seat available?" talks to the contract, never to Stripe directly. Same idea as a Filesystem disk — the caller says "store this", the driver decides where.
The free-seat default is config, not a constant
// config/billing.php
'seats' => [
'free_default' => env('BILLING_FREE_SEATS', 1),
'bulk_enabled' => true,
],
The manual provider reads the config; the Stripe provider can override per-plan from the subscription metadata. Customer-facing tweaks (a promo bumping free seats to 5) move to DB-backed settings so an admin flips it without a deploy. Code reads one path; the value's origin is an implementation detail.
Bulk seats: quantity is a first-class input
Adding seats one-by-one is fine until a customer onboards 40 people. Bulk just means purchaseSeats($account, 40) instead of a loop — the provider decides how to price and record it. The win is that the gate logic doesn't change: it still asks "seats_used < seats_total?".
Tax belongs to the provider, not the checkout view
Tax rules vary by region and change without warning. Putting tax math in a Blade view or controller is how you end up auditing checkout code. Instead, tax is a provider concern — the Stripe driver delegates to Stripe Tax (it already knows the customer's location and current rates), the manual driver returns "tax handled on the invoice". The app just asks appliesTax().
checkout ──► BillingProvider (contract)
├─ ManualBillingProvider ─► invoice handles tax
└─ StripeBillingProvider ─► Stripe Tax computes it
Test the rule, not the vendor
You don't need a live Stripe call to prove the policy. Bind a fake provider and assert behaviour:
it('grants the configured number of free seats', function () {
config(['billing.seats.free_default' => 3]);
$account = Account::factory()->create();
expect(app(BillingProvider::class)->freeSeatDefault())->toBe(3);
expect($account->availableSeats())->toBe(3);
});
The contract is the seam and the test boundary — swap in a manual provider, assert the entitlement gate counts seats correctly, done. No network, no flake.
Decision rule
| If the value… | Put it in… |
|---|---|
| Never changes, is engineering-internal | a constant |
| Changes per environment/deploy |
config() + .env
|
| A customer or admin should change live | DB-backed settings |
| Is a swappable behaviour | a driver behind a contract |
Free seats, bulk pricing, tax handling — every one of them is a business decision dressed up as a code value. Move them out, and the next "can we make it 5?" is a settings toggle, not a release.
Top comments (0)