output:
Wall time: 4.5240 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Scout Installation Queueing\nScout | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scout.md\n\n\n### Queueing\n\nWhen using an engine that is not the `database` or `collection` engine, you should strongly consider configuring a [queue driver](/docs/{{version}}/queues) before using the library. Running a queue worker will allow Scout to queue all operations that sync your model information to your search indexes, providing much better response times for your application's web interface.\n\nOnce you have configured a queue driver, set the value of the `queue` option in your `config/scout.php` configuration file to `true`:\n\n```php\n'queue' => true,\n```\n\nEven when the `queue` option is set to `false`, it's important to remember that some Scout drivers like Algolia and Meilisearch always index records asynchronously. In other words, even though the index operation has completed within your Laravel application, the search engine itself may not reflect the new and updated records immediately.\n\nTo specify the connection and queue that your Scout jobs utilize, you may define the `queue` configuration option as an array:\n\n```php\n'queue' => [\n    'connection' => 'redis',\n    'queue' => 'scout'\n],\n```\n\nOf course, if you customize the connection and queue that Scout jobs utilize, you should run a queue worker to process jobs on that connection and queue:\n\n```shell\nphp artisan queue:work redis --queue=scout\n```\n\n---\n\n## Verification Introduction Database Preparation\nVerification | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/verification.md\n\n\n### Database Preparation\n\nNext, your `users` table must contain an `email_verified_at` column to store the date and time that the user's email address was verified. Typically, this is included in Laravel's default `0001_01_01_000000_create_users_table.php` database migration.\n\n---\n\n## Urls URLs for Named Routes Eloquent Models\nUrls | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/urls.md\n\n\n#### Eloquent Models\n\nYou will often be generating URLs using the route key (typically the primary key) of [Eloquent models](/docs/{{version}}/eloquent). For this reason, you may pass Eloquent models as parameter values. The `route` helper will automatically extract the model's route key:\n\n```php\necho route('post.show', ['post' => $post]);\n```\n\n---\n\n## Scheduling Defining Schedules Scheduling Queued Jobs\nScheduling | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scheduling.md\n\n\n### Scheduling Queued Jobs\n\nThe `job` method may be used to schedule a [queued job](/docs/{{version}}/queues). This method provides a convenient way to schedule queued jobs without using the `call` method to define closures to queue the job:\n\n```php\nuse App\\Jobs\\Heartbeat;\nuse Illuminate\\Support\\Facades\\Schedule;\n\nSchedule::job(new Heartbeat)->everyFiveMinutes();\n```\n\nOptional second and third arguments may be provided to the `job` method which specifies the queue name and queue connection that should be used to queue the job:\n\n```php\nuse App\\Jobs\\Heartbeat;\nuse Illuminate\\Support\\Facades\\Schedule;\n\n// Dispatch the job to the \"heartbeats\" queue on the \"sqs\" connection...\nSchedule::job(new Heartbeat, 'heartbeats', 'sqs')->everyFiveMinutes();\n```\n\n---\n\n## Upgrade Upgrading To 12.0 From 11.x Database Database Constructor Signature Changes\nUpgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md\n\n\n#### Database Constructor Signature Changes\n\n**Likelihood Of Impact: Very Low**\n\nIn Laravel 12, several low-level database classes now require an `Illuminate\\Database\\Connection` instance to be provided via their constructors.\n\n**These changes are primarily applicable to database package maintainers - it is extremely unlikely any of these changes affect normal application development.**\n\n`Illuminate\\Database\\Schema\\Blueprint`\n\nThe constructor of the `Illuminate\\Database\\Schema\\Blueprint` class now expects a `Connection` instance as its first argument. This primarily affects applications or packages that manually instantiate `Blueprint` instances.\n\n`Illuminate\\Database\\Grammar`\n\nThe constructor of the `Illuminate\\Database\\Grammar` class also now requires a `Connection` instance. In previous versions, the connection was assigned after construction using the `setConnection()` method. This method has been removed in Laravel 12:\n\n```php\n// Laravel <= 11.x\n$grammar = new MySqlGrammar;\n$grammar->setConnection($connection);\n\n// Laravel >= 12.x\n$grammar = new MySqlGrammar($connection);\n````\n\nIn addition, the following APIs have been removed or deprecated:\n\n
\n\n- The `Blueprint::getPrefix()` method is deprecated.\n- The `Connection::withTablePrefix()` method has been removed.\n- The `Grammar::getTablePrefix()` and `setTablePrefix()` methods are deprecated.\n- The `Grammar::setConnection()` method has been removed.\n\n
\n\nWhen working with table prefixes, you should now retrieve them directly from the database connection:\n\n```php\n$prefix = $connection->getTablePrefix();\n```\n\nIf you maintain custom database drivers, schema builders, or grammar implementations, you should review their constructors and ensure a `Connection` instance is provided.\n\n---\n\n## Upgrade Upgrading To 12.0 From 11.x Eloquent Models and UUIDv7\nUpgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md\n\n\n#### Models and UUIDv7\n\n**Likelihood Of Impact: Medium**\n\nThe `HasUuids` trait now returns UUIDs that are compatible with version 7 of the UUID spec (ordered UUIDs). If you would like to continue using ordered UUIDv4 strings for your model's IDs, you should now use the `HasVersion4Uuids` trait:\n\n```php\nuse Illuminate\\Database\\Eloquent\\Concerns\\HasUuids; // [tl! remove]\nuse Illuminate\\Database\\Eloquent\\Concerns\\HasVersion4Uuids as HasUuids; // [tl! add]\n```\n\nThe `HasVersion7Uuids` trait has been removed. If you were previously using this trait, you should use the `HasUuids` trait instead, which now provides the same behavior.\n\n---\n\n## Database Database Transactions\nDatabase | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database.md\n\n\n## Database Transactions\n\nYou may use the `transaction` method provided by the `DB` facade to run a set of operations within a database transaction. If an exception is thrown within the transaction closure, the transaction will automatically be rolled back and the exception is re-thrown. If the closure executes successfully, the transaction will automatically be committed. You don't need to worry about manually rolling back or committing while using the `transaction` method:\n\n```php\nuse Illuminate\\Support\\Facades\\DB;\n\nDB::transaction(function () {\n    DB::update('update users set votes = 1');\n\n    DB::delete('delete from posts');\n});\n```\n\n---\n\n## Queues Job Batching\nQueues | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queues.md\n\n\n## Job Batching\n\nLaravel's job batching feature allows you to easily execute a group of jobs in parallel and then perform some action when the batch of jobs has completed executing.\n\nBefore getting started, you should create a database migration to build a table which will contain meta information about your job batches, such as their completion percentage. This migration may be generated using the `make:queue-batches-table` Artisan command:\n\n```shell\nphp artisan make:queue-batches-table\n\nphp artisan migrate\n```\n\n---\n\n## Eloquent Serialization Serializing Models and Collections Serializing to JSON Relationships\nEloquent Serialization | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-serialization.md\n\n\n#### Relationships\n\nWhen an Eloquent model is converted to JSON, its loaded relationships will automatically be included as attributes on the JSON object. Also, though Eloquent relationship methods are defined using \"camel case\" method names, a relationship's JSON attribute will be \"snake case\".\n\n---\n\n## Database Testing\nDatabase Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md\n\n# Database Testing\n\n- [Introduction](#introduction)\n    - [Resetting the Database After Each Test](#resetting-the-database-after-each-test)\n- [Model Factories](#model-factories)\n- [Running Seeders](#running-seeders)\n- [Available Assertions](#available-assertions)\n\n---\n\n## Queues Testing Testing Job / Queue Interactions\nQueues | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queues.md\n\n\n### Testing Job / Queue Interactions\n\nSometimes, you may need to test that a queued job [releases itself back onto the queue](#manually-releasing-a-job). Or, you may need to test that the job deleted itself. You may test these queue interactions by instantiating the job and invoking the `withFakeQueueInteractions` method.\n\nOnce the job's queue interactions have been faked, you may invoke the `handle` method on the job. After invoking the job, various assertion methods are available to verify the job's queue interactions:\n\n```php\nuse App\\Exceptions\\CorruptedAudioException;\nuse App\\Jobs\\ProcessPodcast;\n\n$job = (new ProcessPodcast)->withFakeQueueInteractions();\n\n$job->handle();\n\n$job->assertReleased(delay: 30);\n$job->assertDeleted();\n$job->assertNotDeleted();\n$job->assertFailed();\n$job->assertFailedWith(CorruptedAudioException::class);\n$job->assertNotFailed();\n```\n\n---\n\n## Queues Job Events\nQueues | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queues.md\n\n\n## Job Events\n\nUsing the `before` and `after` methods on the `Queue` [facade](/docs/{{version}}/facades), you may specify callbacks to be executed before or after a queued job is processed. These callbacks are a great opportunity to perform additional logging or increment statistics for a dashboard. Typically, you should call these methods from the `boot` method of a [service provider](/docs/{{version}}/providers). For example, we may use the `AppServiceProvider` that is included with Laravel:\n\n```php\n<?php\n\nnamespace App\\Providers;\n\nuse Illuminate\\Support\\Facades\\Queue;\nuse Illuminate\\Support\\ServiceProvider;\nuse Illuminate\\Queue\\Events\\JobProcessed;\nuse Illuminate\\Queue\\Events\\JobProcessing;\n\nclass AppServiceProvider extends ServiceProvider\n{\n    /**\n     * Register any application services.\n     */\n    public function register(): void\n    {\n        // ...\n    }\n\n    /**\n     * Bootstrap any application services.\n     */\n    public function boot(): void\n    {\n        Queue::before(function (JobProcessing $event) {\n            // $event->connectionName\n            // $event->job\n            // $event->job->payload()\n        });\n\n        Queue::after(function (JobProcessed $event) {\n            // $event->connectionName\n            // $event->job\n            // $event->job->payload()\n        });\n    }\n}\n```\n\nUsing the `looping` method on the `Queue` [facade](/docs/{{version}}/facades), you may specify callbacks that execute before the worker attempts to fetch a job from a queue. For example, you might register a closure to rollback any transactions that were left open by a previously failed job:\n\n```php\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Queue;\n\nQueue::looping(function () {\n    while (DB::transactionLevel() > 0) {\n        DB::rollBack();\n    }\n});\n```\n\n---\n\n## Upgrade Upgrading To 12.0 From 11.x Database Multi-Schema Database Inspecting\nUpgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md\n\n\n#### Multi-Schema Database Inspecting\n\n**Likelihood Of Impact: Low**\n\nThe `Schema::getTables()`, `Schema::getViews()`, and `Schema::getTypes()` methods now include the results from all schemas by default. You may pass the `schema` argument to retrieve the result for the given schema only:\n\n```php\n// All tables on all schemas...\n$tables = Schema::getTables();\n\n// All tables on the 'main' schema...\n$tables = Schema::getTables(schema: 'main');\n\n// All tables on the 'main' and 'blog' schemas...\n$tables = Schema::getTables(schema: ['main', 'blog']);\n```\n\nThe `Schema::getTableListing()` method now returns schema-qualified table names by default. You may pass the `schemaQualified` argument to change the behavior as desired:\n\n```php\n$tables = Schema::getTableListing();\n// ['main.migrations', 'main.users', 'blog.posts']\n\n$tables = Schema::getTableListing(schema: 'main');\n// ['main.migrations', 'main.users']\n\n$tables = Schema::getTableListing(schema: 'main', schemaQualified: false);\n// ['migrations', 'users']\n```\n\nThe `db:table` and `db:show` commands now output the results of all schemas on MySQL, MariaDB, and SQLite, just like PostgreSQL and SQL Server.\n\n---\n\n## Scout Searching Where Clauses Customizing the Eloquent Results Query\nScout | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scout.md\n\n\n#### Customizing the Eloquent Results Query\n\nAfter Scout retrieves a list of matching Eloquent models from your application's search engine, Eloquent is used to retrieve all of the matching models by their primary keys. You may customize this query by invoking the `query` method. The `query` method accepts a closure that will receive the Eloquent query builder instance as an argument:\n\n```php\nuse App\\Models\\Order;\nuse Illuminate\\Database\\Eloquent\\Builder;\n\n$orders = Order::search('Star Trek')\n    ->query(fn (Builder $query) => $query->with('invoices'))\n    ->get();\n```\n\nWhen using a third-party engine, this callback is invoked after the relevant models have already been retrieved from the search engine, so it should not be used for \"filtering\" results — use [Scout where clauses](#where-clauses) instead. However, when using the database engine, the `query` method's constraints are applied directly to the database query, so you may use it for filtering as well.\n\n---\n\n## Queues Testing Testing Job Batches Testing Job / Batch Interaction\nQueues | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queues.md\n\n\n#### Testing Job / Batch Interaction\n\nIn addition, you may occasionally need to test an individual job's interaction with its underlying batch. For example, you may need to test if a job cancelled further processing for its batch. To accomplish this, you need to assign a fake batch to the job via the `withFakeBatch` method. The `withFakeBatch` method returns a tuple containing the job instance and the fake batch:\n\n```php\n[$job, $batch] = (new ShipOrder)->withFakeBatch();\n\n$job->handle();\n\n$this->assertTrue($batch->cancelled());\n$this->assertEmpty($batch->added);\n```\n\n---\n\n## Queues Testing\nQueues | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queues.md\n\n\n## Testing\n\nWhen testing code that dispatches jobs, you may wish to instruct Laravel to not actually execute the job itself, since the job's code can be tested directly and separately of the code that dispatches it. Of course, to test the job itself, you may instantiate a job instance and invoke the `handle` method directly in your test.\n\nYou may use the `Queue` facade's `fake` method to prevent queued jobs from actually being pushed to the queue. After calling the `Queue` facade's `fake` method, you may then assert that the application attempted to push jobs to the queue:\n\n```php tab=Pest\n<?php\n\nuse App\\Jobs\\AnotherJob;\nuse App\\Jobs\\ShipOrder;\nuse Illuminate\\Support\\Facades\\Queue;\n\ntest('orders can be shipped', function () {\n    Queue::fake();\n\n    // Perform order shipping...\n\n    // Assert that no jobs were pushed...\n    Queue::assertNothingPushed();\n\n    // Assert a job was pushed to a given queue...\n    Queue::assertPushedOn('queue-name', ShipOrder::class);\n\n    // Assert a job was pushed\n    Queue::assertPushed(ShipOrder::class);\n\n    // Assert a job was pushed twice...\n    Queue::assertPushedTimes(ShipOrder::class, 2);\n\n    // Assert a job was not pushed...\n    Queue::assertNotPushed(AnotherJob::class);\n\n    // Assert that a closure was pushed to the queue...\n    Queue::assertClosurePushed();\n\n    // Assert that a closure was not pushed...\n    Queue::assertClosureNotPushed();\n\n    // Assert the total number of jobs that were pushed...\n    Queue::assertCount(3);\n});\n```\n\n```php tab=PHPUnit\n<?php\n\nnamespace Tests\\Feature;\n\nuse App\\Jobs\\AnotherJob;\nuse App\\Jobs\\ShipOrder;\nuse Illuminate\\Support\\Facades\\Queue;\nuse Tests\\TestCase;\n\nclass ExampleTest extends TestCase\n{\n    public function test_orders_can_be_shipped(): void\n    {\n        Queue::fake();\n\n        // Perform order shipping...\n\n        // Assert that no jobs were pushed...\n        Queue::assertNothingPushed();\n\n        // Assert a job was pushed to a given queue...\n        Queue::assertPushedOn('queue-name', ShipOrder::class);\n\n        // Assert a job was pushed\n        Queue::assertPushed(ShipOrder::class);\n\n        // Assert a job was pushed twice...\n        Queue::assertPushedTimes(ShipOrder::class, 2);\n\n        // Assert a job was not pushed...\n        Queue::assertNotPushed(AnotherJob::class);\n\n        // Assert that a closure was pushed to the queue...\n        Queue::assertClosurePushed();\n\n        // Assert that a closure was not pushed...\n        Queue::assertClosureNotPushed();\n\n        // Assert the total number of jobs that were pushed...\n        Queue::assertCount(3);\n    }\n}\n```\n\nYou may pass a closure to the `assertPushed`, `assertNotPushed`, `assertClosurePushed`, or `assertClosureNotPushed` methods in order to assert that a job was pushed that passes a given \"truth test\". If at least one job was pushed that passes the given truth test then the assertion will be successful:\n\n```php\nuse Illuminate\\Queue\\CallQueuedClosure;\n\nQueue::assertPushed(function (ShipOrder $job) use ($order) {\n    return $job->order->id === $order->id;\n});\n\nQueue::assertClosurePushed(function (CallQueuedClosure $job) {\n    return $job->name === 'validate-order';\n});\n```\n\n---\n\n## Upgrade Upgrading To 12.0 From 11.x Authentication Updated `DatabaseTokenRepository` Constructor Signature\nUpgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md\n\n\n#### Updated `DatabaseTokenRepository` Constructor Signature\n\n**Likelihood Of Impact: Very Low**\n\nThe constructor of the `Illuminate\\Auth\\Passwords\\DatabaseTokenRepository` class now expects the `$expires` parameter to be given in seconds, rather than minutes.\n\n---\n\n## Responses Redirects Redirecting to Named Routes Populating Parameters via Eloquent Models\nResponses | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/responses.md\n\n\n#### Populating Parameters via Eloquent Models\n\nIf you are redirecting to a route with an \"ID\" parameter that is being populated from an Eloquent model, you may pass the model itself. The ID will be extracted automatically:\n\n```php\n// For a route with the following URI: /profile/{id}\n\nreturn redirect()->route('profile', [$user]);\n```\n\nIf you would like to customize the value that is placed in the route parameter, you can specify the column in the route parameter definition (`/profile/{id:slug}`) or you can override the `getRouteKey` method on your Eloquent model:\n\n```php\n/**\n * Get the value of the model's route key.\n */\npublic function getRouteKey(): mixed\n{\n    return $this->slug;\n}\n```\n\n---\n\n## Database Database Transactions Handling Deadlocks\nDatabase | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database.md\n\n\n#### Handling Deadlocks\n\nThe `transaction` method accepts an optional second argument which defines the number of times a transaction should be retried when a deadlock occurs. Once these attempts have been exhausted, an exception will be thrown:\n\n```php\nuse Illuminate\\Support\\Facades\\DB;\n\nDB::transaction(function () {\n    DB::update('update users set votes = 1');\n\n    DB::delete('delete from posts');\n}, attempts: 5);\n```\n\n---\n\n## Testing Running Tests Running Tests in Parallel Parallel Testing and Databases\nTesting | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/testing.md\n\n\n#### Parallel Testing and Databases\n\nAs long as you have configured a primary database connection, Laravel automatically handles creating and migrating a test database for each parallel process that is running your tests. The test databases will be suffixed with a process token which is unique per process. For example, if you have two parallel test processes, Laravel will create and use `your_db_test_1` and `your_db_test_2` test databases.\n\nBy default, test databases persist between calls to the `test` Artisan command so that they can be used again by subsequent `test` invocations. However, you may re-create them using the `--recreate-databases` option:\n\n```shell\nphp artisan test --parallel --recreate-databases\n```\n\n---\n\n## Queues Testing Testing Job Batches\nQueues | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queues.md\n\n\n### Testing Job Batches\n\nThe `Bus` facade's `assertBatched` method may be used to assert that a [batch of jobs](/docs/{{version}}/queues#job-batching) was dispatched. The closure given to the `assertBatched` method receives an instance of `Illuminate\\Bus\\PendingBatch`, which may be used to inspect the jobs within the batch:\n\n```php\nuse Illuminate\\Bus\\PendingBatch;\nuse Illuminate\\Support\\Facades\\Bus;\n\nBus::fake();\n\n// ...\n\nBus::assertBatched(function (PendingBatch $batch) {\n    return $batch->name == 'Import CSV' &&\n           $batch->jobs->count() === 10;\n});\n```\n\nThe `hasJobs` method may be used on the pending batch to verify that the batch contains the expected jobs. The method accepts an array of job instances, class names, or closures:\n\n```php\nBus::assertBatched(function (PendingBatch $batch) {\n    return $batch->hasJobs([\n        new ProcessCsvRow(row: 1),\n        new ProcessCsvRow(row: 2),\n        new ProcessCsvRow(row: 3),\n    ]);\n});\n```\n\nWhen using closures, the closure will receive the job instance. The expected job type will be inferred from the closure's type hint:\n\n```php\nBus::assertBatched(function (PendingBatch $batch) {\n    return $batch->hasJobs([\n        fn (ProcessCsvRow $job) => $job->row === 1,\n        fn (ProcessCsvRow $job) => $job->row === 2,\n        fn (ProcessCsvRow $job) => $job->row === 3,\n    ]);\n});\n```\n\nYou may use the `assertBatchCount` method to assert that a given number of batches were dispatched:\n\n```php\nBus::assertBatchCount(3);\n```\n\nYou may use `assertNothingBatched` to assert that no batches were dispatched:\n\n```php\nBus::assertNothingBatched();\n```\n\n---\n\n## Database Testing Model Factories\nDatabase Testing | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/database-testing.md\n\n\n## Model Factories\n\nWhen testing, you may need to insert a few records into your database before executing your test. Instead of manually specifying the value of each column when you create this test data, Laravel allows you to define a set of default attributes for each of your [Eloquent models](/docs/{{version}}/eloquent) using [model factories](/docs/{{version}}/eloquent-factories).\n\nTo learn more about creating and utilizing model factories to create models, please consult the complete [model factory documentation](/docs/{{version}}/eloquent-factories). Once you have defined a model factory, you may utilize the factory within your test to create models:\n\n```php tab=Pest\nuse App\\Models\\User;\n\ntest('models can be instantiated', function () {\n    $user = User::factory()->create();\n\n    // ...\n});\n```\n\n```php tab=PHPUnit\nuse App\\Models\\User;\n\npublic function test_models_can_be_instantiated(): void\n{\n    $user = User::factory()->create();\n\n    // ...\n}\n```\n\n_[Truncated]_"}]


