================================================================
FILE: .\app\Modules\Payments\Application\Settlement\CollectPaymentAction.php
================================================================
<?php

namespace App\Modules\Payments\Application\Settlement;

use App\Modules\Audit\Application\AuditRecorder;
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,
    ) {}

    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.');
            }

            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.');
                }

                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);

            $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: .\app\Modules\Payments\Application\Settlement\ResolvePaymentAction.php
================================================================
<?php

namespace App\Modules\Payments\Application\Settlement;

use App\Modules\Audit\Application\AuditRecorder;
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 Symfony\Component\HttpKernel\Exception\ConflictHttpException;

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

    public function execute(
        Payment $payment,
        array $data,
        int $actorUserId,
        Request $request,
    ): Payment {
        $existingAttempt=PaymentAttempt::query()
            ->where('tenant_id',$payment->tenant_id)
            ->where('operation_key',$data['resolution_key'])
            ->first();

        if ($existingAttempt) {
            if ($existingAttempt->payment_id !== $payment->id) {
                throw new ConflictHttpException('Resolution key already belongs to another payment.');
            }

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

        return DB::transaction(function () use ($payment,$data,$actorUserId,$request) {
            $locked=Payment::query()->whereKey($payment->id)->lockForUpdate()->firstOrFail();

            $existingAttempt=PaymentAttempt::query()
                ->where('tenant_id',$locked->tenant_id)
                ->where('operation_key',$data['resolution_key'])
                ->lockForUpdate()
                ->first();

            if ($existingAttempt) {
                if ($existingAttempt->payment_id !== $locked->id) {
                    throw new ConflictHttpException('Resolution key already belongs to another payment.');
                }

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

            if ($locked->status !== 'unknown') {
                throw new ConflictHttpException('Only UNKNOWN payments can be resolved.');
            }

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

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

            $locked->save();

            PaymentAttempt::query()->create([
                'tenant_id'=>$locked->tenant_id,
                'payment_id'=>$locked->id,
                'operation_key'=>$data['resolution_key'],
                'attempt_type'=>'resolve',
                'status'=>$data['outcome']==='succeeded' ? 'succeeded' : 'failed',
                'provider_reference'=>$data['provider_reference']??$locked->provider_reference,
                'request_snapshot'=>[
                    'outcome'=>$data['outcome'],
                ],
                'response_snapshot'=>[
                    'payment_status'=>$locked->status,
                ],
                'started_at'=>now(),
                'completed_at'=>now(),
            ]);

            $sale=Sale::query()->whereKey($locked->sale_id)->lockForUpdate()->firstOrFail();
            $saleAfter=$this->statusUpdater->refresh($sale);

            $this->auditRecorder->record(
                'payments.payment.resolved',
                $locked->tenant_id,
                $actorUserId,
                'payments.payment',
                $locked->public_id,
                before:['status'=>$before],
                after:[
                    'status'=>$locked->status,
                    'sale_id'=>$sale->public_id,
                    'sale_payment_status'=>$saleAfter->payment_status,
                ],
                request:$request,
            );

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

================================================================
FILE: .\app\Modules\Payments\Application\Settlement\SalePaymentStatusUpdater.php
================================================================
<?php

namespace App\Modules\Payments\Application\Settlement;

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

final class SalePaymentStatusUpdater
{
    public function refresh(Sale $sale): Sale
    {
        $captured='0.000000';

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

        $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($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;
    }
}

================================================================
FILE: .\app\Modules\Payments\Http\Controllers\SalePaymentController.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\Settlement\CollectPaymentAction;
use App\Modules\Payments\Domain\Models\Payment;
use App\Modules\Payments\Http\Requests\CollectPaymentRequest;
use App\Modules\Payments\Http\Resources\PaymentResource;
use App\Modules\Sales\Domain\Models\Sale;
use Illuminate\Http\JsonResponse;

final class SalePaymentController extends Controller
{
    public function index(string $salePublicId,TenantContext $tenantContext): JsonResponse
    {
        $sale=Sale::query()
            ->where('tenant_id',$tenantContext->tenantId())
            ->where('public_id',$salePublicId)
            ->firstOrFail();

        return response()->json([
            'data'=>PaymentResource::collection(
                Payment::query()
                    ->with('attempts')
                    ->where('tenant_id',$tenantContext->tenantId())
                    ->where('sale_id',$sale->id)
                    ->orderBy('id')
                    ->get()
            ),
        ]);
    }

    public function store(
        string $salePublicId,
        CollectPaymentRequest $request,
        TenantContext $tenantContext,
        CollectPaymentAction $action,
    ): PaymentResource {
        $sale=Sale::query()
            ->where('tenant_id',$tenantContext->tenantId())
            ->where('public_id',$salePublicId)
            ->firstOrFail();

        $payment=$action->execute(
            sale:$sale,
            data:$request->validated(),
            actorUserId:$request->user()->id,
            request:$request,
        );

        return new PaymentResource($payment);
    }
}

================================================================
FILE: .\app\Modules\Payments\Http\Controllers\PaymentResolutionController.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\Settlement\ResolvePaymentAction;
use App\Modules\Payments\Domain\Models\Payment;
use App\Modules\Payments\Http\Requests\ResolvePaymentRequest;
use App\Modules\Payments\Http\Resources\PaymentResource;

final class PaymentResolutionController extends Controller
{
    public function update(
        string $paymentPublicId,
        ResolvePaymentRequest $request,
        TenantContext $tenantContext,
        ResolvePaymentAction $action,
    ): PaymentResource {
        $payment=Payment::query()
            ->where('tenant_id',$tenantContext->tenantId())
            ->where('public_id',$paymentPublicId)
            ->firstOrFail();

        return new PaymentResource(
            $action->execute(
                payment:$payment,
                data:$request->validated(),
                actorUserId:$request->user()->id,
                request:$request,
            )
        );
    }
}

================================================================
FILE: .\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\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,
    ) {}

    public function execute(
        Cart $cart,
        int $expectedVersion,
        string $idempotencyKey,
        int $actorUserId,
        Request $request,
    ): Sale {
        $existing=CheckoutCommand::query()
            ->where('tenant_id',$cart->tenant_id)
            ->where('idempotency_key',$idempotencyKey)
            ->first();

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

        return DB::transaction(function () use ($cart,$expectedVersion,$idempotencyKey,$actorUserId,$request) {
            $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?->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,
                'status'=>'started',
                'created_at'=>now(),
            ]);

            $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,
                '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,
                '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,
                ]);
            }

            $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,
            ];
            $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:$request,
            );

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

================================================================
FILE: .\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: .\app\Modules\Sales\Domain\Models\Cart.php
================================================================
<?php

namespace App\Modules\Sales\Domain\Models;

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

final class Cart extends Model
{
    use HasPublicUlid;

    protected $table = 'sales.carts';

    protected $fillable = [
        'tenant_id','company_id','branch_id','register_id','created_by_user_id',
        'customer_public_id','customer_group_key','currency_code','status',
        'subtotal_amount','discount_amount','total_amount','version','expires_at','metadata',
    ];

    protected function casts(): array
    {
        return [
            'subtotal_amount'=>'decimal:6',
            'discount_amount'=>'decimal:6',
            'total_amount'=>'decimal:6',
            'version'=>'integer',
            'expires_at'=>'immutable_datetime',
            'metadata'=>'array',
        ];
    }

    public function lines(): HasMany
    {
        return $this->hasMany(CartLine::class, 'cart_id');
    }
}

================================================================
FILE: .\app\Modules\Sales\Domain\Models\Sale.php
================================================================
<?php

namespace App\Modules\Sales\Domain\Models;

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

final class Sale extends Model
{
    use HasPublicUlid;

    protected $table = 'sales.sales';

    protected $fillable = [
        'tenant_id','company_id','branch_id','register_id','cart_id',
        'created_by_user_id','sale_number','business_date','occurred_at',
        'currency_code','customer_public_id','customer_group_key',
        'subtotal_amount','discount_amount','total_amount',
        'status','payment_status','pricing_snapshot','promotion_snapshot','metadata',
    ];

    protected function casts(): array
    {
        return [
            'business_date'=>'date:Y-m-d',
            'occurred_at'=>'immutable_datetime',
            'subtotal_amount'=>'decimal:6',
            'discount_amount'=>'decimal:6',
            'total_amount'=>'decimal:6',
            'pricing_snapshot'=>'array',
            'promotion_snapshot'=>'array',
            'metadata'=>'array',
        ];
    }

    public function lines(): HasMany
    {
        return $this->hasMany(SaleLine::class, 'sale_id');
    }
}

================================================================
FILE: .\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','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',
        ];
    }
}

