output:
Chunk ID: f14fe8
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1456
Output:
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('organizations', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('billing_email')->index();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('organizations');
    }
};
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('stores', function (Blueprint $table) {
            $table->id();
            $table->foreignId('organization_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('handle')->unique();
            $table->enum('status', ['active', 'suspended'])->default('active')->index();
            $table->string('default_currency', 3)->default('USD');
            $table->string('default_locale')->default('en');
            $table->string('timezone')->default('UTC');
            $table->timestamps();

            $table->index('organization_id');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('stores');
    }
};
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('store_domains', function (Blueprint $table) {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('hostname')->unique();
            $table->enum('type', ['storefront', 'admin', 'api'])->default('storefront');
            $table->boolean('is_primary')->default(false);
            $table->enum('tls_mode', ['managed', 'bring_your_own'])->default('managed');
            $table->timestamp('created_at')->nullable();

            $table->index('store_id');
            $table->index(['store_id', 'is_primary']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('store_domains');
    }
};
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('store_settings', function (Blueprint $table) {
            $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
            $table->text('settings_json')->default('{}');
            $table->timestamp('updated_at')->nullable();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('store_settings');
    }
};
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('store_users', function (Blueprint $table) {
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->enum('role', ['owner', 'admin', 'staff', 'support'])->default('staff');
            $table->timestamp('created_at')->nullable();

            $table->primary(['store_id', 'user_id']);
            $table->index('user_id');
            $table->index(['store_id', 'role']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('store_users');
    }
};
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('customers', function (Blueprint $table) {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('email');
            $table->string('password_hash')->nullable();
            $table->string('name')->nullable();
            $table->boolean('marketing_opt_in')->default(false);
            $table->timestamps();

            $table->unique(['store_id', 'email']);
            $table->index('store_id');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('customers');
    }
};
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('customer_password_reset_tokens', function (Blueprint $table) {
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('email');
            $table->string('token');
            $table->timestamp('created_at')->nullable();

            $table->primary(['store_id', 'email']);
        });
    }

    /**


command: rg -n "settings_json|order_number|default_currency|stores" specs app database -g '*.php' -g '*.md'

type: search

cmd: rg -n 'settings_json|order_number|default_currency|stores' specs app database -g '*.php' -g '*.md'

query: settings_json|order_number|default_currency|stores

path: specs

aggregated_output:
database/factories/ProductFactory.php:72:                    'currency' => $product->store->default_currency,
database/factories/StoreSettingsFactory.php:22:            'settings_json' => [
app/Models/Organization.php:25:    public function stores(): HasMany
specs/01-DATABASE-SCHEMA.md:27:Billing entity that owns one or more stores.
specs/01-DATABASE-SCHEMA.md:43:### stores
specs/01-DATABASE-SCHEMA.md:54:| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
specs/01-DATABASE-SCHEMA.md:62:- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing
specs/01-DATABASE-SCHEMA.md:63:- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org
specs/01-DATABASE-SCHEMA.md:64:- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status
specs/01-DATABASE-SCHEMA.md:75:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:92:Admin/staff users who manage stores (not storefront customers).
specs/01-DATABASE-SCHEMA.md:115:**Notes:** The `two_factor_*` columns align with the existing migration `2025_08_14_170933_add_two_factor_columns_to_users_table.php`. The `password_hash` column maps to Laravel's default `password` field internally but stores a bcrypt/argon2 hash.
specs/01-DATABASE-SCHEMA.md:121:Pivot table linking users to stores with a role. Uses composite primary key.
specs/01-DATABASE-SCHEMA.md:125:| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |
specs/01-DATABASE-SCHEMA.md:134:- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user
specs/01-DATABASE-SCHEMA.md:145:| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
specs/01-DATABASE-SCHEMA.md:146:| settings_json | TEXT | No | '{}' | - | JSON settings object |
specs/01-DATABASE-SCHEMA.md:162:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:282:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:304:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:376:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:418:| settings_json | TEXT | No | '{}' | - | JSON settings object |
specs/01-DATABASE-SCHEMA.md:430:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:454:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:498:| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
specs/01-DATABASE-SCHEMA.md:512:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:535:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:584:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:608:**Notes:** `totals_json` contains a snapshot: `{"subtotal": 5000, "discount": 500, "shipping": 799, "tax": 430, "total": 5729}`. `tax_provider_snapshot_json` stores the full tax calculation response for audit purposes.
specs/01-DATABASE-SCHEMA.md:619:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:664:| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
specs/01-DATABASE-SCHEMA.md:679:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:713:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:758:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:760:| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |
specs/01-DATABASE-SCHEMA.md:780:- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store
specs/01-DATABASE-SCHEMA.md:842:**Notes:** `raw_json_encrypted` stores the mock payment provider response, encrypted at the application layer. `amount` is in minor units (cents). The `provider` is always `mock` since the platform uses a mock PSP (no real payment gateway integration). The `method` column records which payment method the customer selected (credit_card, paypal, or bank_transfer).
specs/01-DATABASE-SCHEMA.md:921:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:949:| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |
specs/01-DATABASE-SCHEMA.md:995:| store_id | IN…21702 chars truncated…py path |
specs/09-IMPLEMENTATION-ROADMAP.md:1384:| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1668:| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1669:| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1670:| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1671:| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1672:| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |
specs/09-IMPLEMENTATION-ROADMAP.md:1673:| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |
specs/09-IMPLEMENTATION-ROADMAP.md:1682:| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1683:| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1684:| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1685:| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1686:| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |
specs/08-PLAYWRIGHT-E2E-PLAN.md:2442:- Products from other stores (e.g., electronics from Store 2) are absent
database/seeders/ProductSeeder.php:130:                'currency' => $store->default_currency,
specs/03-ADMIN-UI.md:127:| `stores` | `Collection` | List of stores the user has access to |
specs/03-ADMIN-UI.md:155:- When user clicks the store name, a dropdown appears listing all stores. Clicking a store calls `switchStore()` which updates the session and redirects to the dashboard.
specs/07-SEEDERS-AND-TEST-DATA.md:16:| 2 | StoreSeeder | Organizations | Creates stores under organizations |
specs/07-SEEDERS-AND-TEST-DATA.md:17:| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |
specs/07-SEEDERS-AND-TEST-DATA.md:19:| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |
specs/07-SEEDERS-AND-TEST-DATA.md:102:| default_currency | "EUR" |
specs/07-SEEDERS-AND-TEST-DATA.md:362:| order_number | "#" + Faker unique integer 1001-9999 |
specs/07-SEEDERS-AND-TEST-DATA.md:762:Creates two stores under "Acme Corp".
specs/07-SEEDERS-AND-TEST-DATA.md:771:| default_currency | EUR |
specs/07-SEEDERS-AND-TEST-DATA.md:782:| default_currency | EUR |
specs/07-SEEDERS-AND-TEST-DATA.md:833:**Acme Fashion - settings_json contents:**
specs/07-SEEDERS-AND-TEST-DATA.md:839:| order_number_prefix | # |
specs/07-SEEDERS-AND-TEST-DATA.md:840:| order_number_start | 1001 |
specs/07-SEEDERS-AND-TEST-DATA.md:842:**Acme Electronics - settings_json contents:**
specs/07-SEEDERS-AND-TEST-DATA.md:848:| order_number_prefix | # |
specs/07-SEEDERS-AND-TEST-DATA.md:849:| order_number_start | 5001 |
specs/07-SEEDERS-AND-TEST-DATA.md:2068:| checkout_completed | order_id, order_number, total_amount |
specs/07-SEEDERS-AND-TEST-DATA.md:2196:Each seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.
database/migrations/2026_05_03_223837_create_theme_settings_table.php:16:            $table->text('settings_json')->default('{}');
database/migrations/2026_05_04_000408_create_orders_table.php:19:            $table->string('order_number');
database/migrations/2026_05_04_000408_create_orders_table.php:36:            $table->unique(['store_id', 'order_number']);
database/migrations/2026_05_03_211413_create_stores_table.php:14:        Schema::create('stores', function (Blueprint $table) {
database/migrations/2026_05_03_211413_create_stores_table.php:20:            $table->string('default_currency', 3)->default('USD');
database/migrations/2026_05_03_211413_create_stores_table.php:34:        Schema::dropIfExists('stores');
database/migrations/2026_05_03_211419_create_store_settings_table.php:16:            $table->text('settings_json')->default('{}');

status: completed


output:
Chunk ID: 934944
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 7926
Output:
database/factories/ProductFactory.php:72:                    'currency' => $product->store->default_currency,
database/factories/StoreSettingsFactory.php:22:            'settings_json' => [
app/Models/Organization.php:25:    public function stores(): HasMany
specs/01-DATABASE-SCHEMA.md:27:Billing entity that owns one or more stores.
specs/01-DATABASE-SCHEMA.md:43:### stores
specs/01-DATABASE-SCHEMA.md:54:| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
specs/01-DATABASE-SCHEMA.md:62:- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing
specs/01-DATABASE-SCHEMA.md:63:- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org
specs/01-DATABASE-SCHEMA.md:64:- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status
specs/01-DATABASE-SCHEMA.md:75:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:92:Admin/staff users who manage stores (not storefront customers).
specs/01-DATABASE-SCHEMA.md:115:**Notes:** The `two_factor_*` columns align with the existing migration `2025_08_14_170933_add_two_factor_columns_to_users_table.php`. The `password_hash` column maps to Laravel's default `password` field internally but stores a bcrypt/argon2 hash.
specs/01-DATABASE-SCHEMA.md:121:Pivot table linking users to stores with a role. Uses composite primary key.
specs/01-DATABASE-SCHEMA.md:125:| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |
specs/01-DATABASE-SCHEMA.md:134:- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user
specs/01-DATABASE-SCHEMA.md:145:| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
specs/01-DATABASE-SCHEMA.md:146:| settings_json | TEXT | No | '{}' | - | JSON settings object |
specs/01-DATABASE-SCHEMA.md:162:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:282:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:304:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:376:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:418:| settings_json | TEXT | No | '{}' | - | JSON settings object |
specs/01-DATABASE-SCHEMA.md:430:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:454:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:498:| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
specs/01-DATABASE-SCHEMA.md:512:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:535:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:584:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:608:**Notes:** `totals_json` contains a snapshot: `{"subtotal": 5000, "discount": 500, "shipping": 799, "tax": 430, "total": 5729}`. `tax_provider_snapshot_json` stores the full tax calculation response for audit purposes.
specs/01-DATABASE-SCHEMA.md:619:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:664:| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
specs/01-DATABASE-SCHEMA.md:679:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:713:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:758:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:760:| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |
specs/01-DATABASE-SCHEMA.md:780:- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store
specs/01-DATABASE-SCHEMA.md:842:**Notes:** `raw_json_encrypted` stores the mock payment provider response, encrypted at the application layer. `amount` is in minor units (cents). The `provider` is always `mock` since the platform uses a mock PSP (no real payment gateway integration). The `method` column records which payment method the customer selected (credit_card, paypal, or bank_transfer).
specs/01-DATABASE-SCHEMA.md:921:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:949:| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |
specs/01-DATABASE-SCHEMA.md:995:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |
specs/01-DATABASE-SCHEMA.md:1005:- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed
specs/01-DATABASE-SCHEMA.md:1055:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:1193:| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:1208:    organizations ||--o{ stores : "owns"
specs/01-DATABASE-SCHEMA.md:1209:    stores ||--o{ store_domains : "has"
specs/01-DATABASE-SCHEMA.md:1210:    stores ||--o{ store_users : "links"
specs/01-DATABASE-SCHEMA.md:1212:    stores ||--|| store_settings : "has"
specs/01-DATABASE-SCHEMA.md:1219:    stores {
specs/01-DATABASE-SCHEMA.md:1247:        TEXT settings_json
specs/01-DATABASE-SCHEMA.md:1255:    stores ||--o{ products : "has"
specs/01-DATABASE-SCHEMA.md:1256:    stores ||--o{ collections : "has"
specs/01-DATABASE-SCHEMA.md:1262:    stores ||--o{ inventory_items : "has"
specs/01-DATABASE-SCHEMA.md:1322:    stores ||--o{ themes : "has"
specs/01-DATABASE-SCHEMA.md:1325:    stores ||--o{ pages : "has"
specs/01-DATABASE-SCHEMA.md:1326:    stores ||--o{ navigation_menus : "has"
specs/01-DATABASE-SCHEMA.md:1343:        TEXT settings_json
specs/01-DATABASE-SCHEMA.md:1371:    stores ||--o{ customers : "has"
specs/01-DATABASE-SCHEMA.md:1373:    stores ||--o{ carts : "has"
specs/01-DATABASE-SCHEMA.md:1377:    stores ||--o{ checkouts : "has"
specs/01-DATABASE-SCHEMA.md:1379:    stores ||--o{ orders : "has"
specs/01-DATABASE-SCHEMA.md:1405:        TEXT order_number
specs/01-DATABASE-SCHEMA.md:1432:    stores ||--o{ analytics_events : "records"
specs/01-DATABASE-SCHEMA.md:1433:    stores ||--o{ analytics_daily : "aggregates"
specs/01-DATABASE-SCHEMA.md:1436:    stores ||--o{ app_installations : "has"
specs/01-DATABASE-SCHEMA.md:1439:    stores ||--o{ webhook_subscriptions : "registers"
specs/01-DATABASE-SCHEMA.md:1519:        stores
specs/01-DATABASE-SCHEMA.md:1580:    organizations --> stores
specs/01-DATABASE-SCHEMA.md:1583:    stores --> store_domains
specs/01-DATABASE-SCHEMA.md:1584:    stores --> store_users
specs/01-DATABASE-SCHEMA.md:1586:    stores --> store_settings
specs/01-DATABASE-SCHEMA.md:1587:    stores --> customers
specs/01-DATABASE-SCHEMA.md:1588:    stores --> themes
specs/01-DATABASE-SCHEMA.md:1589:    stores --> pages
specs/01-DATABASE-SCHEMA.md:1590:    stores --> navigation_menus
specs/01-DATABASE-SCHEMA.md:1591:    stores --> search_settings
specs/01-DATABASE-SCHEMA.md:1592:    stores --> shipping_zones
specs/01-DATABASE-SCHEMA.md:1593:    stores --> tax_settings
specs/01-DATABASE-SCHEMA.md:1594:    stores --> discounts
specs/01-DATABASE-SCHEMA.md:1595:    stores --> products
specs/01-DATABASE-SCHEMA.md:1596:    stores --> collections
specs/01-DATABASE-SCHEMA.md:1597:    stores --> app_installations
specs/01-DATABASE-SCHEMA.md:1604:    stores --> carts
specs/01-DATABASE-SCHEMA.md:1609:    stores --> search_queries
specs/01-DATABASE-SCHEMA.md:1612:    stores --> webhook_subscriptions
specs/01-DATABASE-SCHEMA.md:1615:    stores --> inventory_items
specs/01-DATABASE-SCHEMA.md:1619:    stores --> checkouts
specs/01-DATABASE-SCHEMA.md:1622:    stores --> orders
specs/01-DATABASE-SCHEMA.md:1624:    stores --> analytics_events
specs/01-DATABASE-SCHEMA.md:1626:    stores --> analytics_daily
specs/01-DATABASE-SCHEMA.md:1644:- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)
specs/01-DATABASE-SCHEMA.md:1673:| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |
database/factories/StoreFactory.php:29:            'default_currency' => 'EUR',
app/Models/StoreSettings.php:25:        'settings_json',
app/Models/StoreSettings.php:42:            'settings_json' => 'array',
database/factories/ThemeSettingsFactory.php:22:            'settings_json' => [
app/Models/ThemeSettings.php:26:        'settings_json',
app/Models/ThemeSettings.php:64:            'settings_json' => 'array',
specs/06-AUTH-AND-SECURITY.md:63:- The `remember_token` column on the `users` table stores the hashed token.
specs/06-AUTH-AND-SECURITY.md:161:- The same email address can exist in different stores (multi-tenant isolation).
specs/06-AUTH-AND-SECURITY.md:318:- A User can hold different roles in different stores (via the `store_users` pivot table).
specs/06-AUTH-AND-SECURITY.md:324:The `store_users` pivot table links a `User` to a `Store` with a `role`. A user can have different roles in different stores.
specs/06-AUTH-AND-SECURITY.md:796:| `UpdateStoreSettingsRequest` | Update store settings | `name`: required, string. `default_currency`: in allowed currencies. `default_locale`: in allowed locales. `timezone`: valid timezone string. |
app/Models/User.php:48:    public function stores(): BelongsToMany
app/Models/User.php:62:        $role = $this->stores()
app/Services/VariantMatrixService.php:79:                'currency' => $this->store($product)->default_currency,
app/Services/VariantMatrixService.php:172:            'currency' => $template?->currency ?? $this->store($product)->default_currency,
app/Models/Store.php:26:        'default_currency',
app/Services/CartService.php:29:            'currency' => $store->default_currency,
app/Models/Order.php:27:        'order_number',
app/Services/ProductService.php:63:                    'currency' => $store->default_currency,
app/Services/ProductService.php:174:            'currency' => $variantData['currency'] ?? $store->default_currency,
app/Services/ThemeSettingsService.php:24:            $settings = $theme->settings?->settings_json ?? [];
app/Services/ThemeSettingsService.php:54:                'text' => "Free shipping on orders over 75.00 {$store->default_currency}",
specs/02-API-ROUTES.md:226:| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |
specs/02-API-ROUTES.md:899:    "order_number": "#1016",
specs/02-API-ROUTES.md:917:    "order_number": "#1016",
specs/02-API-ROUTES.md:975:  "order_number": "#1001",
specs/02-API-ROUTES.md:1263:#### POST /api/admin/v1/platform/stores
specs/02-API-ROUTES.md:1277:  "default_currency": "EUR",
specs/02-API-ROUTES.md:1288:| `default_currency` | string | Yes | Valid ISO 4217 code |
specs/02-API-ROUTES.md:1302:    "default_currency": "EUR",
specs/02-API-ROUTES.md:1316:#### POST /api/admin/v1/stores/{storeId}/invites
specs/02-API-ROUTES.md:1357:#### GET /api/admin/v1/stores/{storeId}/me
specs/02-API-ROUTES.md:1399:| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1400:| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1401:| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1402:| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1403:| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1404:| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1406:#### GET /api/admin/v1/stores/{storeId}/products
specs/02-API-ROUTES.md:1457:#### POST /api/admin/v1/stores/{storeId}/products
specs/02-API-ROUTES.md:1547:#### GET /api/admin/v1/stores/{storeId}/products/{productId}
specs/02-API-ROUTES.md:1617:        "storage_key": "stores/1/products/10/main.jpg",
specs/02-API-ROUTES.md:1618:        "url": "https://cdn.example.com/stores/1/products/10/main.jpg",
specs/02-API-ROUTES.md:1645:#### PUT /api/admin/v1/stores/{storeId}/products/{productId}
specs/02-API-ROUTES.md:1672:#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}
specs/02-API-ROUTES.md:1694:#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload
specs/02-API-ROUTES.md:1723:  "storage_key": "stores/1/products/10/media/uuid.jpg",
specs/02-API-ROUTES.md:1742:| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1743:| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1744:| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1745:| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1747:#### GET /api/admin/v1/stores/{storeId}/collections
specs/02-API-ROUTES.md:1789:#### POST /api/admin/v1/stores/{storeId}/collections
specs/02-API-ROUTES.md:1825:#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}
specs/02-API-ROUTES.md:1840:#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}
specs/02-API-ROUTES.md:1866:| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1867:| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1868:| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1869:| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1871:#### GET /api/admin/v1/stores/{storeId}/orders
specs/02-API-ROUTES.md:1897:      "order_number": "#1001",
specs/02-API-ROUTES.md:1928:#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}
specs/02-API-ROUTES.md:1939:    "order_number": "#1001",
specs/02-API-ROUTES.md:2016:#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments
specs/02-API-ROUTES.md:2077:#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds
specs/02-API-ROUTES.md:2138:| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2139:| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2140:| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2141:| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2143:#### GET /api/admin/v1/stores/{storeId}/discounts
specs/02-API-ROUTES.md:2192:#### POST /api/admin/v1/stores/{storeId}/discounts
specs/02-API-ROUTES.md:2243:#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}
specs/02-API-ROUTES.md:2258:#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}
specs/02-API-ROUTES.md:2284:| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2285:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2286:| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2287:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2289:#### GET /api/admin/v1/stores/{storeId}/shipping/zones
specs/02-API-ROUTES.md:2337:#### POST /api/admin/v1/stores/{storeId}/shipping/zones
specs/02-API-ROUTES.md:2365:#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}
specs/02-API-ROUTES.md:2380:#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates
specs/02-API-ROUTES.md:2420:| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2421:| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2423:#### GET /api/admin/v1/stores/{storeId}/tax/settings
specs/02-API-ROUTES.md:2460:#### PUT /api/admin/v1/stores/{storeId}/tax/settings
specs/02-API-ROUTES.md:2498:| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2499:| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2500:| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2502:#### POST /api/admin/v1/stores/{storeId}/themes
specs/02-API-ROUTES.md:2535:#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish
specs/02-API-ROUTES.md:2558:#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings
specs/02-API-ROUTES.md:2566:  "settings_json": {
specs/02-API-ROUTES.md:2587:| `settings_json` | object | Yes | Must conform to the theme's settings schema. |
specs/02-API-ROUTES.md:2604:| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2605:| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2606:| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2607:| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2609:#### GET /api/admin/v1/stores/{storeId}/pages
specs/02-API-ROUTES.md:2648:#### POST /api/admin/v1/stores/{storeId}/pages
specs/02-API-ROUTES.md:2678:#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}
specs/02-API-ROUTES.md:2693:#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}
specs/02-API-ROUTES.md:2719:| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2720:| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2722:#### POST /api/admin/v1/stores/{storeId}/search/reindex
specs/02-API-ROUTES.md:2743:#### GET /api/admin/v1/stores/{storeId}/search/status
specs/02-API-ROUTES.md:2770:| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2771:| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2772:| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2774:#### GET /api/admin/v1/stores/{storeId}/analytics/summary
specs/02-API-ROUTES.md:2834:#### POST /api/admin/v1/stores/{storeId}/exports/orders
specs/02-API-ROUTES.md:2868:#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}
specs/02-API-ROUTES.md:2983:| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
specs/02-API-ROUTES.md:2984:| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
specs/02-API-ROUTES.md:2985:| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |
specs/02-API-ROUTES.md:3054:- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).
specs/02-API-ROUTES.md:3064:- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.
app/Livewire/Admin/Products/Form.php:275:                'currency' => $store->default_currency,
database/seeders/ThemeSettingsSeeder.php:24:                        'settings_json' => app(ThemeSettingsService::class)->defaultsForStore($theme->store),
app/Http/Middleware/ResolveStore.php:66:            $storeId = $user->stores()->oldest('stores.id')->value('stores.id');
app/Http/Middleware/ResolveStore.php:73:        if (! $storeId || ! $user->stores()->whereKey($storeId)->exists()) {
app/Livewire/Storefront/Checkout/Show.php:308:            'currency' => $cart?->currency ?? $this->store()->default_currency,
specs/05-BUSINESS-LOGIC.md:16:| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |
specs/05-BUSINESS-LOGIC.md:289:- Sets: store_id, currency from store's default_currency, initial cart_version = 1
specs/05-BUSINESS-LOGIC.md:489:**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method
specs/05-BUSINESS-LOGIC.md:636:| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |
specs/05-BUSINESS-LOGIC.md:1047:- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.
specs/05-BUSINESS-LOGIC.md:1082:- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.
specs/05-BUSINESS-LOGIC.md:1283:**CSV columns:** `order_number`, `created_at`, `status`, `financial_status`, `fulfillment_status`, `customer_email`, `customer_name`, `subtotal_amount`, `discount_amount`, `shipping_amount`, `tax_amount`, `total_amount`, `currency`, `shipping_method`, `tracking_number`
specs/05-BUSINESS-LOGIC.md:1317:**Store isolation:** Customer accounts are per-store. The same email address can exist in different stores as separate accounts. Authentication always scopes the query to the current store.
app/Http/Requests/Api/Storefront/V1/StoreCartRequest.php:30:            $currencyRules[] = Rule::in([$store->default_currency]);
database/seeders/StoreSettingsSeeder.php:20:                    'settings_json' => [
specs/progress.md:35:| Seed/test data | `specs/07-SEEDERS-AND-TEST-DATA.md` | partial | Seeders create Acme Fashion and Acme Electronics stores/domains/settings/admin access, 6 collections, 25 products, 127 variants, 127 inventory rows, 206 variant option pivots, 2 themes, 6 theme files, 2 theme settings rows, 6 pages, 4 navigation menus, 17 navigation items, 2 tax settings rows, 2 shipping zones, 6 shipping rates, 6 discounts, and no product media/runtime carts. Order/payment seed data is still missing. |
specs/progress.md:50:- 2026-05-03: Boost schema summary confirmed Phase 1 tables: organizations, stores, store_domains, store_users, store_settings, customers, customer_password_reset_tokens, users with password_hash.
specs/progress.md:57:- 2026-05-03: Boost schema summary confirmed Phase 2 catalog tables. Boost query counts after fresh seed: stores 2, products 25, variants 127, inventory_items 127, collections 6, product_media 0, variant_option_values 206.
database/seeders/StoreSeeder.php:28:                    'default_currency' => 'EUR',
specs/09-IMPLEMENTATION-ROADMAP.md:105:2. `create_stores_table` - FK: `organization_id`
specs/09-IMPLEMENTATION-ROADMAP.md:161:4. Return 503 for suspended stores on storefront routes
specs/09-IMPLEMENTATION-ROADMAP.md:962:6. Seed data: comprehensive seeders for demo stores
specs/09-IMPLEMENTATION-ROADMAP.md:1150:| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |
specs/09-IMPLEMENTATION-ROADMAP.md:1151:| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |
specs/09-IMPLEMENTATION-ROADMAP.md:1183:| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |
specs/09-IMPLEMENTATION-ROADMAP.md:1194:| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1195:| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |
specs/09-IMPLEMENTATION-ROADMAP.md:1196:| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |
specs/09-IMPLEMENTATION-ROADMAP.md:1231:| allows duplicate SKU across different stores | SKU "TSH-001" in store A; create variant with "TSH-001" in store B | success | edge case |
specs/09-IMPLEMENTATION-ROADMAP.md:1346:| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1384:| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1668:| lists products with authentication | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response, paginated product list | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1669:| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1670:| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1671:| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1672:| requires write-products ability for mutations | token with only read-products; POST /api/admin/v1/stores/{storeId}/products | 403 response | authorization |
specs/09-IMPLEMENTATION-ROADMAP.md:1673:| returns 401 without token | GET /api/admin/v1/stores/{storeId}/products without auth header | 401 response | authorization |
specs/09-IMPLEMENTATION-ROADMAP.md:1682:| lists orders with authentication | GET /api/admin/v1/stores/{storeId}/orders with Bearer token | 200 response, order list | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1683:| retrieves a single order | GET /api/admin/v1/stores/{storeId}/orders/{id} | 200 response, order with lines, payments, fulfillments | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1684:| filters orders by status | GET /api/admin/v1/stores/{storeId}/orders?status=paid | only paid orders returned | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1685:| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1686:| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |
specs/08-PLAYWRIGHT-E2E-PLAN.md:2442:- Products from other stores (e.g., electronics from Store 2) are absent
database/seeders/ProductSeeder.php:130:                'currency' => $store->default_currency,
specs/03-ADMIN-UI.md:127:| `stores` | `Collection` | List of stores the user has access to |
specs/03-ADMIN-UI.md:155:- When user clicks the store name, a dropdown appears listing all stores. Clicking a store calls `switchStore()` which updates the session and redirects to the dashboard.
specs/07-SEEDERS-AND-TEST-DATA.md:16:| 2 | StoreSeeder | Organizations | Creates stores under organizations |
specs/07-SEEDERS-AND-TEST-DATA.md:17:| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |
specs/07-SEEDERS-AND-TEST-DATA.md:19:| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |
specs/07-SEEDERS-AND-TEST-DATA.md:102:| default_currency | "EUR" |
specs/07-SEEDERS-AND-TEST-DATA.md:362:| order_number | "#" + Faker unique integer 1001-9999 |
specs/07-SEEDERS-AND-TEST-DATA.md:762:Creates two stores under "Acme Corp".
specs/07-SEEDERS-AND-TEST-DATA.md:771:| default_currency | EUR |
specs/07-SEEDERS-AND-TEST-DATA.md:782:| default_currency | EUR |
specs/07-SEEDERS-AND-TEST-DATA.md:833:**Acme Fashion - settings_json contents:**
specs/07-SEEDERS-AND-TEST-DATA.md:839:| order_number_prefix | # |
specs/07-SEEDERS-AND-TEST-DATA.md:840:| order_number_start | 1001 |
specs/07-SEEDERS-AND-TEST-DATA.md:842:**Acme Electronics - settings_json contents:**
specs/07-SEEDERS-AND-TEST-DATA.md:848:| order_number_prefix | # |
specs/07-SEEDERS-AND-TEST-DATA.md:849:| order_number_start | 5001 |
specs/07-SEEDERS-AND-TEST-DATA.md:2068:| checkout_completed | order_id, order_number, total_amount |
specs/07-SEEDERS-AND-TEST-DATA.md:2196:Each seeder should use `firstOrCreate` or `updateOrCreate` keyed on unique business fields (handle, email, order_number) so that `php artisan db:seed` can be run multiple times without duplicating data.
database/migrations/2026_05_03_223837_create_theme_settings_table.php:16:            $table->text('settings_json')->default('{}');
database/migrations/2026_05_04_000408_create_orders_table.php:19:            $table->string('order_number');
database/migrations/2026_05_04_000408_create_orders_table.php:36:            $table->unique(['store_id', 'order_number']);
database/migrations/2026_05_03_211413_create_stores_table.php:14:        Schema::create('stores', function (Blueprint $table) {
database/migrations/2026_05_03_211413_create_stores_table.php:20:            $table->string('default_currency', 3)->default('USD');
database/migrations/2026_05_03_211413_create_stores_table.php:34:        Schema::dropIfExists('stores');
database/migrations/2026_05_03_211419_create_store_settings_table.php:16:            $table->text('settings_json')->default('{}');


cmd: sed -n '480,560p' specs/05-BUSINESS-LOGIC.md


cmd: sed -n '929,1120p' specs/05-BUSINESS-LOGIC.md


cmd: sed -n '1120,1280p' specs/05-BUSINESS-LOGIC.md


cmd: sed -n '751,910p' specs/01-DATABASE-SCHEMA.md


command: sed -n '480,560p' specs/05-BUSINESS-LOGIC.md

type: read

aggregated_output:
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


command: sed -n '929,1120p' specs/05-BUSINESS-LOGIC.md

aggregated_output:
## 10. Payment Processing (Mock PSP)

### 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.

### 10.8 Auto-Cancel Unpaid Bank Transfer Orders

**Job:** `App\Jobs\CancelUnpaidBankTransferOrders`

- Schedule: daily
- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.
- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.

**Cancel action:**
1. Release reserved inventory via `InventoryService.release()`.
2. Update `orders.financial_status` to `voided`.
3. Update `orders.status` to `cancelled`.
4. Update `payments.status` to `failed`.
5. Dispatch `OrderCancelled` event.

---

## 11. Order Management

### 11.1 Order Creation (from Completed Checkout)

See Section 6.2 "Transition: payment_selected -> completed" for the full procedure. The order creation is the central action within the completeCheckout transaction.

**Key requirements for the order record:**
- Snapshot all checkout data (addresses, email, totals) at time of order creation.
- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.
- The mock payment response is encrypted before storage.
- The entire operation must be atomic (single database transaction).
- The `payment_method` from the checkout is copied to the order record.

**Payment-method-specific order creation:**

| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |
|---|---|---|---|---|
| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |
| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |
| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |

### 11.2 Order Numbering

- Sequential per store, starting at 1001.
- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.
- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.
- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.

### 11.3 Order Statuses

Three independent status dimensions tracked on each order:

**Order status** (`orders.status`):

| Status | Description |
|---|---|
| `pending` | Order created, awaiting processing |
| `paid` | Payment confirmed |
| `fulfilled` | All items shipped |
| `cancelled` | Order cancelled before fulfillment |
| `refunded` | Fully refunded |

**Financial status** (`orders.financial_status`):

| Status | Description |
|---|---|
| `pending` | Payment not yet confirmed |
| `authorized` | Payment authorized but not captured |
| `paid` | Payment captured successfully |
| `partially_refunded` | Some items or partial amount refunded |
| `refunded` | Fully refunded |
| `voided` | Authorization voided (no capture occurred) |

**Fulfillment status** (`orders.fulfillment_status`):

| Status | Description |
|---|---|
| `unfulfilled` | No items have been shipped |
| `partial` | Some items shipped, others pending |
| `fulfilled` | All items shipped |

### 11.4 Refund Processing


output:
Chunk ID: a2c088
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 841
Output:
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


output:
Chunk ID: a2d641
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2101
Output:
## 10. Payment Processing (Mock PSP)

### 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.

### 10.8 Auto-Cancel Unpaid Bank Transfer Orders

**Job:** `App\Jobs\CancelUnpaidBankTransferOrders`

- Schedule: daily
- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.
- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.

**Cancel action:**
1. Release reserved inventory via `InventoryService.release()`.
2. Update `orders.financial_status` to `voided`.
3. Update `orders.status` to `cancelled`.
4. Update `payments.status` to `failed`.
5. Dispatch `OrderCancelled` event.

---

## 11. Order Management

### 11.1 Order Creation (from Completed Checkout)

See Section 6.2 "Transition: payment_selected -> completed" for the full procedure. The order creation is the central action within the completeCheckout transaction.

**Key requirements for the order record:**
- Snapshot all checkout data (addresses, email, totals) at time of order creation.
- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.
- The mock payment response is encrypted before storage.
- The entire operation must be atomic (single database transaction).
- The `payment_method` from the checkout is copied to the order record.

**Payment-method-specific order creation:**

| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |
|---|---|---|---|---|
| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |
| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |
| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |

### 11.2 Order Numbering

- Sequential per store, starting at 1001.
- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.
- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.
- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.

### 11.3 Order Statuses

Three independent status dimensions tracked on each order:

**Order status** (`orders.status`):

| Status | Description |
|---|---|
| `pending` | Order created, awaiting processing |
| `paid` | Payment confirmed |
| `fulfilled` | All items shipped |
| `cancelled` | Order cancelled before fulfillment |
| `refunded` | Fully refunded |

**Financial status** (`orders.financial_status`):

| Status | Description |
|---|---|
| `pending` | Payment not yet confirmed |
| `authorized` | Payment authorized but not captured |
| `paid` | Payment captured successfully |
| `partially_refunded` | Some items or partial amount refunded |
| `refunded` | Fully refunded |
| `voided` | Authorization voided (no capture occurred) |

**Fulfillment status** (`orders.fulfillment_status`):

| Status | Description |
|---|---|
| `unfulfilled` | No items have been shipped |
| `partial` | Some items shipped, others pending |
| `fulfilled` | All items shipped |

### 11.4 Refund Processing


command: sed -n '1120,1280p' specs/05-BUSINESS-LOGIC.md

aggregated_output:
**Service:** `App\Services\RefundService`

**Input (RefundRequest):**

| Field | Type | Required | Description |
|---|---|---|---|
| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |
| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |
| `reason` | string | No | Text reason for the refund. |
| `restock` | boolean | No | Whether to restock inventory for refunded items. |

**Process:**

```
PROCEDURE processRefund(order, request):
    1. Calculate remaining refundable amount:
       refundable = order.total_amount - SUM(existing refunds for this order)

    2. IF request.amount is set:
           VALIDATE request.amount <= refundable
       ELSE IF request.lines is set:
           CALCULATE amount from specified lines and quantities
       ELSE:
           amount = refundable (full refund)

    3. CALL PaymentProvider.refund(order's payment, amount)

    4. CREATE Refund record with status = 'pending'

    5. On provider confirmation:
       UPDATE refund status = 'processed'

    6. Calculate total refunded for this order:
       IF total refunded = order.total_amount:
           SET order.financial_status = 'refunded'
           SET order.status = 'refunded'
       ELSE:
           SET order.financial_status = 'partially_refunded'

    7. IF request.restock = true:
       FOR EACH refunded line and quantity:
           CALL InventoryService.restock(variant, quantity)

    8. DISPATCH OrderRefunded event
```

### 11.5 Fulfillment

**Service:** `App\Services\FulfillmentService`

#### Fulfillment Guard

**CRITICAL:** A fulfillment cannot be created unless the order's `financial_status` is `paid` or `partially_refunded`. This prevents shipping goods before payment is confirmed.

| `financial_status` | Fulfillment Allowed? | Reason |
|---|---|---|
| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |
| `authorized` | **No** | Payment authorized but not captured |
| `paid` | **Yes** | Payment confirmed |
| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |
| `refunded` | **No** | Fully refunded, nothing to ship |
| `voided` | **No** | Payment voided / cancelled |

When fulfillment is blocked by the guard, the admin UI shows an info callout: "Fulfillment cannot be created until payment is confirmed." The "Create fulfillment" button is disabled.

#### Creating a Fulfillment

Input:
- `lines` - map of order_line_id to quantity, specifying which items are in this shipment
- `trackingData` - tracking_company, tracking_number, tracking_url

**Process:**

```
PROCEDURE createFulfillment(order, lines, trackingData):
    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')
       IF NOT: throw FulfillmentGuardException

    2. FOR EACH line in request:
       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)
       unfulfilled = order_line.quantity - fulfilledSoFar
       VALIDATE requested quantity <= unfulfilled

    3. CREATE Fulfillment record with status = 'pending' and tracking data

    4. CREATE fulfillment_lines for each order line and quantity

    5. Determine new fulfillment status:
       FOR EACH order_line in order:
           totalFulfilled = SUM(all fulfillment_lines for this order_line)
           IF totalFulfilled < order_line.quantity:
               allFulfilled = false

       IF allFulfilled:
           SET order.fulfillment_status = 'fulfilled'
           SET order.status = 'fulfilled'
       ELSE:
           SET order.fulfillment_status = 'partial'

    6. DISPATCH FulfillmentCreated event
```

#### Fulfillment Status Transitions (Admin Button Actions)

Fulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.

```mermaid
stateDiagram-v2
    [*] --> pending : Fulfillment created (admin clicks "Create fulfillment")
    pending --> shipped : Admin clicks "Mark as shipped"
    shipped --> delivered : Admin clicks "Mark as delivered"

    note right of pending : Fulfillment record created with tracking data
    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer
    note right of delivered : Sets delivered_at timestamp
```

**Admin buttons on each fulfillment card:**

| Button | Shown When | Action |
|---|---|---|
| "Mark as shipped" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |
| "Mark as delivered" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |

When transitioning to `shipped`:
- Set `shipped_at` timestamp.
- Dispatch `FulfillmentShipped` event.
- Send email notification to customer with tracking information.

When transitioning to `delivered`:
- Dispatch `FulfillmentDelivered` event.

### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method

The fulfillment guard interacts with inventory differently depending on the payment method:

| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |
|---|---|---|---|
| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |
| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |

For bank transfer orders, inventory is held in a reserved state until the admin confirms payment. If the order is auto-cancelled (see Section 10.8), the reserved inventory is released back.

### 11.7 Auto-Fulfillment for Digital Products

When a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):

1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).
2. If yes: automatically create a fulfillment record covering all order lines:
   - `status = 'delivered'` (skip pending and shipped)
   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`
   - `shipped_at = now`
   - Set `order.fulfillment_status = 'fulfilled'`
   - Set `order.status = 'fulfilled'`
3. If the order contains a mix of physical and digital products: do **not** auto-fulfill. The admin must create fulfillments manually (they can fulfill digital items immediately and physical items when shipped).

This ensures digital product orders are immediately marked as complete after payment.

### 11.6 Order Export


output:
Chunk ID: 201a9d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1661
Output:

**Service:** `App\Services\RefundService`

**Input (RefundRequest):**

| Field | Type | Required | Description |
|---|---|---|---|
| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |
| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |
| `reason` | string | No | Text reason for the refund. |
| `restock` | boolean | No | Whether to restock inventory for refunded items. |

**Process:**

```
PROCEDURE processRefund(order, request):
    1. Calculate remaining refundable amount:
       refundable = order.total_amount - SUM(existing refunds for this order)

    2. IF request.amount is set:
           VALIDATE request.amount <= refundable
       ELSE IF request.lines is set:
           CALCULATE amount from specified lines and quantities
       ELSE:
           amount = refundable (full refund)

    3. CALL PaymentProvider.refund(order's payment, amount)

    4. CREATE Refund record with status = 'pending'

    5. On provider confirmation:
       UPDATE refund status = 'processed'

    6. Calculate total refunded for this order:
       IF total refunded = order.total_amount:
           SET order.financial_status = 'refunded'
           SET order.status = 'refunded'
       ELSE:
           SET order.financial_status = 'partially_refunded'

    7. IF request.restock = true:
       FOR EACH refunded line and quantity:
           CALL InventoryService.restock(variant, quantity)

    8. DISPATCH OrderRefunded event
```

### 11.5 Fulfillment

**Service:** `App\Services\FulfillmentService`

#### Fulfillment Guard

**CRITICAL:** A fulfillment cannot be created unless the order's `financial_status` is `paid` or `partially_refunded`. This prevents shipping goods before payment is confirmed.

| `financial_status` | Fulfillment Allowed? | Reason |
|---|---|---|
| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |
| `authorized` | **No** | Payment authorized but not captured |
| `paid` | **Yes** | Payment confirmed |
| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |
| `refunded` | **No** | Fully refunded, nothing to ship |
| `voided` | **No** | Payment voided / cancelled |

When fulfillment is blocked by the guard, the admin UI shows an info callout: "Fulfillment cannot be created until payment is confirmed." The "Create fulfillment" button is disabled.

#### Creating a Fulfillment

Input:
- `lines` - map of order_line_id to quantity, specifying which items are in this shipment
- `trackingData` - tracking_company, tracking_number, tracking_url

**Process:**

```
PROCEDURE createFulfillment(order, lines, trackingData):
    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')
       IF NOT: throw FulfillmentGuardException

    2. FOR EACH line in request:
       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)
       unfulfilled = order_line.quantity - fulfilledSoFar
       VALIDATE requested quantity <= unfulfilled

    3. CREATE Fulfillment record with status = 'pending' and tracking data

    4. CREATE fulfillment_lines for each order line and quantity

    5. Determine new fulfillment status:
       FOR EACH order_line in order:
           totalFulfilled = SUM(all fulfillment_lines for this order_line)
           IF totalFulfilled < order_line.quantity:
               allFulfilled = false

       IF allFulfilled:
           SET order.fulfillment_status = 'fulfilled'
           SET order.status = 'fulfilled'
       ELSE:
           SET order.fulfillment_status = 'partial'

    6. DISPATCH FulfillmentCreated event
```

#### Fulfillment Status Transitions (Admin Button Actions)

Fulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.

```mermaid
stateDiagram-v2
    [*] --> pending : Fulfillment created (admin clicks "Create fulfillment")
    pending --> shipped : Admin clicks "Mark as shipped"
    shipped --> delivered : Admin clicks "Mark as delivered"

    note right of pending : Fulfillment record created with tracking data
    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer
    note right of delivered : Sets delivered_at timestamp
```

**Admin buttons on each fulfillment card:**

| Button | Shown When | Action |
|---|---|---|
| "Mark as shipped" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |
| "Mark as delivered" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |

When transitioning to `shipped`:
- Set `shipped_at` timestamp.
- Dispatch `FulfillmentShipped` event.
- Send email notification to customer with tracking information.

When transitioning to `delivered`:
- Dispatch `FulfillmentDelivered` event.

### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method

The fulfillment guard interacts with inventory differently depending on the payment method:

| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |
|---|---|---|---|
| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |
| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |

For bank transfer orders, inventory is held in a reserved state until the admin confirms payment. If the order is auto-cancelled (see Section 10.8), the reserved inventory is released back.

### 11.7 Auto-Fulfillment for Digital Products

When a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):

1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).
2. If yes: automatically create a fulfillment record covering all order lines:
   - `status = 'delivered'` (skip pending and shipped)
   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`
   - `shipped_at = now`
   - Set `order.fulfillment_status = 'fulfilled'`
   - Set `order.status = 'fulfilled'`
3. If the order contains a mix of physical and digital products: do **not** auto-fulfill. The admin must create fulfillments manually (they can fulfill digital items immediately and physical items when shipped).

This ensures digital product orders are immediately marked as complete after payment.

### 11.6 Order Export


command: sed -n '751,910p' specs/01-DATABASE-SCHEMA.md

aggregated_output:
### orders

Placed orders with financial and fulfillment tracking.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |
| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |
| payment_method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used for this order |
| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |
| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |
| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |
| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |
| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |
| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |
| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |
| total_amount | INTEGER | No | 0 | - | Grand total in minor units |
| email | TEXT | Yes | NULL | - | Contact email at time of order |
| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |
| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |
| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store
- `idx_orders_store_id` on (`store_id`) - FK lookup
- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer
- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status
- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status
- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status
- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries

---

### order_lines

Individual items within an order. Contains snapshots of product data at time of purchase.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |
| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |
| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |
| title_snapshot | TEXT | No | - | - | Product title at time of purchase |
| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |
| quantity | INTEGER | No | 1 | - | Number of units |
| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |
| total_amount | INTEGER | No | 0 | - | Line total in minor units |
| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |
| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |

**Indexes:**

- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order
- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product
- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant

**Notes:** `product_id` and `variant_id` are nullable with SET NULL on delete so that order history survives product deletion. `tax_lines_json` example: `[{"title": "State Tax", "rate": 800, "amount": 400}]` (rate in basis points, e.g. 800 = 8%). `discount_allocations_json` example: `[{"discount_id": 5, "amount": 250}]`.

---

### payments

Payment attempts and captures for an order.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |
| provider | TEXT | No | 'mock' | CHECK IN ('mock') | Payment provider (mock PSP only) |
| method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used |
| provider_payment_id | TEXT | Yes | NULL | - | Mock payment reference ID |
| status | TEXT | No | 'pending' | CHECK IN ('pending', 'captured', 'failed', 'refunded') | |
| amount | INTEGER | No | 0 | - | Payment amount in minor units |
| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order
- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Payment reconciliation by provider ID
- `idx_payments_method` on (`method`) - Filtering payments by method
- `idx_payments_status` on (`status`) - Filtering payments by status

**Notes:** `raw_json_encrypted` stores the mock payment provider response, encrypted at the application layer. `amount` is in minor units (cents). The `provider` is always `mock` since the platform uses a mock PSP (no real payment gateway integration). The `method` column records which payment method the customer selected (credit_card, paypal, or bank_transfer).

---

### refunds

Refund records linked to an order and payment.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |
| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |
| amount | INTEGER | No | 0 | - | Refund amount in minor units |
| reason | TEXT | Yes | NULL | - | Refund reason |
| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |
| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order
- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment
- `idx_refunds_status` on (`status`) - Filtering refunds by status

---

### fulfillments

Shipment tracking for order fulfillment.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |
| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |
| tracking_company | TEXT | Yes | NULL | - | Carrier name |
| tracking_number | TEXT | Yes | NULL | - | Tracking number |
| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |
| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order
- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status
- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup

---

### fulfillment_lines

Links specific order lines and quantities to a fulfillment.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |
| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |
| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |

**Indexes:**

- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment
- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment

**Notes:** An order with multiple line items may be partially fulfilled. This table tracks how many units of each line item are included in each shipment.

---