agent_type: explorer

message: You are a read-only explorer for /Users/fabianwesner/Herd/shop. Focus on Phase 4 backend/data/service foundation only (cart, checkout, discounts, shipping, taxes), not UI. Inspect specs/09-IMPLEMENTATION-ROADMAP.md Phase 4, specs/01-DATABASE-SCHEMA.md Epic 5, specs/05-BUSINESS-LOGIC.md Sections 4-9, and current catalog/customer/inventory code. Return a concise implementation checklist, service edge cases to test, and naming/convention risks. Do not edit files.

reasoning_effort: medium


new_agent_nickname: Erdos

new_agent_role: explorer

prompt: You are a read-only explorer for /Users/fabianwesner/Herd/shop. Focus on Phase 4 backend/data/service foundation only (cart, checkout, discounts, shipping, taxes), not UI. Inspect specs/09-IMPLEMENTATION-ROADMAP.md Phase 4, specs/01-DATABASE-SCHEMA.md Epic 5, specs/05-BUSINESS-LOGIC.md Sections 4-9, and current catalog/customer/inventory code. Return a concise implementation checklist, service edge cases to test, and naming/convention risks. Do not edit files.

reasoning_effort: medium


output: {"agent_id":"019df00b-76eb-7621-85c6-3c9ddae08f97","nickname":"Erdos"}


