Output:
Wall time: 3.9707 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Components Button > Loading\nComponents Button | livewire/flux@2.x | Source: https://fluxui.dev/components/button#livewire/flux\n\n
Loading

Buttons with wire:click or type=\"submit\" will automatically show a loading indicator and disable pointer events during network requests.

<flux:button wire:click=\"save\">    Save changes</flux:button>

You can disable this behavior using :loading=\"false\".

<flux:button wire:click=\"save\" :loading=\"false\">

\n\n---\n\n## Redirecting Redirecting to full-page components\nRedirecting | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/redirecting\n\n## Redirecting to full-page components\n\nBecause Livewire uses Laravel's built-in redirection feature, you can use all of the redirection methods available to you in a typical Laravel application.\n\nFor example, if you are using a Livewire component as a full-page component for a route like so:\n\n```php\nRoute::livewire('/posts', 'pages::show-posts');\n```\n\nYou can redirect to it simply by using the route path:\n\n```php\npublic function save()\n{\n    // ...\n\n    $this->redirect('/posts');\n}\n```\n\n---\n\n## Security Authorizing action parameters\nSecurity | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/security\n\n## Authorizing action parameters\n\nLivewire actions are extremely powerful, however, any parameters passed to Livewire actions are mutable on the client and should be treated as un-trusted user input.\n\nArguably the most common security pitfall in Livewire is failing to validate and authorize Livewire action calls before persisting changes to the database.\n\nHere is an example of an insecurity resulting from a lack of authorization:\n\n```php\n<?php\n\nuse App\\Models\\Post;\nuse Livewire\\Component;\n\nclass ShowPost extends Component\n{\n    // ...\n\n    public function delete($id)\n    {\n        // INSECURE!\n\n        $post = Post::find($id);\n\n        $post->delete();\n    }\n}\n```\n\n```html\n<button wire:click=\"delete({{ $post->id }})\">Delete Post</button>\n```\n\n\nThe reason the above example is insecure is that `wire:click=\"delete(...)\"` can be modified in the browser to pass ANY post ID a malicious user wishes.\n\nAction parameters (like `$id` in this case) should be treated the same as any untrusted input from the browser.\n\nTherefore, to keep this application secure and prevent a user from deleting another user's post, we must add authorization to the `delete()` action.\n\nFirst, let's create a [Laravel Policy](https://laravel.com/docs/authorization#creating-policies) for the Post model by running the following command:\n\n```bash\nphp artisan make:policy PostPolicy --model=Post\n```\n\nAfter running the above command, a new Policy will be created inside `app/Policies/PostPolicy.php`. We can then update its contents with a `delete` method like so:\n\n```php\n<?php\n\nnamespace App\\Policies;\n\nuse App\\Models\\Post;\nuse App\\Models\\User;\n\nclass PostPolicy\n{\n    /**\n     * Determine if the given post can be deleted by the user.\n     */\n    public function delete(?User $user, Post $post): bool\n    {\n        return $user?->id === $post->user_id;\n    }\n}\n```\n\nNow, we can use the `$this->authorize()` method from the Livewire component to ensure the user owns the post before deleting it:\n\n```php\npublic function delete($id)\n{\n    $post = Post::find($id);\n\n    // If the user doesn't own the post,\n    // an AuthorizationException will be thrown...\n    $this->authorize('delete', $post); // [tl! highlight]\n\n    $post->delete();\n}\n```\n\nFurther reading:\n* [Laravel Gates](https://laravel.com/docs/authorization#gates)\n* [Laravel Policies](https://laravel.com/docs/authorization#creating-policies)\n\n---\n\n## Understanding Nesting Every component is independent {#every-component-is-an-island}\nUnderstanding Nesting | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/understanding-nesting\n\n## Every component is independent {#every-component-is-an-island}\n\nIn Livewire, every component on a page tracks its state and makes updates independently of other components.\n\nFor example, consider the following `Posts` and nested `ShowPost` components:\n\n```php\n<?php\n\nnamespace App\\Livewire;\n\nuse Illuminate\\Support\\Facades\\Auth;\nuse Livewire\\Component;\n\nclass Posts extends Component\n{\n    public $postLimit = 2;\n\n    public function render()\n    {\n        return view('livewire.posts', [\n            'posts' => Auth::user()->posts()\n                ->limit($this->postLimit)->get(),\n        ]);\n    }\n}\n```\n\n```blade\n<div>\n    Post Limit: <input type=\"number\" wire:model.live=\"postLimit\">\n\n    @foreach ($posts as $post)\n        <livewire:show-post :$post :wire:key=\"$post->id\">\n    @endforeach\n</div>\n```\n\n```php\n<?php\n\nnamespace App\\Livewire;\n\nuse Illuminate\\Support\\Facades\\Auth;\nuse Livewire\\Component;\nuse App\\Models\\Post;\n\nclass ShowPost extends Component\n{\n    public Post $post;\n\n    public function render()\n    {\n        return view('livewire.show-post');\n    }\n}\n```\n\n```blade\n<div>\n    <h1>{{ $post->title }}</h1>\n\n    <p>{{ $post->content }}</p>\n\n    <button wire:click=\"$refresh\">Refresh post</button>\n</div>\n```\n\nHere's what the HTML for the entire component tree might look like on initial page load:\n\n```html\n<div wire:id=\"123\" wire:snapshot=\"...\">\n    Post Limit: <input type=\"number\" wire:model.live=\"postLimit\">\n\n    <div wire:id=\"456\" wire:snapshot=\"...\">\n        <h1>The first post</h1>\n\n        <p>Post content</p>\n\n        <button wire:click=\"$refresh\">Refresh post</button>\n    </div>\n\n    <div wire:id=\"789\" wire:snapshot=\"...\">\n        <h1>The second post</h1>\n\n        <p>Post content</p>\n\n        <button wire:click=\"$refresh\">Refresh post</button>\n    </div>\n</div>\n```\n\nNotice that the parent component contains both its rendered template and the rendered templates of all the components nested within it.\n\nBecause each component is independent, they each have their own IDs and snapshots (`wire:id` and `wire:snapshot`) embedded in their HTML for Livewire's JavaScript core to extract and track.\n\nLet's consider a few different update scenarios to see the differences in how Livewire handles different levels of nesting.\n\n### Updating a child\n\nIf you were to click the \"Refresh post\" button in one of the child `show-post` components, here is what would be sent to the server:\n\n```js\n{\n    memo: { name: 'show-post', id: '456' },\n\n    state: { ... },\n}\n```\n\nHere's the HTML that would get sent back:\n\n```html\n<div wire:id=\"456\">\n    <h1>The first post</h1>\n\n    <p>Post content</p>\n\n    <button wire:click=\"$refresh\">Refresh post</button>\n</div>\n```\n\nThe important thing to note here is that when an update is triggered on a child component, only that component's data is sent to the server, and only that component is re-rendered.\n\nNow let's look at the less intuitive scenario: updating a parent component.\n\n### Updating the parent\n\nAs a reminder, here's the Blade template of the parent `Posts` component:\n\n```blade\n<div>\n    Post Limit: <input type=\"number\" wire:model.live=\"postLimit\">\n\n    @foreach ($posts as $post)\n        <livewire:show-post :$post :wire:key=\"$post->id\">\n    @endforeach\n</div>\n```\n\nIf a user changes the \"Post Limit\" value from `2` to `1`, an update will be solely triggered on the parent.\n\nHere's an example of what the request payload might look like:\n\n```js\n{\n    updates: { postLimit: 1 },\n\n    snapshot: {\n        memo: { name: 'posts', id: '123' },\n\n        state: { postLimit: 2, ... },\n    },\n}\n```\n\nAs you can see, only the snapshot for the parent `Posts` component is sent along to the server.\n\nAn important question you might be asking yourself is: what happens when the parent component re-renders and encounters the child `show-post` components? How will it re-render the children if their snapshots haven't been included in the request payload?\n\nThe answer is: they won't be re-rendered.\n\nWhen Livewire renders the `Posts` component, it will render placeholders for any child components it encounters.\n\nHere is an example of what the rendered HTML for the `Posts` component might be after the above update:\n\n```html\n<div wire:id=\"123\">\n    Post Limit: <input type=\"number\" wire:model.live=\"postLimit\">\n\n    <div wire:id=\"456\"></div>\n</div>\n```\n\nAs you can see, only one child has been rendered because `postLimit` was updated to `1`. However, you will also notice that instead of the full child component, there is only an empty `<div></div>` with the matching `wire:id` attribute.\n\nWhen this HTML is received on the frontend, Livewire will _morph_ the old HTML for this component into this new HTML, but intelligently skip any child component placeholders.\n\nThe effect is that, after _morphing_, the final DOM content of the parent `Posts` component will be the following:\n\n```html\n<div wire:id=\"123\">\n    Post Limit: <input type=\"number\" wire:model.live=\"postLimit\">\n\n    <div wire:id=\"456\">\n        <h1>The first post</h1>\n\n        <p>Post content</p>\n\n        <button wire:click=\"$refresh\">Refresh post</button>\n    </div>\n</div>\n```\n\n---\n\n## Lazy Full-page lazy loading\nLazy | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/lazy\n\n## Full-page lazy loading\n\nYou can lazy load or defer full-page Livewire components using route methods.\n\n### Lazy loading full pages\n\nUse `->lazy()` to load the component when it enters the viewport:\n\n```php\nRoute::livewire('/dashboard', 'pages::dashboard')->lazy();\n```\n\n### Deferring full pages\n\nUse `->defer()` to load the component immediately after the page loads:\n\n```php\nRoute::livewire('/dashboard', 'pages::dashboard')->defer();\n```\n\n### Disabling lazy/defer loading\n\nIf a component is lazy or deferred by default (via the `#[Lazy]` or `#[Defer]` attribute), you can opt-out using `enabled: false`:\n\n```php\nRoute::livewire('/dashboard', 'pages::dashboard')->lazy(enabled: false);\nRoute::livewire('/dashboard', 'pages::dashboard')->defer(enabled: false);\n```\n\n---\n\n## Testing Configuring Pest for view-based components\nTesting | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/testing\n\n## Configuring Pest for view-based components\n\nIf you're writing tests alongside your view-based components (single-file or multi-file), you'll need to configure Pest to recognize these test files.\n\nFirst, update your `tests/Pest.php` file to include the `resources/views` directory:\n\n```php\npest()->extend(Tests\\TestCase::class)\n    // ...\n    ->in('Feature', '../resources/views');\n```\n\nThis tells Pest to use your `TestCase` base class for tests found in both the `tests/Feature` directory and anywhere within `resources/views`.\n\nNext, update your `phpunit.xml` file to include a test suite for component tests:\n\n```xml\n<testsuite name=\"Components\">\n    <directory suffix=\".test.php\">resources/views</directory>\n</testsuite>\n```\n\nNow Pest will recognize and run tests located next to your components when you run `./vendor/bin/pest`.\n\n---\n\n## Attribute Title Only for full-page components\nAttribute Title | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-title\n\n## Only for full-page components\n\n> [!info] Full-page components only\n> The `#[Title]` attribute only works for full-page components accessed via routes. Regular components rendered within other views don't use titles—they inherit the parent page's title.\n\n---\n\n## Components Modal > $flux.modal()\nComponents Modal | livewire/flux@2.x | Source: https://fluxui.dev/components/modal#livewire/flux\n\n
$flux.modal()

