OCTO Ops Help User guides and product documentation

2026 09 11 Billing 3C Storage

On this page 8

Billing 3c — storage quota

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: Stop a team uploading past its storage quota, keep the figure honest, and warn before it bites.

Architecture: Every rule already exists — Entitlements::storageQuotaBytesFor() for the quota, Entitlement::isOverStorageQuota() for the decision, Entitlements::storagePercentUsedFor() for the percentage, Bytes::format() for display. 3c calls them at the four upload sites, keeps billing_storage_used_bytes current, and puts the figure on screen.

Tech Stack: Laravel 13, PHP 8.4, Inertia v3 + Vue 3, MySQL for billing, MongoDB for the uploads collection.

Spec: docs/superpowers/specs/2026-09-09-saas-monetization-design.md"Increment 3 in detail — enforcement" → "Storage quota" and "The banner", plus the top-level "Storage quota" section. The spec is the binding authority.

What this sub-increment has already learned

3a and 3b each shipped fail-open bugs and each needed rounds to close doors one at a time. Two rules carry forward and are not negotiable here:

  1. Enumerate the write sites up front, and never claim a completeness you have not verified. I ran the enumeration before writing this plan. There are exactly four places an Upload row is created, each immediately setting fileSize, and they pair 1:1:

    new Upload fileSize set
    app/Support/Uploads.php:109 :113
    app/Http/Controllers/Api/V1/Files/UploadController.php:50 :52
    …UploadController.php:112 :116
    …UploadController.php:158 :162

    If you add a guard, guard all four. If you find a fifth, that is a finding — say so rather than guarding it silently.

  2. A false docblock is worse than a gap, because it is what the next person trusts instead of checking. Say what is covered and what is not.

Global Constraints

  • PHP binary: plain php is MAMP's PHP 8.2 and too old. Use herd php artisan ....
  • Tests: herd php -d memory_limit=2G artisan test --compact <path>. For the whole suite you must use herd php -d memory_limit=2G vendor/bin/phpunitartisan test spawns a child through PhpExecutableFinder which drops -d and dies at 256 MB in TaskAttachmentTest. Never background or kill a whole-suite run: it corrupts this repo's shared MongoDB test state and produces hundreds of false failures.
  • No billing rule gets a second implementation. Entitlement::isOverStorageQuota() is the only over-quota comparison; Entitlements::storageQuotaBytesFor() the only quota derivation; Entitlements::storagePercentUsedFor() the only percentage; Bytes::format() the only byte formatter. Writing used >= quota or used / quota * 100 anywhere is a defect.
  • Enforcement fails closed. An upload whose team cannot be resolved is refused, not allowed.
  • The unit seam is this sub-increment's Money/Rate. uploads.fileSize is kilobytes stored as a 2dp float; teams.billing_storage_quota_bytes and billing_storage_used_bytes are integer bytes. StorageQuota owns that conversion and is the only place it happens. A float of kilobytes and an integer of bytes are exactly as confusable as 4500 cents and 4500 millicents were in 2d.
  • MongoDB has no transactions here — the test mongod is standalone. The reconciliation reads Mongo and writes MySQL; there is no transaction spanning both, and the command must not pretend otherwise.
  • Never cast a Mongo model field as 'array', and never pluck('_id') expecting strings — both are documented footguns in this repo.
  • No .vue file performs arithmetic on bytes or percentages. Formatted strings and a level arrive from the server.
  • No new model factories. TeamFactory/UserFactory exist and are required — Team::create() silently drops billing columns.
  • Formatting: vendor/bin/pint --dirty --format agent; npm run format/npm run lint for frontend changes, reverting repo-wide collateral.
  • Run nothing against any database but the test database.
  • Pre-existing failures you must NOT fix: 24 failures + 3 errors, nine ViteExceptions, known intermittents in ReturnsRegisterTest, UtilizationTest, AtpTest, Sales\OrderConfirmAuditTest, a pre-existing DepartmentTest failure, and a ~0.5% Faker flake in SuperAdmin\TeamCrudTest::test_index_lists_teams.