cmd: sed -n '430,930p' specs/05-BUSINESS-LOGIC.md


cmd: sed -n '930,1040p' specs/05-BUSINESS-LOGIC.md


cmd: sed -n '1034,1390p' specs/09-IMPLEMENTATION-ROADMAP.md


cmd: sed -n '1,240p' app/Services/InventoryService.php


cmd: sed -n '1,180p' app/Exceptions/InsufficientInventoryException.php


command: sed -n '430,930p' specs/05-BUSINESS-LOGIC.md

type: read

aggregated_output:
    note right of addressed : Triggers pricing recalculation
    note right of shipping_selected : Triggers pricing recalculation
    note right of payment_selected : Inventory reserved
    note right of completed : Order created, inventory committed
    note right of expired : Reserved inventory released
```

### 6.2 Transitions and Validation

**Service:** `App\Services\CheckoutService`

#### Transition: started -> addressed

**Method:** `setAddress(checkout, addressData)`

**Required data:**
- `email` - valid email address
- `shipping_address` with required fields:

| Field | Required | Type | Notes |
|---|---|---|---|
| `first_name` | Yes | string | |
| `last_name` | Yes | string | |
| `address1` | Yes | string | |
| `address2` | No | string | |
| `company` | No | string | |
| `city` | Yes | string | |
| `province` | No | string | |
| `province_code` | No | string | Used for zone matching |
| `country` | Yes | string | 2-letter ISO 3166-1 code |
| `postal_code` | Yes | string | |
| `phone` | No | string | |

**Actions:**
1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).
2. Save email and shipping_address_json on the checkout.
3. Copy shipping address to billing_address_json by default (can be overridden later).
4. Recalculate pricing (shipping zones and tax regions may change based on address).
5. Transition status to `addressed`.
6. Dispatch `CheckoutAddressed` event.

#### Transition: addressed -> shipping_selected

**Method:** `setShippingMethod(checkout, shippingRateId)`

**Requires:**
- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.
- **Skip condition:** If no cart lines have `requires_shipping = true` on their variant, skip this step. Set status to `shipping_selected` with shipping_method_id = null and shipping_amount = 0.

**Actions:**
1. Resolve available shipping zones for the address (see Section 9).
2. Verify the selected rate belongs to an applicable zone.
3. Save shipping_method_id on the checkout.
4. Recalculate pricing with the selected shipping rate.
5. Transition status to `shipping_selected`.
6. Dispatch `CheckoutShippingSelected` event.

#### Transition: shipping_selected -> payment_selected

**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method

**Requires:**
- All previous data is valid (address, shipping method).
- Totals have been calculated and stored.
- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.

**Actions:**
1. Store `payment_method` on the checkout record.
2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).
3. Set `expires_at` on the checkout to current time + 24 hours.
4. Transition status to `payment_selected`.

#### Transition: payment_selected -> completed

**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order

**Triggered by:** Customer submitting the payment form (onsite, no external redirect).

**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., double-click), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.

**Actions (all within a single database transaction):**

```
PROCEDURE completeCheckout(checkout, paymentMethodData):
    BEGIN TRANSACTION

    1. IF order already exists for this checkout:
           RETURN existing order (idempotent)

    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)
       IF result.success = false:
           Release reserved inventory
           THROW PaymentFailedException(result.errorCode)

    3. Generate next sequential order number for this store

    4. DETERMINE order statuses based on payment method:
       IF checkout.payment_method IN ('credit_card', 'paypal'):
           orderStatus = 'paid'
           financialStatus = 'paid'
           paymentStatus = 'captured'
           inventoryAction = 'commit'
       ELSE IF checkout.payment_method = 'bank_transfer':
           orderStatus = 'pending'
           financialStatus = 'pending'
           paymentStatus = 'pending'
           inventoryAction = 'keep_reserved'

    5. CREATE order record with:
       - All checkout data (addresses, email, totals snapshot)
       - payment_method = checkout.payment_method
       - status = orderStatus, financial_status = financialStatus
       - fulfillment_status = 'unfulfilled'
       - placed_at = now

    6. FOR EACH cart line:
       CREATE order_line with snapshot data:
       - title_snapshot = product title + variant option labels
       - sku_snapshot = variant SKU at time of purchase
       - unit_price_amount, quantity, total_amount
       - tax_lines_json, discount_allocations_json

    7. CREATE payment record:
       - Link to order, provider = 'mock', method = checkout.payment_method
       - provider_payment_id = mock reference ID
       - status = paymentStatus, amo…8935 chars truncated…0) = intdiv(11900000, 11900) = 1000