Alpine.js magic method for controlling modals.

<ui-table-scroll-area>

Parameter

Description

\n    default|name\n

Name of the modal to control.

</ui-table-scroll-area>

<ui-table-scroll-area>

Method

Description

\n    show()\n

Shows the modal.

\n    close()\n

Closes the modal.

</ui-table-scroll-area>

\n\n---\n\n## Attribute Authorize Learn more\nAttribute Authorize | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-authorize\n\n## Learn more\n\nFor more information on defining abilities and policies, see the [Laravel Authorization documentation](https://laravel.com/docs/authorization).\n\n---\n\n## Troubleshooting Component mismatches\nTroubleshooting | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/troubleshooting\n\n## Component mismatches\n\nWhen interacting with Livewire components on your page, you may encounter odd behavior or error messages like the following:\n\n```\nError: Component already initialized\n```\n\n```\nError: Snapshot missing on Livewire component with id: ...\n```\n\nThere are lots of reasons why you may encounter these messages, but the most common one is forgetting to add `wire:key` to elements and components inside a `@foreach` loop.\n\n### Adding `wire:key`\n\nAny time you have a loop or switch case in your Blade templates using something like `@foreach`, or `@switch`/`@case`, you need to add `wire:key` to the opening tag of the first element:\n\n```blade\n@foreach($posts as $post)\n    <div wire:key=\"{{ $post->id }}\"> <!-- [tl! highlight] -->\n        ...\n    </div>\n@endforeach\n```\n\nThis ensures that Livewire can keep track of different elements in the loop or conditional when it changes.\n\nThe same applies to Livewire components within a loop:\n\n```blade\n@foreach($posts as $post)\n    <livewire:show-post :$post :wire:key=\"$post->id\" /> <!-- [tl! highlight] -->\n@endforeach\n```\n\nHowever, here's a tricky scenario you might not have assumed:\n\nWhen you have a Livewire component deeply nested inside a `@foreach` loop, you STILL need to add a key to it. For example:\n\n```blade\n@foreach($posts as $post)\n    <div wire:key=\"{{ $post->id }}\">\n        ...\n        <livewire:show-post :$post :wire:key=\"$post->id\" /> <!-- [tl! highlight] -->\n        ...\n    </div>\n@endforeach\n```\n\nWithout the key on the nested Livewire component, Livewire will be unable to match the looped components up between network requests.\n\n#### Prefixing keys\n\nAnother tricky scenario you may run into is having duplicate keys within the same component. This often results from using model IDs as keys, which can sometimes collide.\n\nHere's an example where we need to add a `post-` and an `author-` prefix to designate each set of keys as unique. Otherwise, if you have a `$post` and `$author` model with the same ID, you would have an ID collision:\n\n```blade\n<div>\n    @foreach($posts as $post)\n        <div wire:key=\"post-{{ $post->id }}\">...</div> <!-- [tl! highlight] -->\n    @endforeach\n\n    @foreach($authors as $author)\n        <div wire:key=\"author-{{ $author->id }}\">...</div> <!-- [tl! highlight] -->\n    @endforeach\n</div>\n```\n\n---\n\n## Authorization Authorizing Actions Using Policies\nAuthorization | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/authorization.md\n\n\n## Authorizing Actions Using Policies\n\n---\n\n## Components Modal > Flux::modals()\nComponents Modal | livewire/flux@2.x | Source: https://fluxui.dev/components/modal#livewire/flux\n\n
Flux::modals()

PHP method for controlling all modals on the page.

<ui-table-scroll-area>

Method

Description

\n    close()\n

Closes all modals on the page.

</ui-table-scroll-area>

\n\n---\n\n## Attribute Authorize When NOT to use\nAttribute Authorize | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-authorize\n\n## When NOT to use\n\n> [!warning]\n> The `#[Authorize]` attribute only protects server-side execution of an action. It does not hide UI elements in your Blade template.\n\nYou should still use Blade's `@can` directives to hide buttons the user isn't allowed to use:\n\n```blade\n@can('update', $post)\n    <button wire:click=\"save\">Save</button>\n@endcan\n```\n\n---\n\n## Components Button > Full width\nComponents Button | livewire/flux@2.x | Source: https://fluxui.dev/components/button#livewire/flux\n\n
Full width

A button that spans the full width of the container.

<flux:button variant=\"primary\" class=\"w-full\">Send invite</flux:button>

\n\n---\n\n## Components Modal > Flux::modal()\nComponents Modal | livewire/flux@2.x | Source: https://fluxui.dev/components/modal#livewire/flux\n\n
Flux::modal()

PHP method for controlling modals from Livewire components.

<ui-table-scroll-area>

Parameter

Description

\n    default|name\n

Name of the modal to control.

</ui-table-scroll-area>

<ui-table-scroll-area>

Method

Description

\n    close()\n

Closes the modal.

</ui-table-scroll-area>

\n\n---\n\n## Components Modal > flux:modal.close\nComponents Modal | livewire/flux@2.x | Source: https://fluxui.dev/components/modal#livewire/flux\n\n
flux:modal.close

<ui-table-scroll-area>

Slot

Description

\n    default\n

The close trigger element (e.g., button).

</ui-table-scroll-area>

\n\n---\n\n## Components Modal > Modal\nComponents Modal | livewire/flux@2.x | Source: https://fluxui.dev/components/modal#livewire/flux\n\n

\n    Modal\n

Display content in a layer above the main page. Ideal for confirmations, alerts, and forms.

<flux:modal.trigger name=\"edit-profile\">    <flux:button>Edit profile</flux:button></flux:modal.trigger><flux:modal name=\"edit-profile\" class=\"md:w-96\">

            <flux:heading size=\"lg\">Update profile</flux:heading>            <flux:text class=\"mt-2\">Make changes to your personal details.</flux:text>
        <flux:input label=\"Name\" placeholder=\"Your name\" />        <flux:input label=\"Date of birth\" type=\"date\" />
            <flux:spacer />            <flux:button type=\"submit\" variant=\"primary\">Save changes</flux:button>

</flux:modal>

\n\n---\n\n## Search Full-Text Search Running Full-Text Queries\nSearch | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/search.md\n\n\n### Running Full-Text Queries\n\nOnce the index is in place, use the `whereFullText` query builder method to search against it. Laravel will generate the appropriate SQL for your database driver — for example, `MATCH(...) AGAINST(...)` on MariaDB and MySQL, and `to_tsvector(...) @@ plainto_tsquery(...)` on PostgreSQL:\n\n```php\n$articles = Article::whereFullText('body', 'web developer')->get();\n```\n\nWhen using MariaDB and MySQL, results are automatically ordered by relevance score. On PostgreSQL, `whereFullText` filters matching records but does not order them by relevance — if you need automatic relevance ordering on PostgreSQL, consider using [Scout's database engine](#database-engine), which handles this for you.\n\nIf you created a composite full-text index across multiple columns, you may search against all of them by passing the same array of columns to `whereFullText`:\n\n```php\n$articles = Article::whereFullText(\n    ['title', 'body'], 'web developer'\n)->get();\n```\n\nThe `orWhereFullText` method may be used to add a full-text search clause as an \"or\" condition. For complete details, consult the [query builder documentation](/docs/{{version}}/queries#full-text-where-clauses).\n\n---\n\n## Attribute Authorize Stacking multiple checks\nAttribute Authorize | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-authorize\n\n## Stacking multiple checks\n\nThe attribute is repeatable, so you can stack multiple authorization checks on a single method:\n\n```php\n#[Authorize('create', Post::class)]\n#[Authorize('update', 'post')]\npublic function save()\n{\n    // Both checks must pass...\n}\n```\n\n---\n\n## Styles When to use component styles\nStyles | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/styles\n\n## When to use component styles\n\n**Use scoped styles when:**\n- Styling is specific to a single component\n- You want to avoid CSS class name collisions\n- You're building reusable, self-contained components\n\n**Use global styles when:**\n- You need to style elements outside your component\n- You're defining utility classes used across multiple components\n- You're overriding third-party library styles\n\n**Use `@assets` for external stylesheets:**\n- When loading CSS from a CDN\n- When including third-party library styles\n\n```blade\n@assets\n<link rel=\"stylesheet\" href=\"https://cdn.example.com/library.css\">\n@endassets\n```\n\n---\n\n## Components See also\nComponents | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/components\n\n## See also\n\n- **[Properties](/docs/4.x/properties)** — Manage component state and data\n- **[Actions](/docs/4.x/actions)** — Handle user interactions with methods\n- **[Pages](/docs/4.x/pages)** — Use components as full pages with routing\n- **[Nesting](/docs/4.x/nesting)** — Compose components together and pass data between them\n- **[Lifecycle Hooks](/docs/4.x/lifecycle-hooks)** — Execute code at specific points in a component's lifecycle\n\n_[Truncated]_"}]


I’m gathering the admin order requirements and the existing admin component patterns before editing. The aim is to keep this slice narrow: order list/detail plus service-backed payment, refund, and fulfillment actions.


rg -n "order|fulfillment|refund|payment|bank" specs/03-ADMIN-UI.md specs/02-API-ROUTES.md specs/05-BUSINESS-LOGIC.md specs/09-IMPLEMENTATION-ROADMAP.md


find app/Livewire/Admin resources/views/livewire/admin tests/Feature -maxdepth 4 -type f | sort


/bin/zsh
-lc
sed -n '1,240p' routes/web.php

<?php