A decision this plan makes, and its consequence

The spec says "Over quota blocks new uploads only". That is a check on the state before the upload — isOverStorageQuota(), which is used >= quota — not "would this file take us over".

The consequence, stated so nobody treats it as a bug: a single upload can overshoot the quota. A team at 9.9 GB of 10 GB can upload a 5 GB file successfully, landing at 14.9 GB; every upload after that is refused. The alternative — refusing a file that would exceed — needs the incoming size in the decision and would mean a second comparison beside isOverStorageQuota().

Following the spec keeps one rule and one comparison. Do not "improve" it into a would-exceed check without changing the spec first.

File Structure

Task 1 — the guard:

  • app/Support/Billing/StorageQuota.php — the conversion and the two operations
  • the four upload sites above (modify)
  • tests/Feature/Billing/StorageQuotaTest.php

Task 2 — the figure and the warning:

  • app/Console/Commands/RecalculateStorage.php, routes/console.php (modify)
  • app/Http/Middleware/HandleInertiaRequests.php (modify — the lazy prop)
  • resources/js/layouts/AppLayout.vue (modify — the banner), resources/js/types/billing.ts (modify)
  • tests/Feature/Billing/RecalculateStorageTest.php, tests/Feature/Billing/StorageBannerTest.php

Task 1: The guard

Files:

  • Create: app/Support/Billing/StorageQuota.php, tests/Feature/Billing/StorageQuotaTest.php
  • Modify: app/Support/Uploads.php, app/Http/Controllers/Api/V1/Files/UploadController.php

Interfaces:

  • Consumes: Entitlements::for(?Team): Entitlement, Entitlement::isOverStorageQuota(): bool, Entitlements::flush().

  • Produces:

    • StorageQuota::assertCanStore(Team $team): void — aborts 413 when the team is already over
    • StorageQuota::kilobytesToBytes(float $kilobytes): int — the single conversion point
    • StorageQuota::record(Team $team, float $kilobytes): void — adds an upload's size to the team's used figure
  • Step 1: Write the failing test

Create tests/Feature/Billing/StorageQuotaTest.php. Cover at minimum:

php
public function test_a_team_under_quota_may_store(): void
public function test_a_team_at_exactly_its_quota_may_not_store(): void        // used >= quota
public function test_a_team_over_quota_may_not_store(): void
public function test_a_team_with_an_explicit_quota_uses_it(): void            // enterprise override
public function test_a_team_without_an_explicit_quota_gets_ten_gb_per_seat(): void
public function test_a_team_with_no_active_members_still_gets_one_seats_worth(): void  // the max(1, …) floor
public function test_recording_an_upload_converts_kilobytes_to_bytes(): void
public function test_recording_an_upload_rounds_a_fractional_kilobyte(): void
public function test_recording_an_upload_flushes_the_entitlement_cache(): void
public function test_recording_accumulates_across_uploads(): void

Seed with Team::factory()->create(['billing_storage_used_bytes' => …, 'billing_storage_quota_bytes' => …])never Team::create().

  • Step 2: Run it and watch it failClass "App\Support\Billing\StorageQuota" not found.

  • Step 3: Write StorageQuota

php
<?php

namespace App\Support\Billing;

use App\Models\Team;

/**
 * Whether a team may store another file, and what storing one costs it
 * (design doc, "Increment 3 in detail" → "Storage quota").
 *
 * **This class exists for the unit seam.** `uploads.fileSize` is kilobytes
 * held as a 2dp float; `teams.billing_storage_used_bytes` and
 * `billing_storage_quota_bytes` are integer bytes. Converting in more than
 * one place is how a figure ends up 1024x wrong — the same hazard `Money`
 * and `Rate` exist for, and the reason every caller hands this class
 * kilobytes and never does the multiplication itself.
 *
 * It decides nothing about quota on its own: `Entitlement::isOverStorageQuota()`
 * owns that comparison and `Entitlements::storageQuotaBytesFor()` owns the
 * quota. This class only knows *when* to ask and how to convert.
 */