- tax = 1190 - 1000 = 190

#### Tax-exclusive pricing (prices_include_tax = false)

- Displayed prices are net (excluding tax).
- Tax added on top:

```
tax = ROUND(net_amount * rate / 10000)
total = net_amount + tax
```

**Example:** Price = 1000 cents, rate = 1900.
- tax = ROUND(1000 * 1900 / 10000) = 190
- total = 1190

### 8.4 Stripe Tax Provider (Stub)

**Implementation:** `App\Services\Tax\StripeTaxProvider`

- Sends line items and shipping address to Stripe Tax API.
- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.
- Fallback behavior (configurable per store in `tax_settings.config_json`):

| Fallback Setting | Behavior |
|---|---|
| `"block"` | Block checkout progression if Stripe Tax API errors |
| `"allow"` | Allow checkout to proceed without tax (log a warning) |

### 8.5 Discount-Before-Tax Semantics

Discounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.

For tax-inclusive pricing (`prices_include_tax = true`): The discount is subtracted from the gross (tax-inclusive) subtotal. Tax is then extracted from the post-discount gross amount: `tax = post_discount_gross * rate / (1 + rate)`. This means the tax portion decreases proportionally when a discount is applied.

**Calculation order within the pricing pipeline:**
1. Calculate line subtotals.
2. Apply discount allocations to lines.
3. Calculate tax on discounted line amounts.
4. Sum everything for the total.

