DEV Community

Cover image for Stop Hardcoding Your Commercial Rules: Config-Driven Free Seats, Bulk Seats, and Tax
Nasrul Hazim Bin Mohamad
Nasrul Hazim Bin Mohamad

Posted on

Stop Hardcoding Your Commercial Rules: Config-Driven Free Seats, Bulk Seats, and Tax

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 BillingProvider contract 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 if statement.

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;
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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,
],
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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)