class StorageQuota
{
    private const BYTES_PER_KILOBYTE = 1024;

    /**
     * Refuse a new upload when the team is already over quota.
     *
     * Deliberately a check on the state *before* the upload, per the spec's
     * "over quota blocks new uploads only" — not "would this file take us
     * over". One consequence, which is intended: a single upload can
     * overshoot, and every upload after it is refused.
     *
     * 413 rather than 403: the request is refused because of size, and an
     * API client reading status codes should be able to tell that from a
     * permission failure.
     */
    public static function assertCanStore(?Team $team): void
    {
        abort_if($team === null, 413, 'Storage cannot be attributed to an organization.');

        abort_if(
            Entitlements::for($team)->isOverStorageQuota(),
            413,
            'This organization has used all of its storage. Existing files are unaffected; free some space or contact us to add more.',
        );
    }

    /**
     * The single conversion point from what an upload records to what a team
     * accrues.
     */
    public static function kilobytesToBytes(float $kilobytes): int
    {
        return (int) round($kilobytes * self::BYTES_PER_KILOBYTE);
    }

    /**
     * Add a stored file to the team's running total.
     *
     * Incremented cheaply here and reconciled nightly by
     * `billing:recalculate-storage`, which is authoritative — this keeps the
     * figure fresh between runs, so drift is corrected within a day rather
     * than compounding.
     */
    public static function record(?Team $team, float $kilobytes): void
    {
        if ($team === null) {
            return;
        }

        $team->increment('billing_storage_used_bytes', self::kilobytesToBytes($kilobytes));

        Entitlements::flush();
    }
}
  • Step 4: Guard all four upload sites

At each of the four sites listed at the top of this plan: call assertCanStore() before the file is written, and record() after fileSize is set. Resolve the team the same way each site already resolves its tenant — read the surrounding code; Uploads.php and the three UploadController sites do not necessarily resolve it identically, and if any site cannot resolve a team, assertCanStore() refuses, which is the fail-closed behaviour this increment requires.

Add a comment at each site citing the spec section.

  • Step 5: Run the guard tests, then the broader surface
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/Support/Billing app/Support/Uploads.php app/Http/Controllers/Api/V1/Files --no-progress
vendor/bin/pint --dirty --format agent

Find the existing tests for these upload paths and confirm none was weakened.

  • Step 6: Commit
bash
git add app/Support/Billing/StorageQuota.php app/Support/Uploads.php \
        app/Http/Controllers/Api/V1/Files/UploadController.php \
        tests/Feature/Billing/StorageQuotaTest.php
git commit -m "Billing: stop a team uploading past its storage quota"

Task 2: The figure and the warning

Files:

  • Create: app/Console/Commands/RecalculateStorage.php, tests/Feature/Billing/RecalculateStorageTest.php, tests/Feature/Billing/StorageBannerTest.php
  • Modify: routes/console.php, app/Http/Middleware/HandleInertiaRequests.php, resources/js/layouts/AppLayout.vue, resources/js/types/billing.ts

Interfaces:

  • Consumes: StorageQuota::kilobytesToBytes() from Task 1, Entitlements::for(), Entitlement::storagePercentUsed(), Bytes::format().

  • Produces: billing:recalculate-storage; a shared Inertia prop carrying formatted storage state.

  • Step 1: Write the failing command test

tests/Feature/Billing/RecalculateStorageTest.php must cover: a team's used figure recomputed from its uploads; soft-deleted uploads excluded; uploads belonging to another team not counted; a team with no uploads set to zero rather than left stale; billing_storage_calculated_at stamped; and a --dry-run writing nothing.

Build Upload rows explicitly on the mongodb connection with a real oid and fileSize. Remember fileSize is kilobytes — a test seeding bytes there will assert a figure 1024× wrong and pass against equally wrong code.

  • Step 2: Write the command