---

## 9. Shipping Calculation

### 9.1 Zone Matching

**Service:** `App\Services\ShippingCalculator`

**Zone matching algorithm:**

```
PROCEDURE getMatchingZone(address, store):
    zones = load all shipping zones for store

    bestMatch = null
    bestSpecificity = -1

    FOR EACH zone IN zones:
        countryMatch = address.country_code IN zone.countries_json
        regionMatch = address.province_code IN zone.regions_json

        IF countryMatch AND regionMatch:
            specificity = 2  // Most specific
        ELSE IF countryMatch:
            specificity = 1  // Country-only match
        ELSE:
            CONTINUE  // No match

        IF specificity > bestSpecificity:
            bestMatch = zone
            bestSpecificity = specificity
        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:
            bestMatch = zone  // Tie-break: lowest ID wins

    RETURN bestMatch  // null if no zone matches
```

**When no zone matches:** The address is unserviceable. Block checkout progression with error "Cannot ship to this address".

### 9.2 Rate Types

For the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:

#### Flat rate

Config structure: `{"amount": 500}`

- Cost = fixed amount regardless of cart contents.

#### Weight-based rate

Config structure: `{"ranges": [{"min_g": 0, "max_g": 1000, "amount": 500}, {"min_g": 1001, "max_g": 5000, "amount": 1000}]}`

```
PROCEDURE calculateWeightRate(config, cart):
    totalWeight = 0
    FOR EACH line IN cart.lines:
        IF line.variant.requires_shipping = true:
            totalWeight += line.variant.weight_g * line.quantity

    FOR EACH range IN config.ranges:
        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:
            RETURN range.amount

    RETURN null  // Rate not available for this weight
```

#### Price-based rate

Config structure: `{"ranges": [{"min_amount": 0, "max_amount": 5000, "amount": 500}, {"min_amount": 5001, "amount": 0}]}`

```
PROCEDURE calculatePriceRate(config, cartSubtotal):
    FOR EACH range IN config.ranges:
        IF range.min_amount <= cartSubtotal:
            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:
                RETURN range.amount

    RETURN null  // Rate not available for this subtotal
```

A range without `max_amount` matches all subtotals above `min_amount` (useful for "free shipping over X" rules).

#### Carrier-calculated rate (stub)

Config structure: `{"carrier": "ups", "service": "ground"}`

- Placeholder for future carrier API integration.
- Returns a hardcoded rate or error until implemented.

### 9.3 Requires Shipping Check

- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.
- shipping_amount = 0 in the pricing result.
- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).

**Mixed carts** (some physical, some digital):
- Shipping is calculated only for physical items' weight.
- Digital items are excluded from weight calculations.

### 9.4 Free Shipping Discount Override

If a validated discount has `value_type = 'free_shipping'`:
- Override the calculated shipping amount to 0.
- The shipping method is still selected (for fulfillment routing), but the customer is not charged.

---

## 10. Payment Processing (Mock PSP)

status: completed


command: sed -n '930,1040p' specs/05-BUSINESS-LOGIC.md

aggregated_output:
### 10.1 Mock Payment Provider Architecture

The platform uses a **Mock Payment Service Provider (PSP)** instead of real payment gateways. All payment processing happens in-process with no external API calls, no webhooks, and no redirects. This enables a fully functional checkout flow for development, testing, and demonstration purposes.

**Interface:** `App\Contracts\PaymentProvider`

Must implement:
- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)
- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult

**Service:** `App\Services\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.

**Implementation:** `App\Services\Payment\MockPaymentProvider`

### 10.2 Supported Payment Methods

**Enum:** `App\Enums\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)

| Method | Behavior | Capture Timing |
|---|---|---|
| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |
| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |
| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |

### 10.3 Magic Card Numbers (Credit Card Testing)

When payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:

| Card Number | Outcome | PaymentResult |
|---|---|---|
| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |
| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |
| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |
| Any other valid-looking number | Success | `success = true`, status = `captured` |

The mock provider does **not** validate card expiry, CVC, or cardholder name. These fields exist in the UI for realism but are ignored by the backend.

### 10.4 Mock Payment Flow

```mermaid
sequenceDiagram
    participant Customer
    participant Storefront
    participant Backend
    participant MockPSP

    Customer->>Storefront: Select payment method + enter details
    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)
    Backend->>Backend: Reserve inventory (if not already reserved)
    Backend->>MockPSP: charge(checkout, paymentMethodData)

    alt Credit Card / PayPal (instant capture)
        MockPSP-->>Backend: PaymentResult (success/failure)
        alt Success
            Backend->>Backend: Create order (financial_status = paid)
            Backend->>Backend: Commit inventory, mark cart converted
            Backend-->>Storefront: 200 OK (order confirmation)
        else Failure (decline / insufficient funds)
            Backend->>Backend: Release reserved inventory
            Backend-->>Storefront: 422 (error code + message)
        end
    else Bank Transfer (deferred)
        MockPSP-->>Backend: PaymentResult (success = true, status = pending)
        Backend->>Backend: Create order (financial_status = pending)
        Backend->>Backend: Inventory remains reserved (not committed)
        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)
    end
```

### 10.5 Payment State Machine

**Enum:** `App\Enums\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`

```mermaid
stateDiagram-v2
    [*] --> captured : Credit card / PayPal (success)
    [*] --> failed : Credit card (decline)
    [*] --> pending : Bank transfer (awaiting payment)
    pending --> captured : Admin confirms payment received
    pending --> failed : Auto-cancel job (unpaid timeout)
    captured --> refunded : Full refund processed
```

