OCTO Ops Help User guides and product documentation

2026 09 10 Billing 2D Rates And Drafts

On this page 6

Billing 2d — Rates and the drafting command

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Price the services increment 6 will meter, and draft enterprise renewal invoices automatically.

Architecture: Two unrelated pieces sharing a sub-increment because neither is big enough to ship alone. The Rates screen writes effective-dated rows to billing_usage_rates, which nothing outside the console reads until increment 6. The command creates draft invoices through the existing CreateInvoice Action, so a drafted invoice is indistinguishable from a hand-made one.

Tech Stack: Laravel 13, PHP 8.4, Inertia v3 + Vue 3, Wayfinder, Bootstrap/Metronic, MySQL (SQLite in tests).

Spec: docs/superpowers/specs/2026-09-09-saas-monetization-design.md — read the section "2d in detail — Rates and the drafting command" (line ~915). It is the binding authority; where this plan and the spec disagree, the spec wins.

Global Constraints

  • PHP binary: plain php is MAMP's PHP 8.2 and too old. Artisan runs as herd php artisan ....
  • Tests: herd php -d memory_limit=2G artisan test --compact <path>; whole suite herd php -d memory_limit=2G vendor/bin/phpunit (artisan test spawns children that do NOT inherit -d memory_limit).
  • Rates are millicents. Everything else is cents. Money::format(4500) is $45.00; Rate::format(4500) is $0.045. These are the only non-cent integers in the subsystem. Never pass a rate to Money, or an amount to Rate.
  • Amounts are integers — never floats. String parsing on the way in (Money::toCents, Rate::toMillicents), integer arithmetic throughout. 0.045 * 1000 is not 45 in IEEE 754.
  • No .vue file performs arithmetic on money, rates or bytes. Formatted strings and labels arrive from the server.
  • No billing rule gets a second implementation. If a rule exists in PHP, SQL narrows and PHP applies it. A forwarding static left behind after a move is a second name for one rule — move, do not delegate.
  • All billing tables are MySQL (the default connection; sqlite in tests). Never MongoDB.
  • No new model factories. Build rows with explicit Model::create([...]).
  • No Action performs authorization. Every console route sits inside the existing EnsureSuperAdmin group.
  • Wayfinder: herd php artisan wayfinder:generate --with-form. The --with-form flag is mandatory — the bare command strips .form() and breaks existing pages.
  • npm run build before any Inertia render test. A stale Vite manifest throws a ViteException that looks like a code error and is not.
  • Formatting: vendor/bin/pint --dirty --format agent, then npm run format and npm run lint. Both rewrite unrelated files repo-wide — revert the collateral so the commit stays scoped. ESLint's pre-existing errors live in resources/js/pages/maintenance/**; leave them.
  • Frontend conventions: Bootstrap cards, the Heading component, breadcrumbs via defineOptions, types imported from @/types (the barrel, not a deep path). No DataTables, no FilterDrawer.
  • Pre-existing test failures you did NOT cause and must NOT fix: 24 failures + 3 errors across the suite, nine of them ViteExceptions, plus intermittent Mongo-backed failures in ReturnsRegisterTest/UtilizationTest/AtpTest. tests/Feature/Billing, tests/Unit/Billing and tests/Feature/SuperAdmin are green — any failure there is yours.

File Structure

Task 1 — rate foundations (no UI):

  • app/Enums/UsageUnit.php — the unit a rate is quoted in, and its block size.
  • app/Enums/UsageService.php — what is metered, and which unit it is quoted in.
  • app/Support/Billing/Rate.php — the millicent conversion point, mirroring Money.
  • database/migrations/2026_09_11_100000_rename_usage_rate_amount_to_millicents.php
  • app/Models/Billing/UsageRate.php — casts follow the renamed column (modify).
  • database/seeders/BillingUsageRatesSeeder.php + a call in database/seeders/DatabaseSeeder.php.
  • app/Actions/Billing/SaveUsageRate.php, app/Actions/Billing/DeleteScheduledRate.php.

Task 2 — the Rates screen:

  • app/Http/Controllers/SuperAdmin/Billing/RateController.php
  • app/Http/Requests/SuperAdmin/Billing/SaveUsageRateRequest.php
  • routes/web/superadmin.php (modify), resources/js/types/billing.ts (modify)
  • resources/js/pages/superadmin/billing/Rates.vue
  • resources/js/pages/superadmin/billing/Overview.vue (modify — a link to Rates)

Task 3 — the command:

  • app/Support/Billing/EnterpriseDraft.php
  • app/Http/Controllers/SuperAdmin/Billing/InvoiceController.php (modify — statics move out)
  • app/Actions/Billing/CreateInvoice.php (modify — nullable actor)
  • app/Console/Commands/DraftEnterpriseInvoices.php, routes/console.php (modify)

Task 1: Rate foundations

Files:

  • Create: app/Enums/UsageUnit.php, app/Enums/UsageService.php, app/Support/Billing/Rate.php, app/Actions/Billing/SaveUsageRate.php, app/Actions/Billing/DeleteScheduledRate.php, database/seeders/BillingUsageRatesSeeder.php
  • Create: database/migrations/2026_09_11_100000_rename_usage_rate_amount_to_millicents.php
  • Modify: app/Models/Billing/UsageRate.php, database/seeders/DatabaseSeeder.php
  • Test: tests/Unit/Billing/RateTest.php, tests/Feature/Billing/UsageRateActionsTest.php

Interfaces:

  • Consumes: UsageRate::resolve(string $service, Team $team, ?CarbonInterface $moment = null): ?UsageRate (shipped, unchanged). BillingEvent::record(?Team, string, array, ?int, ?string).

  • Produces:

    • UsageUnit::blockSize(): int, UsageUnit::label(): string, UsageUnit::perLabel(): string
    • UsageService::unit(): UsageUnit, UsageService::label(): string
    • Rate::toMillicents(string): int, Rate::toInput(int): string, Rate::format(int $millicents, string $currency = 'usd'): string, Rate::lineCents(int $quantity, int $unitAmountMillicents, UsageUnit $unit): int
    • SaveUsageRate::handle(UsageService $service, string $amount, CarbonInterface $effectiveFrom, User $actor, ?Team $team = null): UsageRate
    • DeleteScheduledRate::handle(UsageRate $rate, User $actor): void
  • Step 1: Write the failing unit test for Rate

Create tests/Unit/Billing/RateTest.php:

php
<?php

namespace Tests\Unit\Billing;

use App\Enums\UsageUnit;
use App\Support\Billing\Money;
use App\Support\Billing\Rate;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

class RateTest extends TestCase
{
    /**
     * The whole point of this test. A rate and a money amount are both
     * integers, and 4500 means two different things depending on which it is.
     * If someone "simplifies" Rate::format() into Money::format(), or the
     * reverse, this assertion is what stops them — do not delete it.
     */
    public function test_a_rate_and_a_money_amount_read_differently_from_the_same_integer(): void
    {
        $this->assertSame('$0.045', Rate::format(4500));
        $this->assertSame('$45.00', Money::format(4500));
    }

    public function test_it_parses_a_decimal_string_into_millicents(): void
    {
        $this->assertSame(4500, Rate::toMillicents('0.045'));
        $this->assertSame(300000, Rate::toMillicents('3.00'));
        $this->assertSame(1200000, Rate::toMillicents('12'));
        $this->assertSame(1, Rate::toMillicents('0.00001'));
    }

    /**
     * Parsed as a string, not multiplied as a float: 0.045 * 100000 is
     * 4499.999999999999 in IEEE 754, which an (int) cast truncates to 4499.
     */
    public function test_it_does_not_lose_a_millicent_to_floating_point(): void
    {
        $this->assertSame(4500, Rate::toMillicents('0.045'));
        $this->assertSame(11500, Rate::toMillicents('0.115'));
    }

    public function test_it_refuses_anything_that_is_not_a_plain_decimal(): void
    {
        $this->expectException(InvalidArgumentException::class);

        Rate::toMillicents('$0.045');
    }

    public function test_it_refuses_a_negative_rate(): void
    {
        $this->expectException(InvalidArgumentException::class);

        Rate::toMillicents('-0.045');
    }