use App\Livewire\Admin\Auth\Login as AdminLogin;
use App\Livewire\Admin\Collections\Form as AdminCollectionForm;
use App\Livewire\Admin\Collections\Index as AdminCollectionsIndex;
use App\Livewire\Admin\Inventory\Index as AdminInventoryIndex;
use App\Livewire\Admin\Products\Form as AdminProductForm;
use App\Livewire\Admin\Products\Index as AdminProductsIndex;
use App\Livewire\Storefront\Account\Auth\Login as CustomerLogin;
use App\Livewire\Storefront\Account\Auth\Register as CustomerRegister;
use App\Livewire\Storefront\Account\Orders\Index as CustomerOrdersIndex;
use App\Livewire\Storefront\Account\Orders\Show as CustomerOrderShow;
use App\Livewire\Storefront\Cart\Show as StorefrontCartShow;
use App\Livewire\Storefront\Checkout\Confirmation as StorefrontCheckoutConfirmation;
use App\Livewire\Storefront\Checkout\Show as StorefrontCheckoutShow;
use App\Livewire\Storefront\Collections\Index as StorefrontCollectionsIndex;
use App\Livewire\Storefront\Collections\Show as StorefrontCollectionShow;
use App\Livewire\Storefront\Home as StorefrontHome;
use App\Livewire\Storefront\Pages\Show as StorefrontPageShow;
use App\Livewire\Storefront\Products\Show as StorefrontProductShow;
use App\Livewire\Storefront\Search\Index as StorefrontSearchIndex;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;

Route::middleware(['storefront'])->group(function (): void {
    Route::livewire('/', StorefrontHome::class)->name('home');
    Route::livewire('collections', StorefrontCollectionsIndex::class)->name('collections.index');
    Route::livewire('collections/{handle}', StorefrontCollectionShow::class)->name('collections.show');
    Route::livewire('products/{handle}', StorefrontProductShow::class)->name('products.show');
    Route::livewire('cart', StorefrontCartShow::class)->name('cart.show');
    Route::livewire('checkout', StorefrontCheckoutShow::class)->name('checkout.show');
    Route::livewire('checkout/confirmation/{order}', StorefrontCheckoutConfirmation::class)->name('checkout.confirmation');
    Route::livewire('search', StorefrontSearchIndex::class)->name('search.index');
    Route::livewire('pages/{handle}', StorefrontPageShow::class)->name('pages.show');
});

Route::livewire('admin/login', AdminLogin::class)
    ->middleware('guest')
    ->name('admin.login');

Route::post('admin/logout', function () {
    Auth::guard('web')->logout();

    request()->session()->invalidate();
    request()->session()->regenerateToken();

    return redirect()->route('admin.login');
})->middleware('auth')->name('admin.logout');

Route::middleware(['auth', 'verified', 'admin'])->prefix('admin')->name('admin.')->group(function (): void {
    Route::view('/', 'dashboard')->name('dashboard');
    Route::livewire('products', AdminProductsIndex::class)->name('products.index');
    Route::livewire('products/create', AdminProductForm::class)->name('products.create');
    Route::livewire('products/{product}/edit', AdminProductForm::class)->name('products.edit');
    Route::livewire('inventory', AdminInventoryIndex::class)->name('inventory.index');
    Route::livewire('collections', AdminCollectionsIndex::class)->name('collections.index');
    Route::livewire('collections/create', AdminCollectionForm::class)->name('collections.create');
    Route::livewire('collections/{collection}/edit', AdminCollectionForm::class)->name('collections.edit');
});

Route::middleware(['storefront'])->group(function (): void {
    Route::livewire('account/login', CustomerLogin::class)
        ->middleware('guest:customer')
        ->name('account.login');

    Route::livewire('account/register', CustomerRegister::class)
        ->middleware('guest:customer')
        ->name('account.register');

    Route::livewire('account', CustomerOrdersIndex::class)
        ->middleware('auth:customer')
        ->name('account.dashboard');

    Route::livewire('account/orders/{order}', CustomerOrderShow::class)
        ->middleware('auth:customer')
        ->name('account.orders.show');
});

Route::redirect('dashboard', 'admin')
    ->middleware(['auth', 'verified'])
    ->name('dashboard');

require __DIR__.'/settings.php';


rg -n "order|fulfillment|refund|payment|bank" specs/03-ADMIN-UI.md specs/02-API-ROUTES.md specs/05-BUSINESS-LOGIC.md specs/09-IMPLEMENTATION-ROADMAP.md

rg -n 'order|fulfillment|refund|payment|bank' specs/03-ADMIN-UI.md specs/02-API-ROUTES.md specs/05-BUSINESS-LOGIC.md specs/09-IMPLEMENTATION-ROADMAP.md

Question: order|fulfillment|refund|payment|bank