### 10.6 Bank Transfer Instructions

When `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:

| Field | Value |
|---|---|
| Bank name | Mock Bank AG |
| IBAN | DE89 3704 0044 0532 0130 00 |
| BIC | COBADEFFXXX |
| Reference | Order number (e.g., #1001) |
| Amount | Order total formatted in EUR |

These are static mock values. No real bank interaction occurs.

### 10.7 Admin: Confirm Payment (Bank Transfer)

Admin users can confirm that a bank transfer payment has been received via a "Confirm Payment" button on the order detail page (visible only when `payment_method = bank_transfer` AND `financial_status = pending`).

**Action flow:**
1. Admin clicks "Confirm Payment" button.
2. Backend updates `payments.status` from `pending` to `captured`.
3. Backend updates `orders.financial_status` from `pending` to `paid`.
4. Backend updates `orders.status` from `pending` to `paid`.
5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).
6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).
7. Dispatch `OrderPaid` event.


output:
Chunk ID: 03b811
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 4734
Output:
    note right of addressed : Triggers pricing recalculation
    note right of shipping_selected : Triggers pricing recalculation
    note right of payment_selected : Inventory reserved
    note right of completed : Order created, inventory committed
    note right of expired : Reserved inventory released
```

### 6.2 Transitions and Validation

**Service:** `App\Services\CheckoutService`

#### Transition: started -> addressed

**Method:** `setAddress(checkout, addressData)`

**Required data:**
- `email` - valid email address
- `shipping_address` with required fields:

| Field | Required | Type | Notes |
|---|---|---|---|
| `first_name` | Yes | string | |
| `last_name` | Yes | string | |
| `address1` | Yes | string | |
| `address2` | No | string | |
| `company` | No | string | |
| `city` | Yes | string | |
| `province` | No | string | |
| `province_code` | No | string | Used for zone matching |
| `country` | Yes | string | 2-letter ISO 3166-1 code |
| `postal_code` | Yes | string | |
| `phone` | No | string | |

**Actions:**
1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).
2. Save email and shipping_address_json on the checkout.
3. Copy shipping address to billing_address_json by default (can be overridden later).
4. Recalculate pricing (shipping zones and tax regions may change based on address).
5. Transition status to `addressed`.
6. Dispatch `CheckoutAddressed` event.

#### Transition: addressed -> shipping_selected

**Method:** `setShippingMethod(checkout, shippingRateId)`

**Requires:**
- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.
- **Skip condition:** If no cart lines have `requires_shipping = true` on their variant, skip this step. Set status to `shipping_selected` with shipping_method_id = null and shipping_amount = 0.

**Actions:**
1. Resolve available shipping zones for the address (see Section 9).
2. Verify the selected rate belongs to an applicable zone.
3. Save shipping_method_id on the checkout.
4. Recalculate pricing with the selected shipping rate.
5. Transition status to `shipping_selected`.
6. Dispatch `CheckoutShippingSelected` event.

#### Transition: shipping_selected -> payment_selected

**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method

**Requires:**
- All previous data is valid (address, shipping method).
- Totals have been calculated and stored.
- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.

**Actions:**
1. Store `payment_method` on the checkout record.
2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).
3. Set `expires_at` on the checkout to current time + 24 hours.
4. Transition status to `payment_selected`.

#### Transition: payment_selected -> completed

**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order

**Triggered by:** Customer submitting the payment form (onsite, no external redirect).

**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., double-click), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.

**Actions (all within a single database transaction):**

```
PROCEDURE completeCheckout(checkout, paymentMethodData):
    BEGIN TRANSACTION

    1. IF order already exists for this checkout:
           RETURN existing order (idempotent)

    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)
       IF result.success = false:
           Release reserved inventory
           THROW PaymentFailedException(result.errorCode)

    3. Generate next sequential order number for this store

    4. DETERMINE order statuses based on payment method:
       IF checkout.payment_method IN ('credit_card', 'paypal'):
           orderStatus = 'paid'
           financialStatus = 'paid'
           paymentStatus = 'captured'
           inventoryAction = 'commit'
       ELSE IF checkout.payment_method = 'bank_transfer':
           orderStatus = 'pending'
           financialStatus = 'pending'
           paymentStatus = 'pending'
           inventoryAction = 'keep_reserved'

    5. CREATE order record with:
       - All checkout data (addresses, email, totals snapshot)
       - payment_method = checkout.payment_method
       - status = orderStatus, financial_status = financialStatus
       - fulfillment_status = 'unfulfilled'
       - placed_at = now

    6. FOR EACH cart line:
       CREATE order_line with snapshot data:
       - title_snapshot = product title + variant option labels
       - sku_snapshot = variant SKU at time of purchase
       - unit_price_amount, quantity, total_amount
       - tax_lines_json, discount_allocations_json

    7. CREATE payment record:
       - Link to order, provider = 'mock', method = checkout.payment_method
       - provider_payment_id = mock reference ID
       - status = paymentStatus, amount = order total

    8. IF inventoryAction = 'commit':
       FOR EACH cart line:
           CALL InventoryService.commit(variant, quantity)
       // Note: for bank_transfer, inventory stays reserved until admin confirms payment

    9. IF checkout has a discount code:
       INCREMENT discount's usage_count by 1

    10. SET cart status = 'converted'

    11. SET checkout status = 'completed'

    12. IF inventoryAction = 'commit' AND all order lines are digital:
        Auto-create fulfillment (see Section 11.7)

    13. DISPATCH OrderCreated event

    COMMIT TRANSACTION
    RETURN order
```

#### Transition: Any active state -> expired

**Triggered by:** `App\Jobs\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).

**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.

**Actions:**
1. If inventory was reserved (status was `payment_selected`): release all reserved inventory via InventoryService.release.
2. Transition status to `expired`.
3. Dispatch `CheckoutExpired` event.

### 6.3 Events

| Event | Dispatched When |
|---|---|
| `CheckoutAddressed` | Address saved on checkout |
| `CheckoutShippingSelected` | Shipping method selected |
| `CheckoutCompleted` | Payment confirmed and order created |
| `CheckoutExpired` | Checkout expired due to inactivity |

---

## 7. Discount Engine

### 7.1 Discount Types

**Enum:** `App\Enums\DiscountType` - values: `code`, `automatic`

| Type | Behavior |
|---|---|
| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |
| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |

**Stacking rules:**
- Only one code discount per checkout.
- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).

### Discount Status Lifecycle

| Status | Description | Transition |
|--------|-------------|------------|
| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |
| `active` | Live and applicable to carts (subject to date range and usage limits). | Automatic transition when `starts_at` is reached (if set). Admin can manually activate. |
| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |
| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |

**Allowed transitions:**
- `draft` -> `active`
- `active` -> `disabled`
- `active` -> `expired` (automatic)
- `disabled` -> `active`

### 7.2 Value Types

**Enum:** `App\Enums\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`

| Value Type | Calculation | Notes |
|---|---|---|
| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |
| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |
| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |

### 7.3 Rules

Rules are stored in `discounts.rules_json` with the following structure:

| Rule Key | Type | Description |
|---|---|---|
| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |
| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |
| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |
| `customer_eligibility` | string | `"all"` for all customers. Future expansion: `"returning"`, `"specific"`. |

**Applicability logic:** When both `applicable_product_ids` and `applicable_collection_ids` are set, a product qualifies if it matches **either** condition (union, not intersection). When both are null or empty, the discount applies to the entire order.

### 7.4 Validation Flow

**Service:** `App\Services\DiscountService`

**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object

```mermaid
flowchart TD
    A[Input: discount code + cart] --> B{Code exists in store?
    Case-insensitive lookup}
    B -->|No| C["Error: discount_not_found"]
    B -->|Yes| D{Status = active?}
    D -->|No| E["Error: discount_expired"]
    D -->|Yes| F{starts_at <= now?}
    F -->|No| G["Error: discount_not_yet_active"]
    F -->|Yes| H{ends_at is null OR ends_at >= now?}
    H -->|No| I["Error: discount_expired"]
    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}
    J -->|Yes| K["Error: discount_usage_limit_reached"]
    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}
    L -->|Yes| M["Error: discount_min_purchase_not_met"]
    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}
    N -->|Yes| O["Error: discount_not_applicable"]
    N -->|No| P["Valid - return discount"]