    public function test_to_input_round_trips_through_to_millicents(): void
    {
        foreach (['0.045', '3.00000', '12.50000', '0.00001'] as $amount) {
            $this->assertSame(
                Rate::toMillicents($amount),
                Rate::toMillicents(Rate::toInput(Rate::toMillicents($amount))),
            );
        }
    }

    public function test_it_formats_against_the_unit_block(): void
    {
        $this->assertSame('$0.045', Rate::format(4500));
        $this->assertSame('$3.00', Rate::format(300000));
        $this->assertSame('$12.00', Rate::format(1200000));
    }

    public function test_a_line_total_rounds_once_at_the_line(): void
    {
        // 2,500 messages at $0.045 each is $112.50, not 2,500 roundings.
        $this->assertSame(11250, Rate::lineCents(2500, 4500, UsageUnit::Message));

        // 250,000 input tokens at $3.00 per million is $0.75.
        $this->assertSame(75, Rate::lineCents(250_000, 300_000, UsageUnit::MillionTokens));
    }

    public function test_a_line_total_rounds_half_up(): void
    {
        // 33 messages at 4,500 millicents is 148.5 cents.
        $this->assertSame(149, Rate::lineCents(33, 4500, UsageUnit::Message));

        // 11 messages at 4,500 millicents is 49.5 cents.
        $this->assertSame(50, Rate::lineCents(11, 4500, UsageUnit::Message));

        // 10 messages at 4,500 millicents is exactly 45 cents — no rounding.
        $this->assertSame(45, Rate::lineCents(10, 4500, UsageUnit::Message));
    }

    public function test_a_zero_quantity_costs_nothing(): void
    {
        $this->assertSame(0, Rate::lineCents(0, 4500, UsageUnit::Message));
    }
}
  • Step 2: Run it and watch it fail

Run: herd php -d memory_limit=2G artisan test --compact tests/Unit/Billing/RateTest.php Expected: FAIL — Class "App\Enums\UsageUnit" not found.

  • Step 3: Write UsageUnit

Create app/Enums/UsageUnit.php:

php
<?php

namespace App\Enums;

/**
 * The unit a usage rate is quoted in (design doc, "2d in detail" → "Units and
 * block sizes").
 *
 * Millicents make SMS exact but still cannot price a single AI token — $3.00
 * per million tokens is 0.0003 of a cent each — so the unit carries a block
 * size and the rate prices one block.
 */
enum UsageUnit: string
{
    case Message = 'message';
    case ThousandMessages = '1k_messages';
    case MillionTokens = '1m_tokens';

    /**
     * How many billable items one rate covers.
     */
    public function blockSize(): int
    {
        return match ($this) {
            self::Message => 1,
            self::ThousandMessages => 1_000,
            self::MillionTokens => 1_000_000,
        };
    }

    /**
     * What one block is called, for "$0.045 per message".
     */
    public function perLabel(): string
    {
        return match ($this) {
            self::Message => 'message',
            self::ThousandMessages => '1,000 messages',
            self::MillionTokens => '1M tokens',
        };
    }

    public function label(): string
    {
        return ucfirst($this->perLabel());
    }
}
  • Step 4: Write UsageService

Create app/Enums/UsageService.php:

php
<?php

namespace App\Enums;

/**
 * What the platform meters (design doc, "2d in detail" → "Units and block
 * sizes"). Nothing records usage until increment 6; these names are the
 * contract that increment's recorder will call `Usage::record()` with.
 *
 * AI input and output are separate services rather than one blended rate
 * because every model provider bills us that way — a blended price is a guess
 * at the mix, and a customer whose usage skews to output quietly becomes
 * unprofitable.
 */
enum UsageService: string
{
    case Sms = 'sms';
    case AiTokensInput = 'ai_tokens_input';
    case AiTokensOutput = 'ai_tokens_output';

    /**
     * The unit this service is quoted in. A rate row cannot pair SMS with a
     * token unit because the unit is not independently chosen.
     */
    public function unit(): UsageUnit
    {
        return match ($this) {
            self::Sms => UsageUnit::Message,
            self::AiTokensInput, self::AiTokensOutput => UsageUnit::MillionTokens,
        };
    }

    public function label(): string
    {
        return match ($this) {
            self::Sms => 'SMS',
            self::AiTokensInput => 'AI tokens (input)',
            self::AiTokensOutput => 'AI tokens (output)',
        };
    }
}
  • Step 5: Write Rate

Create app/Support/Billing/Rate.php:

php
<?php

namespace App\Support\Billing;

use App\Enums\UsageUnit;
use InvalidArgumentException;

/**
 * The single conversion point between the price an operator types and the
 * integer millicents a rate stores (design doc, "2d in detail" → "The
 * precision problem, and the answer").
 *
 * Deliberately mirrors `Money` method for method, because these are the only
 * integers in the subsystem that are NOT cents and the resemblance is what
 * makes the difference visible: `Money::format(4500)` is `$45.00`, and
 * `Rate::format(4500)` is `$0.045`. `RateTest` asserts
 * both side by side so neither can be "corrected" into the other.
 */
class Rate
{
    /**
     * One cent, in millicents.
     */
    public const PER_CENT = 1000;

    /**
     * Convert a validated decimal string to integer millicents.
     *
     * Parsed as a string rather than multiplied as a float for the same
     * reason `Money::toCents()` is: in IEEE 754, `0.045 * 100000` is
     * 4499.999999999999, which an `(int)` cast truncates to 4499 — a rate
     * one millicent light on every invoice forever.
     *
     * Five decimal places, because a millicent is the third decimal of a cent
     * and a cent is the second decimal of a dollar. A rate cannot be
     * negative: there is no such thing as being paid to send an SMS.
     */
    public static function toMillicents(string $amount): int
    {
        $normalised = trim($amount);

        if (preg_match('/^\d{1,9}(\.\d{1,5})?$/', $normalised) !== 1) {
            throw new InvalidArgumentException("Not a plain non-negative rate: [{$amount}].");
        }

        [$whole, $fraction] = array_pad(explode('.', $normalised, 2), 2, '');

        $fraction = str_pad(substr($fraction, 0, 5), 5, '0');

        return ((int) $whole) * 100_000 + (int) $fraction;
    }

    /**
     * Integer millicents as an editable input value — plain digits and a dot,
     * so `toMillicents()` accepts it back unchanged. This is the inverse of
     * `toMillicents()`; `format()` is not, and must never be fed back in.
     */
    public static function toInput(int $millicents): string
    {
        return number_format($millicents / 100_000, 5, '.', '');
    }

    /**
     * Render a rate for display: what one block costs, in dollars.
     *
     * Trailing zeros beyond two decimals are trimmed so a whole-dollar rate
     * reads `$3.00` rather than `$3.00000`, while a sub-cent rate keeps the
     * precision it needs — `$0.045`.
     */
    public static function format(int $millicents, UsageUnit $unit, string $currency = 'usd'): string
    {
        $prefix = $currency === 'usd' ? '$' : strtoupper($currency).' ';

        $dollars = number_format($millicents / 100_000, 5, '.', '');
        $trimmed = rtrim($dollars, '0');
        $decimals = strlen($trimmed) - strpos($trimmed, '.') - 1;

        return $prefix.number_format($millicents / 100_000, max(2, $decimals), '.', ',');
    }

    /**
     * What a quantity of a service costs, in whole cents.
     *
     * Rounds half up, ONCE, at the line — never per event. Rounding each of
     * 2,500 SMS events to the nearest cent would bill $25.00 for $112.50 of
     * traffic. The doubling is round-half-up in integers: no float touches
     * this calculation.
     */
    public static function lineCents(int $quantity, int $unitAmountMillicents, UsageUnit $unit): int
    {
        $numerator = $quantity * $unitAmountMillicents;
        $denominator = $unit->blockSize() * self::PER_CENT;

        return intdiv($numerator * 2 + $denominator, $denominator * 2);
    }
}
  • Step 6: Run the unit test until it passes

Run: herd php -d memory_limit=2G artisan test --compact tests/Unit/Billing/RateTest.php Expected: PASS, 10 tests.

If test_it_formats_against_the_unit_block fails on trailing zeros, fix format() — do not weaken the assertion.

  • Step 7: Write the rename migration

Create database/migrations/2026_09_11_100000_rename_usage_rate_amount_to_millicents.php:

php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

