================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Application\Customers\CreateCustomerAction.php
================================================================
<?php

namespace App\Modules\CRM\Application\Customers;

use App\Modules\Audit\Application\AuditRecorder;
use App\Modules\CRM\Application\Normalization\CustomerContactNormalizer;
use App\Modules\CRM\Domain\Models\Customer;
use App\Modules\CRM\Domain\Models\CustomerGroup;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

final readonly class CreateCustomerAction
{
    public function __construct(
        private CustomerContactNormalizer $normalizer,
        private AuditRecorder $auditRecorder,
    ) {}

    public function execute(
        int $tenantId,
        array $data,
        int $actorUserId,
        Request $request,
    ): Customer {
        return DB::transaction(function () use ($tenantId,$data,$actorUserId,$request) {
            $groupId=$this->resolveGroupId($tenantId,$data['customer_group_id']??null);

            $customer=Customer::query()->create([
                'tenant_id'=>$tenantId,
                'customer_group_id'=>$groupId,
                'display_name'=>$data['display_name'],
                'first_name'=>$data['first_name']??null,
                'last_name'=>$data['last_name']??null,
                'phone'=>$data['phone']??null,
                'phone_normalized'=>$this->normalizer->phone($data['phone']??null),
                'email'=>$data['email']??null,
                'email_normalized'=>$this->normalizer->email($data['email']??null),
                'status'=>'active',
                'notes'=>$data['notes']??null,
                'metadata'=>$data['metadata']??null,
                'created_by_user_id'=>$actorUserId,
            ]);

            $this->auditRecorder->record(
                'crm.customer.created',
                $tenantId,
                $actorUserId,
                'crm.customer',
                $customer->public_id,
                after:[
                    'display_name'=>$customer->display_name,
                    'customer_group_id'=>$data['customer_group_id']??null,
                    'status'=>$customer->status,
                ],
                request:$request,
            );

            return $customer->fresh('group');
        });
    }

    private function resolveGroupId(int $tenantId,?string $publicId): ?int
    {
        if($publicId===null) return null;

        $group=CustomerGroup::query()
            ->where('tenant_id',$tenantId)
            ->where('public_id',$publicId)
            ->where('status','active')
            ->first();

        if(!$group) {
            throw ValidationException::withMessages([
                'customer_group_id'=>['Active customer group was not found in this tenant.'],
            ]);
        }

        return $group->id;
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Application\Customers\UpdateCustomerAction.php
================================================================
<?php

namespace App\Modules\CRM\Application\Customers;

use App\Modules\Audit\Application\AuditRecorder;
use App\Modules\CRM\Application\Normalization\CustomerContactNormalizer;
use App\Modules\CRM\Domain\Models\Customer;
use App\Modules\CRM\Domain\Models\CustomerGroup;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

final readonly class UpdateCustomerAction
{
    public function __construct(
        private CustomerContactNormalizer $normalizer,
        private AuditRecorder $auditRecorder,
    ) {}

    public function execute(
        Customer $customer,
        array $data,
        int $actorUserId,
        Request $request,
    ): Customer {
        return DB::transaction(function () use ($customer,$data,$actorUserId,$request) {
            $locked=Customer::query()
                ->where('tenant_id',$customer->tenant_id)
                ->whereKey($customer->id)
                ->lockForUpdate()
                ->firstOrFail();

            $before=[
                'display_name'=>$locked->display_name,
                'status'=>$locked->status,
                'customer_group_id'=>$locked->customer_group_id,
            ];

            if(array_key_exists('customer_group_id',$data)) {
                $locked->customer_group_id=$this->resolveGroupId(
                    $locked->tenant_id,
                    $data['customer_group_id']
                );
            }

            foreach(['display_name','first_name','last_name','phone','email','status','notes','metadata'] as $field) {
                if(array_key_exists($field,$data)) {
                    $locked->{$field}=$data[$field];
                }
            }

            if(array_key_exists('phone',$data)) {
                $locked->phone_normalized=$this->normalizer->phone($data['phone']);
            }

            if(array_key_exists('email',$data)) {
                $locked->email_normalized=$this->normalizer->email($data['email']);
            }

            $locked->updated_by_user_id=$actorUserId;
            $locked->save();

            $this->auditRecorder->record(
                'crm.customer.updated',
                $locked->tenant_id,
                $actorUserId,
                'crm.customer',
                $locked->public_id,
                before:$before,
                after:[
                    'display_name'=>$locked->display_name,
                    'status'=>$locked->status,
                    'customer_group_id'=>$locked->customer_group_id,
                ],
                request:$request,
            );

            return $locked->fresh('group');
        });
    }

    private function resolveGroupId(int $tenantId,?string $publicId): ?int
    {
        if($publicId===null) return null;

        $group=CustomerGroup::query()
            ->where('tenant_id',$tenantId)
            ->where('public_id',$publicId)
            ->where('status','active')
            ->first();

        if(!$group) {
            throw ValidationException::withMessages([
                'customer_group_id'=>['Active customer group was not found in this tenant.'],
            ]);
        }

        return $group->id;
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Application\Normalization\CustomerContactNormalizer.php
================================================================
<?php

namespace App\Modules\CRM\Application\Normalization;

final class CustomerContactNormalizer
{
    public function phone(?string $value): ?string
    {
        if($value===null) return null;

        $value=trim($value);
        if($value==='') return null;

        $hasPlus=str_starts_with($value,'+');
        $digits=preg_replace('/\D+/','',$value);

        if($digits===null || $digits==='') return null;

        if(str_starts_with($digits,'00')) {
            return '+'.substr($digits,2);
        }

        return $hasPlus ? '+'.$digits : $digits;
    }

    public function email(?string $value): ?string
    {
        if($value===null) return null;

        $value=trim(mb_strtolower($value));

        return $value==='' ? null : $value;
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Application\Presentation\CustomerPresenter.php
================================================================
<?php

namespace App\Modules\CRM\Application\Presentation;

use App\Modules\CRM\Domain\Models\Customer;

final class CustomerPresenter
{
    public function masked(Customer $customer): array
    {
        $customer->loadMissing('group');

        return [
            'id'=>$customer->public_id,
            'display_name'=>$customer->display_name,
            'first_name'=>$customer->first_name,
            'last_name'=>$customer->last_name,
            'phone'=>$this->maskPhone($customer->phone),
            'email'=>$this->maskEmail($customer->email),
            'group'=>$customer->group ? [
                'id'=>$customer->group->public_id,
                'key'=>$customer->group->group_key,
                'name'=>$customer->group->name,
            ] : null,
            'status'=>$customer->status,
            'metadata'=>$customer->metadata,
            'created_at'=>$customer->created_at?->toISOString(),
            'updated_at'=>$customer->updated_at?->toISOString(),
        ];
    }

    public function sensitive(Customer $customer): array
    {
        $data=$this->masked($customer);

        $data['phone']=$customer->phone;
        $data['email']=$customer->email;
        $data['notes']=$customer->notes;

        return $data;
    }

    private function maskPhone(?string $value): ?string
    {
        if($value===null || $value==='') return null;

        $length=mb_strlen($value);
        if($length<=4) return str_repeat('*',$length);

        return str_repeat('*',max(0,$length-4)).mb_substr($value,-4);
    }

    private function maskEmail(?string $value): ?string
    {
        if($value===null || $value==='') return null;

        [$local,$domain]=array_pad(explode('@',$value,2),2,null);

        if($domain===null) {
            return mb_substr($value,0,1).'***';
        }

        $visible=mb_substr($local,0,1);

        return $visible.'***@'.$domain;
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Application\Resolution\CustomerSnapshotResolver.php
================================================================
<?php

namespace App\Modules\CRM\Application\Resolution;

use App\Modules\CRM\Domain\Models\Customer;
use Illuminate\Validation\ValidationException;

final class CustomerSnapshotResolver
{
    public function findActive(int $tenantId,string $customerPublicId): Customer
    {
        $customer=Customer::query()
            ->with('group')
            ->where('tenant_id',$tenantId)
            ->where('public_id',$customerPublicId)
            ->where('status','active')
            ->first();

        if(!$customer) {
            throw ValidationException::withMessages([
                'customer_id'=>['Active customer was not found in this tenant.'],
            ]);
        }

        return $customer;
    }

    public function snapshot(?int $tenantId,?string $customerPublicId): ?array
    {
        if($tenantId===null || $customerPublicId===null) return null;

        $customer=Customer::query()
            ->with('group')
            ->where('tenant_id',$tenantId)
            ->where('public_id',$customerPublicId)
            ->first();

        if(!$customer) return null;

        return [
            'customer_id'=>$customer->public_id,
            'display_name'=>$customer->display_name,
            'phone_normalized'=>$customer->phone_normalized,
            'email_normalized'=>$customer->email_normalized,
            'customer_group_key'=>$customer->group?->group_key,
        ];
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Application\Timeline\CustomerTimelineService.php
================================================================
<?php

namespace App\Modules\CRM\Application\Timeline;

use App\Modules\CRM\Domain\Models\Customer;
use App\Modules\Payments\Domain\Models\Payment;
use App\Modules\Payments\Domain\Models\PaymentRefund;
use App\Modules\Sales\Domain\Models\Sale;
use App\Modules\Sales\Domain\Models\SaleReturn;

final class CustomerTimelineService
{
    public function build(Customer $customer): array
    {
        $sales=Sale::query()
            ->where('tenant_id',$customer->tenant_id)
            ->where(function($q) use ($customer) {
                $q->where('customer_id',$customer->id)
                  ->orWhere('customer_public_id',$customer->public_id);
            })
            ->orderByDesc('occurred_at')
            ->get();

        $saleIds=$sales->pluck('id');

        $returns=$saleIds->isEmpty()
            ? collect()
            : SaleReturn::query()
                ->where('tenant_id',$customer->tenant_id)
                ->whereIn('sale_id',$saleIds)
                ->where('status','posted')
                ->orderByDesc('posted_at')
                ->get();

        $payments=$saleIds->isEmpty()
            ? collect()
            : Payment::query()
                ->where('tenant_id',$customer->tenant_id)
                ->whereIn('sale_id',$saleIds)
                ->orderByDesc('id')
                ->get();

        $paymentIds=$payments->pluck('id');

        $refunds=$paymentIds->isEmpty()
            ? collect()
            : PaymentRefund::query()
                ->where('tenant_id',$customer->tenant_id)
                ->whereIn('payment_id',$paymentIds)
                ->where('status','completed')
                ->orderByDesc('resolved_at')
                ->get();

        $completedSales=$sales->where('status','completed');
        $grossSales='0.000000';
        $returned='0.000000';
        $captured='0.000000';
        $refunded='0.000000';

        foreach($completedSales as $sale) {
            $grossSales=bcadd($grossSales,(string)$sale->total_amount,6);
        }

        foreach($returns as $return) {
            $returned=bcadd($returned,(string)$return->total_amount,6);
        }

        foreach($payments->where('status','captured') as $payment) {
            $captured=bcadd($captured,(string)$payment->amount,6);
        }

        foreach($refunds as $refund) {
            $refunded=bcadd($refunded,(string)$refund->amount,6);
        }

        $events=[];

        foreach($sales as $sale) {
            $events[]=[
                'type'=>'sale',
                'occurred_at'=>$sale->occurred_at?->toISOString(),
                'reference'=>$sale->public_id,
                'number'=>$sale->sale_number,
                'amount'=>$sale->total_amount,
                'status'=>$sale->status,
            ];
        }

        foreach($returns as $return) {
            $events[]=[
                'type'=>'return',
                'occurred_at'=>$return->posted_at?->toISOString(),
                'reference'=>$return->public_id,
                'number'=>$return->return_number,
                'amount'=>$return->total_amount,
                'status'=>$return->refund_status,
            ];
        }

        foreach($payments as $payment) {
            $events[]=[
                'type'=>'payment',
                'occurred_at'=>$payment->resolved_at?->toISOString() ?? $payment->created_at?->toISOString(),
                'reference'=>$payment->public_id,
                'method'=>$payment->method_type,
                'amount'=>$payment->amount,
                'status'=>$payment->status,
            ];
        }

        foreach($refunds as $refund) {
            $events[]=[
                'type'=>'payment_refund',
                'occurred_at'=>$refund->resolved_at?->toISOString(),
                'reference'=>$refund->public_id,
                'method'=>$refund->method_type,
                'amount'=>$refund->amount,
                'status'=>$refund->status,
            ];
        }

        usort($events,fn($a,$b)=>strcmp((string)($b['occurred_at']??''),(string)($a['occurred_at']??'')));

        return [
            'customer_id'=>$customer->public_id,
            'summary'=>[
                'completed_sales_count'=>$completedSales->count(),
                'gross_sales_amount'=>$grossSales,
                'posted_return_amount'=>$returned,
                'net_sales_amount'=>bcsub($grossSales,$returned,6),
                'captured_payment_amount'=>$captured,
                'completed_refund_amount'=>$refunded,
                'last_purchase_at'=>$completedSales->sortByDesc('occurred_at')->first()?->occurred_at?->toISOString(),
            ],
            'events'=>$events,
        ];
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Domain\Models\Customer.php
================================================================
<?php

namespace App\Modules\CRM\Domain\Models;

use App\Modules\Core\Domain\Concerns\HasPublicUlid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

final class Customer extends Model
{
    use HasPublicUlid;

    protected $table='crm.customers';

    protected $fillable=[
        'tenant_id','customer_group_id','display_name','first_name','last_name',
        'phone','phone_normalized','email','email_normalized','status',
        'notes','metadata','created_by_user_id','updated_by_user_id',
    ];

    protected function casts(): array
    {
        return ['metadata'=>'array'];
    }

    public function group(): BelongsTo
    {
        return $this->belongsTo(CustomerGroup::class,'customer_group_id');
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Domain\Models\CustomerGroup.php
================================================================
<?php

namespace App\Modules\CRM\Domain\Models;

use App\Modules\Core\Domain\Concerns\HasPublicUlid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

final class CustomerGroup extends Model
{
    use HasPublicUlid;

    protected $table='crm.customer_groups';

    protected $fillable=[
        'tenant_id','group_key','name','status','metadata',
    ];

    protected function casts(): array
    {
        return ['metadata'=>'array'];
    }

    public function customers(): HasMany
    {
        return $this->hasMany(Customer::class,'customer_group_id');
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Http\Controllers\CustomerController.php
================================================================
<?php

namespace App\Modules\CRM\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Modules\Core\Application\Context\TenantContext;
use App\Modules\CRM\Application\Customers\CreateCustomerAction;
use App\Modules\CRM\Application\Customers\UpdateCustomerAction;
use App\Modules\CRM\Application\Normalization\CustomerContactNormalizer;
use App\Modules\CRM\Application\Presentation\CustomerPresenter;
use App\Modules\CRM\Domain\Models\Customer;
use App\Modules\CRM\Http\Requests\StoreCustomerRequest;
use App\Modules\CRM\Http\Requests\UpdateCustomerRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class CustomerController extends Controller
{
    public function index(
        Request $request,
        TenantContext $tenantContext,
        CustomerPresenter $presenter,
        CustomerContactNormalizer $normalizer,
    ): JsonResponse {
        $query=Customer::query()
            ->with('group')
            ->where('tenant_id',$tenantContext->tenantId());

        if($request->filled('status')) {
            $query->where('status',$request->string('status')->toString());
        }

        if($request->filled('group_key')) {
            $groupKey=$request->string('group_key')->toString();
            $query->whereHas('group',fn($q)=>$q->where('group_key',$groupKey));
        }

        if($request->filled('q')) {
            $term=trim($request->string('q')->toString());
            $phone=$normalizer->phone($term);
            $email=$normalizer->email($term);

            $query->where(function($q) use ($term,$phone,$email) {
                $q->where('display_name','ilike','%'.$term.'%')
                  ->orWhere('first_name','ilike','%'.$term.'%')
                  ->orWhere('last_name','ilike','%'.$term.'%');

                if($phone!==null) {
                    $q->orWhere('phone_normalized','like','%'.$phone.'%');
                }

                if($email!==null && str_contains($email,'@')) {
                    $q->orWhere('email_normalized','like','%'.$email.'%');
                }
            });
        }

        $limit=max(1,min((int)$request->integer('limit',50),200));

        return response()->json([
            'data'=>$query
                ->orderBy('display_name')
                ->limit($limit)
                ->get()
                ->map(fn(Customer $customer)=>$presenter->masked($customer))
                ->values(),
        ]);
    }

    public function store(
        StoreCustomerRequest $request,
        TenantContext $tenantContext,
        CreateCustomerAction $action,
        CustomerPresenter $presenter,
    ): JsonResponse {
        $customer=$action->execute(
            $tenantContext->tenantId(),
            $request->validated(),
            $request->user()->id,
            $request,
        );

        return response()->json(['data'=>$presenter->masked($customer)],201);
    }

    public function show(
        string $customerPublicId,
        TenantContext $tenantContext,
        CustomerPresenter $presenter,
    ): JsonResponse {
        $customer=$this->customer($tenantContext->tenantId(),$customerPublicId);

        return response()->json(['data'=>$presenter->masked($customer)]);
    }

    public function sensitive(
        string $customerPublicId,
        TenantContext $tenantContext,
        CustomerPresenter $presenter,
    ): JsonResponse {
        $customer=$this->customer($tenantContext->tenantId(),$customerPublicId);

        return response()->json(['data'=>$presenter->sensitive($customer)]);
    }

    public function update(
        string $customerPublicId,
        UpdateCustomerRequest $request,
        TenantContext $tenantContext,
        UpdateCustomerAction $action,
        CustomerPresenter $presenter,
    ): JsonResponse {
        $customer=$this->customer($tenantContext->tenantId(),$customerPublicId);

        $customer=$action->execute(
            $customer,
            $request->validated(),
            $request->user()->id,
            $request,
        );

        return response()->json(['data'=>$presenter->masked($customer)]);
    }

    private function customer(int $tenantId,string $publicId): Customer
    {
        return Customer::query()
            ->with('group')
            ->where('tenant_id',$tenantId)
            ->where('public_id',$publicId)
            ->firstOrFail();
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Http\Controllers\CustomerGroupController.php
================================================================
<?php

namespace App\Modules\CRM\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Modules\Audit\Application\AuditRecorder;
use App\Modules\Core\Application\Context\TenantContext;
use App\Modules\CRM\Domain\Models\CustomerGroup;
use App\Modules\CRM\Http\Requests\StoreCustomerGroupRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;

final class CustomerGroupController extends Controller
{
    public function index(TenantContext $tenantContext): JsonResponse
    {
        return response()->json([
            'data'=>CustomerGroup::query()
                ->where('tenant_id',$tenantContext->tenantId())
                ->orderBy('name')
                ->get()
                ->map(fn($group)=>[
                    'id'=>$group->public_id,
                    'key'=>$group->group_key,
                    'name'=>$group->name,
                    'status'=>$group->status,
                    'metadata'=>$group->metadata,
                ])->values(),
        ]);
    }

    public function store(
        StoreCustomerGroupRequest $request,
        TenantContext $tenantContext,
        AuditRecorder $auditRecorder,
    ): JsonResponse {
        $tenantId=$tenantContext->tenantId();
        $data=$request->validated();
        $key=strtolower($data['group_key']);

        if(CustomerGroup::query()->where('tenant_id',$tenantId)->where('group_key',$key)->exists()) {
            throw ValidationException::withMessages([
                'group_key'=>['Customer group key already exists in this tenant.'],
            ]);
        }

        $group=CustomerGroup::query()->create([
            'tenant_id'=>$tenantId,
            'group_key'=>$key,
            'name'=>$data['name'],
            'status'=>'active',
            'metadata'=>$data['metadata']??null,
        ]);

        $auditRecorder->record(
            'crm.customer_group.created',
            $tenantId,
            $request->user()->id,
            'crm.customer_group',
            $group->public_id,
            after:['group_key'=>$group->group_key,'name'=>$group->name],
            request:$request,
        );

        return response()->json([
            'data'=>[
                'id'=>$group->public_id,
                'key'=>$group->group_key,
                'name'=>$group->name,
                'status'=>$group->status,
            ],
        ],201);
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Http\Controllers\CustomerTimelineController.php
================================================================
<?php

namespace App\Modules\CRM\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Modules\Core\Application\Context\TenantContext;
use App\Modules\CRM\Application\Timeline\CustomerTimelineService;
use App\Modules\CRM\Domain\Models\Customer;
use Illuminate\Http\JsonResponse;

final class CustomerTimelineController extends Controller
{
    public function show(
        string $customerPublicId,
        TenantContext $tenantContext,
        CustomerTimelineService $timeline,
    ): JsonResponse {
        $customer=Customer::query()
            ->where('tenant_id',$tenantContext->tenantId())
            ->where('public_id',$customerPublicId)
            ->firstOrFail();

        return response()->json(['data'=>$timeline->build($customer)]);
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Http\Requests\StoreCustomerGroupRequest.php
================================================================
<?php

namespace App\Modules\CRM\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class StoreCustomerGroupRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'group_key'=>['required','string','max:100','regex:/^[A-Za-z0-9._-]+$/'],
            'name'=>['required','string','max:180'],
            'metadata'=>['nullable','array'],
        ];
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Http\Requests\StoreCustomerRequest.php
================================================================
<?php

namespace App\Modules\CRM\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class StoreCustomerRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'display_name'=>['required','string','max:220'],
            'first_name'=>['nullable','string','max:120'],
            'last_name'=>['nullable','string','max:120'],
            'phone'=>['nullable','string','max:80'],
            'email'=>['nullable','email','max:254'],
            'customer_group_id'=>['nullable','string','size:26'],
            'notes'=>['nullable','string','max:5000'],
            'metadata'=>['nullable','array'],
        ];
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Http\Requests\UpdateCustomerRequest.php
================================================================
<?php

namespace App\Modules\CRM\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

final class UpdateCustomerRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'display_name'=>['sometimes','required','string','max:220'],
            'first_name'=>['sometimes','nullable','string','max:120'],
            'last_name'=>['sometimes','nullable','string','max:120'],
            'phone'=>['sometimes','nullable','string','max:80'],
            'email'=>['sometimes','nullable','email','max:254'],
            'customer_group_id'=>['sometimes','nullable','string','size:26'],
            'status'=>['sometimes','required',Rule::in(['active','inactive'])],
            'notes'=>['sometimes','nullable','string','max:5000'],
            'metadata'=>['sometimes','nullable','array'],
        ];
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Application\Checkout\CheckoutCartAction.php
================================================================
<?php

namespace App\Modules\Sales\Application\Checkout;

use App\Modules\Audit\Application\AuditRecorder;
use App\Modules\Catalog\Domain\Models\Variant;
use App\Modules\CRM\Application\Resolution\CustomerSnapshotResolver;
use App\Modules\Promotions\Application\Usage\ConsumeCheckoutPromotionsAction;
use App\Modules\Sales\Application\Numbering\SaleNumberGenerator;
use App\Modules\Sales\Domain\Models\Cart;
use App\Modules\Sales\Domain\Models\CheckoutCommand;
use App\Modules\Sales\Domain\Models\Sale;
use App\Modules\Sales\Domain\Models\SaleLine;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;

final readonly class CheckoutCartAction
{
    public function __construct(
        private SaleNumberGenerator $numberGenerator,
        private AuditRecorder $auditRecorder,
        private ConsumeCheckoutPromotionsAction $promotionUsage,
        private CustomerSnapshotResolver $customerSnapshotResolver,
    ) {}

    public function execute(
        Cart $cart,
        int $expectedVersion,
        string $idempotencyKey,
        int $actorUserId,
        Request $request,
    ): Sale {
        $fingerprint=$this->fingerprint($cart,$expectedVersion);

        $existing=CheckoutCommand::query()
            ->where('tenant_id',$cart->tenant_id)
            ->where('idempotency_key',$idempotencyKey)
            ->first();

        if($existing) {
            $this->assertReplayMatches($existing,$cart,$fingerprint);

            if($existing->sale_id) {
                return Sale::query()->with('lines')->findOrFail($existing->sale_id);
            }
        }

        return DB::transaction(function () use (
            $cart,$expectedVersion,$idempotencyKey,$actorUserId,$request,$fingerprint
        ) {
            $locked=Cart::query()
                ->with('lines')
                ->whereKey($cart->id)
                ->lockForUpdate()
                ->firstOrFail();

            $existing=CheckoutCommand::query()
                ->where('tenant_id',$locked->tenant_id)
                ->where('idempotency_key',$idempotencyKey)
                ->lockForUpdate()
                ->first();

            if($existing) {
                $this->assertReplayMatches($existing,$locked,$fingerprint);

                if($existing->sale_id) {
                    return Sale::query()->with('lines')->findOrFail($existing->sale_id);
                }
            }

            if($locked->status==='converted') {
                $sale=Sale::query()
                    ->with('lines')
                    ->where('tenant_id',$locked->tenant_id)
                    ->where('cart_id',$locked->id)
                    ->first();

                if($sale) return $sale;

                throw new ConflictHttpException('Cart was already converted.');
            }

            if($locked->status!=='open') {
                throw new ConflictHttpException('Cart is not open for checkout.');
            }

            if($locked->version!==$expectedVersion) {
                throw new ConflictHttpException('Cart version conflict.');
            }

            if($locked->lines->isEmpty()) {
                throw ValidationException::withMessages([
                    'cart'=>['Cannot checkout an empty cart.'],
                ]);
            }

            if(bccomp((string)$locked->total_amount,'0.000000',6)<0) {
                throw ValidationException::withMessages([
                    'cart'=>['Cart total is invalid.'],
                ]);
            }

            $command=$existing ?? CheckoutCommand::query()->create([
                'tenant_id'=>$locked->tenant_id,
                'cart_id'=>$locked->id,
                'idempotency_key'=>$idempotencyKey,
                'request_fingerprint'=>$fingerprint,
                'status'=>'started',
                'created_at'=>now(),
            ]);

            /*
             * Reserve/consume promotion usage before materializing the sale.
             * It is inside the same DB transaction, so any later checkout failure
             * rolls counters and consumption records back.
             */
            $this->promotionUsage->execute($locked,$command);

            $occurredAt=CarbonImmutable::now('UTC');
            $number=$this->numberGenerator->next(
                $locked->tenant_id,
                $locked->branch_id,
                $occurredAt,
            );

            $sale=Sale::query()->create([
                'tenant_id'=>$locked->tenant_id,
                'company_id'=>$locked->company_id,
                'branch_id'=>$locked->branch_id,
                'register_id'=>$locked->register_id,
                'cart_id'=>$locked->id,
                'created_by_user_id'=>$actorUserId,
                'customer_id'=>$locked->customer_id,
                'sale_number'=>$number['sale_number'],
                'business_date'=>$number['business_date'],
                'occurred_at'=>$occurredAt,
                'currency_code'=>$locked->currency_code,
                'customer_public_id'=>$locked->customer_public_id,
                'customer_group_key'=>$locked->customer_group_key,
                'customer_snapshot'=>$this->customerSnapshotResolver->snapshot(
                    $locked->tenant_id,
                    $locked->customer_public_id,
                ),
                'subtotal_amount'=>$locked->subtotal_amount,
                'discount_amount'=>$locked->discount_amount,
                'total_amount'=>$locked->total_amount,
                'status'=>'pending_payment',
                'payment_status'=>'unpaid',
                'pricing_snapshot'=>[
                    'source'=>'server_cart',
                    'cart_version'=>$locked->version,
                ],
                'promotion_snapshot'=>[
                    'line_promotions'=>$locked->lines
                        ->filter(fn($line)=>!empty($line->promotion_snapshot))
                        ->map(fn($line)=>[
                            'cart_line_id'=>$line->public_id,
                            'promotions'=>$line->promotion_snapshot,
                        ])->values()->all(),
                ],
            ]);

            foreach($locked->lines as $line) {
                $variant=Variant::query()
                    ->where('tenant_id',$locked->tenant_id)
                    ->whereKey($line->variant_id)
                    ->firstOrFail();

                SaleLine::query()->create([
                    'tenant_id'=>$locked->tenant_id,
                    'sale_id'=>$sale->id,
                    'variant_id'=>$line->variant_id,
                    'variant_unit_id'=>$line->variant_unit_id,
                    'sku_snapshot'=>$variant->sku,
                    'name_snapshot'=>$variant->name,
                    'quantity'=>$line->quantity,
                    'unit_price_amount'=>$line->unit_price_amount,
                    'gross_amount'=>$line->gross_amount,
                    'discount_amount'=>$line->discount_amount,
                    'net_amount'=>$line->net_amount,
                    'price_snapshot'=>$line->price_snapshot,
                    'promotion_snapshot'=>$line->promotion_snapshot,
                    'metadata'=>$line->metadata,
                    'created_at'=>$occurredAt,
                ]);
            }

            $this->promotionUsage->execute($locked,$command,$sale);

            $locked->status='converted';
            $locked->version++;
            $locked->save();

            $command->sale_id=$sale->id;
            $command->status='completed';
            $command->response_snapshot=[
                'sale_id'=>$sale->public_id,
                'sale_number'=>$sale->sale_number,
                'status'=>$sale->status,
                'payment_status'=>$sale->payment_status,
                'total_amount'=>$sale->total_amount,
                'request_fingerprint'=>$fingerprint,
            ];
            $command->completed_at=now();
            $command->save();

            $this->auditRecorder->record(
                'sales.checkout.completed',
                $locked->tenant_id,
                $actorUserId,
                'sales.sale',
                $sale->public_id,
                after:[
                    'cart_id'=>$locked->public_id,
                    'sale_number'=>$sale->sale_number,
                    'total_amount'=>$sale->total_amount,
                    'status'=>$sale->status,
                    'payment_status'=>$sale->payment_status,
                    'request_fingerprint'=>$fingerprint,
                ],
                request:$request,
            );

            return $sale->fresh('lines');
        });
    }

    private function fingerprint(Cart $cart,int $expectedVersion): string
    {
        return hash('sha256',json_encode([
            'tenant_id'=>$cart->tenant_id,
            'cart_id'=>$cart->id,
            'expected_version'=>$expectedVersion,
        ],JSON_THROW_ON_ERROR));
    }

    private function assertReplayMatches(
        CheckoutCommand $command,
        Cart $cart,
        string $fingerprint,
    ): void {
        if($command->cart_id!==$cart->id) {
            throw new ConflictHttpException(
                'Idempotency key already belongs to another cart.'
            );
        }

        /*
         * Pre-v40 commands have no fingerprint. They remain replayable only for
         * their original cart; new commands are strict.
         */
        if(
            $command->request_fingerprint!==null
            && !hash_equals($command->request_fingerprint,$fingerprint)
        ) {
            throw new ConflictHttpException(
                'Idempotency key was already used with different checkout input.'
            );
        }
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Application\Customers\AttachCustomerToCartAction.php
================================================================
<?php

namespace App\Modules\Sales\Application\Customers;

use App\Modules\Audit\Application\AuditRecorder;
use App\Modules\CRM\Application\Resolution\CustomerSnapshotResolver;
use App\Modules\Sales\Application\Cart\CartRecalculator;
use App\Modules\Sales\Domain\Models\Cart;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;

final readonly class AttachCustomerToCartAction
{
    public function __construct(
        private CustomerSnapshotResolver $customerResolver,
        private CartRecalculator $recalculator,
        private AuditRecorder $auditRecorder,
    ) {}

    public function execute(
        Cart $cart,
        ?string $customerPublicId,
        int $expectedVersion,
        int $actorUserId,
        Request $request,
    ): Cart {
        return DB::transaction(function () use (
            $cart,$customerPublicId,$expectedVersion,$actorUserId,$request
        ) {
            $locked=Cart::query()
                ->where('tenant_id',$cart->tenant_id)
                ->whereKey($cart->id)
                ->lockForUpdate()
                ->firstOrFail();

            if($locked->status!=='open') {
                throw new ConflictHttpException('Customer can be changed only on an open cart.');
            }

            if($locked->version!==$expectedVersion) {
                throw new ConflictHttpException('Cart version conflict.');
            }

            $before=[
                'customer_public_id'=>$locked->customer_public_id,
                'customer_group_key'=>$locked->customer_group_key,
                'version'=>$locked->version,
            ];

            if($customerPublicId===null) {
                $locked->customer_id=null;
                $locked->customer_public_id=null;
                $locked->customer_group_key=null;
            } else {
                $customer=$this->customerResolver->findActive(
                    $locked->tenant_id,
                    $customerPublicId
                );

                $locked->customer_id=$customer->id;
                $locked->customer_public_id=$customer->public_id;
                $locked->customer_group_key=$customer->group?->group_key;
            }

            $locked->save();

            /*
             * Customer/group scopes can change price lists and promotions, so this
             * is not a cosmetic assignment. Reprice the complete cart.
             */
            $recalculated=$this->recalculator->recalculate($locked);
            $recalculated->version++;
            $recalculated->save();

            $this->auditRecorder->record(
                'sales.cart.customer_changed',
                $locked->tenant_id,
                $actorUserId,
                'sales.cart',
                $locked->public_id,
                before:$before,
                after:[
                    'customer_public_id'=>$recalculated->customer_public_id,
                    'customer_group_key'=>$recalculated->customer_group_key,
                    'version'=>$recalculated->version,
                ],
                request:$request,
            );

            return $recalculated->fresh('lines');
        });
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Domain\Models\CheckoutCommand.php
================================================================
<?php

namespace App\Modules\Sales\Domain\Models;

use App\Modules\Core\Domain\Concerns\HasPublicUlid;
use Illuminate\Database\Eloquent\Model;

final class CheckoutCommand extends Model
{
    use HasPublicUlid;

    public $timestamps=false;

    protected $table='sales.checkout_commands';

    protected $fillable=[
        'tenant_id','cart_id','sale_id','idempotency_key','request_fingerprint',
        'status','response_snapshot','failure_code','created_at','completed_at',
    ];

    protected function casts(): array
    {
        return [
            'response_snapshot'=>'array',
            'created_at'=>'immutable_datetime',
            'completed_at'=>'immutable_datetime',
        ];
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Http\Controllers\CartCustomerController.php
================================================================
<?php

namespace App\Modules\Sales\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Modules\Core\Application\Context\TenantContext;
use App\Modules\Sales\Application\Customers\AttachCustomerToCartAction;
use App\Modules\Sales\Domain\Models\Cart;
use App\Modules\Sales\Http\Requests\AttachCartCustomerRequest;
use Illuminate\Http\JsonResponse;

final class CartCustomerController extends Controller
{
    public function update(
        string $cartPublicId,
        AttachCartCustomerRequest $request,
        TenantContext $tenantContext,
        AttachCustomerToCartAction $action,
    ): JsonResponse {
        $cart=Cart::query()
            ->where('tenant_id',$tenantContext->tenantId())
            ->where('public_id',$cartPublicId)
            ->firstOrFail();

        $cart=$action->execute(
            $cart,
            $request->validated()['customer_id']??null,
            (int)$request->validated()['expected_version'],
            $request->user()->id,
            $request,
        );

        return response()->json([
            'data'=>[
                'id'=>$cart->public_id,
                'customer_id'=>$cart->customer_public_id,
                'customer_group_key'=>$cart->customer_group_key,
                'subtotal_amount'=>$cart->subtotal_amount,
                'discount_amount'=>$cart->discount_amount,
                'total_amount'=>$cart->total_amount,
                'version'=>$cart->version,
            ],
        ]);
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Http\Controllers\CheckoutController.php
================================================================
<?php

namespace App\Modules\Sales\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Modules\Core\Application\Context\TenantContext;
use App\Modules\Sales\Application\Checkout\CheckoutCartAction;
use App\Modules\Sales\Domain\Models\Cart;
use App\Modules\Sales\Http\Requests\CheckoutCartRequest;
use App\Modules\Sales\Http\Resources\SaleResource;

final class CheckoutController extends Controller
{
    public function store(
        string $cartPublicId,
        CheckoutCartRequest $request,
        TenantContext $tenantContext,
        CheckoutCartAction $action,
    ): SaleResource {
        $cart=Cart::query()
            ->where('tenant_id',$tenantContext->tenantId())
            ->where('public_id',$cartPublicId)
            ->firstOrFail();

        $v=$request->validated();

        $sale=$action->execute(
            cart:$cart,
            expectedVersion:(int)$v['expected_version'],
            idempotencyKey:$v['idempotency_key'],
            actorUserId:$request->user()->id,
            request:$request,
        );

        return new SaleResource($sale);
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Http\Requests\AttachCartCustomerRequest.php
================================================================
<?php

namespace App\Modules\Sales\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class AttachCartCustomerRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'customer_id'=>['nullable','string','size:26'],
            'expected_version'=>['required','integer','min:1'],
        ];
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Http\Requests\CheckoutCartRequest.php
================================================================
<?php

namespace App\Modules\Sales\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class CheckoutCartRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'expected_version'=>['required','integer','min:1'],
            'idempotency_key'=>['required','string','max:120'],
        ];
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Payments\Application\Refunds\RefundCapturedPaymentAction.php
================================================================
<?php

namespace App\Modules\Payments\Application\Refunds;

use App\Modules\Cash\Application\Shifts\PostCashRefundToShiftAction;
use App\Modules\Core\Domain\Models\Register;
use App\Modules\Payments\Domain\Models\Payment;
use App\Modules\Payments\Domain\Models\PaymentRefund;
use App\Modules\Sales\Domain\Models\Sale;
use App\Modules\Sales\Domain\Models\SaleReturn;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;

final readonly class RefundCapturedPaymentAction
{
    public function __construct(
        private PostCashRefundToShiftAction $cashRefund,
    ) {}

    public function execute(
        Payment $payment,
        SaleReturn $saleReturn,
        string $amount,
        string $idempotencyKey,
        int $actorUserId,
        Request $request,
        ?array $metadata=null,
    ): PaymentRefund {
        $existing=PaymentRefund::query()
            ->where('tenant_id',$payment->tenant_id)
            ->where('idempotency_key',$idempotencyKey)
            ->first();

        if($existing) return $existing;

        return DB::transaction(function () use (
            $payment,$saleReturn,$amount,$idempotencyKey,$actorUserId,$request,$metadata
        ) {
            $lockedPayment=Payment::query()
                ->where('tenant_id',$payment->tenant_id)
                ->whereKey($payment->id)
                ->lockForUpdate()
                ->firstOrFail();

            if($lockedPayment->status!=='captured') {
                throw new ConflictHttpException('Only captured payments can be refunded.');
            }

            if($lockedPayment->sale_id!==$saleReturn->sale_id) {
                throw ValidationException::withMessages([
                    'payment_id'=>['Refund payment does not belong to the returned sale.'],
                ]);
            }

            $existing=PaymentRefund::query()
                ->where('tenant_id',$lockedPayment->tenant_id)
                ->where('idempotency_key',$idempotencyKey)
                ->lockForUpdate()
                ->first();

            if($existing) return $existing;

            $allocated='0.000000';

            PaymentRefund::query()
                ->where('tenant_id',$lockedPayment->tenant_id)
                ->where('payment_id',$lockedPayment->id)
                ->whereIn('status',['pending','completed'])
                ->pluck('amount')
                ->each(function($value) use (&$allocated) {
                    $allocated=bcadd($allocated,(string)$value,6);
                });

            if(bccomp(bcadd($allocated,$amount,6),(string)$lockedPayment->amount,6)>0) {
                throw ValidationException::withMessages([
                    'amount'=>['Refund allocation exceeds remaining refundable payment amount.'],
                ]);
            }

            $refund=PaymentRefund::query()->create([
                'tenant_id'=>$lockedPayment->tenant_id,
                'payment_id'=>$lockedPayment->id,
                'sale_return_id'=>$saleReturn->id,
                'method_type'=>$lockedPayment->method_type,
                'amount'=>$amount,
                'currency_code'=>$lockedPayment->currency_code,
                'status'=>'pending',
                'idempotency_key'=>$idempotencyKey,
                'created_by_user_id'=>$actorUserId,
                'metadata'=>$metadata,
                'created_at'=>now(),
            ]);

            if($lockedPayment->method_type==='cash') {
                $sale=Sale::query()
                    ->where('tenant_id',$lockedPayment->tenant_id)
                    ->whereKey($lockedPayment->sale_id)
                    ->firstOrFail();

                if($sale->register_id===null) {
                    throw new ConflictHttpException('Cash refund requires a register-linked sale.');
                }

                $register=Register::query()
                    ->where('tenant_id',$sale->tenant_id)
                    ->whereKey($sale->register_id)
                    ->firstOrFail();

                $cash=$this->cashRefund->execute(
                    $lockedPayment,
                    [
                        'register_id'=>$register->public_id,
                        'amount'=>$amount,
                        'idempotency_key'=>'return-cash-'.$refund->public_id,
                        'reason'=>'Sales return '.$saleReturn->return_number,
                        'metadata'=>[
                            'sale_return_id'=>$saleReturn->public_id,
                            'payment_refund_id'=>$refund->public_id,
                        ],
                    ],
                    $actorUserId,
                    $request,
                );

                $refund->status='completed';
                $refund->resolved_by_user_id=$actorUserId;
                $refund->resolved_at=now();
                $refund->metadata=array_merge(
                    $refund->metadata ?? [],
                    ['cash_sale_refund_id'=>$cash->public_id]
                );
                $refund->save();
            }

            return $refund->fresh();
        });
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Payments\Application\Refunds\ResolvePaymentRefundAction.php
================================================================
<?php

namespace App\Modules\Payments\Application\Refunds;

use App\Modules\Audit\Application\AuditRecorder;
use App\Modules\Payments\Domain\Models\PaymentRefund;
use App\Modules\Payments\Application\Settlement\SalePaymentStatusUpdater;
use App\Modules\Sales\Domain\Models\Sale;
use App\Modules\Sales\Domain\Models\SaleReturn;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;

final readonly class ResolvePaymentRefundAction
{
    public function __construct(
        private SaleRefundStatusUpdater $returnStatus,
        private SalePaymentStatusUpdater $salePaymentStatus,
        private AuditRecorder $auditRecorder,
    ) {}

    public function execute(
        PaymentRefund $refund,
        array $data,
        int $actorUserId,
        Request $request,
    ): PaymentRefund {
        return DB::transaction(function () use ($refund,$data,$actorUserId,$request) {
            $locked=PaymentRefund::query()
                ->where('tenant_id',$refund->tenant_id)
                ->whereKey($refund->id)
                ->lockForUpdate()
                ->firstOrFail();

            if($locked->method_type==='cash') {
                throw new ConflictHttpException('Cash refunds are resolved synchronously through the register shift.');
            }

            if($locked->status!=='pending') {
                return $locked;
            }

            $before=$locked->status;
            $locked->status=$data['outcome']==='succeeded' ? 'completed' : 'failed';
            $locked->provider_reference=$data['provider_reference'] ?? $locked->provider_reference;
            $locked->resolved_by_user_id=$actorUserId;
            $locked->resolved_at=now();

            if(!empty($data['metadata'])) {
                $locked->metadata=array_merge($locked->metadata ?? [],$data['metadata']);
            }

            $locked->save();

            if($locked->sale_return_id!==null) {
                $return=SaleReturn::query()->whereKey($locked->sale_return_id)->lockForUpdate()->firstOrFail();
                $this->returnStatus->refresh($return);

                $sale=Sale::query()->whereKey($return->sale_id)->lockForUpdate()->firstOrFail();
                $this->salePaymentStatus->refresh($sale);
            }

            $this->auditRecorder->record(
                'payments.refund.resolved',
                $locked->tenant_id,
                $actorUserId,
                'payments.refund',
                $locked->public_id,
                before:['status'=>$before],
                after:[
                    'status'=>$locked->status,
                    'provider_reference'=>$locked->provider_reference,
                ],
                request:$request,
            );

            return $locked->fresh();
        });
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Payments\Application\Refunds\SaleRefundStatusUpdater.php
================================================================
<?php

namespace App\Modules\Payments\Application\Refunds;

use App\Modules\Payments\Domain\Models\PaymentRefund;
use App\Modules\Sales\Domain\Models\SaleReturn;

final class SaleRefundStatusUpdater
{
    public function refresh(SaleReturn $return): SaleReturn
    {
        $refunds=PaymentRefund::query()
            ->where('tenant_id',$return->tenant_id)
            ->where('sale_return_id',$return->id)
            ->get();

        if($refunds->isEmpty()) {
            $status='none';
        } else {
            $completed='0.000000';
            $pending=false;
            $failed=false;

            foreach($refunds as $refund) {
                if($refund->status==='completed') {
                    $completed=bcadd($completed,(string)$refund->amount,6);
                } elseif($refund->status==='pending') {
                    $pending=true;
                } elseif($refund->status==='failed') {
                    $failed=true;
                }
            }

            if(bccomp($completed,(string)$return->total_amount,6)>=0) {
                $status='completed';
            } elseif(bccomp($completed,'0.000000',6)>0) {
                $status='partial';
            } elseif($pending) {
                $status='pending';
            } elseif($failed) {
                $status='failed';
            } else {
                $status='none';
            }
        }

        $return->refund_status=$status;
        $return->save();

        return $return->fresh();
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Payments\Application\Settlement\CollectPaymentAction.php
================================================================
<?php

namespace App\Modules\Payments\Application\Settlement;

use App\Modules\Audit\Application\AuditRecorder;
use App\Modules\Cash\Application\Shifts\AutoPostCapturedCashPaymentToShiftAction;
use App\Modules\Payments\Domain\Models\Payment;
use App\Modules\Payments\Domain\Models\PaymentAttempt;
use App\Modules\Sales\Domain\Models\Sale;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;

final readonly class CollectPaymentAction
{
    public function __construct(
        private SalePaymentStatusUpdater $statusUpdater,
        private AuditRecorder $auditRecorder,
        private AutoPostCapturedCashPaymentToShiftAction $autoPostCashShift,
    ) {}

    public function execute(
        Sale $sale,
        array $data,
        int $actorUserId,
        Request $request,
    ): Payment {
        $existing=Payment::query()
            ->where('tenant_id',$sale->tenant_id)
            ->where('idempotency_key',$data['idempotency_key'])
            ->first();

        if ($existing) {
            if ($existing->sale_id !== $sale->id) {
                throw new ConflictHttpException('Idempotency key already belongs to another sale.');
            }

            /*
             * Recovery behavior:
             * an old captured cash payment may predate automatic posting.
             * A safe retry can complete the missing shift posting exactly once.
             */
            if ($existing->status==='captured' && $existing->method_type==='cash') {
                $saleFresh=Sale::query()->findOrFail($existing->sale_id);
                $this->autoPostCashShift->execute($existing,$saleFresh,$actorUserId);
            }

            return $existing->load('attempts');
        }

        return DB::transaction(function () use ($sale,$data,$actorUserId,$request) {
            $lockedSale=Sale::query()
                ->whereKey($sale->id)
                ->lockForUpdate()
                ->firstOrFail();

            $existing=Payment::query()
                ->where('tenant_id',$lockedSale->tenant_id)
                ->where('idempotency_key',$data['idempotency_key'])
                ->lockForUpdate()
                ->first();

            if ($existing) {
                if ($existing->sale_id !== $lockedSale->id) {
                    throw new ConflictHttpException('Idempotency key already belongs to another sale.');
                }

                if ($existing->status==='captured' && $existing->method_type==='cash') {
                    $this->autoPostCashShift->execute($existing,$lockedSale,$actorUserId);
                }

                return $existing->load('attempts');
            }

            if (in_array($lockedSale->status,['voided','reversed'],true)) {
                throw new ConflictHttpException('Sale cannot accept payments.');
            }

            if ($lockedSale->payment_status === 'paid') {
                throw new ConflictHttpException('Sale is already fully paid.');
            }

            $captured=$this->statusUpdater->capturedAmount($lockedSale);
            $remaining=bcsub((string)$lockedSale->total_amount,$captured,6);
            $amount=(string)$data['amount'];

            if (bccomp($amount,$remaining,6) > 0) {
                throw ValidationException::withMessages([
                    'amount'=>['Payment amount cannot exceed the remaining sale balance.'],
                ]);
            }

            $method=$data['method_type'];
            $outcome=$method === 'cash'
                ? 'succeeded'
                : ($data['outcome'] ?? null);

            if ($method !== 'cash' && $outcome === null) {
                throw ValidationException::withMessages([
                    'outcome'=>['Card and external payments require succeeded, failed, or unknown outcome.'],
                ]);
            }

            $status=match($outcome) {
                'succeeded'=>'captured',
                'failed'=>'failed',
                'unknown'=>'unknown',
                default=>'pending',
            };

            $payment=Payment::query()->create([
                'tenant_id'=>$lockedSale->tenant_id,
                'sale_id'=>$lockedSale->id,
                'method_type'=>$method,
                'amount'=>$amount,
                'currency_code'=>$lockedSale->currency_code,
                'status'=>$status,
                'idempotency_key'=>$data['idempotency_key'],
                'provider_reference'=>$data['provider_reference']??null,
                'created_by_user_id'=>$actorUserId,
                'resolved_at'=>in_array($status,['captured','failed'],true) ? now() : null,
                'metadata'=>$data['metadata']??null,
            ]);

            PaymentAttempt::query()->create([
                'tenant_id'=>$lockedSale->tenant_id,
                'payment_id'=>$payment->id,
                'operation_key'=>$data['idempotency_key'],
                'attempt_type'=>'collect',
                'status'=>match($status) {
                    'captured'=>'succeeded',
                    'failed'=>'failed',
                    'unknown'=>'unknown',
                    default=>'started',
                },
                'provider_reference'=>$data['provider_reference']??null,
                'request_snapshot'=>[
                    'method_type'=>$method,
                    'amount'=>$amount,
                ],
                'response_snapshot'=>[
                    'outcome'=>$outcome,
                ],
                'started_at'=>now(),
                'completed_at'=>$status==='pending' ? null : now(),
            ]);

            $saleAfter=$this->statusUpdater->refresh($lockedSale);

            /*
             * Same database transaction:
             * if a registered cash sale has no open shift, this throws and the
             * payment capture rolls back. No captured cash can escape the shift ledger.
             */
            if ($payment->status==='captured' && $payment->method_type==='cash') {
                $this->autoPostCashShift->execute($payment,$saleAfter,$actorUserId);
            }

            $this->auditRecorder->record(
                'payments.payment.collected',
                $lockedSale->tenant_id,
                $actorUserId,
                'payments.payment',
                $payment->public_id,
                after:[
                    'sale_id'=>$lockedSale->public_id,
                    'method_type'=>$payment->method_type,
                    'amount'=>$payment->amount,
                    'status'=>$payment->status,
                    'sale_payment_status'=>$saleAfter->payment_status,
                ],
                request:$request,
            );

            return $payment->fresh('attempts');
        });
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Payments\Application\Settlement\SalePaymentStatusUpdater.php
================================================================
<?php

namespace App\Modules\Payments\Application\Settlement;

use App\Modules\Payments\Domain\Models\Payment;
use App\Modules\Payments\Domain\Models\PaymentRefund;
use App\Modules\Sales\Domain\Models\Sale;

final class SalePaymentStatusUpdater
{
    public function refresh(Sale $sale): Sale
    {
        $captured=$this->capturedAmount($sale);
        $refunded=$this->completedRefundAmount($sale);

        $hasUnknown=Payment::query()
            ->where('tenant_id',$sale->tenant_id)
            ->where('sale_id',$sale->id)
            ->where('status','unknown')
            ->exists();

        if($hasUnknown) {
            $paymentStatus='unknown';
            $saleStatus='pending_payment';
        } elseif(bccomp($refunded,'0.000000',6)>0 && bccomp($refunded,$captured,6)>=0) {
            $paymentStatus='refunded';
            $saleStatus=bccomp($captured,(string)$sale->total_amount,6)>=0
                ? 'completed'
                : 'pending_payment';
        } elseif(bccomp($refunded,'0.000000',6)>0) {
            $paymentStatus='partially_refunded';
            $saleStatus=bccomp($captured,(string)$sale->total_amount,6)>=0
                ? 'completed'
                : 'pending_payment';
        } elseif(bccomp($captured,(string)$sale->total_amount,6)>=0) {
            $paymentStatus='paid';
            $saleStatus='completed';
        } elseif(bccomp($captured,'0.000000',6)>0) {
            $paymentStatus='partial';
            $saleStatus='pending_payment';
        } else {
            $paymentStatus='unpaid';
            $saleStatus='pending_payment';
        }

        $sale->payment_status=$paymentStatus;
        $sale->status=$saleStatus;
        $sale->save();

        return $sale->fresh();
    }

    public function capturedAmount(Sale $sale): string
    {
        $total='0.000000';

        Payment::query()
            ->where('tenant_id',$sale->tenant_id)
            ->where('sale_id',$sale->id)
            ->where('status','captured')
            ->pluck('amount')
            ->each(function($amount) use (&$total) {
                $total=bcadd($total,(string)$amount,6);
            });

        return $total;
    }

    public function completedRefundAmount(Sale $sale): string
    {
        $total='0.000000';

        PaymentRefund::query()
            ->from('payments.refunds as r')
            ->join('payments.payments as p','p.id','=','r.payment_id')
            ->where('r.tenant_id',$sale->tenant_id)
            ->where('p.sale_id',$sale->id)
            ->where('r.status','completed')
            ->pluck('r.amount')
            ->each(function($amount) use (&$total) {
                $total=bcadd($total,(string)$amount,6);
            });

        return $total;
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Payments\Domain\Models\PaymentRefund.php
================================================================
<?php

namespace App\Modules\Payments\Domain\Models;

use App\Modules\Core\Domain\Concerns\HasPublicUlid;
use Illuminate\Database\Eloquent\Model;

final class PaymentRefund extends Model
{
    use HasPublicUlid;

    public $timestamps=false;

    protected $table='payments.refunds';

    protected $fillable=[
        'tenant_id','payment_id','sale_return_id','method_type',
        'amount','currency_code','status','idempotency_key',
        'provider_reference','created_by_user_id','resolved_by_user_id',
        'resolved_at','metadata','created_at',
    ];

    protected function casts(): array
    {
        return [
            'amount'=>'decimal:6',
            'resolved_at'=>'immutable_datetime',
            'metadata'=>'array',
            'created_at'=>'immutable_datetime',
        ];
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Payments\Http\Controllers\PaymentRefundResolutionController.php
================================================================
<?php

namespace App\Modules\Payments\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Modules\Core\Application\Context\TenantContext;
use App\Modules\Payments\Application\Refunds\ResolvePaymentRefundAction;
use App\Modules\Payments\Domain\Models\PaymentRefund;
use App\Modules\Payments\Http\Requests\ResolvePaymentRefundRequest;
use Illuminate\Http\JsonResponse;

final class PaymentRefundResolutionController extends Controller
{
    public function update(
        string $refundPublicId,
        ResolvePaymentRefundRequest $request,
        TenantContext $tenantContext,
        ResolvePaymentRefundAction $action,
    ): JsonResponse {
        $refund=PaymentRefund::query()
            ->where('tenant_id',$tenantContext->tenantId())
            ->where('public_id',$refundPublicId)
            ->firstOrFail();

        $refund=$action->execute(
            $refund,
            $request->validated(),
            $request->user()->id,
            $request,
        );

        return response()->json([
            'data'=>[
                'id'=>$refund->public_id,
                'payment_id'=>$refund->payment_id,
                'method_type'=>$refund->method_type,
                'amount'=>$refund->amount,
                'status'=>$refund->status,
                'provider_reference'=>$refund->provider_reference,
                'resolved_at'=>$refund->resolved_at?->toISOString(),
            ],
        ]);
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Payments\Http\Requests\ResolvePaymentRefundRequest.php
================================================================
<?php

namespace App\Modules\Payments\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

final class ResolvePaymentRefundRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'outcome'=>['required',Rule::in(['succeeded','failed'])],
            'provider_reference'=>['nullable','string','max:255'],
            'metadata'=>['nullable','array'],
        ];
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_01_093000_create_sales_cart_foundation_tables.php
================================================================
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        DB::statement('CREATE SCHEMA IF NOT EXISTS sales');

        Schema::create('sales.carts', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->ulid('public_id')->unique();

            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('company_id')->nullable();
            $table->unsignedBigInteger('branch_id')->nullable();
            $table->unsignedBigInteger('register_id')->nullable();
            $table->unsignedBigInteger('created_by_user_id');

            $table->string('customer_public_id', 64)->nullable();
            $table->string('customer_group_key', 100)->nullable();

            $table->string('currency_code', 3)->default('EGP');
            $table->string('status', 30)->default('open');

            $table->decimal('subtotal_amount', 18, 6)->default(0);
            $table->decimal('discount_amount', 18, 6)->default(0);
            $table->decimal('total_amount', 18, 6)->default(0);

            $table->unsignedBigInteger('version')->default(1);

            $table->timestampTz('expires_at')->nullable();
            $table->jsonb('metadata')->nullable();

            $table->timestampsTz();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('company_id')->references('id')->on('core.companies')->restrictOnDelete();
            $table->foreign('branch_id')->references('id')->on('core.branches')->restrictOnDelete();
            $table->foreign('register_id')->references('id')->on('core.registers')->restrictOnDelete();
            $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete();

            $table->index(['tenant_id', 'status', 'updated_at']);
            $table->index(['tenant_id', 'branch_id', 'status']);
            $table->index(['tenant_id', 'created_by_user_id', 'status']);
        });

        DB::statement("
            ALTER TABLE sales.carts
            ADD CONSTRAINT sales_carts_status_check
            CHECK (status IN ('open','converted','abandoned','cancelled'))
        ");

        DB::statement("
            ALTER TABLE sales.carts
            ADD CONSTRAINT sales_carts_amounts_check
            CHECK (
                subtotal_amount >= 0
                AND discount_amount >= 0
                AND total_amount >= 0
                AND discount_amount <= subtotal_amount
                AND total_amount = subtotal_amount - discount_amount
            )
        ");

        Schema::create('sales.cart_lines', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->ulid('public_id')->unique();

            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('cart_id');

            $table->unsignedBigInteger('variant_id');
            $table->unsignedBigInteger('variant_unit_id')->nullable();

            $table->decimal('quantity', 18, 6);
            $table->decimal('unit_price_amount', 18, 6);

            $table->decimal('gross_amount', 18, 6);
            $table->decimal('discount_amount', 18, 6)->default(0);
            $table->decimal('net_amount', 18, 6);

            $table->jsonb('price_snapshot');
            $table->jsonb('promotion_snapshot')->nullable();
            $table->jsonb('metadata')->nullable();

            $table->timestampsTz();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('cart_id')->references('id')->on('sales.carts')->restrictOnDelete();
            $table->foreign('variant_id')->references('id')->on('catalog.variants')->restrictOnDelete();
            $table->foreign('variant_unit_id')->references('id')->on('catalog.variant_units')->restrictOnDelete();

            $table->index(['tenant_id', 'cart_id']);
            $table->index(['tenant_id', 'variant_id']);
        });

        DB::statement("
            ALTER TABLE sales.cart_lines
            ADD CONSTRAINT sales_cart_lines_amounts_check
            CHECK (
                quantity > 0
                AND unit_price_amount >= 0
                AND gross_amount >= 0
                AND discount_amount >= 0
                AND net_amount >= 0
                AND discount_amount <= gross_amount
                AND net_amount = gross_amount - discount_amount
            )
        ");

        Schema::create('sales.cart_operations', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->ulid('public_id')->unique();

            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('cart_id');

            $table->string('client_operation_id', 100);
            $table->string('operation_type', 40);

            $table->jsonb('response_snapshot')->nullable();

            $table->timestampTz('created_at')->useCurrent();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('cart_id')->references('id')->on('sales.carts')->restrictOnDelete();

            $table->unique(['tenant_id', 'client_operation_id']);
            $table->index(['tenant_id', 'cart_id', 'created_at']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('sales.cart_operations');
        Schema::dropIfExists('sales.cart_lines');
        Schema::dropIfExists('sales.carts');
    }
};

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_01_103000_create_sales_checkout_foundation_tables.php
================================================================
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        DB::statement('CREATE SCHEMA IF NOT EXISTS sales');

        Schema::create('sales.number_sequences', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('branch_id')->nullable();
            $table->date('business_date');
            $table->unsignedBigInteger('last_value')->default(0);
            $table->timestampsTz();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('branch_id')->references('id')->on('core.branches')->restrictOnDelete();

            $table->unique(['tenant_id','branch_id','business_date']);
        });

        Schema::create('sales.sales', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->ulid('public_id')->unique();

            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('company_id')->nullable();
            $table->unsignedBigInteger('branch_id')->nullable();
            $table->unsignedBigInteger('register_id')->nullable();
            $table->unsignedBigInteger('cart_id');

            $table->unsignedBigInteger('created_by_user_id');

            $table->string('sale_number', 80);
            $table->date('business_date');
            $table->timestampTz('occurred_at');

            $table->string('currency_code', 3)->default('EGP');

            $table->string('customer_public_id', 64)->nullable();
            $table->string('customer_group_key', 100)->nullable();

            $table->decimal('subtotal_amount', 18, 6);
            $table->decimal('discount_amount', 18, 6);
            $table->decimal('total_amount', 18, 6);

            $table->string('status', 30)->default('pending_payment');
            $table->string('payment_status', 30)->default('unpaid');

            $table->jsonb('pricing_snapshot')->nullable();
            $table->jsonb('promotion_snapshot')->nullable();
            $table->jsonb('metadata')->nullable();

            $table->timestampsTz();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('company_id')->references('id')->on('core.companies')->restrictOnDelete();
            $table->foreign('branch_id')->references('id')->on('core.branches')->restrictOnDelete();
            $table->foreign('register_id')->references('id')->on('core.registers')->restrictOnDelete();
            $table->foreign('cart_id')->references('id')->on('sales.carts')->restrictOnDelete();
            $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete();

            $table->unique(['tenant_id','sale_number']);
            $table->unique(['tenant_id','cart_id']);
            $table->index(['tenant_id','business_date','status']);
            $table->index(['tenant_id','branch_id','business_date']);
            $table->index(['tenant_id','payment_status','created_at']);
        });

        DB::statement("
            ALTER TABLE sales.sales
            ADD CONSTRAINT sales_sales_amounts_check
            CHECK (
                subtotal_amount >= 0
                AND discount_amount >= 0
                AND total_amount >= 0
                AND discount_amount <= subtotal_amount
                AND total_amount = subtotal_amount - discount_amount
            )
        ");

        DB::statement("
            ALTER TABLE sales.sales
            ADD CONSTRAINT sales_sales_status_check
            CHECK (status IN ('pending_payment','completed','voided','reversed'))
        ");

        DB::statement("
            ALTER TABLE sales.sales
            ADD CONSTRAINT sales_sales_payment_status_check
            CHECK (payment_status IN ('unpaid','partial','paid','refunded','partially_refunded'))
        ");

        Schema::create('sales.sale_lines', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->ulid('public_id')->unique();

            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('sale_id');

            $table->unsignedBigInteger('variant_id');
            $table->unsignedBigInteger('variant_unit_id')->nullable();

            $table->string('sku_snapshot', 160)->nullable();
            $table->string('name_snapshot', 220);

            $table->decimal('quantity', 18, 6);
            $table->decimal('unit_price_amount', 18, 6);
            $table->decimal('gross_amount', 18, 6);
            $table->decimal('discount_amount', 18, 6);
            $table->decimal('net_amount', 18, 6);

            $table->jsonb('price_snapshot');
            $table->jsonb('promotion_snapshot')->nullable();
            $table->jsonb('metadata')->nullable();

            $table->timestampTz('created_at')->useCurrent();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('sale_id')->references('id')->on('sales.sales')->restrictOnDelete();
            $table->foreign('variant_id')->references('id')->on('catalog.variants')->restrictOnDelete();
            $table->foreign('variant_unit_id')->references('id')->on('catalog.variant_units')->restrictOnDelete();

            $table->index(['tenant_id','sale_id']);
            $table->index(['tenant_id','variant_id']);
        });

        DB::statement("
            ALTER TABLE sales.sale_lines
            ADD CONSTRAINT sales_sale_lines_amounts_check
            CHECK (
                quantity > 0
                AND unit_price_amount >= 0
                AND gross_amount >= 0
                AND discount_amount >= 0
                AND net_amount >= 0
                AND discount_amount <= gross_amount
                AND net_amount = gross_amount - discount_amount
            )
        ");

        Schema::create('sales.checkout_commands', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->ulid('public_id')->unique();

            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('cart_id');
            $table->unsignedBigInteger('sale_id')->nullable();

            $table->string('idempotency_key', 120);
            $table->string('status', 30)->default('started');

            $table->jsonb('response_snapshot')->nullable();
            $table->text('failure_code')->nullable();

            $table->timestampTz('created_at')->useCurrent();
            $table->timestampTz('completed_at')->nullable();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('cart_id')->references('id')->on('sales.carts')->restrictOnDelete();
            $table->foreign('sale_id')->references('id')->on('sales.sales')->restrictOnDelete();

            $table->unique(['tenant_id','idempotency_key']);
            $table->index(['tenant_id','cart_id','status']);
        });

        DB::statement("
            ALTER TABLE sales.checkout_commands
            ADD CONSTRAINT sales_checkout_commands_status_check
            CHECK (status IN ('started','completed','failed','unknown'))
        ");

        DB::statement(<<<'SQL'
CREATE OR REPLACE FUNCTION sales.prevent_sale_line_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    RAISE EXCEPTION 'sales.sale_lines are immutable';
END;
$$
SQL);

        DB::statement(<<<'SQL'
CREATE TRIGGER sales_sale_lines_immutable_update
BEFORE UPDATE ON sales.sale_lines
FOR EACH ROW EXECUTE FUNCTION sales.prevent_sale_line_mutation()
SQL);

        DB::statement(<<<'SQL'
CREATE TRIGGER sales_sale_lines_immutable_delete
BEFORE DELETE ON sales.sale_lines
FOR EACH ROW EXECUTE FUNCTION sales.prevent_sale_line_mutation()
SQL);
    }

    public function down(): void
    {
        DB::statement('DROP TRIGGER IF EXISTS sales_sale_lines_immutable_delete ON sales.sale_lines');
        DB::statement('DROP TRIGGER IF EXISTS sales_sale_lines_immutable_update ON sales.sale_lines');
        DB::statement('DROP FUNCTION IF EXISTS sales.prevent_sale_line_mutation()');

        Schema::dropIfExists('sales.checkout_commands');
        Schema::dropIfExists('sales.sale_lines');
        Schema::dropIfExists('sales.sales');
        Schema::dropIfExists('sales.number_sequences');
    }
};

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_01_113000_create_payments_foundation_tables.php
================================================================
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        DB::statement('CREATE SCHEMA IF NOT EXISTS payments');

        // Extend the existing sale payment-state contract for uncertain terminal outcomes.
        DB::statement('ALTER TABLE sales.sales DROP CONSTRAINT IF EXISTS sales_sales_payment_status_check');
        DB::statement("
            ALTER TABLE sales.sales
            ADD CONSTRAINT sales_sales_payment_status_check
            CHECK (payment_status IN ('unpaid','partial','paid','unknown','refunded','partially_refunded'))
        ");

        Schema::create('payments.payments', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->ulid('public_id')->unique();

            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('sale_id');

            $table->string('method_type', 30);
            $table->decimal('amount', 18, 6);
            $table->string('currency_code', 3);

            $table->string('status', 30)->default('pending');

            $table->string('idempotency_key', 120);
            $table->string('provider_reference', 180)->nullable();

            $table->unsignedBigInteger('created_by_user_id');
            $table->timestampTz('resolved_at')->nullable();

            $table->jsonb('metadata')->nullable();

            $table->timestampsTz();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('sale_id')->references('id')->on('sales.sales')->restrictOnDelete();
            $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete();

            $table->unique(['tenant_id','idempotency_key']);
            $table->index(['tenant_id','sale_id','status']);
            $table->index(['tenant_id','provider_reference']);
        });

        DB::statement("
            ALTER TABLE payments.payments
            ADD CONSTRAINT payments_payments_method_check
            CHECK (method_type IN ('cash','card','external'))
        ");

        DB::statement("
            ALTER TABLE payments.payments
            ADD CONSTRAINT payments_payments_status_check
            CHECK (status IN ('pending','captured','failed','unknown','reversed'))
        ");

        DB::statement("
            ALTER TABLE payments.payments
            ADD CONSTRAINT payments_payments_amount_check
            CHECK (amount > 0)
        ");

        Schema::create('payments.attempts', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->ulid('public_id')->unique();

            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('payment_id');

            $table->string('operation_key', 120);
            $table->string('attempt_type', 30);
            $table->string('status', 30);

            $table->string('provider_reference', 180)->nullable();

            $table->jsonb('request_snapshot')->nullable();
            $table->jsonb('response_snapshot')->nullable();

            $table->text('failure_code')->nullable();

            $table->timestampTz('started_at')->useCurrent();
            $table->timestampTz('completed_at')->nullable();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('payment_id')->references('id')->on('payments.payments')->restrictOnDelete();

            $table->unique(['tenant_id','operation_key']);
            $table->index(['tenant_id','payment_id','started_at']);
        });

        DB::statement("
            ALTER TABLE payments.attempts
            ADD CONSTRAINT payments_attempts_type_check
            CHECK (attempt_type IN ('collect','resolve'))
        ");

        DB::statement("
            ALTER TABLE payments.attempts
            ADD CONSTRAINT payments_attempts_status_check
            CHECK (status IN ('started','succeeded','failed','unknown'))
        ");
    }

    public function down(): void
    {
        Schema::dropIfExists('payments.attempts');
        Schema::dropIfExists('payments.payments');

        DB::statement('ALTER TABLE sales.sales DROP CONSTRAINT IF EXISTS sales_sales_payment_status_check');
        DB::statement("
            ALTER TABLE sales.sales
            ADD CONSTRAINT sales_sales_payment_status_check
            CHECK (payment_status IN ('unpaid','partial','paid','refunded','partially_refunded'))
        ");
    }
};

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_04_043000_harden_cash_payment_shift_integrity.php
================================================================
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('cash.payment_refund_balances', function (Blueprint $table) {
            $table->bigIncrements('id');

            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('payment_id');

            $table->decimal('refunded_amount', 18, 6)->default(0);
            $table->timestampTz('last_refund_at')->nullable();

            $table->timestampsTz();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('payment_id')->references('id')->on('payments.payments')->restrictOnDelete();

            $table->unique(['tenant_id','payment_id']);
        });

        DB::statement("
            INSERT INTO cash.payment_refund_balances
                (tenant_id, payment_id, refunded_amount, last_refund_at, created_at, updated_at)
            SELECT
                tenant_id,
                payment_id,
                COALESCE(SUM(amount), 0),
                MAX(posted_at),
                NOW(),
                NOW()
            FROM cash.sale_cash_refunds
            GROUP BY tenant_id, payment_id
            ON CONFLICT (tenant_id, payment_id) DO NOTHING
        ");

        DB::statement("
            ALTER TABLE cash.payment_refund_balances
            ADD CONSTRAINT cash_payment_refund_balances_non_negative_check
            CHECK (refunded_amount >= 0)
        ");
    }

    public function down(): void
    {
        Schema::dropIfExists('cash.payment_refund_balances');
    }
};

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_04_063000_create_sales_return_refund_bundle.php
================================================================
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('sales.returns', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->ulid('public_id')->unique();

            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('sale_id');
            $table->string('return_number', 90);
            $table->string('status', 30)->default('draft');
            $table->string('refund_status', 30)->default('none');

            $table->decimal('subtotal_amount', 18, 6)->default(0);
            $table->decimal('total_amount', 18, 6)->default(0);

            $table->string('reason', 255);
            $table->string('idempotency_key', 120);
            $table->unsignedBigInteger('approval_request_id')->nullable();

            $table->unsignedBigInteger('created_by_user_id');
            $table->unsignedBigInteger('posted_by_user_id')->nullable();
            $table->timestampTz('posted_at')->nullable();

            $table->jsonb('metadata')->nullable();
            $table->timestampsTz();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('sale_id')->references('id')->on('sales.sales')->restrictOnDelete();
            $table->foreign('approval_request_id')->references('id')->on('approvals.requests')->restrictOnDelete();
            $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete();
            $table->foreign('posted_by_user_id')->references('id')->on('users')->restrictOnDelete();

            $table->unique(['tenant_id','return_number']);
            $table->unique(['tenant_id','idempotency_key']);
            $table->index(['tenant_id','sale_id','status']);
            $table->index(['tenant_id','refund_status','created_at']);
        });

        DB::statement("
            ALTER TABLE sales.returns
            ADD CONSTRAINT sales_returns_status_check
            CHECK (status IN ('draft','posted','cancelled'))
        ");

        DB::statement("
            ALTER TABLE sales.returns
            ADD CONSTRAINT sales_returns_refund_status_check
            CHECK (refund_status IN ('none','pending','partial','completed','failed'))
        ");

        DB::statement("
            ALTER TABLE sales.returns
            ADD CONSTRAINT sales_returns_amounts_check
            CHECK (
                subtotal_amount >= 0
                AND total_amount >= 0
                AND total_amount = subtotal_amount
            )
        ");

        Schema::create('sales.return_lines', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->ulid('public_id')->unique();

            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('sale_return_id');
            $table->unsignedBigInteger('sale_line_id');

            $table->unsignedBigInteger('variant_id');
            $table->unsignedBigInteger('variant_unit_id')->nullable();
            $table->unsignedBigInteger('warehouse_id')->nullable();

            $table->decimal('quantity', 18, 6);
            $table->decimal('return_amount', 18, 6);
            $table->string('disposition', 30)->default('no_stock');

            $table->jsonb('metadata')->nullable();
            $table->timestampTz('created_at')->useCurrent();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('sale_return_id')->references('id')->on('sales.returns')->restrictOnDelete();
            $table->foreign('sale_line_id')->references('id')->on('sales.sale_lines')->restrictOnDelete();
            $table->foreign('variant_id')->references('id')->on('catalog.variants')->restrictOnDelete();
            $table->foreign('variant_unit_id')->references('id')->on('catalog.variant_units')->restrictOnDelete();
            $table->foreign('warehouse_id')->references('id')->on('core.warehouses')->restrictOnDelete();

            $table->index(['tenant_id','sale_return_id']);
            $table->index(['tenant_id','sale_line_id']);
            $table->index(['tenant_id','warehouse_id']);
        });

        DB::statement("
            ALTER TABLE sales.return_lines
            ADD CONSTRAINT sales_return_lines_quantity_amount_check
            CHECK (quantity > 0 AND return_amount >= 0)
        ");

        DB::statement("
            ALTER TABLE sales.return_lines
            ADD CONSTRAINT sales_return_lines_disposition_check
            CHECK (disposition IN ('restock','damaged','waste','no_stock'))
        ");

        Schema::create('payments.refunds', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->ulid('public_id')->unique();

            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('payment_id');
            $table->unsignedBigInteger('sale_return_id')->nullable();

            $table->string('method_type', 30);
            $table->decimal('amount', 18, 6);
            $table->string('currency_code', 3);
            $table->string('status', 30)->default('pending');

            $table->string('idempotency_key', 140);
            $table->string('provider_reference')->nullable();

            $table->unsignedBigInteger('created_by_user_id');
            $table->unsignedBigInteger('resolved_by_user_id')->nullable();
            $table->timestampTz('resolved_at')->nullable();

            $table->jsonb('metadata')->nullable();
            $table->timestampTz('created_at')->useCurrent();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('payment_id')->references('id')->on('payments.payments')->restrictOnDelete();
            $table->foreign('sale_return_id')->references('id')->on('sales.returns')->restrictOnDelete();
            $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete();
            $table->foreign('resolved_by_user_id')->references('id')->on('users')->restrictOnDelete();

            $table->unique(['tenant_id','idempotency_key']);
            $table->index(['tenant_id','payment_id','status']);
            $table->index(['tenant_id','sale_return_id','status']);
        });

        DB::statement("
            ALTER TABLE payments.refunds
            ADD CONSTRAINT payments_refunds_method_check
            CHECK (method_type IN ('cash','card','external'))
        ");

        DB::statement("
            ALTER TABLE payments.refunds
            ADD CONSTRAINT payments_refunds_status_check
            CHECK (status IN ('pending','completed','failed'))
        ");

        DB::statement("
            ALTER TABLE payments.refunds
            ADD CONSTRAINT payments_refunds_amount_check
            CHECK (amount > 0)
        ");

        DB::statement(<<<'SQL'
CREATE OR REPLACE FUNCTION sales.prevent_posted_return_line_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE return_status text;
BEGIN
    SELECT status INTO return_status
    FROM sales.returns
    WHERE id = OLD.sale_return_id;

    IF return_status = 'posted' THEN
        RAISE EXCEPTION 'posted sales.return_lines are immutable';
    END IF;

    RETURN NEW;
END;
$$
SQL);

        DB::statement(<<<'SQL'
CREATE TRIGGER sales_return_lines_guard_update
BEFORE UPDATE ON sales.return_lines
FOR EACH ROW EXECUTE FUNCTION sales.prevent_posted_return_line_mutation()
SQL);

        DB::statement(<<<'SQL'
CREATE TRIGGER sales_return_lines_guard_delete
BEFORE DELETE ON sales.return_lines
FOR EACH ROW EXECUTE FUNCTION sales.prevent_posted_return_line_mutation()
SQL);
    }

    public function down(): void
    {
        DB::statement('DROP TRIGGER IF EXISTS sales_return_lines_guard_delete ON sales.return_lines');
        DB::statement('DROP TRIGGER IF EXISTS sales_return_lines_guard_update ON sales.return_lines');
        DB::statement('DROP FUNCTION IF EXISTS sales.prevent_posted_return_line_mutation()');

        Schema::dropIfExists('payments.refunds');
        Schema::dropIfExists('sales.return_lines');
        Schema::dropIfExists('sales.returns');
    }
};

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_04_083000_create_crm_customer_foundation.php
================================================================
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        DB::statement('CREATE SCHEMA IF NOT EXISTS crm');

        Schema::create('crm.customer_groups', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->ulid('public_id')->unique();
            $table->unsignedBigInteger('tenant_id');

            $table->string('group_key', 100);
            $table->string('name', 180);
            $table->string('status', 30)->default('active');
            $table->jsonb('metadata')->nullable();
            $table->timestampsTz();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->unique(['tenant_id','group_key']);
            $table->index(['tenant_id','status','name']);
        });

        DB::statement("
            ALTER TABLE crm.customer_groups
            ADD CONSTRAINT crm_customer_groups_status_check
            CHECK (status IN ('active','inactive'))
        ");

        Schema::create('crm.customers', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->ulid('public_id')->unique();
            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('customer_group_id')->nullable();

            $table->string('display_name', 220);
            $table->string('first_name', 120)->nullable();
            $table->string('last_name', 120)->nullable();

            $table->string('phone', 80)->nullable();
            $table->string('phone_normalized', 80)->nullable();
            $table->string('email', 254)->nullable();
            $table->string('email_normalized', 254)->nullable();

            $table->string('status', 30)->default('active');
            $table->text('notes')->nullable();
            $table->jsonb('metadata')->nullable();

            $table->unsignedBigInteger('created_by_user_id');
            $table->unsignedBigInteger('updated_by_user_id')->nullable();
            $table->timestampsTz();

            $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete();
            $table->foreign('customer_group_id')->references('id')->on('crm.customer_groups')->restrictOnDelete();
            $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete();
            $table->foreign('updated_by_user_id')->references('id')->on('users')->restrictOnDelete();

            $table->index(['tenant_id','status','display_name']);
            $table->index(['tenant_id','phone_normalized']);
            $table->index(['tenant_id','email_normalized']);
            $table->index(['tenant_id','customer_group_id','status']);
        });

        DB::statement("
            ALTER TABLE crm.customers
            ADD CONSTRAINT crm_customers_status_check
            CHECK (status IN ('active','inactive'))
        ");

        Schema::table('sales.carts', function (Blueprint $table) {
            $table->unsignedBigInteger('customer_id')->nullable()->after('created_by_user_id');

            $table->foreign('customer_id')->references('id')->on('crm.customers')->restrictOnDelete();
            $table->index(['tenant_id','customer_id','status']);
        });

        Schema::table('sales.sales', function (Blueprint $table) {
            $table->unsignedBigInteger('customer_id')->nullable()->after('created_by_user_id');
            $table->jsonb('customer_snapshot')->nullable()->after('customer_group_key');

            $table->foreign('customer_id')->references('id')->on('crm.customers')->restrictOnDelete();
            $table->index(['tenant_id','customer_id','business_date']);
        });
    }

    public function down(): void
    {
        Schema::table('sales.sales', function (Blueprint $table) {
            $table->dropForeign(['customer_id']);
            $table->dropIndex(['tenant_id','customer_id','business_date']);
            $table->dropColumn(['customer_id','customer_snapshot']);
        });

        Schema::table('sales.carts', function (Blueprint $table) {
            $table->dropForeign(['customer_id']);
            $table->dropIndex(['tenant_id','customer_id','status']);
            $table->dropColumn('customer_id');
        });

        Schema::dropIfExists('crm.customers');
        Schema::dropIfExists('crm.customer_groups');
    }
};