billing:recalculate-storage, scheduled nightly in routes/console.php with withoutOverlapping() and a comment matching its neighbours' convention.

It aggregates uploads by oid, summing fileSize, excluding soft-deleted rows, then writes each team's billing_storage_used_bytes and billing_storage_calculated_at. Convert through StorageQuota::kilobytesToBytes() — do not multiply by 1024 in the command.

Structural requirements, each of which has bitten this project before:

  • No transaction spans Mongo and MySQL. Compute per team, write per team; a partial run leaves some teams reconciled and the next run fixes them. Say so in the docblock.

  • A team with uploads that are all soft-deleted, or none at all, must be set to zero — otherwise a stale figure keeps a team blocked forever after they delete files.

  • --dry-run prints and writes nothing.

  • Do not hold every team in memory; chunk.

  • Step 3: Write the failing banner test

tests/Feature/Billing/StorageBannerTest.php: no banner below 80%; a warning at 80%; a stronger one at 95%; the prop carries formatted strings and a level, never raw numbers; and a user with no team gets no banner rather than an error.

  • Step 4: Add the shared prop

In HandleInertiaRequests::share(), add a lazy prop (a closure, like currentTeam and modules beside it) carrying used, quota (both via Bytes::format()), percent and a level of null / 'warning' / 'critical'.

The percentage comes from Entitlements::storagePercentUsedFor()do not compute it here. The thresholds (80, 95) live in one place; name them.

  • Step 5: Render the banner

In AppLayout.vue, above the header, shown only when level is not null, dismissible for the session. It renders the strings it is given: no arithmetic, no formatting, no threshold logic in the .vue.

  • Step 6: Build, run, check and commit
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=2G vendor/bin/phpunit    # foreground, once, against baseline
herd php -d memory_limit=1G vendor/bin/phpstan analyse app/Console/Commands app/Http/Middleware app/Support/Billing --no-progress
vendor/bin/pint --dirty --format agent && npm run format && npm run lint

The whole-suite run matters: a shared Inertia prop executes on every page render in the suite.



Task 3: The 1 GB per-file limit

Added after the plan was written, at the product owner's request. It is not a billing rule — it is a hard ceiling on an individual file, applying to every team regardless of plan — but it lives here because it guards the same doors Task 1 just guarded.

Files:

  • Create: app/Http/Middleware/EnforceUploadSizeLimit.php, tests/Feature/Files/UploadSizeLimitTest.php
  • Modify: bootstrap/app.php, app/Support/Uploads.php (the shared limit), and the validation at every entry point below

Interfaces:

  • Produces: Uploads::MAX_FILE_BYTES (1073741824), Uploads::MAX_FILE_KILOBYTES, and a shared rule fragment every file field uses.

The enumeration, done before dispatch. Eleven entry points, in four families:

Family Sites
Files/Upload API Support/Uploads.php:111; Api/V1/Files/UploadController.php:52, :122, :177
Container Excel imports Containers/InwardsController.php:143, OutwardsController.php:107, DischargeListsController.php:45, ReservationsController.php:81
Project management ProjectManagement/TaskAttachmentController.php:37Actions/ProjectManagement/AttachFileToTask
Settings Settings/Modules/SystemController.php:98

app/Http/Requests/Files/UploadFileRequest.php:15 currently reads 'file' => ['required', 'file']no size rule at all. Several of the others validate the file loosely or not at all; read each before changing it.

If you find a twelfth entry point, that is a finding — report it, do not silently guard it.

  • Step 1: Write the failing tests

tests/Feature/Files/UploadSizeLimitTest.php must cover: a file under the limit is accepted; a file over it is refused with a message naming size; the refusal happens for each family above, not only the Files API; an API caller gets JSON rather than a redirect; and — the one that matters most — a request PHP itself truncated reports "too large", not "file is required".