/**
 * `unit_amount_cents` cannot hold the prices we charge: $0.045 per SMS is not
 * an integer number of cents (design doc, "2d in detail"). Rates move to
 * millicents.
 *
 * A rename and not a data migration — nothing has ever written this table,
 * and nothing outside the console reads it until increment 6.
 */
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('billing_usage_rates', function (Blueprint $table) {
            $table->renameColumn('unit_amount_cents', 'unit_amount_millicents');
        });
    }

    public function down(): void
    {
        Schema::table('billing_usage_rates', function (Blueprint $table) {
            $table->renameColumn('unit_amount_millicents', 'unit_amount_cents');
        });
    }
};
  • Step 8: Update the UsageRate model

In app/Models/Billing/UsageRate.php, rename the property in the docblock and the cast:

php
 * @property int $unit_amount_millicents
php
    protected $casts = [
        'team_id' => 'integer',
        'service' => UsageService::class,
        'unit' => UsageUnit::class,
        'unit_amount_millicents' => 'integer',
        'effective_from' => 'datetime',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

Add the two use statements. resolve() is unchanged — but note its where('service', $service) now compares against an enum-cast column, which Eloquent handles for a backed enum. Add a test in Step 12 that proves it still resolves.

  • Step 9: Write the seeder

Create database/seeders/BillingUsageRatesSeeder.php:

php
<?php

namespace Database\Seeders;

use App\Enums\UsageService;
use App\Models\Billing\UsageRate;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;

/**
 * The platform's default price list (design doc, "2d in detail" → "Units and
 * block sizes").
 *
 * Seeded as real, resolvable rates rather than zeros, so increment 6 has
 * something to meter against on day one. A superadmin changes any of them on
 * the Rates screen without a deploy.
 *
 * Idempotent on `(service, team_id, effective_from)`, so re-running it never
 * creates a second default for the same date.
 */
class BillingUsageRatesSeeder extends Seeder
{
    public function run(): void
    {
        $effectiveFrom = Carbon::create(2026, 1, 1, 0, 0, 0);

        $rates = [
            [UsageService::Sms, 4_500],
            [UsageService::AiTokensInput, 300_000],
            [UsageService::AiTokensOutput, 1_200_000],
        ];

        foreach ($rates as [$service, $millicents]) {
            UsageRate::query()->updateOrCreate(
                [
                    'service' => $service->value,
                    'team_id' => null,
                    'effective_from' => $effectiveFrom,
                ],
                [
                    'unit' => $service->unit()->value,
                    'unit_amount_millicents' => $millicents,
                ],
            );
        }
    }
}

Then add $this->call(BillingUsageRatesSeeder::class); to database/seeders/DatabaseSeeder.php, on the line immediately after the existing $this->call(BillingProductsSeeder::class);.

  • Step 10: Write the failing Action tests

Create tests/Feature/Billing/UsageRateActionsTest.php:

php
<?php

namespace Tests\Feature\Billing;

use App\Actions\Billing\DeleteScheduledRate;
use App\Actions\Billing\SaveUsageRate;
use App\Enums\UsageService;
use App\Enums\UsageUnit;
use App\Models\Billing\BillingEvent;
use App\Models\Billing\UsageRate;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;

class UsageRateActionsTest extends TestCase
{
    use RefreshDatabase;

    private function actor(): User
    {
        return User::create([
            'name' => 'Rita Ops',
            'email' => '[email protected]',
            'password' => bcrypt('password'),
            'superAdmin' => true,
        ]);
    }

    private function team(): Team
    {
        return Team::create([
            'name' => 'Acme Logistics',
            'slug' => 'acme-logistics',
            'is_personal' => false,
        ]);
    }

    public function test_it_appends_a_platform_default_rate(): void
    {
        $rate = app(SaveUsageRate::class)->handle(
            UsageService::Sms,
            '0.045',
            Carbon::now()->addDay(),
            $this->actor(),
        );

        $this->assertNull($rate->team_id);
        $this->assertSame(4500, $rate->unit_amount_millicents);
        $this->assertSame(UsageUnit::Message, $rate->unit);
        $this->assertSame(UsageService::Sms, $rate->service);
    }

    public function test_it_takes_the_unit_from_the_service_not_the_caller(): void
    {
        $rate = app(SaveUsageRate::class)->handle(
            UsageService::AiTokensInput,
            '3.00',
            Carbon::now()->addDay(),
            $this->actor(),
        );

        $this->assertSame(UsageUnit::MillionTokens, $rate->unit);
        $this->assertSame(300000, $rate->unit_amount_millicents);
    }

    public function test_it_appends_rather_than_editing_so_the_old_rate_survives(): void
    {
        $actor = $this->actor();

        app(SaveUsageRate::class)->handle(UsageService::Sms, '0.045', Carbon::now(), $actor);
        app(SaveUsageRate::class)->handle(UsageService::Sms, '0.050', Carbon::now()->addMonth(), $actor);

        $this->assertSame(2, UsageRate::query()->where('service', UsageService::Sms->value)->count());
    }

    public function test_a_scheduled_rate_does_not_take_effect_early(): void
    {
        $actor = $this->actor();
        $team = $this->team();

        app(SaveUsageRate::class)->handle(UsageService::Sms, '0.045', Carbon::now()->subMonth(), $actor);
        app(SaveUsageRate::class)->handle(UsageService::Sms, '0.050', Carbon::now()->addMonth(), $actor);

        $this->assertSame(4500, UsageRate::resolve(UsageService::Sms->value, $team)->unit_amount_millicents);
        $this->assertSame(
            5000,
            UsageRate::resolve(UsageService::Sms->value, $team, Carbon::now()->addMonths(2))->unit_amount_millicents,
        );
    }

    public function test_a_team_override_beats_the_platform_default(): void
    {
        $actor = $this->actor();
        $team = $this->team();

        app(SaveUsageRate::class)->handle(UsageService::Sms, '0.045', Carbon::now()->subDay(), $actor);
        app(SaveUsageRate::class)->handle(UsageService::Sms, '0.040', Carbon::now()->subDay(), $actor, $team);

        $this->assertSame(4000, UsageRate::resolve(UsageService::Sms->value, $team)->unit_amount_millicents);
    }

    public function test_it_refuses_a_backdated_effective_date(): void
    {
        $this->expectException(HttpException::class);

        app(SaveUsageRate::class)->handle(
            UsageService::Sms,
            '0.045',
            Carbon::now()->subDay()->startOfDay()->subSecond(),
            $this->actor(),
        );
    }

    public function test_it_refuses_a_rate_that_is_not_a_plain_decimal(): void
    {
        $this->expectException(HttpException::class);

        app(SaveUsageRate::class)->handle(UsageService::Sms, '$0.045', Carbon::now(), $this->actor());
    }

    public function test_it_reports_a_duplicate_date_rather_than_crashing(): void
    {
        $actor = $this->actor();
        $moment = Carbon::now()->addDay();

        app(SaveUsageRate::class)->handle(UsageService::Sms, '0.045', $moment, $actor);

        $this->expectException(HttpException::class);

        app(SaveUsageRate::class)->handle(UsageService::Sms, '0.050', $moment, $actor);
    }

    public function test_it_records_a_billing_event(): void
    {
        $actor = $this->actor();

        app(SaveUsageRate::class)->handle(UsageService::Sms, '0.045', Carbon::now(), $actor);

        $this->assertSame(1, BillingEvent::query()->where('type', 'rate.saved')->count());
    }

    public function test_it_deletes_a_scheduled_rate(): void
    {
        $actor = $this->actor();

        $rate = app(SaveUsageRate::class)->handle(UsageService::Sms, '0.045', Carbon::now()->addMonth(), $actor);

        app(DeleteScheduledRate::class)->handle($rate, $actor);

        $this->assertSame(0, UsageRate::query()->count());
        $this->assertSame(1, BillingEvent::query()->where('type', 'rate.scheduled_deleted')->count());
    }

    public function test_it_refuses_to_delete_a_rate_already_in_force(): void
    {
        $actor = $this->actor();

        $rate = app(SaveUsageRate::class)->handle(UsageService::Sms, '0.045', Carbon::now(), $actor);

        Carbon::setTestNow(Carbon::now()->addDay());

        $this->expectException(HttpException::class);

        try {
            app(DeleteScheduledRate::class)->handle($rate, $actor);
        } finally {
            Carbon::setTestNow();
        }
    }
}
  • Step 11: Run and watch it fail

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/UsageRateActionsTest.php Expected: FAIL — Class "App\Actions\Billing\SaveUsageRate" not found.

  • Step 12: Write SaveUsageRate

Create app/Actions/Billing/SaveUsageRate.php:

php
<?php

namespace App\Actions\Billing;

use App\Enums\UsageService;
use App\Models\Billing\BillingEvent;
use App\Models\Billing\UsageRate;
use App\Models\Team;
use App\Models\User;
use App\Support\Billing\Rate;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;

/**
 * Appends a usage rate (design doc, "2d in detail" → "Changing a rate").
 *
 * Rates are append-only and effective-dated: a change is a new row, never an
 * edit, because `UsageRate::resolve()` reads history and an edit would
 * silently restate what a past moment resolved to.
 *
 * The unit comes from the service rather than the caller, so a rate row
 * cannot pair SMS with a token unit.
 */
class SaveUsageRate
{
    public function handle(
        UsageService $service,
        string $amount,
        CarbonInterface $effectiveFrom,
        User $actor,
        ?Team $team = null,
    ): UsageRate {
        abort_if(
            $effectiveFrom->isBefore(Carbon::now()->startOfDay()),
            422,
            'A rate cannot start in the past — it would change what a moment that has already passed resolved to.',
        );

        try {
            $millicents = Rate::toMillicents($amount);
        } catch (InvalidArgumentException) {
            abort(422, 'Enter the rate in dollars, with at most five decimal places and no symbols — for example 0.045.');
        }

        $unit = $service->unit();

        return DB::transaction(function () use ($service, $team, $unit, $millicents, $effectiveFrom, $actor) {
            $exists = UsageRate::query()
                ->where('service', $service->value)
                ->where('team_id', $team?->id)
                ->where('effective_from', $effectiveFrom)
                ->lockForUpdate()
                ->exists();

            abort_if($exists, 422, 'A rate for this service already starts on that date. Pick another date, or delete the scheduled one first.');

            $rate = UsageRate::create([
                'service' => $service->value,
                'team_id' => $team?->id,
                'unit' => $unit->value,
                'unit_amount_millicents' => $millicents,
                'effective_from' => $effectiveFrom,
            ]);

            BillingEvent::record(
                $team,
                'rate.saved',
                [
                    'service' => $service->value,
                    'unit' => $unit->value,
                    'unit_amount_millicents' => $millicents,
                    'effective_from' => $effectiveFrom->toDateTimeString(),
                    'scope' => $team === null ? 'platform' : 'team',
                ],
                $actor->id,
            );

            return $rate;
        });
    }
}
  • Step 13: Write DeleteScheduledRate

Create app/Actions/Billing/DeleteScheduledRate.php:

php
<?php

namespace App\Actions\Billing;

use App\Models\Billing\BillingEvent;
use App\Models\Billing\UsageRate;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

/**
 * Removes a rate that has not taken effect yet (design doc, "2d in detail" →
 * "Changing a rate").
 *
 * The only row deletion in the billing subsystem, and it is safe for exactly
 * one reason: a future-dated rate has priced nothing, so no usage event can
 * have denormalized it. A rate already in force is superseded by appending,
 * never deleted.
 */
class DeleteScheduledRate
{
    public function handle(UsageRate $rate, User $actor): void
    {
        DB::transaction(function () use ($rate, $actor) {
            // Lock, then re-check: a rate that took effect between the
            // operator's click and this write is no longer deletable.
            $locked = UsageRate::query()->whereKey($rate->getKey())->lockForUpdate()->firstOrFail();

            abort_if(
                ! $locked->effective_from->isAfter(Carbon::now()),
                422,
                'That rate is already in force. Supersede it by scheduling a new one instead of deleting it.',
            );

            $locked->delete();

            BillingEvent::record(
                $locked->team_id === null ? null : $locked->team,
                'rate.scheduled_deleted',
                [
                    'service' => $locked->service->value,
                    'unit_amount_millicents' => $locked->unit_amount_millicents,
                    'effective_from' => $locked->effective_from->toDateTimeString(),
                ],
                $actor->id,
            );
        });
    }
}

If UsageRate has no team() relation, add one: public function team(): BelongsTo { return $this->belongsTo(Team::class); }.

  • Step 14: Run the Action tests until green

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/UsageRateActionsTest.php tests/Unit/Billing/RateTest.php Expected: PASS, 22 tests.

  • Step 15: Run the whole billing surface, PHPStan and Pint
bash
herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing tests/Unit/Billing tests/Feature/SuperAdmin
herd php -d memory_limit=1G vendor/bin/phpstan analyse app/Actions/Billing app/Enums app/Models/Billing app/Support/Billing --no-progress
vendor/bin/pint --dirty --format agent

All three green before committing. PHPStan output in this environment is JSON regardless of --error-format; that is expected and the output is complete.

  • Step 16: Commit
bash
git add app/Enums/UsageService.php app/Enums/UsageUnit.php app/Support/Billing/Rate.php \
        app/Actions/Billing/SaveUsageRate.php app/Actions/Billing/DeleteScheduledRate.php \
        app/Models/Billing/UsageRate.php database/migrations database/seeders \
        tests/Unit/Billing/RateTest.php tests/Feature/Billing/UsageRateActionsTest.php
git commit -m "Billing: price the metered services in millicents"

Task 2: The Rates screen

Files:

  • Create: app/Http/Controllers/SuperAdmin/Billing/RateController.php, app/Http/Requests/SuperAdmin/Billing/SaveUsageRateRequest.php, resources/js/pages/superadmin/billing/Rates.vue
  • Modify: routes/web/superadmin.php, resources/js/types/billing.ts, resources/js/pages/superadmin/billing/Overview.vue
  • Test: tests/Feature/SuperAdmin/Billing/RatesScreenTest.php

Interfaces:

  • Consumes: SaveUsageRate::handle(), DeleteScheduledRate::handle(), UsageService, UsageUnit, Rate::format(), Rate::toInput() (all from Task 1).

  • Produces: routes superadmin.billing.rates.index, superadmin.billing.rates.store, superadmin.billing.rates.destroy.

  • Step 1: Write the failing screen test

Create tests/Feature/SuperAdmin/Billing/RatesScreenTest.php:

php
<?php

namespace Tests\Feature\SuperAdmin\Billing;

use App\Enums\UsageService;
use App\Models\Billing\UsageRate;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;

class RatesScreenTest extends TestCase
{
    use RefreshDatabase;

    private function superAdmin(): User
    {
        return User::create([
            'name' => 'Rita Ops',
            'email' => '[email protected]',
            'password' => bcrypt('password'),
            'superAdmin' => true,
        ]);
    }

    private function seedDefault(UsageService $service, int $millicents, ?Carbon $from = null, ?int $teamId = null): UsageRate
    {
        return UsageRate::create([
            'service' => $service->value,
            'team_id' => $teamId,
            'unit' => $service->unit()->value,
            'unit_amount_millicents' => $millicents,
            'effective_from' => $from ?? Carbon::now()->subMonth(),
        ]);
    }

    public function test_a_non_superadmin_cannot_reach_the_screen(): void
    {
        $user = User::create([
            'name' => 'Nadia Clerk',
            'email' => '[email protected]',
            'password' => bcrypt('password'),
        ]);

        $this->actingAs($user)->get('/superadmin/billing/rates')->assertForbidden();
    }

    public function test_it_renders_the_rate_in_force_for_each_service(): void
    {
        $this->seedDefault(UsageService::Sms, 4500);
        $this->seedDefault(UsageService::AiTokensInput, 300000);

        $this->actingAs($this->superAdmin())
            ->get('/superadmin/billing/rates')
            ->assertOk()
            ->assertInertia(fn (AssertableInertia $page) => $page
                ->component('superadmin/billing/Rates')
                ->where('services.0.service', 'sms')
                ->where('services.0.current.amount', '$0.045')
                ->where('services.0.unit_label', 'message')
            );
    }

    public function test_it_shows_a_scheduled_successor_separately_from_the_rate_in_force(): void
    {
        $this->seedDefault(UsageService::Sms, 4500);
        $this->seedDefault(UsageService::Sms, 5000, Carbon::now()->addMonth());

        $this->actingAs($this->superAdmin())
            ->get('/superadmin/billing/rates')
            ->assertOk()
            ->assertInertia(fn (AssertableInertia $page) => $page
                ->where('services.0.current.amount', '$0.045')
                ->where('services.0.scheduled.0.amount', '$0.050')
            );
    }

    public function test_a_service_with_no_rate_yet_renders_without_crashing(): void
    {
        $this->actingAs($this->superAdmin())
            ->get('/superadmin/billing/rates')
            ->assertOk()
            ->assertInertia(fn (AssertableInertia $page) => $page
                ->where('services.0.current', null)
            );
    }

    public function test_it_lists_team_overrides(): void
    {
        $team = Team::create(['name' => 'Acme Logistics', 'slug' => 'acme-logistics', 'is_personal' => false]);
        $this->seedDefault(UsageService::Sms, 4000, null, $team->id);

        $this->actingAs($this->superAdmin())
            ->get('/superadmin/billing/rates')
            ->assertOk()
            ->assertInertia(fn (AssertableInertia $page) => $page
                ->where('overrides.0.team.name', 'Acme Logistics')
                ->where('overrides.0.amount', '$0.040')
            );
    }

    public function test_it_saves_a_new_platform_rate(): void
    {
        $this->actingAs($this->superAdmin())
            ->post('/superadmin/billing/rates', [
                'service' => 'sms',
                'amount' => '0.050',
                'effective_from' => Carbon::now()->addMonth()->toDateString(),
            ])
            ->assertRedirect('/superadmin/billing/rates');

        $this->assertSame(5000, UsageRate::query()->sole()->unit_amount_millicents);
    }

    public function test_it_rejects_a_backdated_rate(): void
    {
        $this->actingAs($this->superAdmin())
            ->post('/superadmin/billing/rates', [
                'service' => 'sms',
                'amount' => '0.050',
                'effective_from' => Carbon::now()->subMonth()->toDateString(),
            ])
            ->assertSessionHasErrors('effective_from');

        $this->assertSame(0, UsageRate::query()->count());
    }

    public function test_it_rejects_an_amount_that_is_not_a_plain_decimal(): void
    {
        $this->actingAs($this->superAdmin())
            ->post('/superadmin/billing/rates', [
                'service' => 'sms',
                'amount' => '$0.050',
                'effective_from' => Carbon::now()->addMonth()->toDateString(),
            ])
            ->assertSessionHasErrors('amount');
    }

    public function test_it_deletes_a_scheduled_rate(): void
    {
        $rate = $this->seedDefault(UsageService::Sms, 5000, Carbon::now()->addMonth());

        $this->actingAs($this->superAdmin())
            ->delete("/superadmin/billing/rates/{$rate->id}")
            ->assertRedirect('/superadmin/billing/rates');

        $this->assertSame(0, UsageRate::query()->count());
    }

    public function test_it_refuses_to_delete_a_rate_in_force(): void
    {
        $rate = $this->seedDefault(UsageService::Sms, 4500);

        $this->actingAs($this->superAdmin())
            ->delete("/superadmin/billing/rates/{$rate->id}")
            ->assertStatus(422);

        $this->assertSame(1, UsageRate::query()->count());
    }
}
  • Step 2: Run and watch it fail

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/SuperAdmin/Billing/RatesScreenTest.php Expected: FAIL — 404, the route does not exist.

  • Step 3: Write the FormRequest

Create app/Http/Requests/SuperAdmin/Billing/SaveUsageRateRequest.php:

php
<?php

namespace App\Http\Requests\SuperAdmin\Billing;

use App\Enums\UsageService;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

/**
 * `unit` is deliberately absent: it comes from the service, so a client
 * cannot pair SMS with a token unit.
 *
 * The amount allows five decimal places because rates are millicents — see
 * `Rate::toMillicents()`. It allows no leading `-`: there is no such thing as
 * being paid to send an SMS.
 */
class SaveUsageRateRequest extends FormRequest
{
    /**
     * @return array<string, ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'service' => ['required', Rule::enum(UsageService::class)],
            'amount' => ['required', 'string', 'regex:/^\d{1,9}(\.\d{1,5})?$/'],
            'effective_from' => ['required', 'date', 'after_or_equal:today'],
            'team_id' => ['nullable', 'integer', 'exists:teams,id'],
        ];
    }

    /**
     * @return array<string, string>
     */
    public function messages(): array
    {
        return [
            'amount.regex' => 'Enter the rate in dollars, with at most five decimal places and no symbols — for example 0.045.',
            'effective_from.after_or_equal' => 'A rate cannot start in the past — it would change what a moment that has already passed resolved to.',
        ];
    }
}
  • Step 4: Write the controller

Create app/Http/Controllers/SuperAdmin/Billing/RateController.php:

php
<?php

namespace App\Http\Controllers\SuperAdmin\Billing;

use App\Actions\Billing\DeleteScheduledRate;
use App\Actions\Billing\SaveUsageRate;
use App\Enums\UsageService;
use App\Http\Controllers\Controller;
use App\Http\Requests\SuperAdmin\Billing\SaveUsageRateRequest;
use App\Models\Billing\UsageRate;
use App\Models\Team;
use App\Support\Billing\Rate;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Inertia\Inertia;
use Inertia\Response;

/**
 * The price list (design doc, "2d in detail" → "The Rates screen").
 *
 * Every figure leaves here as a formatted string. `Rate::format()` is the only
 * thing that turns millicents into dollars, and no `.vue` file does arithmetic
 * on a rate — 4500 means $0.045 here and $45.00 in `Money`, so the conversion
 * never happens twice.
 */
class RateController extends Controller
{
    public function index(): Response
    {
        $now = Carbon::now();

        $rates = UsageRate::query()
            ->with('team:id,name,slug')
            ->orderBy('service')
            ->orderByDesc('effective_from')
            ->get();

        $platform = $rates->whereNull('team_id');

        $services = array_map(function (UsageService $service) use ($platform, $now) {
            $forService = $platform->where('service', $service);

            $current = $forService->first(fn (UsageRate $rate) => ! $rate->effective_from->isAfter($now));
            $scheduled = $forService->filter(fn (UsageRate $rate) => $rate->effective_from->isAfter($now));

            return [
                'service' => $service->value,
                'label' => $service->label(),
                'unit' => $service->unit()->value,
                'unit_label' => $service->unit()->perLabel(),
                'current' => $current === null ? null : self::row($current),
                'scheduled' => $scheduled->map(fn (UsageRate $rate) => self::row($rate))->values()->all(),
            ];
        }, UsageService::cases());

        return Inertia::render('superadmin/billing/Rates', [
            'services' => $services,
            'overrides' => $rates->whereNotNull('team_id')->map(fn (UsageRate $rate) => self::row($rate) + [
                'team' => [
                    'id' => $rate->team?->id,
                    'name' => $rate->team?->name,
                    'slug' => $rate->team?->slug,
                ],
                'service' => $rate->service->value,
                'service_label' => $rate->service->label(),
                'unit_label' => $rate->unit->perLabel(),
            ])->values()->all(),
            'serviceOptions' => array_map(
                fn (UsageService $service) => [
                    'value' => $service->value,
                    'label' => $service->label(),
                    'unit_label' => $service->unit()->perLabel(),
                ],
                UsageService::cases(),
            ),
        ]);
    }

    public function store(SaveUsageRateRequest $request, SaveUsageRate $saveUsageRate): RedirectResponse
    {
        $saveUsageRate->handle(
            UsageService::from($request->string('service')->value()),
            (string) $request->string('amount'),
            $request->date('effective_from'),
            $request->user(),
            $request->integer('team_id') === 0 ? null : Team::findOrFail($request->integer('team_id')),
        );

        Inertia::flash('toast', ['type' => 'success', 'message' => __('Rate saved.')]);

        return to_route('superadmin.billing.rates.index');
    }

    public function destroy(Request $request, UsageRate $rate, DeleteScheduledRate $deleteScheduledRate): RedirectResponse
    {
        $deleteScheduledRate->handle($rate, $request->user());

        Inertia::flash('toast', ['type' => 'success', 'message' => __('Scheduled rate removed.')]);

        return to_route('superadmin.billing.rates.index');
    }

    /**
     * @return array{id: int, amount: string, amount_input: string, effective_from: string, is_scheduled: bool}
     */
    private static function row(UsageRate $rate): array
    {
        return [
            'id' => $rate->id,
            'amount' => Rate::format($rate->unit_amount_millicents),
            'amount_input' => Rate::toInput($rate->unit_amount_millicents),
            'effective_from' => $rate->effective_from->toDateString(),
            'is_scheduled' => $rate->effective_from->isAfter(Carbon::now()),
        ];
    }
}
  • Step 5: Register the routes

In routes/web/superadmin.php, add the import alongside the other billing controllers:

php
use App\Http\Controllers\SuperAdmin\Billing\RateController;

and inside the Route::prefix('billing')->name('billing.') group, below the teams routes so rates cannot be read as a team slug:

php
            Route::get('rates', [RateController::class, 'index'])->name('rates.index');
            Route::post('rates', [RateController::class, 'store'])->name('rates.store');
            Route::delete('rates/{rate}', [RateController::class, 'destroy'])->name('rates.destroy');

Verify live, do not just read the file:

bash
herd php artisan route:list --path=billing

billing/rates must appear as its own literal and must not be shadowed by billing/teams/{team}.

  • Step 6: Generate Wayfinder and read what it produced
bash
herd php artisan wayfinder:generate --with-form
ls -R resources/js/routes/superadmin/billing

Import what is actually there — do not guess the names.

  • Step 7: Add the types

Append to resources/js/types/billing.ts:

ts
export type BillingRateRow = {
    id: number;
    amount: string;
    amount_input: string;
    effective_from: string;
    is_scheduled: boolean;
};

export type BillingRateService = {
    service: string;
    label: string;
    unit: string;
    unit_label: string;
    current: BillingRateRow | null;
    scheduled: BillingRateRow[];
};

export type BillingRateOverride = BillingRateRow & {
    team: { id: number; name: string; slug: string };
    service: string;
    service_label: string;
    unit_label: string;
};

export type BillingRateServiceOption = {
    value: string;
    label: string;
    unit_label: string;
};

Check resources/js/types/index.ts re-exports ./billing; if the existing billing types are reached through the barrel, these are too, and no change is needed.

  • Step 8: Write Rates.vue

Create resources/js/pages/superadmin/billing/Rates.vue. Two cards, matching Overview.vue's conventions: Head, Heading, breadcrumbs via defineOptions, types from @/types, Bootstrap markup, no arithmetic.

vue
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import Heading from '@/components/Heading.vue';
import { index as superadminIndex } from '@/routes/superadmin';
import { index as billingIndex } from '@/routes/superadmin/billing';
import { store as ratesStore, destroy as ratesDestroy } from '@/routes/superadmin/billing/rates';
import type { BillingRateOverride, BillingRateService, BillingRateServiceOption } from '@/types';

const props = defineProps<{
    services: BillingRateService[];
    overrides: BillingRateOverride[];
    serviceOptions: BillingRateServiceOption[];
}>();

defineOptions({
    layout: {
        breadcrumbs: [
            { title: 'Superadmin', href: superadminIndex() },
            { title: 'Billing', href: billingIndex() },
            { title: 'Rates', href: '' },
        ],
    },
});

const showForm = ref(false);

const form = useForm({
    service: props.serviceOptions[0]?.value ?? '',
    amount: '',
    effective_from: '',
    team_id: null as number | null,
});

const submit = () => {
    form.post(ratesStore().url, {
        preserveScroll: true,
        onSuccess: () => {
            form.reset();
            showForm.value = false;
        },
    });
};
</script>

<template>
    <Head title="Usage rates" />

    <Heading variant="small" title="Usage rates" description="What each metered service costs" class="mb-6" />

    <p class="text-muted mb-6">
        Rates are effective-dated. Changing one schedules a new price from a date you choose — it never rewrites what a past
        period cost. Nothing meters usage until usage billing ships; these prices are what it will charge against.
    </p>

    <div class="card mb-6">
        <div class="card-header">
            <h3 class="card-title">Platform defaults</h3>
            <div class="card-toolbar">
                <button type="button" class="btn btn-sm btn-primary" @click="showForm = !showForm">Change a rate</button>
            </div>
        </div>
        <div class="card-body">
            <form v-if="showForm" class="row g-4 mb-6 border-bottom pb-6" @submit.prevent="submit">
                <div class="col-md-3">
                    <label class="form-label required">Service</label>
                    <select v-model="form.service" class="form-select">
                        <option v-for="option in serviceOptions" :key="option.value" :value="option.value">
                            {{ option.label }}
                        </option>
                    </select>
                    <div v-if="form.errors.service" class="text-danger fs-7 mt-1">{{ form.errors.service }}</div>
                </div>
                <div class="col-md-3">
                    <label class="form-label required">Price</label>
                    <input v-model="form.amount" type="text" class="form-control" placeholder="0.045" />
                    <div v-if="form.errors.amount" class="text-danger fs-7 mt-1">{{ form.errors.amount }}</div>
                </div>
                <div class="col-md-3">
                    <label class="form-label required">Starts</label>
                    <input v-model="form.effective_from" type="date" class="form-control" />
                    <div v-if="form.errors.effective_from" class="text-danger fs-7 mt-1">{{ form.errors.effective_from }}</div>
                </div>
                <div class="col-md-3 d-flex align-items-end">
                    <button type="submit" class="btn btn-primary" :disabled="form.processing">Save rate</button>
                </div>
            </form>

            <div class="table-responsive">
                <table class="table align-middle">
                    <thead>
                        <tr class="fw-semibold text-muted">
                            <th>Service</th>
                            <th>Price</th>
                            <th>In force since</th>
                        </tr>
                    </thead>
                    <tbody>
                        <template v-for="service in services" :key="service.service">
                            <tr>
                                <td>{{ service.label }}</td>
                                <td v-if="service.current">{{ service.current.amount }} / {{ service.unit_label }}</td>
                                <td v-else class="text-muted">No rate set</td>
                                <td>{{ service.current?.effective_from ?? '—' }}</td>
                            </tr>
                            <tr v-for="row in service.scheduled" :key="row.id" class="text-muted">
                                <td></td>
                                <td>{{ row.amount }} / {{ service.unit_label }}</td>
                                <td>
                                    Scheduled from {{ row.effective_from }}
                                    <Link
                                        :href="ratesDestroy(row.id).url"
                                        method="delete"
                                        as="button"
                                        type="button"
                                        class="btn btn-sm btn-light-danger ms-3"
                                        preserve-scroll
                                    >
                                        Cancel
                                    </Link>
                                </td>
                            </tr>
                        </template>
                    </tbody>
                </table>
            </div>
        </div>
    </div>

    <div class="card">
        <div class="card-header">
            <h3 class="card-title">Team overrides</h3>
        </div>
        <div class="card-body">
            <p v-if="overrides.length === 0" class="text-muted mb-0">No team is on a negotiated rate.</p>
            <div v-else class="table-responsive">
                <table class="table align-middle">
                    <thead>
                        <tr class="fw-semibold text-muted">
                            <th>Organization</th>
                            <th>Service</th>
                            <th>Price</th>
                            <th>In force since</th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr v-for="override in overrides" :key="override.id">
                            <td>{{ override.team.name }}</td>
                            <td>{{ override.service_label }}</td>
                            <td>{{ override.amount }} / {{ override.unit_label }}</td>
                            <td>{{ override.effective_from }}</td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>
    </div>
</template>
  • Step 9: Link Rates from the Overview

In resources/js/pages/superadmin/billing/Overview.vue, add a "Usage rates" link beside the existing "View all" teams link, importing index as billingRatesIndex from the generated @/routes/superadmin/billing/rates.

  • Step 10: Build, then run the screen tests
bash
npm run build
herd php -d memory_limit=2G artisan test --compact tests/Feature/SuperAdmin/Billing/RatesScreenTest.php

Expected: PASS, 10 tests. A ViteException here means the build did not run.

  • Step 11: Full check and commit
bash
herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing tests/Unit/Billing tests/Feature/SuperAdmin
herd php -d memory_limit=1G vendor/bin/phpstan analyse app/Http/Controllers/SuperAdmin app/Http/Requests/SuperAdmin app/Actions/Billing app/Support/Billing --no-progress
vendor/bin/pint --dirty --format agent
npm run format && npm run lint
git add -A && git commit -m "Billing: a price list a superadmin can change without a deploy"

Revert the format/lint collateral in unrelated files before committing.


Task 3: billing:draft-enterprise-invoices

Files:

  • Create: app/Support/Billing/EnterpriseDraft.php, app/Console/Commands/DraftEnterpriseInvoices.php
  • Modify: app/Http/Controllers/SuperAdmin/Billing/InvoiceController.php, app/Actions/Billing/CreateInvoice.php, routes/console.php
  • Test: tests/Feature/Billing/DraftEnterpriseInvoicesTest.php

Interfaces:

  • Consumes: CreateInvoice::handle(Team, CarbonInterface, CarbonInterface, array $lines, ?User $actor): Invoice (nullable actor is this task's change).

  • Produces: EnterpriseDraft::periodFor(Team): array{0: CarbonInterface, 1: CarbonInterface}, EnterpriseDraft::formLinesFor(Team): list<array{..., amount: string}>, EnterpriseDraft::invoiceLinesFor(Team): list<array{..., amount_cents: int}>.

  • Step 1: Move the two statics into EnterpriseDraft

Create app/Support/Billing/EnterpriseDraft.php holding periodFor() and linesFor() — the exact bodies currently in InvoiceController::periodFor() and InvoiceController::draftLinesFor(), including their comments. Rename draftLinesFor to linesFor.

php
<?php

namespace App\Support\Billing;

use App\Enums\BillingCycle;
use App\Enums\BillingInvoiceLineKind;
use App\Models\Team;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;

/**
 * The shape of an enterprise team's next invoice (design doc, "2d in detail" →
 * "billing:draft-enterprise-invoices").
 *
 * 2b left these two rules public and static on `InvoiceController` so the
 * drafting command could reuse them. Reuse was right; the direction was not —
 * a console command must not depend on an HTTP controller. Both moved here,
 * and the controller became a caller like the command. They moved rather than
 * delegating: a forwarding static left behind is a second name for one rule.
 */
class EnterpriseDraft
{
    /**
     * @return array{0: CarbonInterface, 1: CarbonInterface}
     */
    public static function periodFor(Team $team): array
    {
        // ... body moved verbatim from InvoiceController::periodFor()
    }

    /**
     * @return list<array{kind: string, description: string, quantity: string, amount: string}>
     */
    public static function formLinesFor(Team $team): array
    {
        // ... body moved verbatim from InvoiceController::draftLinesFor(),
        // with the internal periodFor() call now resolving to self::
    }

    // plus invoiceLinesFor(), given in full in Step 6.
}

Then in InvoiceController: delete both statics, use App\Support\Billing\EnterpriseDraft;, and replace the two call sites in create() with EnterpriseDraft::periodFor($team) and EnterpriseDraft::formLinesFor($team). Update the class docblock — it currently says the methods are "written here so that command can reuse them"; that is no longer true.

  • Step 2: Prove the move changed nothing

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/SuperAdmin/Billing Expected: PASS, unchanged count. The 2b invoice-create tests exercise the moved code through the controller.

  • Step 3: Make CreateInvoice's actor nullable

In app/Actions/Billing/CreateInvoice.php:

  • User $actor becomes ?User $actor in the signature.

  • $actor->id becomes $actor?->id.

  • Add to the BillingEvent::record() payload: 'origin' => $actor === null ? 'command' : 'console',.

  • Extend the class docblock: a null actor means the drafting command created it, and an invoice nobody can be shown to have created must still say what made it.

  • Step 4: Write the failing command test

Create tests/Feature/Billing/DraftEnterpriseInvoicesTest.php:

php
<?php

namespace Tests\Feature\Billing;

use App\Enums\BillingCycle;
use App\Enums\BillingInvoiceStatus;
use App\Enums\BillingType;
use App\Models\Billing\Invoice;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;

class DraftEnterpriseInvoicesTest extends TestCase
{
    use RefreshDatabase;

    private function enterpriseTeam(string $name, ?Carbon $renewalAt, int $amountCents = 450000): Team
    {
        return Team::create([
            'name' => $name,
            'slug' => str($name)->slug()->value(),
            'is_personal' => false,
            'billing_type' => BillingType::Enterprise->value,
            'billing_enterprise_amount_cents' => $amountCents,
            'billing_enterprise_interval' => BillingCycle::Monthly->value,
            'billing_renewal_at' => $renewalAt,
        ]);
    }

    public function test_it_drafts_an_invoice_for_a_renewal_inside_the_window(): void
    {
        $team = $this->enterpriseTeam('Acme Logistics', Carbon::now()->addDays(10));

        $this->artisan('billing:draft-enterprise-invoices')->assertSuccessful();

        $invoice = Invoice::query()->sole();
        $this->assertSame($team->id, $invoice->team_id);
        $this->assertSame(BillingInvoiceStatus::Draft, $invoice->status);
        $this->assertNull($invoice->number);
        $this->assertSame(450000, $invoice->total_cents);
    }

    public function test_it_ignores_a_renewal_beyond_the_window(): void
    {
        $this->enterpriseTeam('Far Future', Carbon::now()->addDays(45));

        $this->artisan('billing:draft-enterprise-invoices')->assertSuccessful();

        $this->assertSame(0, Invoice::query()->count());
    }

    public function test_it_ignores_a_team_with_no_renewal_date(): void
    {
        $this->enterpriseTeam('No Renewal', null);

        $this->artisan('billing:draft-enterprise-invoices')->assertSuccessful();

        $this->assertSame(0, Invoice::query()->count());
    }

    public function test_it_ignores_a_non_enterprise_team(): void
    {
        Team::create([
            'name' => 'Self Serve',
            'slug' => 'self-serve',
            'is_personal' => false,
            'billing_type' => BillingType::SelfServe->value,
            'billing_renewal_at' => Carbon::now()->addDays(5),
        ]);

        $this->artisan('billing:draft-enterprise-invoices')->assertSuccessful();

        $this->assertSame(0, Invoice::query()->count());
    }

    /**
     * The property worth testing hardest: this runs every day for the thirty
     * days before a renewal, and must produce one draft, not thirty.
     */
    public function test_running_it_every_day_produces_one_draft(): void
    {
        $this->enterpriseTeam('Acme Logistics', Carbon::now()->addDays(10));

        $this->artisan('billing:draft-enterprise-invoices')->assertSuccessful();
        $this->artisan('billing:draft-enterprise-invoices')->assertSuccessful();

        Carbon::setTestNow(Carbon::now()->addDay());
        $this->artisan('billing:draft-enterprise-invoices')->assertSuccessful();

        Carbon::setTestNow(Carbon::now()->addDay());
        $this->artisan('billing:draft-enterprise-invoices')->assertSuccessful();

        Carbon::setTestNow();

        $this->assertSame(1, Invoice::query()->count());
    }

    public function test_it_skips_a_team_that_already_has_an_invoice_for_that_period_in_any_status(): void
    {
        $team = $this->enterpriseTeam('Acme Logistics', Carbon::now()->addDays(10));

        Invoice::create([
            'team_id' => $team->id,
            'source' => 'manual',
            'period_start' => $team->billing_renewal_at,
            'period_end' => $team->billing_renewal_at->copy()->addMonthNoOverflow()->subDay(),
            'subtotal_cents' => 450000,
            'total_cents' => 450000,
            'currency' => 'usd',
            'status' => BillingInvoiceStatus::Void->value,
        ]);

        $this->artisan('billing:draft-enterprise-invoices')->assertSuccessful();

        $this->assertSame(1, Invoice::query()->count());
    }

    public function test_dry_run_writes_nothing(): void
    {
        $this->enterpriseTeam('Acme Logistics', Carbon::now()->addDays(10));

        $this->artisan('billing:draft-enterprise-invoices --dry-run')->assertSuccessful();

        $this->assertSame(0, Invoice::query()->count());
    }

    public function test_the_days_option_widens_the_window(): void
    {
        $this->enterpriseTeam('Far Future', Carbon::now()->addDays(45));

        $this->artisan('billing:draft-enterprise-invoices --days=60')->assertSuccessful();

        $this->assertSame(1, Invoice::query()->count());
    }

    public function test_a_run_that_creates_nothing_says_so(): void
    {
        $this->artisan('billing:draft-enterprise-invoices')
            ->expectsOutputToContain('No enterprise renewals')
            ->assertSuccessful();
    }
}
  • Step 5: Run and watch it fail

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/DraftEnterpriseInvoicesTest.php Expected: FAIL — the command does not exist.

  • Step 6: Write the command

Create app/Console/Commands/DraftEnterpriseInvoices.php:

php
<?php

namespace App\Console\Commands;

use App\Actions\Billing\CreateInvoice;
use App\Enums\BillingType;
use App\Models\Billing\Invoice;
use App\Models\Team;
use App\Support\Billing\EnterpriseDraft;
use App\Support\Billing\Money;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;

/**
 * Drafts the next invoice for every enterprise team renewing soon (design
 * doc, "Enterprise invoicing" and "2d in detail").
 *
 * Idempotent on the renewal date: a team that already has an invoice in any
 * status for that period is skipped, so running daily for thirty days
 * produces one draft rather than thirty. Drafts are created through
 * `CreateInvoice` rather than written here, so a drafted invoice is
 * indistinguishable from a hand-made one — and a human still has to issue it.
 *
 * Only actually runs once `schedule:run` is invoked by a real cron/systemd
 * timer in the deployment.
 */
class DraftEnterpriseInvoices extends Command
{
    protected $signature = 'billing:draft-enterprise-invoices
                            {--dry-run : Show what would be drafted without writing anything}
                            {--days=30 : How many days ahead to look for renewals}';

    protected $description = 'Draft renewal invoices for enterprise teams renewing soon';

    public function handle(CreateInvoice $createInvoice): int
    {
        $days = max(1, (int) $this->option('days'));
        $dryRun = (bool) $this->option('dry-run');
        $horizon = Carbon::now()->addDays($days);

        $rows = [];

        Team::query()
            ->where('billing_type', BillingType::Enterprise->value)
            ->whereNotNull('billing_renewal_at')
            ->whereNotNull('billing_enterprise_amount_cents')
            ->whereNotNull('billing_enterprise_interval')
            ->where('billing_renewal_at', '<=', $horizon)
            ->orderBy('billing_renewal_at')
            ->chunkById(200, function ($teams) use ($createInvoice, $dryRun, &$rows) {
                foreach ($teams as $team) {
                    [$periodStart, $periodEnd] = EnterpriseDraft::periodFor($team);

                    $exists = Invoice::query()
                        ->where('team_id', $team->id)
                        ->whereDate('period_start', $periodStart->toDateString())
                        ->exists();

                    if ($exists) {
                        $rows[] = [$team->name, $periodStart->toDateString(), '—', 'skipped — already invoiced'];

                        continue;
                    }

                    $lines = EnterpriseDraft::invoiceLinesFor($team);

                    if ($lines === []) {
                        $rows[] = [$team->name, $periodStart->toDateString(), '—', 'skipped — no plan amount'];

                        continue;
                    }

                    $amount = Money::format((int) $team->billing_enterprise_amount_cents);

                    if ($dryRun) {
                        $rows[] = [$team->name, $periodStart->toDateString(), $amount, 'would create'];

                        continue;
                    }

                    $createInvoice->handle($team, $periodStart, $periodEnd, $lines, null);

                    $rows[] = [$team->name, $periodStart->toDateString(), $amount, 'created'];
                }
            });

        if ($rows === []) {
            $this->info("No enterprise renewals due within {$days} day(s).");

            return self::SUCCESS;
        }

        $this->table(['Organization', 'Period start', 'Amount', 'Outcome'], $rows);

        if ($dryRun) {
            $this->comment('Dry run — nothing was written.');
        }

        return self::SUCCESS;
    }
}

The two line shapes, and why there are two. CreateInvoice::handle() takes amount_cents: int. The moved linesFor() produces amount: string, because its other caller is a Vue form where an operator edits the figure. Both are needed, so EnterpriseDraft names both and derives one from the other — the command must not convert ad hoc, and neither signature changes:

php
    /**
     * The draft as a form fills it in — amounts as editable decimal strings.
     *
     * @return list<array{kind: string, description: string, quantity: string, amount: string}>
     */
    public static function formLinesFor(Team $team): array
    {
        // ... the body moved from InvoiceController::draftLinesFor()
    }

    /**
     * The same draft as `CreateInvoice` takes it — amounts in integer cents.
     *
     * Derived from `formLinesFor()` rather than built separately, so the two
     * cannot drift, with `Money::toCents()` as the single conversion point.
     *
     * @return list<array{kind: string, description: string, quantity: string, amount_cents: int}>
     */
    public static function invoiceLinesFor(Team $team): array
    {
        return array_map(
            fn (array $line) => [
                'kind' => $line['kind'],
                'description' => $line['description'],
                'quantity' => $line['quantity'],
                'amount_cents' => Money::toCents($line['amount']),
            ],
            self::formLinesFor($team),
        );
    }

InvoiceController::create() calls formLinesFor(); this command calls invoiceLinesFor(). Update Step 1's skeleton accordingly — linesFor is formLinesFor.

  • Step 7: Run the command tests until green

Run: herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing/DraftEnterpriseInvoicesTest.php Expected: PASS, 9 tests.

  • Step 8: Schedule it

In routes/console.php, append, following the commenting convention of the entries above it:

php
// design doc, "Enterprise invoicing": a superadmin should not have to
// remember that Acme renews on the 14th. Daily rather than hourly because a
// renewal date is a date — running it more often would only re-skip the same
// teams. The command is idempotent on the renewal date, so thirty days of
// runs before one renewal produce one draft.
Schedule::command('billing:draft-enterprise-invoices')
    ->daily()
    ->description('Draft renewal invoices for enterprise teams renewing within 30 days');

Verify: herd php artisan schedule:list | grep billing

  • Step 9: Full check
bash
npm run build
herd php -d memory_limit=2G artisan test --compact tests/Feature/Billing tests/Unit/Billing tests/Feature/SuperAdmin
herd php -d memory_limit=1G vendor/bin/phpstan analyse app/Console/Commands app/Actions/Billing app/Support/Billing app/Http/Controllers/SuperAdmin --no-progress
vendor/bin/pint --dirty --format agent
  • Step 10: Whole suite against the baseline
bash
herd php -d memory_limit=2G vendor/bin/phpunit

Expected: the documented baseline — 24 failures + 3 errors, none in tests/Feature/Billing, tests/Unit/Billing or tests/Feature/SuperAdmin.

  • Step 11: Commit
bash
git add -A && git commit -m "Billing: draft enterprise renewals before anyone has to remember them"

Self-review notes

Checked while writing:

  • Spec coverage. Every subsection of "2d in detail" maps to a task: the precision problem and its three guards → Task 1 Steps 1–6; units and block sizes → Steps 3–4; the line-cost rule → Rate::lineCents() and its two tests; changing a rate and per-team overrides → Steps 12–13 and Task 2; the screen → Task 2; the command and the EnterpriseDraft extraction → Task 3.
  • Type consistency. Rate::format(int, string $currency = 'usd') takes no unit — see the correction below. SaveUsageRate::handle()'s ?Team is last and optional in both the tests and the controller. EnterpriseDraft::linesFor() is the renamed draftLinesFor() and is called by that new name everywhere.
  • The line-shape seam is closed, not flagged. CreateInvoice::handle() takes amount_cents: int; the moved draftLinesFor() produces amount: string for the Vue form. Rather than make the command convert ad hoc, EnterpriseDraft names both shapes and derives the cents one from the form one through Money::toCents(), so they cannot drift.
  • A pre-existing float on money, for the reviewer to rule on. CreateInvoice computes a line total as (int) round(((float) $line['quantity']) * $unit) — a float multiplication on cents, which the subsystem's own constraint forbids. 2d cannot trip it (an enterprise draft's quantity is always '1'), and fixing it is outside this plan's scope, but Task 3 modifies this exact method and the reviewer should decide whether it goes in here or into the spec's "Carried into later increments".
  • Corrected after Task 1's review: Rate::format() takes no $unit. The plan originally gave it one, arguing that call sites would then read as "this rate, in this unit". That was wrong — the stored amount already prices one block, the unit label is a separate string, and the parameter could never be read, which also made test_it_formats_against_the_unit_block pass with any unit at all. The signature is format(int $millicents, string $currency = 'usd'). Call sites must not start doing block arithmetic to compensate; nothing needs it.
  • Corrected after Task 1's review: SaveUsageRate normalizes effective_from to startOfDay(). A rate starts on a day, not at an instant. Without this the duplicate-check message says "that date" while the check compares exact timestamps, and Task 2 could break it by posting a time.