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
phpis MAMP's PHP 8.2 and too old. Artisan runs asherd php artisan .... - Tests:
herd php -d memory_limit=2G artisan test --compact <path>; whole suiteherd php -d memory_limit=2G vendor/bin/phpunit(artisan testspawns 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 toMoney, or an amount toRate. - Amounts are integers — never floats. String parsing on the way in (
Money::toCents,Rate::toMillicents), integer arithmetic throughout.0.045 * 1000is not 45 in IEEE 754. - No
.vuefile 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;
sqlitein tests). Never MongoDB. - No new model factories. Build rows with explicit
Model::create([...]). - No Action performs authorization. Every console route sits inside the existing
EnsureSuperAdmingroup. - Wayfinder:
herd php artisan wayfinder:generate --with-form. The--with-formflag is mandatory — the bare command strips.form()and breaks existing pages. npm run buildbefore any Inertia render test. A stale Vite manifest throws aViteExceptionthat looks like a code error and is not.- Formatting:
vendor/bin/pint --dirty --format agent, thennpm run formatandnpm run lint. Both rewrite unrelated files repo-wide — revert the collateral so the commit stays scoped. ESLint's pre-existing errors live inresources/js/pages/maintenance/**; leave them. - Frontend conventions: Bootstrap cards, the
Headingcomponent, breadcrumbs viadefineOptions, types imported from@/types(the barrel, not a deep path). No DataTables, noFilterDrawer. - 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/Billingandtests/Feature/SuperAdminare 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, mirroringMoney.database/migrations/2026_09_11_100000_rename_usage_rate_amount_to_millicents.phpapp/Models/Billing/UsageRate.php— casts follow the renamed column (modify).database/seeders/BillingUsageRatesSeeder.php+ a call indatabase/seeders/DatabaseSeeder.php.app/Actions/Billing/SaveUsageRate.php,app/Actions/Billing/DeleteScheduledRate.php.
Task 2 — the Rates screen:
app/Http/Controllers/SuperAdmin/Billing/RateController.phpapp/Http/Requests/SuperAdmin/Billing/SaveUsageRateRequest.phproutes/web/superadmin.php(modify),resources/js/types/billing.ts(modify)resources/js/pages/superadmin/billing/Rates.vueresources/js/pages/superadmin/billing/Overview.vue(modify — a link to Rates)
Task 3 — the command:
app/Support/Billing/EnterpriseDraft.phpapp/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(): stringUsageService::unit(): UsageUnit,UsageService::label(): stringRate::toMillicents(string): int,Rate::toInput(int): string,Rate::format(int $millicents, string $currency = 'usd'): string,Rate::lineCents(int $quantity, int $unitAmountMillicents, UsageUnit $unit): intSaveUsageRate::handle(UsageService $service, string $amount, CarbonInterface $effectiveFrom, User $actor, ?Team $team = null): UsageRateDeleteScheduledRate::handle(UsageRate $rate, User $actor): void
-
Step 1: Write the failing unit test for
Rate
Create tests/Unit/Billing/RateTest.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
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
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
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
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
UsageRatemodel
In app/Models/Billing/UsageRate.php, rename the property in the docblock and the cast:
* @property int $unit_amount_millicents
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
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
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
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
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
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
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
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
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
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:
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:
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:
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
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:
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.
<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
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
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
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 $actorbecomes?User $actorin the signature. -
$actor->idbecomes$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
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
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:
/**
* 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:
// 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
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
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
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 theEnterpriseDraftextraction → Task 3. - Type consistency.
Rate::format(int, string $currency = 'usd')takes no unit — see the correction below.SaveUsageRate::handle()'s?Teamis last and optional in both the tests and the controller.EnterpriseDraft::linesFor()is the renameddraftLinesFor()and is called by that new name everywhere. - The line-shape seam is closed, not flagged.
CreateInvoice::handle()takesamount_cents: int; the moveddraftLinesFor()producesamount: stringfor the Vue form. Rather than make the command convert ad hoc,EnterpriseDraftnames both shapes and derives the cents one from the form one throughMoney::toCents(), so they cannot drift. - A pre-existing float on money, for the reviewer to rule on.
CreateInvoicecomputes 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 madetest_it_formats_against_the_unit_blockpass with any unit at all. The signature isformat(int $millicents, string $currency = 'usd'). Call sites must not start doing block arithmetic to compensate; nothing needs it. - Corrected after Task 1's review:
SaveUsageRatenormalizeseffective_fromtostartOfDay(). 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.