```

### 7.5 Error Codes

| Code | Description |
|---|---|
| `discount_not_found` | Code does not exist in this store |
| `discount_expired` | Past `ends_at` date or status is not active |
| `discount_not_yet_active` | Before `starts_at` date |
| `discount_usage_limit_reached` | `usage_count >= usage_limit` |
| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |
| `discount_not_applicable` | No qualifying products in cart |

### 7.6 Discount Allocation

For `percent` and `fixed` discounts with applicable product restrictions:

```
PROCEDURE allocateDiscount(discount, cartLines):
    qualifyingLines = FILTER cartLines where product matches applicable_product_ids
                      OR product belongs to applicable_collection_ids
    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)

    IF discount.value_type = 'percent':
        totalDiscount = qualifyingSubtotal * discount.value_amount / 100
    ELSE IF discount.value_type = 'fixed':
        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)

    remainingDiscount = totalDiscount

    FOR EACH qualifyingLine (except last):
        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)
        remainingDiscount -= lineDiscount
        SET line.line_discount_amount = lineDiscount

    // Last line gets the remainder (largest-remainder method)
    SET lastLine.line_discount_amount = remainingDiscount

    RECALCULATE line_total_amount for each affected line
```

**Storage of allocations:**
- During checkout: update `cart_lines.line_discount_amount`.
- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.

### 7.7 Usage Tracking

When an order is successfully created with a discount code:
- Increment `discounts.usage_count` by 1.
- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).

---

## 8. Tax Calculation

### 8.1 Tax Provider Architecture

**Interface:** `App\Contracts\TaxProvider`

- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.
- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.
- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.

**Service:** `App\Services\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.

**Implementations:**
- `App\Services\Tax\ManualTaxProvider` - default, uses manually configured rates
- `App\Services\Tax\StripeTaxProvider` - stub for Stripe Tax API integration

### 8.2 Manual Tax Mode

**Rate resolution:**
1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).
2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.
3. Rate stored as integer basis points (e.g., 1900 = 19.00%).

**Calculation per line:**

```
line_tax = ROUND(line_total_after_discount * rate / 10000)
```

Round each line's tax independently, then sum for the total tax amount.

### 8.3 Prices Include Tax

Controlled by `tax_settings.prices_include_tax` (boolean).

#### Tax-inclusive pricing (prices_include_tax = true)

- Displayed prices on the storefront are gross (include tax).
- Subtotal shown to customer is gross.
- Tax is "extracted" from the gross amount:

```
net_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)
tax = gross_amount - net_amount
```

> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.

**Example:** Price = 1190 cents, rate = 1900 (19%).
- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000
- tax = 1190 - 1000 = 190

#### Tax-exclusive pricing (prices_include_tax = false)

- Displayed prices are net (excluding tax).
- Tax added on top:

```
tax = ROUND(net_amount * rate / 10000)
total = net_amount + tax
```

**Example:** Price = 1000 cents, rate = 1900.
- tax = ROUND(1000 * 1900 / 10000) = 190
- total = 1190

### 8.4 Stripe Tax Provider (Stub)

**Implementation:** `App\Services\Tax\StripeTaxProvider`

- Sends line items and shipping address to Stripe Tax API.
- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.
- Fallback behavior (configurable per store in `tax_settings.config_json`):

| Fallback Setting | Behavior |
|---|---|
| `"block"` | Block checkout progression if Stripe Tax API errors |
| `"allow"` | Allow checkout to proceed without tax (log a warning) |

### 8.5 Discount-Before-Tax Semantics

Discounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.

For tax-inclusive pricing (`prices_include_tax = true`): The discount is subtracted from the gross (tax-inclusive) subtotal. Tax is then extracted from the post-discount gross amount: `tax = post_discount_gross * rate / (1 + rate)`. This means the tax portion decreases proportionally when a discount is applied.

**Calculation order within the pricing pipeline:**
1. Calculate line subtotals.
2. Apply discount allocations to lines.
3. Calculate tax on discounted line amounts.
4. Sum everything for the total.

---

## 9. Shipping Calculation

### 9.1 Zone Matching

**Service:** `App\Services\ShippingCalculator`

**Zone matching algorithm:**

```
PROCEDURE getMatchingZone(address, store):
    zones = load all shipping zones for store

    bestMatch = null
    bestSpecificity = -1

    FOR EACH zone IN zones:
        countryMatch = address.country_code IN zone.countries_json
        regionMatch = address.province_code IN zone.regions_json

        IF countryMatch AND regionMatch:
            specificity = 2  // Most specific
        ELSE IF countryMatch:
            specificity = 1  // Country-only match
        ELSE:
            CONTINUE  // No match

        IF specificity > bestSpecificity:
            bestMatch = zone
            bestSpecificity = specificity
        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:
            bestMatch = zone  // Tie-break: lowest ID wins

    RETURN bestMatch  // null if no zone matches
```

**When no zone matches:** The address is unserviceable. Block checkout progression with error "Cannot ship to this address".

### 9.2 Rate Types

For the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:

#### Flat rate

Config structure: `{"amount": 500}`

- Cost = fixed amount regardless of cart contents.

#### Weight-based rate

Config structure: `{"ranges": [{"min_g": 0, "max_g": 1000, "amount": 500}, {"min_g": 1001, "max_g": 5000, "amount": 1000}]}`

```
PROCEDURE calculateWeightRate(config, cart):
    totalWeight = 0
    FOR EACH line IN cart.lines:
        IF line.variant.requires_shipping = true:
            totalWeight += line.variant.weight_g * line.quantity

    FOR EACH range IN config.ranges:
        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:
            RETURN range.amount

    RETURN null  // Rate not available for this weight
```

#### Price-based rate