specs/02-API-ROUTES.md:42:| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\Dashboard` |
specs/02-API-ROUTES.md:70:| GET | `/admin/orders` | Order list with status filters | `Admin\Orders\Index` |
specs/02-API-ROUTES.md:71:| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\Orders\Show` |
specs/02-API-ROUTES.md:78:| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |
specs/02-API-ROUTES.md:169:| GET | `/account/orders` | Order history list | `Storefront\Account\Orders\Index` |
specs/02-API-ROUTES.md:170:| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\Account\Orders\Show` |
specs/02-API-ROUTES.md:193:| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\Checkout\Confirmation` |
specs/02-API-ROUTES.md:439:    C->>API: POST /checkouts/{id}/pay (payment_method + details)
specs/02-API-ROUTES.md:441:    API->>MockPSP: charge(checkout, paymentMethodData)
specs/02-API-ROUTES.md:445:        API->>API: Create order (paid), commit inventory
specs/02-API-ROUTES.md:446:        API-->>C: 200 (order confirmation data)
specs/02-API-ROUTES.md:453:        API->>API: Create order (pending), inventory stays reserved
specs/02-API-ROUTES.md:454:        API-->>C: 200 (order confirmation + bank transfer instructions)
specs/02-API-ROUTES.md:721:#### PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method
specs/02-API-ROUTES.md:723:Select a payment method for the checkout. This step records the chosen method without processing payment.
specs/02-API-ROUTES.md:732:  "payment_method": "credit_card"
specs/02-API-ROUTES.md:738:| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |
specs/02-API-ROUTES.md:742:Returns the full checkout object. Status transitions to `payment_selected`.
specs/02-API-ROUTES.md:747:  "status": "payment_selected",
specs/02-API-ROUTES.md:748:  "payment_method": "credit_card",
specs/02-API-ROUTES.md:763:- `422` - Invalid payment method or checkout not in `shipping_selected` state
specs/02-API-ROUTES.md:807:      "description": "10% off your first order"
specs/02-API-ROUTES.md:850:Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.
specs/02-API-ROUTES.md:859:  "payment_method": "credit_card",
specs/02-API-ROUTES.md:871:  "payment_method": "paypal"
specs/02-API-ROUTES.md:879:  "payment_method": "bank_transfer"
specs/02-API-ROUTES.md:885:| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |
specs/02-API-ROUTES.md:886:| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |
specs/02-API-ROUTES.md:887:| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |
specs/02-API-ROUTES.md:888:| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |
specs/02-API-ROUTES.md:889:| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |
specs/02-API-ROUTES.md:897:  "order": {
specs/02-API-ROUTES.md:899:    "order_number": "#1016",
specs/02-API-ROUTES.md:902:    "payment_method": "credit_card",
specs/02-API-ROUTES.md:909:**Success response (Bank Transfer - deferred payment):** `200 OK`
specs/02-API-ROUTES.md:915:  "order": {
specs/02-API-ROUTES.md:917:    "order_number": "#1016",
specs/02-API-ROUTES.md:920:    "payment_method": "bank_transfer",
specs/02-API-ROUTES.md:924:  "bank_transfer_instructions": {
specs/02-API-ROUTES.md:925:    "bank_name": "Mock Bank AG",
specs/02-API-ROUTES.md:936:- `409` - Checkout not in a valid state for payment (must be `payment_selected`)
specs/02-API-ROUTES.md:937:- `422` - Validation errors (missing card fields, invalid payment method)
specs/02-API-ROUTES.md:958:#### GET /api/storefront/v1/orders/{orderNumber}
specs/02-API-ROUTES.md:960:Retrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).
specs/02-API-ROUTES.md:969:| `token` | string | Yes | HMAC-signed access token for this order |
specs/02-API-ROUTES.md:975:  "order_number": "#1001",
specs/02-API-ROUTES.md:978:  "fulfillment_status": "unfulfilled",
specs/02-API-ROUTES.md:1007:  "fulfillments": []
specs/02-API-ROUTES.md:1377:      "read-orders",
specs/02-API-ROUTES.md:1378:      "write-orders",
specs/02-API-ROUTES.md:1674:Archive a product (soft delete). Products with existing orders cannot be hard-deleted.
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-o…56888 chars truncated…t. If the order is auto-cancelled (see Section 10.8), the reserved inventory is released back.
specs/05-BUSINESS-LOGIC.md:1266:When a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):
specs/05-BUSINESS-LOGIC.md:1268:1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).
specs/05-BUSINESS-LOGIC.md:1269:2. If yes: automatically create a fulfillment record covering all order lines:
specs/05-BUSINESS-LOGIC.md:1273:   - Set `order.fulfillment_status = 'fulfilled'`
specs/05-BUSINESS-LOGIC.md:1274:   - Set `order.status = 'fulfilled'`
specs/05-BUSINESS-LOGIC.md:1275: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).
specs/05-BUSINESS-LOGIC.md:1277:This ensures digital product orders are immediately marked as complete after payment.
specs/05-BUSINESS-LOGIC.md:1281:`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.
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:1285:All monetary values are exported in minor units (cents). Dates use ISO-8601 format. The response uses `Content-Type: text/csv` with `Content-Disposition: attachment; filename="orders-{date}.csv"`.
specs/05-BUSINESS-LOGIC.md:1321:- Customers can only view their own orders (scoped by customer_id match).
specs/05-BUSINESS-LOGIC.md:1328:PROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):
specs/05-BUSINESS-LOGIC.md:1331:        LINK order to existingCustomer
specs/05-BUSINESS-LOGIC.md:1337:        LINK order to new customer
specs/05-BUSINESS-LOGIC.md:1355:| `order.created` | OrderCreated event |
specs/05-BUSINESS-LOGIC.md:1356:| `order.paid` | Payment captured successfully |
specs/05-BUSINESS-LOGIC.md:1357:| `order.fulfilled` | All order lines fulfilled |
specs/05-BUSINESS-LOGIC.md:1358:| `order.refunded` | Refund processed |
specs/05-BUSINESS-LOGIC.md:1396:| `X-Platform-Event` | Event type string (e.g., `order.created`) |
specs/05-BUSINESS-LOGIC.md:1481:| `orders_count` | Count of `checkout_completed` events for the day |
specs/05-BUSINESS-LOGIC.md:1482:| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |
specs/05-BUSINESS-LOGIC.md:1483:| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |
specs/05-BUSINESS-LOGIC.md:1579:    6. RETURN ordered collection
specs/05-BUSINESS-LOGIC.md:1594:| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |
specs/05-BUSINESS-LOGIC.md:1595:| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |
specs/05-BUSINESS-LOGIC.md:1604:| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |
specs/05-BUSINESS-LOGIC.md:1605:| `FulfillmentCreated` | Update order fulfillment status |
specs/05-BUSINESS-LOGIC.md:1606:| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |
specs/05-BUSINESS-LOGIC.md:1607:| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |
specs/05-BUSINESS-LOGIC.md:1621:| `App\Jobs\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |
specs/05-BUSINESS-LOGIC.md:1650:| MockPaymentProvider | `App\Services\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |
specs/05-BUSINESS-LOGIC.md:1681:| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentSelected (`payment_selected`), Completed (`completed`), Expired (`expired`) |
specs/05-BUSINESS-LOGIC.md:1682:| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |
specs/05-BUSINESS-LOGIC.md:1683:| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |
specs/05-BUSINESS-LOGIC.md:1684:| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |
specs/05-BUSINESS-LOGIC.md:1685:| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |
specs/05-BUSINESS-LOGIC.md:1705:| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |


Output:
Chunk ID: e42e0a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 16722
Output:
Total output lines: 540

specs/02-API-ROUTES.md:42:| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\Dashboard` |
specs/02-API-ROUTES.md:70:| GET | `/admin/orders` | Order list with status filters | `Admin\Orders\Index` |
specs/02-API-ROUTES.md:71:| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\Orders\Show` |
specs/02-API-ROUTES.md:78:| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |
specs/02-API-ROUTES.md:169:| GET | `/account/orders` | Order history list | `Storefront\Account\Orders\Index` |
specs/02-API-ROUTES.md:170:| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\Account\Orders\Show` |
specs/02-API-ROUTES.md:193:| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\Checkout\Confirmation` |
specs/02-API-ROUTES.md:439:    C->>API: POST /checkouts/{id}/pay (payment_method + details)
specs/02-API-ROUTES.md:441:    API->>MockPSP: charge(checkout, paymentMethodData)
specs/02-API-ROUTES.md:445:        API->>API: Create order (paid), commit inventory
specs/02-API-ROUTES.md:446:        API-->>C: 200 (order confirmation data)
specs/02-API-ROUTES.md:453:        API->>API: Create order (pending), inventory stays reserved
specs/02-API-ROUTES.md:454:        API-->>C: 200 (order confirmation + bank transfer instructions)
specs/02-API-ROUTES.md:721:#### PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method
specs/02-API-ROUTES.md:723:Select a payment method for the checkout. This step records the chosen method without processing payment.
specs/02-API-ROUTES.md:732:  "payment_method": "credit_card"
specs/02-API-ROUTES.md:738:| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |
specs/02-API-ROUTES.md:742:Returns the full checkout object. Status transitions to `payment_selected`.
specs/02-API-ROUTES.md:747:  "status": "payment_selected",
specs/02-API-ROUTES.md:748:  "payment_method": "credit_card",
specs/02-API-ROUTES.md:763:- `422` - Invalid payment method or checkout not in `shipping_selected` state
specs/02-API-ROUTES.md:807:      "description": "10% off your first order"
specs/02-API-ROUTES.md:850:Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.
specs/02-API-ROUTES.md:859:  "payment_method": "credit_card",
specs/02-API-ROUTES.md:871:  "payment_method": "paypal"
specs/02-API-ROUTES.md:879:  "payment_method": "bank_transfer"
specs/02-API-ROUTES.md:885:| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |
specs/02-API-ROUTES.md:886:| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |
specs/02-API-ROUTES.md:887:| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |
specs/02-API-ROUTES.md:888:| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |
specs/02-API-ROUTES.md:889:| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |
specs/02-API-ROUTES.md:897:  "order": {
specs/02-API-ROUTES.md:899:    "order_number": "#1016",
specs/02-API-ROUTES.md:902:    "payment_method": "credit_card",
specs/02-API-ROUTES.md:909:**Success response (Bank Transfer - deferred payment):** `200 OK`
specs/02-API-ROUTES.md:915:  "order": {
specs/02-API-ROUTES.md:917:    "order_number": "#1016",
specs/02-API-ROUTES.md:920:    "payment_method": "bank_transfer",
specs/02-API-ROUTES.md:924:  "bank_transfer_instructions": {
specs/02-API-ROUTES.md:925:    "bank_name": "Mock Bank AG",
specs/02-API-ROUTES.md:936:- `409` - Checkout not in a valid state for payment (must be `payment_selected`)
specs/02-API-ROUTES.md:937:- `422` - Validation errors (missing card fields, invalid payment method)
specs/02-API-ROUTES.md:958:#### GET /api/storefront/v1/orders/{orderNumber}
specs/02-API-ROUTES.md:960:Retrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).
specs/02-API-ROUTES.md:969:| `token` | string | Yes | HMAC-signed access token for this order |
specs/02-API-ROUTES.md:975:  "order_number": "#1001",
specs/02-API-ROUTES.md:978:  "fulfillment_status": "unfulfilled",
specs/02-API-ROUTES.md:1007:  "fulfillments": []
specs/02-API-ROUTES.md:1377:      "read-orders",
specs/02-API-ROUTES.md:1378:      "write-orders",
specs/02-API-ROUTES.md:1674:Archive a product (soft delete). Products with existing orders cannot be hard-deleted.
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:1873:List orders with filtering and pagination.
specs/02-API-ROUTES.md:1879:| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |
specs/02-API-ROUTES.md:1880:| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |
specs/02-API-ROUTES.md:1881:| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |
specs/02-API-ROUTES.md:1885:| `query` | string | No | Search by order number or customer email. |
specs/02-API-ROUTES.md:1897:      "order_number": "#1001",
specs/02-API-ROUTES.md:1900:      "fulfillment_status": "unfulfilled",
specs/02-API-ROUTES.md:1928:#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}
specs/02-API-ROUTES.md:1930:Get full order details.
specs/02-API-ROUTES.md:1939:    "order_number": "#1001",
specs/02-API-ROUTES.md:1942:    "fulfillment_status": "unfulfilled",
specs/02-API-ROUTES.md:1989:    "payments": [
specs/02-API-ROUTES.md:1994:        "provider_payment_id": "mock_abc123",
specs/02-API-ROUTES.md:2001:    "fulfillments": [],
specs/02-API-ROUTES.md:2002:    "refunds": [],
specs/02-API-ROUTES.md:2016:#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments
specs/02-API-ROUTES.md:2018:Create a fulfillment (mark items as shipped).
specs/02-API-ROUTES.md:2029:      "order_line_id": 301,
specs/02-API-ROUTES.md:2043:| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |
specs/02-API-ROUTES.md:2053:    "order_id": 50,
specs/02-API-ROUTES.md:2061:        "order_line_id": 301,
specs/02-API-ROUTES.md:2077:#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds
specs/02-API-ROUTES.md:2079:Create a refund. Supports partial refunds by line or amount.
specs/02-API-ROUTES.md:2089:      "order_line_id": 301,
specs/02-API-ROUTES.md:2099:| `amount` | integer | Yes | Min: 1. Must not exceed total paid amount minus already-refunded amount. In minor units. |
specs/02-API-ROUTES.md:2101:| `line_items` | array | No | Specific lines being refunded. |
specs/02-API-ROUTES.md:2102:| `line_items[].order_line_id` | integer | Yes | Must belong to this order. |
specs/02-API-ROUTES.md:2103:| `line_items[].quantity` | integer | Yes | Min: 1. Must not exceed line quantity minus already-refunded quantity. |
specs/02-API-ROUTES.md:2112:    "order_id": 50,
specs/02-API-ROUTES.md:2113:    "payment_id": 401,
specs/02-API-ROUTES.md:2114:    "provider_refund_id": "re_abc123",
specs/02-API-ROUTES.md:2126:- `409` - Order cannot be refunded (not paid, fully refunded already)
specs/02-API-ROUTES.md:2127:- `422` - Validation errors (amount exceeds refundable total)
specs/02-API-ROUTES.md:2128:- `502` - Payment provider refund failed
specs/02-API-ROUTES.md:2579:      "announcement_text": "Free shipping on orders over 50 EUR!"
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:2796:      "orders_count": 156,
specs/02-API-ROUTES.md:2808:        "orders_count": 12,
specs/02-API-ROUTES.md:2834:#### POST /api/admin/v1/stores/{storeId}/exports/orders
specs/02-API-ROUTES.md:2836:Queue an order export job. The export runs asynchronously and produces a downloadable CSV.
specs/02-API-ROUTES.md:2854:| `filters` | object | No | Same filter options as GET /orders. |
specs/02-API-ROUTES.md:2899:> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).
specs/02-API-ROUTES.md:2984:| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
specs/02-API-ROUTES.md:3122:| `GET /api/storefront/v1/orders*` | `api.storefront` |
specs/02-API-ROUTES.md:3143:    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method
specs/02-API-ROUTES.md:3144:    payment_selected --> completed : POST /checkouts/{id}/pay (instant capture: credit_card/paypal)
specs/02-API-ROUTES.md:3145:    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)
specs/02-API-ROUTES.md:3146:    payment_selected --> shipping_selected : Payment declined, customer retries
specs/02-API-ROUTES.md:3150:    payment_selected --> expired : 24-hour TTL reached
specs/02-API-ROUTES.md:3160:| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |
specs/02-API-ROUTES.md:3161:| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |
specs/02-API-ROUTES.md:3162:| `payment_selected` | `shipping_selected` | Payment declined by Mock PSP (magic card number), customer retries |
specs/09-IMPLEMENTATION-ROADMAP.md:3:> E-commerce platform - Step-by-step build order and exhaustive test plan for Laravel 12 + Livewire v4 + Flux UI Free + SQLite + Tailwind CSS v4
specs/09-IMPLEMENTATION-ROADMAP.md:25:| Payments | Stripe + PayPal | Mock PSP (in-process) | No external API calls. MockPaymentProvider handles credit card, PayPal, bank transfer with magic card numbers for testing. |
specs/09-IMPLEMENTATION-ROADMAP.md:35:Each phase builds on the previous. A coding agent should execute these in strict sequential order. Tests are written alongside each phase (see Section 3 for the full test specification).
specs/09-IMPLEMENTATION-ROADMAP.md:102:Create in this dependency order:
specs/09-IMPLEMENTATION-ROADMAP.md:176:Applied on: products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
specs/09-IMPLEMENTATION-ROADMAP.md:232:| Manage orders | Y | Y | Y | Read-only |
specs/09-IMPLEMENTATION-ROADMAP.md:233:| Process refunds | Y | Y | N | N |
specs/09-IMPLEMENTATION-ROADMAP.md:234:| Create fulfillments | Y | Y | Y | N |
specs/09-IMPLEMENTATION-ROADMAP.md:247:**Priority:** HIGH - storefront and orders depend on this.
specs/09-IMPLEMENTATION-ROADMAP.md:251:Create in dependency order:
specs/09-IMPLEMENTATION-ROADMAP.md:309:| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |
specs/09-IMPLEMENTATION-ROADMAP.md:404:| `account/orders/` | `index.blade.php`, `show.blade.php` |
specs/09-IMPLEMENTATION-ROADMAP.md:407:| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |
specs/09-IMPLEMENTATION-ROADMAP.md:575:| `shipping_selected` -> `payment_selected` | `selectPaymentMethod(Checkout, string)` | All data valid, reserve inventory |
specs/09-IMPLEMENTATION-ROADMAP.md:576:| `payment_selected` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |
specs/09-IMPLEMENTATION-ROADMAP.md:595:| `Storefront\Checkout\Confirmation` | Order confirmation: order number, items, totals, next steps |
specs/09-IMPLEMENTATION-ROADMAP.md:607:3. `create_orders_table` - FK: `store_id`, `customer_id`
specs/09-IMPLEMENTATION-ROADMAP.md:608:4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)
specs/09-IMPLEMENTATION-ROADMAP.md:609:5. `create_payments_table` - FK: `order_id`
specs/09-IMPLEMENTATION-ROADMAP.md:610:6. `create_refunds_table` - FK: `order_id`, `payment_id`
specs/09-IMPLEMENTATION-ROADMAP.md:611:7. `create_fulfillments_table` - FK: `order_id`
specs/09-IMPLEMENTATION-ROADMAP.md:612:8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`
specs/09-IMPLEMENTATION-ROADMAP.md:661:| Interface | `App\Contracts\PaymentProvider` | Defines charge, refund methods |
specs/09-IMPLEMENTATION-ROADMAP.md:668:| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |
specs/09-IMPLEMENTATION-ROADMAP.md:669:| `refund(Payment $payment, int $amount): RefundResult` | Issues a mock refund (always succeeds). |
specs/09-IMPLEMENTATION-ROADMAP.md:686:| Job | `App\Jobs\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |
specs/09-IMPLEMENTATION-ROADMAP.md:689:1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`
specs/09-IMPLEMENTATION-ROADMAP.md:691:3. Update payment record status to `captured`
specs/09-IMPLEMENTATION-ROADMAP.md:693:5. If all items are digital (`requires_shipping = false`), auto-create a delivered fulfillment
specs/09-IMPLEMENTATION-ROADMAP.md:702:| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |
specs/09-IMPLEMENTATION-ROADMAP.md:704:| `cancel(Order $order, string $reason): void` | Only if not yet fulfilled, releases inventory |
specs/09-IMPLEMENTATION-ROADMAP.md:706:Order lines store snapshots (`title_snapshot`, `sku_snapshot`) so order history survives product deletion.
specs/09-IMPLEMENTATION-ROADMAP.md:714:| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |
specs/09-IMPLEMENTATION-ROADMAP.md:716:- Partial refunds: `financial_status` -> `partially_refunded`
specs/09-IMPLEMENTATION-ROADMAP.md:717:- Full refunds: `financial_status` -> `refunded`
specs/09-IMPLEMENTATION-ROADMAP.md:726:| `create(Order $order, array $lines, ?array $tracking): Fulfillment` | Creates fulfillment with specified lines and quantities. **Fulfillment guard:** Throws exception if `financial_status` is not `paid` or `partially_refunded`. |
specs/09-IMPLEMENTATION-ROADMAP.md:727:| `markAsShipped(Fulfillment $fulfillment, ?array $tracking): void` | Sets tracking info, transitions to `shipped`, sets `shipped_at` |
specs/09-IMPLEMENTATION-ROADMAP.md:728:| `markAsDelivered(Fulfillment $fulfillment): void` | Transitions to `delivered`, sets `delivered_at`. Dispatches `FulfillmentDelivered` event. |
specs/09-IMPLEMENTATION-ROADMAP.md:730:Updates `order.fulfillment_status` based on whether all lines are fulfilled (`fulfilled`) or some (`partial`).
specs/09-IMPLEMENTATION-ROADMAP.md:732:**Fulfillment guard:** Before creating a fulfillment, the service checks that the order's `financial_status` is `paid` or `partially_refunded`. If the guard fails, a `FulfillmentGuardException` is thrown. This prevents shipping items before payment is confirmed (especially important for bank transfer orders).
specs/09-IMPLEMENTATION-ROADMAP.md:738:| Event | `OrderCreated` | Dispatched when order is created from checkout |
specs/09-IMPLEMENTATION-ROADMAP.md:739:| Event | `OrderPaid` | Dispatched when payment is captured |
specs/09-IMPLEMENTATION-ROADMAP.md:740:| Event | `OrderFulfilled` | Dispatched when all order lines are fulfilled |
specs/09-IMPLEMENTATION-ROADMAP.md:741:| Event | `OrderCancelled` | Dispatched when order is cancelled |
specs/09-IMPLEMENTATION-ROADMAP.md:742:| Event | `OrderRefunded` | Dispatched when refund is processed |
specs/09-IMPLEMENTATION-ROADMAP.md:764:| `Storefront\Account\Dashboard` | `GET /account` | Overview with recent orders |
specs/09-IMPLEMENTATION-ROADMAP.md:765:| `Storefront\Account\Orders\Index` | `GET /account/orders` | Paginated order history |
specs/09-IMPLEMENTATION-ROADMAP.md:766:| `Storefront\Account\Orders\Show` | `GET /account/orders/{orderNumber}` | Order detail with timeline |
specs/09-IMPLEMENTATION-ROADMAP.md:796:- KPI tiles: total sales (period), order count, average order value, conversion rate
specs/09-IMPLEMENTATION-ROADMAP.md:798:- Recent orders table (last 10)
specs/09-IMPLEMENTATION-ROADMAP.md:814:Media upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.
specs/09-IMPLEMENTATION-ROADMAP.md:822:| `Admin\Orders\Index` | Order list with status filters, search by order number/email, date range |
specs/09-IMPLEMENTATION-ROADMAP.md:823:| `Admin\Orders\Show` | Order detail: timeline, line items, payment info, fulfillment modal, refund modal |
specs/09-IMPLEMENTATION-ROADMAP.md:835:| `Admin\Collections\Form` | Shared form component for create/edit collection with product picker, reorder products |
specs/09-IMPLEMENTATION-ROADMAP.md:837:| `Admin\Customers\Show` | Customer detail: info, orders, addresses |
specs/09-IMPLEMENTATION-ROADMAP.md:847:| `Admin\Navigation\Index` | Menu management with drag-and-drop item ordering |
specs/09-IMPLEMENTATION-ROADMAP.md:915:- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count
specs/09-IMPLEMENTATION-ROADMAP.md:977:6. Manual smoke: visit admin login, authenticate, manage products and orders
specs/09-IMPLEMENTATION-ROADMAP.md:1148:| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1213:| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |
specs/09-IMPLEMENTATION-ROADMAP.md:1214:| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1215:| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |
specs/09-IMPLEMENTATION-ROADMAP.md:1227:| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |
specs/09-IMPLEMENTATION-ROADMAP.md:1228:| deletes orphaned variants without orde…6722 tokens truncated…ent method selected
specs/05-BUSINESS-LOGIC.md:423:    payment_selected --> completed : Payment confirmed
specs/05-BUSINESS-LOGIC.md:424:    payment_selected --> expired : Timeout (24h)
specs/05-BUSINESS-LOGIC.md:432:    note right of payment_selected : Inventory reserved
specs/05-BUSINESS-LOGIC.md:487:#### Transition: shipping_selected -> payment_selected
specs/05-BUSINESS-LOGIC.md:489:**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method
specs/05-BUSINESS-LOGIC.md:494:- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.
specs/05-BUSINESS-LOGIC.md:497:1. Store `payment_method` on the checkout record.
specs/05-BUSINESS-LOGIC.md:500:4. Transition status to `payment_selected`.
specs/05-BUSINESS-LOGIC.md:502:#### Transition: payment_selected -> completed
specs/05-BUSINESS-LOGIC.md:504:**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order
specs/05-BUSINESS-LOGIC.md:506:**Triggered by:** Customer submitting the payment form (onsite, no external redirect).
specs/05-BUSINESS-LOGIC.md:508:**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.
specs/05-BUSINESS-LOGIC.md:513:PROCEDURE completeCheckout(checkout, paymentMethodData):
specs/05-BUSINESS-LOGIC.md:516:    1. IF order already exists for this checkout:
specs/05-BUSINESS-LOGIC.md:517:           RETURN existing order (idempotent)
specs/05-BUSINESS-LOGIC.md:519:    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)
specs/05-BUSINESS-LOGIC.md:524:    3. Generate next sequential order number for this store
specs/05-BUSINESS-LOGIC.md:526:    4. DETERMINE order statuses based on payment method:
specs/05-BUSINESS-LOGIC.md:527:       IF checkout.payment_method IN ('credit_card', 'paypal'):
specs/05-BUSINESS-LOGIC.md:528:           orderStatus = 'paid'
specs/05-BUSINESS-LOGIC.md:530:           paymentStatus = 'captured'
specs/05-BUSINESS-LOGIC.md:532:       ELSE IF checkout.payment_method = 'bank_transfer':
specs/05-BUSINESS-LOGIC.md:533:           orderStatus = 'pending'
specs/05-BUSINESS-LOGIC.md:535:           paymentStatus = 'pending'
specs/05-BUSINESS-LOGIC.md:538:    5. CREATE order record with:
specs/05-BUSINESS-LOGIC.md:540:       - payment_method = checkout.payment_method
specs/05-BUSINESS-LOGIC.md:541:       - status = orderStatus, financial_status = financialStatus
specs/05-BUSINESS-LOGIC.md:542:       - fulfillment_status = 'unfulfilled'
specs/05-BUSINESS-LOGIC.md:546:       CREATE order_line with snapshot data:
specs/05-BUSINESS-LOGIC.md:552:    7. CREATE payment record:
specs/05-BUSINESS-LOGIC.md:553:       - Link to order, provider = 'mock', method = checkout.payment_method
specs/05-BUSINESS-LOGIC.md:554:       - provider_payment_id = mock reference ID
specs/05-BUSINESS-LOGIC.md:555:       - status = paymentStatus, amount = order total
specs/05-BUSINESS-LOGIC.md:560:       // Note: for bank_transfer, inventory stays reserved until admin confirms payment
specs/05-BUSINESS-LOGIC.md:569:    12. IF inventoryAction = 'commit' AND all order lines are digital:
specs/05-BUSINESS-LOGIC.md:570:        Auto-create fulfillment (see Section 11.7)
specs/05-BUSINESS-LOGIC.md:575:    RETURN order
specs/05-BUSINESS-LOGIC.md:585:1. If inventory was reserved (status was `payment_selected`): release all reserved inventory via InventoryService.release.
specs/05-BUSINESS-LOGIC.md:595:| `CheckoutCompleted` | Payment confirmed and order created |
specs/05-BUSINESS-LOGIC.md:651:**Applicability logic:** When both `applicable_product_ids` and `applicable_collection_ids` are set, a product qualifies if it matches **either** condition (union, not intersection). When both are null or empty, the discount applies to the entire order.
specs/05-BUSINESS-LOGIC.md:720:- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.
specs/05-BUSINESS-LOGIC.md:724:When an order is successfully created with a discount code:
specs/05-BUSINESS-LOGIC.md:726:- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).
specs/05-BUSINESS-LOGIC.md:815:**Calculation order within the pricing pipeline:**
specs/05-BUSINESS-LOGIC.md:925:- The shipping method is still selected (for fulfillment routing), but the customer is not charged.
specs/05-BUSINESS-LOGIC.md:933: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.
specs/05-BUSINESS-LOGIC.md:938:- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)
specs/05-BUSINESS-LOGIC.md:939:- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult
specs/05-BUSINESS-LOGIC.md:941:**Service:** `App\Services\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.
specs/05-BUSINESS-LOGIC.md:947:**Enum:** `App\Enums\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)
specs/05-BUSINESS-LOGIC.md:953:| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |
specs/05-BUSINESS-LOGIC.md:957:When payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:
specs/05-BUSINESS-LOGIC.md:977:    Customer->>Storefront: Select payment method + enter details
specs/05-BUSINESS-LOGIC.md:978:    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)
specs/05-BUSINESS-LOGIC.md:980:    Backend->>MockPSP: charge(checkout, paymentMethodData)
specs/05-BUSINESS-LOGIC.md:985:            Backend->>Backend: Create order (financial_status = paid)
specs/05-BUSINESS-LOGIC.md:987:            Backend-->>Storefront: 200 OK (order confirmation)
specs/05-BUSINESS-LOGIC.md:994:        Backend->>Backend: Create order (financial_status = pending)
specs/05-BUSINESS-LOGIC.md:996:        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)
specs/05-BUSINESS-LOGIC.md:1002:**Enum:** `App\Enums\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`
specs/05-BUSINESS-LOGIC.md:1008:    [*] --> pending : Bank transfer (awaiting payment)
specs/05-BUSINESS-LOGIC.md:1009:    pending --> captured : Admin confirms payment received
specs/05-BUSINESS-LOGIC.md:1011:    captured --> refunded : Full refund processed
specs/05-BUSINESS-LOGIC.md:1016:When `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:
specs/05-BUSINESS-LOGIC.md:1026:These are static mock values. No real bank interaction occurs.
specs/05-BUSINESS-LOGIC.md:1030: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`).
specs/05-BUSINESS-LOGIC.md:1034:2. Backend updates `payments.status` from `pending` to `captured`.
specs/05-BUSINESS-LOGIC.md:1035:3. Backend updates `orders.financial_status` from `pending` to `paid`.
specs/05-BUSINESS-LOGIC.md:1036:4. Backend updates `orders.status` from `pending` to `paid`.
specs/05-BUSINESS-LOGIC.md:1037:5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).
specs/05-BUSINESS-LOGIC.md:1038:6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).
specs/05-BUSINESS-LOGIC.md:1046:- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.
specs/05-BUSINESS-LOGIC.md:1047:- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.
specs/05-BUSINESS-LOGIC.md:1051:2. Update `orders.financial_status` to `voided`.
specs/05-BUSINESS-LOGIC.md:1052:3. Update `orders.status` to `cancelled`.
specs/05-BUSINESS-LOGIC.md:1053:4. Update `payments.status` to `failed`.
specs/05-BUSINESS-LOGIC.md:1062:See Section 6.2 "Transition: payment_selected -> completed" for the full procedure. The order creation is the central action within the completeCheckout transaction.
specs/05-BUSINESS-LOGIC.md:1064:**Key requirements for the order record:**
specs/05-BUSINESS-LOGIC.md:1065:- Snapshot all checkout data (addresses, email, totals) at time of order creation.
specs/05-BUSINESS-LOGIC.md:1066:- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.
specs/05-BUSINESS-LOGIC.md:1067:- The mock payment response is encrypted before storage.
specs/05-BUSINESS-LOGIC.md:1069:- The `payment_method` from the checkout is copied to the order record.
specs/05-BUSINESS-LOGIC.md:1071:**Payment-method-specific order creation:**
specs/05-BUSINESS-LOGIC.md:1073:| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |
specs/05-BUSINESS-LOGIC.md:1077:| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |
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:1083:- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.
specs/05-BUSINESS-LOGIC.md:1088:Three independent status dimensions tracked on each order:
specs/05-BUSINESS-LOGIC.md:1090:**Order status** (`orders.status`):
specs/05-BUSINESS-LOGIC.md:1097:| `cancelled` | Order cancelled before fulfillment |
specs/05-BUSINESS-LOGIC.md:1098:| `refunded` | Fully refunded |
specs/05-BUSINESS-LOGIC.md:1100:**Financial status** (`orders.financial_status`):
specs/05-BUSINESS-LOGIC.md:1107:| `partially_refunded` | Some items or partial amount refunded |
specs/05-BUSINESS-LOGIC.md:1108:| `refunded` | Fully refunded |
specs/05-BUSINESS-LOGIC.md:1111:**Fulfillment status** (`orders.fulfillment_status`):
specs/05-BUSINESS-LOGIC.md:1127:| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |
specs/05-BUSINESS-LOGIC.md:1128:| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |
specs/05-BUSINESS-LOGIC.md:1129:| `reason` | string | No | Text reason for the refund. |
specs/05-BUSINESS-LOGIC.md:1130:| `restock` | boolean | No | Whether to restock inventory for refunded items. |
specs/05-BUSINESS-LOGIC.md:1135:PROCEDURE processRefund(order, request):
specs/05-BUSINESS-LOGIC.md:1136:    1. Calculate remaining refundable amount:
specs/05-BUSINESS-LOGIC.md:1137:       refundable = order.total_amount - SUM(existing refunds for this order)
specs/05-BUSINESS-LOGIC.md:1140:           VALIDATE request.amount <= refundable
specs/05-BUSINESS-LOGIC.md:1144:           amount = refundable (full refund)
specs/05-BUSINESS-LOGIC.md:1146:    3. CALL PaymentProvider.refund(order's payment, amount)
specs/05-BUSINESS-LOGIC.md:1151:       UPDATE refund status = 'processed'
specs/05-BUSINESS-LOGIC.md:1153:    6. Calculate total refunded for this order:
specs/05-BUSINESS-LOGIC.md:1154:       IF total refunded = order.total_amount:
specs/05-BUSINESS-LOGIC.md:1155:           SET order.financial_status = 'refunded'
specs/05-BUSINESS-LOGIC.md:1156:           SET order.status = 'refunded'
specs/05-BUSINESS-LOGIC.md:1158:           SET order.financial_status = 'partially_refunded'
specs/05-BUSINESS-LOGIC.md:1161:       FOR EACH refunded line and quantity:
specs/05-BUSINESS-LOGIC.md:1173:**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.
specs/05-BUSINESS-LOGIC.md:1177:| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |
specs/05-BUSINESS-LOGIC.md:1180:| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |
specs/05-BUSINESS-LOGIC.md:1181:| `refunded` | **No** | Fully refunded, nothing to ship |
specs/05-BUSINESS-LOGIC.md:1184: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.
specs/05-BUSINESS-LOGIC.md:1189:- `lines` - map of order_line_id to quantity, specifying which items are in this shipment
specs/05-BUSINESS-LOGIC.md:1195:PROCEDURE createFulfillment(order, lines, trackingData):
specs/05-BUSINESS-LOGIC.md:1196:    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')
specs/05-BUSINESS-LOGIC.md:1200:       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)
specs/05-BUSINESS-LOGIC.md:1201:       unfulfilled = order_line.quantity - fulfilledSoFar
specs/05-BUSINESS-LOGIC.md:1206:    4. CREATE fulfillment_lines for each order line and quantity
specs/05-BUSINESS-LOGIC.md:1208:    5. Determine new fulfillment status:
specs/05-BUSINESS-LOGIC.md:1209:       FOR EACH order_line in order:
specs/05-BUSINESS-LOGIC.md:1210:           totalFulfilled = SUM(all fulfillment_lines for this order_line)
specs/05-BUSINESS-LOGIC.md:1211:           IF totalFulfilled < order_line.quantity:
specs/05-BUSINESS-LOGIC.md:1215:           SET order.fulfillment_status = 'fulfilled'
specs/05-BUSINESS-LOGIC.md:1216:           SET order.status = 'fulfilled'
specs/05-BUSINESS-LOGIC.md:1218:           SET order.fulfillment_status = 'partial'
specs/05-BUSINESS-LOGIC.md:1229:    [*] --> pending : Fulfillment created (admin clicks "Create fulfillment")
specs/05-BUSINESS-LOGIC.md:1238:**Admin buttons on each fulfillment card:**
specs/05-BUSINESS-LOGIC.md:1242:| "Mark as shipped" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |
specs/05-BUSINESS-LOGIC.md:1243:| "Mark as delivered" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |
specs/05-BUSINESS-LOGIC.md:1255:The fulfillment guard interacts with inventory differently depending on the payment method:
specs/05-BUSINESS-LOGIC.md:1260:| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |
specs/05-BUSINESS-LOGIC.md:1262: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.
specs/05-BUSINESS-LOGIC.md:1266:When a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):
specs/05-BUSINESS-LOGIC.md:1268:1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).
specs/05-BUSINESS-LOGIC.md:1269:2. If yes: automatically create a fulfillment record covering all order lines:
specs/05-BUSINESS-LOGIC.md:1273:   - Set `order.fulfillment_status = 'fulfilled'`
specs/05-BUSINESS-LOGIC.md:1274:   - Set `order.status = 'fulfilled'`
specs/05-BUSINESS-LOGIC.md:1275: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).
specs/05-BUSINESS-LOGIC.md:1277:This ensures digital product orders are immediately marked as complete after payment.
specs/05-BUSINESS-LOGIC.md:1281:`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.
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:1285:All monetary values are exported in minor units (cents). Dates use ISO-8601 format. The response uses `Content-Type: text/csv` with `Content-Disposition: attachment; filename="orders-{date}.csv"`.
specs/05-BUSINESS-LOGIC.md:1321:- Customers can only view their own orders (scoped by customer_id match).
specs/05-BUSINESS-LOGIC.md:1328:PROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):
specs/05-BUSINESS-LOGIC.md:1331:        LINK order to existingCustomer
specs/05-BUSINESS-LOGIC.md:1337:        LINK order to new customer
specs/05-BUSINESS-LOGIC.md:1355:| `order.created` | OrderCreated event |
specs/05-BUSINESS-LOGIC.md:1356:| `order.paid` | Payment captured successfully |
specs/05-BUSINESS-LOGIC.md:1357:| `order.fulfilled` | All order lines fulfilled |
specs/05-BUSINESS-LOGIC.md:1358:| `order.refunded` | Refund processed |
specs/05-BUSINESS-LOGIC.md:1396:| `X-Platform-Event` | Event type string (e.g., `order.created`) |
specs/05-BUSINESS-LOGIC.md:1481:| `orders_count` | Count of `checkout_completed` events for the day |
specs/05-BUSINESS-LOGIC.md:1482:| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |
specs/05-BUSINESS-LOGIC.md:1483:| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |
specs/05-BUSINESS-LOGIC.md:1579:    6. RETURN ordered collection
specs/05-BUSINESS-LOGIC.md:1594:| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |
specs/05-BUSINESS-LOGIC.md:1595:| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |
specs/05-BUSINESS-LOGIC.md:1604:| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |
specs/05-BUSINESS-LOGIC.md:1605:| `FulfillmentCreated` | Update order fulfillment status |
specs/05-BUSINESS-LOGIC.md:1606:| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |
specs/05-BUSINESS-LOGIC.md:1607:| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |
specs/05-BUSINESS-LOGIC.md:1621:| `App\Jobs\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |
specs/05-BUSINESS-LOGIC.md:1650:| MockPaymentProvider | `App\Services\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |
specs/05-BUSINESS-LOGIC.md:1681:| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentSelected (`payment_selected`), Completed (`completed`), Expired (`expired`) |
specs/05-BUSINESS-LOGIC.md:1682:| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |
specs/05-BUSINESS-LOGIC.md:1683:| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |
specs/05-BUSINESS-LOGIC.md:1684:| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |
specs/05-BUSINESS-LOGIC.md:1685:| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |
specs/05-BUSINESS-LOGIC.md:1705:| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |


Output:
Chunk ID: 4169ad
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 510
Output:
app/Livewire/Admin/Auth/Login.php
app/Livewire/Admin/Collections/Form.php
app/Livewire/Admin/Collections/Index.php
app/Livewire/Admin/Inventory/Index.php
app/Livewire/Admin/Products/Form.php
app/Livewire/Admin/Products/Index.php
resources/views/livewire/admin/auth/login.blade.php
resources/views/livewire/admin/collections/form.blade.php
resources/views/livewire/admin/collections/index.blade.php
resources/views/livewire/admin/inventory/index.blade.php
resources/views/livewire/admin/products/form.blade.php
resources/views/livewire/admin/products/index.blade.php
tests/Feature/Api/StorefrontCartApiTest.php
tests/Feature/Api/StorefrontCheckoutApiTest.php
tests/Feature/Auth/AuthenticationTest.php
tests/Feature/Auth/EmailVerificationTest.php
tests/Feature/Auth/PasswordConfirmationTest.php
tests/Feature/Auth/PasswordResetTest.php
tests/Feature/Auth/RegistrationTest.php
tests/Feature/Auth/TwoFactorChallengeTest.php
tests/Feature/Cart/CartServiceTest.php
tests/Feature/Catalog/CatalogFoundationTest.php
tests/Feature/Catalog/CatalogUiTest.php
tests/Feature/Catalog/InventoryServiceTest.php
tests/Feature/Catalog/MediaProcessingTest.php
tests/Feature/Catalog/ProductServiceTest.php
tests/Feature/Catalog/VariantMatrixServiceTest.php
tests/Feature/Checkout/CheckoutServiceTest.php
tests/Feature/Checkout/PricingServicesTest.php
tests/Feature/DashboardTest.php
tests/Feature/ExampleTest.php
tests/Feature/Foundation/AuthorizationTest.php
tests/Feature/Foundation/CustomerAuthTest.php
tests/Feature/Foundation/TenancyTest.php
tests/Feature/Orders/BankTransferOrderTest.php
tests/Feature/Orders/FulfillmentServiceTest.php
tests/Feature/Orders/OrderServiceTest.php
tests/Feature/Orders/RefundServiceTest.php
tests/Feature/Payments/MockPaymentProviderTest.php
tests/Feature/Settings/PasswordUpdateTest.php
tests/Feature/Settings/ProfileUpdateTest.php
tests/Feature/Settings/TwoFactorAuthenticationTest.php
tests/Feature/Storefront/CartCheckoutUiTest.php
tests/Feature/Storefront/OrderViewsTest.php
tests/Feature/Storefront/ThemeDataTest.php


Output:
Chunk ID: 285d87
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1041
Output:
<?php

use App\Livewire\Admin\Auth\Login as AdminLogin;
use App\Livewire\Admin\Collections\Form as AdminCollectionForm;
use App\Livewire\Admin\Collections\Index as AdminCollectionsIndex;
use App\Livewire\Admin\Inventory\Index as AdminInventoryIndex;
use App\Livewire\Admin\Products\Form as AdminProductForm;
use App\Livewire\Admin\Products\Index as AdminProductsIndex;
use App\Livewire\Storefront\Account\Auth\Login as CustomerLogin;
use App\Livewire\Storefront\Account\Auth\Register as CustomerRegister;
use App\Livewire\Storefront\Account\Orders\Index as CustomerOrdersIndex;
use App\Livewire\Storefront\Account\Orders\Show as CustomerOrderShow;
use App\Livewire\Storefront\Cart\Show as StorefrontCartShow;
use App\Livewire\Storefront\Checkout\Confirmation as StorefrontCheckoutConfirmation;
use App\Livewire\Storefront\Checkout\Show as StorefrontCheckoutShow;
use App\Livewire\Storefront\Collections\Index as StorefrontCollectionsIndex;
use App\Livewire\Storefront\Collections\Show as StorefrontCollectionShow;
use App\Livewire\Storefront\Home as StorefrontHome;
use App\Livewire\Storefront\Pages\Show as StorefrontPageShow;
use App\Livewire\Storefront\Products\Show as StorefrontProductShow;
use App\Livewire\Storefront\Search\Index as StorefrontSearchIndex;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;

Route::middleware(['storefront'])->group(function (): void {
    Route::livewire('/', StorefrontHome::class)->name('home');
    Route::livewire('collections', StorefrontCollectionsIndex::class)->name('collections.index');
    Route::livewire('collections/{handle}', StorefrontCollectionShow::class)->name('collections.show');
    Route::livewire('products/{handle}', StorefrontProductShow::class)->name('products.show');
    Route::livewire('cart', StorefrontCartShow::class)->name('cart.show');
    Route::livewire('checkout', StorefrontCheckoutShow::class)->name('checkout.show');
    Route::livewire('checkout/confirmation/{order}', StorefrontCheckoutConfirmation::class)->name('checkout.confirmation');
    Route::livewire('search', StorefrontSearchIndex::class)->name('search.index');
    Route::livewire('pages/{handle}', StorefrontPageShow::class)->name('pages.show');
});

Route::livewire('admin/login', AdminLogin::class)
    ->middleware('guest')
    ->name('admin.login');

Route::post('admin/logout', function () {
    Auth::guard('web')->logout();

    request()->session()->invalidate();
    request()->session()->regenerateToken();

    return redirect()->route('admin.login');
})->middleware('auth')->name('admin.logout');

Route::middleware(['auth', 'verified', 'admin'])->prefix('admin')->name('admin.')->group(function (): void {
    Route::view('/', 'dashboard')->name('dashboard');
    Route::livewire('products', AdminProductsIndex::class)->name('products.index');
    Route::livewire('products/create', AdminProductForm::class)->name('products.create');
    Route::livewire('products/{product}/edit', AdminProductForm::class)->name('products.edit');
    Route::livewire('inventory', AdminInventoryIndex::class)->name('inventory.index');
    Route::livewire('collections', AdminCollectionsIndex::class)->name('collections.index');
    Route::livewire('collections/create', AdminCollectionForm::class)->name('collections.create');
    Route::livewire('collections/{collection}/edit', AdminCollectionForm::class)->name('collections.edit');
});

Route::middleware(['storefront'])->group(function (): void {
    Route::livewire('account/login', CustomerLogin::class)
        ->middleware('guest:customer')
        ->name('account.login');

    Route::livewire('account/register', CustomerRegister::class)
        ->middleware('guest:customer')
        ->name('account.register');

    Route::livewire('account', CustomerOrdersIndex::class)
        ->middleware('auth:customer')
        ->name('account.dashboard');

    Route::livewire('account/orders/{order}', CustomerOrderShow::class)
        ->middleware('auth:customer')
        ->name('account.orders.show');
});

Route::redirect('dashboard', 'admin')
    ->middleware(['auth', 'verified'])
    ->name('dashboard');

require __DIR__.'/settings.php';


sed -n '1,260p' app/Livewire/Admin/Products/Index.php


sed -n '1,260p' resources/views/livewire/admin/products/index.blade.php


sed -n '1,260p' app/Livewire/Admin/Inventory/Index.php


sed -n '1,260p' resources/views/livewire/admin/inventory/index.blade.php


sed -n '1,260p' app/Livewire/Admin/Products/Index.php

<?php

namespace App\Livewire\Admin\Products;

use App\Enums\ProductStatus;
use App\Models\Product;
use App\Services\ProductService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Livewire\Component;
use Livewire\WithPagination;

class Index extends Component
{
    use WithPagination;

    public string $search = '';

    public string $statusFilter = 'active';

    public string $typeFilter = 'all';

    /**
     * @var array<int, int>
     */
    public array $selectedIds = [];

    public bool $selectAll = false;

    public string $sortField = 'updated_at';

    public string $sortDirection = 'desc';

    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    public function updatedStatusFilter(): void
    {
        $this->resetPage();
    }

    public function updatedTypeFilter(): void
    {
        $this->resetPage();
    }

    public function sortBy(string $field): void
    {
        if (! in_array($field, ['title', 'updated_at'], true)) {
            return;
        }

        if ($this->sortField === $field) {
            $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';

            return;
        }

        $this->sortField = $field;
        $this->sortDirection = 'asc';
    }

    public function toggleSelectAll(): void
    {
        $this->selectAll = ! $this->selectAll;

        $this->selectedIds = $this->selectAll
            ? $this->products()->pluck('id')->map(fn (int $id): int => $id)->all()
            : [];
    }

    public function bulkArchive(): void
    {
        $this->transitionSelected(ProductStatus::Archived);
    }

    public function bulkSetActive(): void
    {
        $this->transitionSelected(ProductStatus::Active);
    }

    public function bulkDelete(): void
    {
        $this->transitionSelected(ProductStatus::Archived);
    }

    public function products(): LengthAwarePaginator
    {
        return Product::query()
            ->with(['variants.inventoryItem'])
            ->withCount('variants')
            ->when($this->search !== '', function (Builder $query): void {
                $search = '%'.$this->search.'%';

                $query->where(function (Builder $query) use ($search): void {
                    $query
                        ->where('title', 'like', $search)
                        ->orWhere('vendor', 'like', $search)
                        ->orWhere('product_type', 'like', $search)
                        ->orWhereHas('variants', fn (Builder $query) => $query->where('sku', 'like', $search));
                });
            })
            ->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter))
            ->when($this->typeFilter !== 'all', fn (Builder $query) => $query->where('product_type', $this->typeFilter))
            ->orderBy($this->sortField, $this->sortDirection)
            ->paginate(15);
    }

    public function productTypes(): Collection
    {
        return Product::query()
            ->whereNotNull('product_type')
            ->distinct()
            ->orderBy('product_type')
            ->pluck('product_type');
    }

    public function render(): mixed
    {
        return view('livewire.admin.products.index', [
            'products' => $this->products(),
            'productTypes' => $this->productTypes(),
        ])->layout('layouts.app', [
            'title' => __('Products'),
        ]);
    }

    private function transitionSelected(ProductStatus $status): void
    {
        $service = app(ProductService::class);

        Product::query()
            ->whereKey($this->selectedIds)
            ->get()
            ->each(fn (Product $product) => $service->transitionStatus($product, $status));

        $this->selectedIds = [];
        $this->selectAll = false;

        $this->dispatch('toast', type: 'success', message: __('Products updated'));
    }
}


sed -n '1,260p' app/Livewire/Admin/Inventory/Index.php

<?php

namespace App\Livewire\Admin\Inventory;

use App\Models\InventoryItem;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Livewire\Component;
use Livewire\WithPagination;

class Index extends Component
{
    use WithPagination;

    public string $search = '';

    public string $stockFilter = 'all';

    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    public function updatedStockFilter(): void
    {
        $this->resetPage();
    }

    public function items(): LengthAwarePaginator
    {
        return InventoryItem::query()
            ->with(['variant.product'])
            ->when($this->search !== '', function (Builder $query): void {
                $search = '%'.$this->search.'%';

                $query->whereHas('variant', function (Builder $query) use ($search): void {
                    $query
                        ->where('sku', 'like', $search)
                        ->orWhereHas('product', fn (Builder $query) => $query->where('title', 'like', $search));
                });
            })
            ->when($this->stockFilter === 'low', fn (Builder $query) => $query->whereRaw('(quantity_on_hand - quantity_reserved) BETWEEN 1 AND 10'))
            ->when($this->stockFilter === 'out', fn (Builder $query) => $query->whereRaw('(quantity_on_hand - quantity_reserved) <= 0'))
            ->orderBy('quantity_on_hand')
            ->paginate(20);
    }

    public function render(): mixed
    {
        return view('livewire.admin.inventory.index', [
            'items' => $this->items(),
        ])->layout('layouts.app', [
            'title' => __('Inventory'),
        ]);
    }
}


sed -n '1,260p' resources/views/livewire/admin/products/index.blade.php

            <flux:heading size="xl">Products</flux:heading>
            <flux:text class="mt-1">Manage catalog items, variants, status, and inventory.</flux:text>

        <flux:button :href="route('admin.products.create')" wire:navigate variant="primary" icon="plus">
            Add product
        </flux:button>

        <flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" placeholder="Search products..." aria-label="Search products" />

        <flux:select wire:model.live="statusFilter" aria-label="Status filter">
            <flux:select.option value="all">All statuses</flux:select.option>
            <flux:select.option value="active">Active</flux:select.option>
            <flux:select.option value="draft">Draft</flux:select.option>
            <flux:select.option value="archived">Archived</flux:select.option>
        </flux:select>

        <flux:select wire:model.live="typeFilter" aria-label="Product type filter">
            <flux:select.option value="all">All types</flux:select.option>
            @foreach ($productTypes as $type)
                <flux:select.option value="{{ $type }}">{{ $type }}</flux:select.option>
            @endforeach
        </flux:select>

    @if (count($selectedIds) > 0)

            <flux:text>{{ count($selectedIds) }} products selected</flux:text>

                <flux:button wire:click="bulkSetActive" variant="ghost">Set active</flux:button>
                <flux:button wire:click="bulkArchive" variant="ghost">Archive</flux:button>
                <flux:button wire:click="bulkDelete" variant="danger">Delete</flux:button>

    @endif

                            <input type="checkbox" wire:click="toggleSelectAll" @checked($selectAll) aria-label="Select all visible products" class="rounded border-zinc-300 dark:border-zinc-600">

                        Product
                        Status
                        Inventory
                        Type
                        Vendor

                            <button wire:click="sortBy('updated_at')" class="font-semibold">Updated</button>

                    @forelse ($products as $product)
                        @php
                            $inventory = $product->variants->sum(fn ($variant) => $variant->inventoryItem?->quantity_on_hand ?? 0);
                            $statusColor = match ($product->status->value) {
                                'active' => 'green',
                                'archived' => 'red',
                                default => 'zinc',
                            };
                        @endphp

getKey() }}">

                                <input type="checkbox" wire:model.live="selectedIds" value="{{ $product->getKey() }}" aria-label="Select {{ $product->title }}" class="rounded border-zinc-300 dark:border-zinc-600">

                                        <flux:icon name="cube" class="size-5" />

                                            {{ $product->title }}

{{ $product->variants_count }} variants

                                <flux:badge :color="$statusColor">{{ Str::title($product->status->value) }}</flux:badge>

                            {{ $inventory }}
                            {{ $product->product_type ?: '-' }}
                            {{ $product->vendor ?: '-' }}
                            {{ $product->updated_at?->diffForHumans() }}

                    @empty

                                        <flux:icon name="cube" class="size-6" />

                                    <flux:heading size="lg">No products found</flux:heading>
                                    <flux:text>Adjust your filters or add a product.</flux:text>
                                    <flux:button :href="route('admin.products.create')" wire:navigate variant="primary">Add product</flux:button>

                    @endforelse

    {{ $products->links() }}


Output:
Chunk ID: 956ae4
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1002
Output:
<?php

namespace App\Livewire\Admin\Products;

use App\Enums\ProductStatus;
use App\Models\Product;
use App\Services\ProductService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Livewire\Component;
use Livewire\WithPagination;

class Index extends Component
{
    use WithPagination;

    public string $search = '';

    public string $statusFilter = 'active';

    public string $typeFilter = 'all';

    /**
     * @var array<int, int>
     */
    public array $selectedIds = [];

    public bool $selectAll = false;

    public string $sortField = 'updated_at';

    public string $sortDirection = 'desc';

    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    public function updatedStatusFilter(): void
    {
        $this->resetPage();
    }

    public function updatedTypeFilter(): void
    {
        $this->resetPage();
    }

    public function sortBy(string $field): void
    {
        if (! in_array($field, ['title', 'updated_at'], true)) {
            return;
        }

        if ($this->sortField === $field) {
            $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';

            return;
        }

        $this->sortField = $field;
        $this->sortDirection = 'asc';
    }

    public function toggleSelectAll(): void
    {
        $this->selectAll = ! $this->selectAll;

        $this->selectedIds = $this->selectAll
            ? $this->products()->pluck('id')->map(fn (int $id): int => $id)->all()
            : [];
    }

    public function bulkArchive(): void
    {
        $this->transitionSelected(ProductStatus::Archived);
    }

    public function bulkSetActive(): void
    {
        $this->transitionSelected(ProductStatus::Active);
    }

    public function bulkDelete(): void
    {
        $this->transitionSelected(ProductStatus::Archived);
    }

    public function products(): LengthAwarePaginator
    {
        return Product::query()
            ->with(['variants.inventoryItem'])
            ->withCount('variants')
            ->when($this->search !== '', function (Builder $query): void {
                $search = '%'.$this->search.'%';

                $query->where(function (Builder $query) use ($search): void {
                    $query
                        ->where('title', 'like', $search)
                        ->orWhere('vendor', 'like', $search)
                        ->orWhere('product_type', 'like', $search)
                        ->orWhereHas('variants', fn (Builder $query) => $query->where('sku', 'like', $search));
                });
            })
            ->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter))
            ->when($this->typeFilter !== 'all', fn (Builder $query) => $query->where('product_type', $this->typeFilter))
            ->orderBy($this->sortField, $this->sortDirection)
            ->paginate(15);
    }

    public function productTypes(): Collection
    {
        return Product::query()
            ->whereNotNull('product_type')
            ->distinct()
            ->orderBy('product_type')
            ->pluck('product_type');
    }

    public function render(): mixed
    {
        return view('livewire.admin.products.index', [
            'products' => $this->products(),
            'productTypes' => $this->productTypes(),
        ])->layout('layouts.app', [
            'title' => __('Products'),
        ]);
    }

    private function transitionSelected(ProductStatus $status): void
    {
        $service = app(ProductService::class);

        Product::query()
            ->whereKey($this->selectedIds)
            ->get()
            ->each(fn (Product $product) => $service->transitionStatus($product, $status));

        $this->selectedIds = [];
        $this->selectAll = false;

        $this->dispatch('toast', type: 'success', message: __('Products updated'));
    }
}