Use UploadedFile::fake()->create('big.bin', $kilobytes), which fabricates the size without writing a gigabyte to disk. Note Laravel's max: for files is in kilobytes: 1 GB is 1048576. A test written in bytes will assert a limit 1024× wrong and pass against equally wrong code — the same unit hazard as Task 1's.

  • Step 2: Define the limit once

In app/Support/Uploads.php:

php
    /**
     * The largest single file anyone may upload, anywhere (design doc,
     * "Increment 3 in detail" → "The per-file upload limit").
     *
     * Not a billing rule: it applies to every team on every plan, and is
     * separate from the storage quota, which is cumulative and tolerates one
     * overshoot. Expressed in bytes here and in kilobytes for Laravel's
     * `max:` rule, which measures files in kilobytes — the conversion lives
     * here so no call site does it.
     */
    public const MAX_FILE_BYTES = 1_073_741_824;

    public const MAX_FILE_KILOBYTES = self::MAX_FILE_BYTES / 1024;
  • Step 3: Apply the rule at every entry point

Every file field gets the shared maximum. Where a FormRequest exists, add it there with a message that names the limit in human terms ("Files must be 1 GB or smaller."). Where validation is inline, add it inline. Do not write 1048576 at a call site — reference the constant.

  • Step 4: Write the backstop middleware

EnforceUploadSizeLimit, registered globally on the web and api stacks in bootstrap/app.php beside EnsureTeamNotRestricted. It walks $request->allFiles() recursivelyattachments is an array of files, so a flat check misses the multi-upload path entirely — and refuses with 413 when any file exceeds the limit.

It must also handle the case Laravel cannot validate: PHP discarded the upload before the framework saw it. Two signals, and you need both:

  • an UploadedFile whose getError() is UPLOAD_ERR_INI_SIZEupload_max_filesize exceeded;
  • an empty $_POST and empty $_FILES with a CONTENT_LENGTH above post_max_size — the whole body was dropped, which is why the field reads as missing.

In both cases report too-large, not missing. JSON for API callers, using the predicate this application already uses ($request->is('api/*') || $request->expectsJson()), as EnsureTeamNotRestricted does.

  • Step 5: Document what deployment must change

The limit is not reachable until upload_max_filesize, post_max_size and the web server's body limit allow it — today PHP caps at 20 MB. State the required values in the middleware's docblock. Do not edit any php.ini or server config; that is the operator's to do per environment.

  • Step 6: Run, check and commit
bash
herd php -d memory_limit=2G artisan test --compact tests/Feature/Files tests/Feature/Billing tests/Unit/Billing tests/Feature/SuperAdmin tests/Feature/Containers tests/Feature/ProjectManagement
herd php -d memory_limit=2G vendor/bin/phpunit    # foreground, once — a global middleware runs on every request in the suite
herd php -d memory_limit=1G vendor/bin/phpstan analyse app/Http/Middleware app/Support app/Http/Requests --no-progress
vendor/bin/pint --dirty --format agent

A globally-registered middleware touching every upload will surface in the import and attachment suites before it surfaces in billing's. Expect that, and fix the tests' fixtures rather than loosening the limit.

Self-review notes

  • Spec coverage. "Storage quota" → Task 1 (the four sites) and Task 2 (the nightly reconciliation). "The banner" → Task 2. Nothing from 3a or 3b leaks in.
  • The enumeration is done and verified, not asserted: four new Upload sites, four fileSize writes, pairing 1:1. That is the discipline 3b's Task 1 needed four rounds to arrive at, applied before the first dispatch this time.
  • One decision is mine and stated with its cost: the over-quota check is on the state before the upload, per the spec, so a single file can overshoot. Recorded so it is not mistaken for a bug or "improved" into a second comparison.
  • The riskiest step is Task 2 Step 4. A shared Inertia prop runs on every authenticated page render in the suite. It must be lazy, and it must not throw for a user with no team — 3a's middleware had exactly that shape of bug.
  • A known gap, deliberate: nothing here tells a team which files are large, or offers to delete any. Seeing usage is increment 6's customer-facing work; 3c only stops the bleeding and warns.