Config structure: `{"ranges": [{"min_amount": 0, "max_amount": 5000, "amount": 500}, {"min_amount": 5001, "amount": 0}]}`

```
PROCEDURE calculatePriceRate(config, cartSubtotal):
    FOR EACH range IN config.ranges:
        IF range.min_amount <= cartSubtotal:
            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:
                RETURN range.amount

    RETURN null  // Rate not available for this subtotal
```

A range without `max_amount` matches all subtotals above `min_amount` (useful for "free shipping over X" rules).

#### Carrier-calculated rate (stub)

Config structure: `{"carrier": "ups", "service": "ground"}`

- Placeholder for future carrier API integration.
- Returns a hardcoded rate or error until implemented.

### 9.3 Requires Shipping Check

- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.
- shipping_amount = 0 in the pricing result.
- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).

**Mixed carts** (some physical, some digital):
- Shipping is calculated only for physical items' weight.
- Digital items are excluded from weight calculations.

### 9.4 Free Shipping Discount Override

If a validated discount has `value_type = 'free_shipping'`:
- Override the calculated shipping amount to 0.
- The shipping method is still selected (for fulfillment routing), but the customer is not charged.

---

## 10. Payment Processing (Mock PSP)


output:
Chunk ID: d86073
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1298
Output:

### 10.1 Mock Payment Provider Architecture

The platform uses a **Mock Payment Service Provider (PSP)** instead of real payment gateways. All payment processing happens in-process with no external API calls, no webhooks, and no redirects. This enables a fully functional checkout flow for development, testing, and demonstration purposes.

**Interface:** `App\Contracts\PaymentProvider`

Must implement:
- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)
- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult

**Service:** `App\Services\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.

**Implementation:** `App\Services\Payment\MockPaymentProvider`

### 10.2 Supported Payment Methods

**Enum:** `App\Enums\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)

| Method | Behavior | Capture Timing |
|---|---|---|
| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |
| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |
| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |

### 10.3 Magic Card Numbers (Credit Card Testing)

When payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:

| Card Number | Outcome | PaymentResult |
|---|---|---|
| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |
| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |
| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |
| Any other valid-looking number | Success | `success = true`, status = `captured` |

The mock provider does **not** validate card expiry, CVC, or cardholder name. These fields exist in the UI for realism but are ignored by the backend.

### 10.4 Mock Payment Flow

```mermaid
sequenceDiagram
    participant Customer
    participant Storefront
    participant Backend
    participant MockPSP

    Customer->>Storefront: Select payment method + enter details
    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)
    Backend->>Backend: Reserve inventory (if not already reserved)
    Backend->>MockPSP: charge(checkout, paymentMethodData)

    alt Credit Card / PayPal (instant capture)
        MockPSP-->>Backend: PaymentResult (success/failure)
        alt Success
            Backend->>Backend: Create order (financial_status = paid)
            Backend->>Backend: Commit inventory, mark cart converted
            Backend-->>Storefront: 200 OK (order confirmation)
        else Failure (decline / insufficient funds)
            Backend->>Backend: Release reserved inventory
            Backend-->>Storefront: 422 (error code + message)
        end
    else Bank Transfer (deferred)
        MockPSP-->>Backend: PaymentResult (success = true, status = pending)
        Backend->>Backend: Create order (financial_status = pending)
        Backend->>Backend: Inventory remains reserved (not committed)
        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)
    end
```

### 10.5 Payment State Machine

**Enum:** `App\Enums\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`

```mermaid
stateDiagram-v2
    [*] --> captured : Credit card / PayPal (success)
    [*] --> failed : Credit card (decline)
    [*] --> pending : Bank transfer (awaiting payment)
    pending --> captured : Admin confirms payment received
    pending --> failed : Auto-cancel job (unpaid timeout)
    captured --> refunded : Full refund processed
```

### 10.6 Bank Transfer Instructions

When `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:

| Field | Value |
|---|---|
| Bank name | Mock Bank AG |
| IBAN | DE89 3704 0044 0532 0130 00 |
| BIC | COBADEFFXXX |
| Reference | Order number (e.g., #1001) |
| Amount | Order total formatted in EUR |

These are static mock values. No real bank interaction occurs.

### 10.7 Admin: Confirm Payment (Bank Transfer)

Admin users can confirm that a bank transfer payment has been received via a "Confirm Payment" button on the order detail page (visible only when `payment_method = bank_transfer` AND `financial_status = pending`).

**Action flow:**
1. Admin clicks "Confirm Payment" button.
2. Backend updates `payments.status` from `pending` to `captured`.
3. Backend updates `orders.financial_status` from `pending` to `paid`.
4. Backend updates `orders.status` from `pending` to `paid`.
5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).
6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).
7. Dispatch `OrderPaid` event.


command: sed -n '1034,1390p' specs/09-IMPLEMENTATION-ROADMAP.md

aggregated_output:
#### tests/Unit/DiscountCalculatorTest.php

**Setup:** Instantiate `DiscountService` with mock store and cart.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |
| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |
| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |
| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |
| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |
| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |
| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |
| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |
| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |
| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |
| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |
| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |
| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |

---

#### tests/Unit/TaxCalculatorTest.php

**Setup:** Instantiate `TaxCalculator` directly.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |
| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |
| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |
| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |
| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |
| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |
| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |

---

#### tests/Unit/ShippingCalculatorTest.php

**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| matches a zone by country code | zone countries_json: ["DE","AT","CH"]; address country: "DE" | zone matched | happy path |
| matches a zone by region code | zone regions_json: ["US-NY","US-CA"]; address: country "US", province_code "US-NY" | zone matched | happy path |
| returns empty when no zone matches the address | zone countries_json: ["DE"]; address country: "FR" | empty collection returned | edge case |
| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |
| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |
| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |
| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |
| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |
| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |

---

#### tests/Unit/CartVersionTest.php

**Setup:** Instantiate Cart model or CartService.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| starts at version 1 | create new cart | cart_version = …19143 chars truncated…st Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |
| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |
| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |
| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |
| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |
| transitions from shipping_selected to payment_selected | select payment method | status = payment_selected, expires_at set, inventory reserved | happy path |
| transitions from payment_selected to completed | simulate successful payment | status = completed, order created | happy path |
| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |
| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |

---

#### tests/Feature/Checkout/PricingIntegrationTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |
| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |
| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |
| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |
| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |

---

#### tests/Feature/Checkout/DiscountTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| applies a valid percent discount code at checkout | discount: 10%, code "SAVE10"; apply to checkout with subtotal 5000 | discount = 500 | happy path |
| applies a valid fixed discount code at checkout | discount: 500 fixed, code "5OFF" | discount = 500 | happy path |
| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |
| rejects expired discount at checkout | discount ended yesterday | error returned | validation |
| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |
| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |

---

#### tests/Feature/Checkout/ShippingTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |
| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |
| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |
| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |
| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |

---

#### tests/Feature/Checkout/TaxTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |
| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |
| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |
| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |

---

#### tests/Feature/Orders/OrderCreationTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |