================================================================
FILE: .\app\Modules\Cash\Domain\Models\RegisterShift.php
================================================================
<?php

namespace App\Modules\Cash\Domain\Models;

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

final class RegisterShift extends Model
{
    use HasPublicUlid;

    protected $table = 'cash.shifts';

    protected $fillable = [
        'tenant_id','register_id','cash_account_id','status','business_date',
        'opening_float_amount','expected_cash_amount',
        'counted_cash_amount','variance_amount',
        'open_idempotency_key','opened_by_user_id','closed_by_user_id',
        'opened_at','closed_at','metadata',
    ];

    protected function casts(): array
    {
        return [
            'business_date'=>'date:Y-m-d',
            'opening_float_amount'=>'decimal:6',
            'expected_cash_amount'=>'decimal:6',
            'counted_cash_amount'=>'decimal:6',
            'variance_amount'=>'decimal:6',
            'opened_at'=>'immutable_datetime',
            'closed_at'=>'immutable_datetime',
            'metadata'=>'array',
        ];
    }

    public function entries(): HasMany
    {
        return $this->hasMany(RegisterShiftEntry::class,'shift_id');
    }
}

================================================================
FILE: .\app\Modules\Cash\Domain\Models\RegisterShiftEntry.php
================================================================
<?php

namespace App\Modules\Cash\Domain\Models;

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

final class RegisterShiftEntry extends Model
{
    use HasPublicUlid;

    public $timestamps = false;

    protected $table = 'cash.shift_entries';

    protected $fillable = [
        'tenant_id','shift_id','entry_type','amount_delta',
        'source_type','source_public_id','occurred_at',
        'created_by_user_id','metadata','created_at',
    ];

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

    public function save(array $options=[]): bool
    {
        if($this->exists) {
            throw new LogicException('Shift entries are immutable.');
        }

        return parent::save($options);
    }

    public function delete(): ?bool
    {
        throw new LogicException('Shift entries are immutable.');
    }
}

================================================================
FILE: .\app\Modules\Cash\Domain\Models\CashTransfer.php
================================================================
<?php

namespace App\Modules\Cash\Domain\Models;

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

final class CashTransfer extends Model
{
    use HasPublicUlid;

    public $timestamps = false;

    protected $table = 'cash.transfers';

    protected $fillable = [
        'tenant_id','shift_id',
        'source_cash_account_id','destination_cash_account_id',
        'amount','currency_code','status',
        'idempotency_key','reason',
        'posted_at','posted_by_user_id','metadata','created_at',
    ];

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

================================================================
FILE: .\app\Modules\Cash\Application\Shifts\OpenRegisterShiftAction.php
================================================================
<?php

namespace App\Modules\Cash\Application\Shifts;

use App\Modules\Audit\Application\AuditRecorder;
use App\Modules\Cash\Application\Ledger\CashLedgerService;
use App\Modules\Cash\Domain\Models\RegisterCashAccountLink;
use App\Modules\Cash\Domain\Models\RegisterShift;
use App\Modules\Cash\Domain\Models\RegisterShiftEntry;
use App\Modules\Core\Domain\Models\Register;
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 OpenRegisterShiftAction
{
    public function __construct(
        private CashLedgerService $cashLedger,
        private AuditRecorder $auditRecorder,
    ) {}

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

        if($existing) return $existing->load('entries');

        return DB::transaction(function () use ($tenantId,$data,$actorUserId,$request) {
            $register=Register::query()
                ->where('tenant_id',$tenantId)
                ->where('public_id',$data['register_id'])
                ->where('status','active')
                ->lockForUpdate()
                ->first();

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

            $link=RegisterCashAccountLink::query()
                ->where('tenant_id',$tenantId)
                ->where('register_id',$register->id)
                ->where('status','active')
                ->orderByDesc('id')
                ->first();

            if(!$link) {
                throw ValidationException::withMessages([
                    'register_id'=>['Register has no active cashbox link.'],
                ]);
            }

            $openExists=RegisterShift::query()
                ->where('tenant_id',$tenantId)
                ->where(function($q) use ($register,$link) {
                    $q->where('register_id',$register->id)
                      ->orWhere('cash_account_id',$link->cash_account_id);
                })
                ->where('status','open')
                ->lockForUpdate()
                ->exists();

            if($openExists) {
                throw new ConflictHttpException('Register or cashbox already has an open shift.');
            }

            $at=CarbonImmutable::now('UTC');
            $opening=(string)$data['opening_float_amount'];

            $shift=RegisterShift::query()->create([
                'tenant_id'=>$tenantId,
                'register_id'=>$register->id,
                'cash_account_id'=>$link->cash_account_id,
                'status'=>'open',
                'business_date'=>$data['business_date'],
                'opening_float_amount'=>$opening,
                'expected_cash_amount'=>$opening,
                'open_idempotency_key'=>$data['idempotency_key'],
                'opened_by_user_id'=>$actorUserId,
                'opened_at'=>$at,
                'metadata'=>$data['metadata']??null,
            ]);

            if(bccomp($opening,'0.000000',6)>0) {
                $this->cashLedger->post(
                    $tenantId,
                    $link->cash_account_id,
                    'shift_opening_float',
                    $opening,
                    'register_shift_open',
                    $shift->public_id,
                    $at,
                    $data['business_date'],
                    $actorUserId,
                    ['register_id'=>$register->public_id],
                );

                RegisterShiftEntry::query()->create([
                    'tenant_id'=>$tenantId,
                    'shift_id'=>$shift->id,
                    'entry_type'=>'opening_float',
                    'amount_delta'=>$opening,
                    'source_type'=>'register_shift_open',
                    'source_public_id'=>$shift->public_id,
                    'occurred_at'=>$at,
                    'created_by_user_id'=>$actorUserId,
                    'metadata'=>['register_id'=>$register->public_id],
                    'created_at'=>now(),
                ]);
            }

            $this->auditRecorder->record(
                'cash.shift.opened',
                $tenantId,
                $actorUserId,
                'cash.shift',
                $shift->public_id,
                after:[
                    'register_id'=>$register->public_id,
                    'opening_float_amount'=>$opening,
                    'business_date'=>$data['business_date'],
                ],
                request:$request,
            );

            return $shift->fresh('entries');
        });
    }
}

================================================================
FILE: .\app\Modules\Cash\Application\Shifts\CloseRegisterShiftAction.php
================================================================
<?php

namespace App\Modules\Cash\Application\Shifts;

use App\Modules\Audit\Application\AuditRecorder;
use App\Modules\Cash\Application\Ledger\CashLedgerService;
use App\Modules\Cash\Domain\Models\RegisterShift;
use App\Modules\Cash\Domain\Models\RegisterShiftEntry;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;

final readonly class CloseRegisterShiftAction
{
    public function __construct(
        private CashLedgerService $cashLedger,
        private AuditRecorder $auditRecorder,
    ) {}

    public function execute(
        RegisterShift $shift,
        string $countedCashAmount,
        int $actorUserId,
        Request $request,
    ): RegisterShift {
        return DB::transaction(function () use ($shift,$countedCashAmount,$actorUserId,$request) {
            $locked=RegisterShift::query()
                ->whereKey($shift->id)
                ->lockForUpdate()
                ->firstOrFail();

            if($locked->status==='closed') return $locked;
            if($locked->status!=='open') {
                throw new ConflictHttpException('Shift is not open.');
            }

            $expected=(string)$locked->expected_cash_amount;
            $counted=$countedCashAmount;
            $variance=bcsub($counted,$expected,6);
            $at=CarbonImmutable::now('UTC');

            if(bccomp($variance,'0.000000',6)!==0) {
                $this->cashLedger->post(
                    $locked->tenant_id,
                    $locked->cash_account_id,
                    'shift_close_adjustment',
                    $variance,
                    'register_shift_close',
                    $locked->public_id,
                    $at,
                    $locked->business_date->format('Y-m-d'),
                    $actorUserId,
                    [
                        'expected_cash_amount'=>$expected,
                        'counted_cash_amount'=>$counted,
                    ],
                );

                RegisterShiftEntry::query()->create([
                    'tenant_id'=>$locked->tenant_id,
                    'shift_id'=>$locked->id,
                    'entry_type'=>'close_variance',
                    'amount_delta'=>$variance,
                    'source_type'=>'register_shift_close',
                    'source_public_id'=>$locked->public_id,
                    'occurred_at'=>$at,
                    'created_by_user_id'=>$actorUserId,
                    'metadata'=>[
                        'expected_cash_amount'=>$expected,
                        'counted_cash_amount'=>$counted,
                    ],
                    'created_at'=>now(),
                ]);
            }

            $locked->counted_cash_amount=$counted;
            $locked->variance_amount=$variance;
            $locked->status='closed';
            $locked->closed_by_user_id=$actorUserId;
            $locked->closed_at=$at;
            $locked->save();

            $this->auditRecorder->record(
                'cash.shift.closed',
                $locked->tenant_id,
                $actorUserId,
                'cash.shift',
                $locked->public_id,
                after:[
                    'expected_cash_amount'=>$expected,
                    'counted_cash_amount'=>$counted,
                    'variance_amount'=>$variance,
                ],
                request:$request,
            );

            return $locked;
        });
    }
}

================================================================
FILE: .\app\Modules\Cash\Application\Shifts\PostRegisterShiftMovementAction.php
================================================================
<?php

namespace App\Modules\Cash\Application\Shifts;

use App\Modules\Audit\Application\AuditRecorder;
use App\Modules\Cash\Application\Ledger\CashLedgerService;
use App\Modules\Cash\Domain\Models\CashCommand;
use App\Modules\Cash\Domain\Models\RegisterShift;
use App\Modules\Cash\Domain\Models\RegisterShiftEntry;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;

final readonly class PostRegisterShiftMovementAction
{
    public function __construct(
        private CashLedgerService $cashLedger,
        private AuditRecorder $auditRecorder,
    ) {}

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

        if($existing) return $existing->response_snapshot;

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

            if($locked->status!=='open') {
                throw new ConflictHttpException('Only open shifts accept cash movements.');
            }

            $amount=(string)$data['amount'];
            $delta=$data['direction']==='in'
                ? $amount
                : bcmul($amount,'-1',6);

            $newExpected=bcadd((string)$locked->expected_cash_amount,$delta,6);

            if(bccomp($newExpected,'0.000000',6)<0) {
                throw new ConflictHttpException('Shift expected cash cannot become negative.');
            }

            $at=CarbonImmutable::now('UTC');
            $entryType=$data['direction']==='in' ? 'manual_cash_in' : 'manual_cash_out';
            $movementType=$data['direction']==='in' ? 'shift_cash_in' : 'shift_cash_out';
            $source='shift-command:'.$data['idempotency_key'];

            $movement=$this->cashLedger->post(
                $locked->tenant_id,
                $locked->cash_account_id,
                $movementType,
                $delta,
                'register_shift_command',
                $source,
                $at,
                $locked->business_date->format('Y-m-d'),
                $actorUserId,
                ['reason'=>$data['reason']],
            );

            $entry=RegisterShiftEntry::query()->create([
                'tenant_id'=>$locked->tenant_id,
                'shift_id'=>$locked->id,
                'entry_type'=>$entryType,
                'amount_delta'=>$delta,
                'source_type'=>'register_shift_command',
                'source_public_id'=>$source,
                'occurred_at'=>$at,
                'created_by_user_id'=>$actorUserId,
                'metadata'=>[
                    'reason'=>$data['reason'],
                    'metadata'=>$data['metadata']??null,
                ],
                'created_at'=>now(),
            ]);

            $locked->expected_cash_amount=$newExpected;
            $locked->save();

            $response=[
                'entry_id'=>$entry->public_id,
                'movement_id'=>$movement->public_id,
                'direction'=>$data['direction'],
                'amount'=>$amount,
                'expected_cash_amount'=>$newExpected,
            ];

            CashCommand::query()->create([
                'tenant_id'=>$locked->tenant_id,
                'idempotency_key'=>$data['idempotency_key'],
                'command_type'=>'shift_cash_'.$data['direction'],
                'status'=>'completed',
                'response_snapshot'=>$response,
                'created_at'=>now(),
            ]);

            $this->auditRecorder->record(
                'cash.shift.movement.posted',
                $locked->tenant_id,
                $actorUserId,
                'cash.shift',
                $locked->public_id,
                after:$response,
                request:$request,
            );

            return $response;
        });
    }
}

================================================================
FILE: .\app\Modules\Cash\Application\Shifts\PostCashDropAction.php
================================================================
<?php

namespace App\Modules\Cash\Application\Shifts;

use App\Modules\Audit\Application\AuditRecorder;
use App\Modules\Cash\Domain\Models\CashAccount;
use App\Modules\Cash\Domain\Models\CashAccountBalance;
use App\Modules\Cash\Domain\Models\CashMovement;
use App\Modules\Cash\Domain\Models\CashTransfer;
use App\Modules\Cash\Domain\Models\RegisterShift;
use App\Modules\Cash\Domain\Models\RegisterShiftEntry;
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 PostCashDropAction
{
    public function __construct(
        private AuditRecorder $auditRecorder,
    ) {}

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

        if($existing) return $existing;

        return DB::transaction(function () use ($shift,$data,$actorUserId,$request) {
            $lockedShift=RegisterShift::query()
                ->whereKey($shift->id)
                ->lockForUpdate()
                ->firstOrFail();

            if($lockedShift->status!=='open') {
                throw new ConflictHttpException('Only open shifts can post a cash drop.');
            }

            $destination=CashAccount::query()
                ->where('tenant_id',$lockedShift->tenant_id)
                ->where('public_id',$data['destination_cash_account_id'])
                ->where('status','active')
                ->first();

            if(!$destination) {
                throw ValidationException::withMessages([
                    'destination_cash_account_id'=>['Active destination cash/bank account was not found in this tenant.'],
                ]);
            }

            if($destination->id===$lockedShift->cash_account_id) {
                throw ValidationException::withMessages([
                    'destination_cash_account_id'=>['Destination must be different from the shift cashbox.'],
                ]);
            }

            $accountIds=[$lockedShift->cash_account_id,$destination->id];
            sort($accountIds,SORT_NUMERIC);

            $accounts=CashAccount::query()
                ->where('tenant_id',$lockedShift->tenant_id)
                ->whereIn('id',$accountIds)
                ->orderBy('id')
                ->lockForUpdate()
                ->get()
                ->keyBy('id');

            $source=$accounts->get($lockedShift->cash_account_id);
            $destination=$accounts->get($destination->id);

            if(!$source || !$destination) {
                throw new ConflictHttpException('Cash accounts changed while posting transfer.');
            }

            if($source->currency_code!==$destination->currency_code) {
                throw ValidationException::withMessages([
                    'destination_cash_account_id'=>['Cash drop cannot cross currencies.'],
                ]);
            }

            $balances=CashAccountBalance::query()
                ->where('tenant_id',$lockedShift->tenant_id)
                ->whereIn('cash_account_id',$accountIds)
                ->orderBy('cash_account_id')
                ->lockForUpdate()
                ->get()
                ->keyBy('cash_account_id');

            $sourceBalance=$balances->get($source->id);
            $destinationBalance=$balances->get($destination->id);

            if(!$sourceBalance) {
                throw new ConflictHttpException('Source cashbox balance projection is missing.');
            }

            if(!$destinationBalance) {
                $destinationBalance=CashAccountBalance::query()->create([
                    'tenant_id'=>$lockedShift->tenant_id,
                    'cash_account_id'=>$destination->id,
                    'balance_amount'=>'0.000000',
                ]);
            }

            $amount=(string)$data['amount'];

            if(bccomp($amount,(string)$lockedShift->expected_cash_amount,6)>0) {
                throw ValidationException::withMessages([
                    'amount'=>['Cash drop exceeds shift expected cash.'],
                ]);
            }

            if(bccomp($amount,(string)$sourceBalance->balance_amount,6)>0) {
                throw ValidationException::withMessages([
                    'amount'=>['Cash drop exceeds source cashbox balance.'],
                ]);
            }

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

            if($existing) return $existing;

            $at=CarbonImmutable::now('UTC');

            $transfer=CashTransfer::query()->create([
                'tenant_id'=>$lockedShift->tenant_id,
                'shift_id'=>$lockedShift->id,
                'source_cash_account_id'=>$source->id,
                'destination_cash_account_id'=>$destination->id,
                'amount'=>$amount,
                'currency_code'=>$source->currency_code,
                'status'=>'posted',
                'idempotency_key'=>$data['idempotency_key'],
                'reason'=>$data['reason'],
                'posted_at'=>$at,
                'posted_by_user_id'=>$actorUserId,
                'metadata'=>$data['metadata']??null,
                'created_at'=>now(),
            ]);

            $sourceMovement=CashMovement::query()->create([
                'tenant_id'=>$lockedShift->tenant_id,
                'cash_account_id'=>$source->id,
                'movement_type'=>'cash_drop_out',
                'amount_delta'=>bcmul($amount,'-1',6),
                'source_type'=>'cash_transfer',
                'source_public_id'=>$transfer->public_id,
                'occurred_at'=>$at,
                'business_date'=>$lockedShift->business_date->format('Y-m-d'),
                'created_by_user_id'=>$actorUserId,
                'metadata'=>[
                    'destination_cash_account_id'=>$destination->public_id,
                    'reason'=>$data['reason'],
                ],
                'created_at'=>now(),
            ]);

            $destinationMovement=CashMovement::query()->create([
                'tenant_id'=>$lockedShift->tenant_id,
                'cash_account_id'=>$destination->id,
                'movement_type'=>'cash_drop_in',
                'amount_delta'=>$amount,
                'source_type'=>'cash_transfer',
                'source_public_id'=>$transfer->public_id,
                'occurred_at'=>$at,
                'business_date'=>$lockedShift->business_date->format('Y-m-d'),
                'created_by_user_id'=>$actorUserId,
                'metadata'=>[
                    'source_cash_account_id'=>$source->public_id,
                    'reason'=>$data['reason'],
                ],
                'created_at'=>now(),
            ]);

            $sourceBalance->balance_amount=bcsub((string)$sourceBalance->balance_amount,$amount,6);
            $sourceBalance->last_movement_at=$at;
            $sourceBalance->save();

            $destinationBalance->balance_amount=bcadd((string)$destinationBalance->balance_amount,$amount,6);
            $destinationBalance->last_movement_at=$at;
            $destinationBalance->save();

            $lockedShift->expected_cash_amount=bcsub((string)$lockedShift->expected_cash_amount,$amount,6);
            $lockedShift->save();

            RegisterShiftEntry::query()->create([
                'tenant_id'=>$lockedShift->tenant_id,
                'shift_id'=>$lockedShift->id,
                'entry_type'=>'cash_drop',
                'amount_delta'=>bcmul($amount,'-1',6),
                'source_type'=>'cash_transfer',
                'source_public_id'=>$transfer->public_id,
                'occurred_at'=>$at,
                'created_by_user_id'=>$actorUserId,
                'metadata'=>[
                    'destination_cash_account_id'=>$destination->public_id,
                    'source_cash_movement_id'=>$sourceMovement->public_id,
                    'destination_cash_movement_id'=>$destinationMovement->public_id,
                    'reason'=>$data['reason'],
                ],
                'created_at'=>now(),
            ]);

            $this->auditRecorder->record(
                'cash.shift.drop.posted',
                $lockedShift->tenant_id,
                $actorUserId,
                'cash.transfer',
                $transfer->public_id,
                after:[
                    'shift_id'=>$lockedShift->public_id,
                    'source_cash_account_id'=>$source->public_id,
                    'destination_cash_account_id'=>$destination->public_id,
                    'amount'=>$amount,
                ],
                request:$request,
            );

            return $transfer;
        });
    }
}

================================================================
FILE: .\app\Modules\Cash\Http\Controllers\RegisterShiftController.php
================================================================
<?php

namespace App\Modules\Cash\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Modules\Cash\Application\Shifts\CloseRegisterShiftAction;
use App\Modules\Cash\Application\Shifts\OpenRegisterShiftAction;
use App\Modules\Cash\Application\Shifts\PostRegisterShiftMovementAction;
use App\Modules\Cash\Domain\Models\RegisterShift;
use App\Modules\Cash\Http\Requests\CloseRegisterShiftRequest;
use App\Modules\Cash\Http\Requests\OpenRegisterShiftRequest;
use App\Modules\Cash\Http\Requests\PostRegisterShiftMovementRequest;
use App\Modules\Core\Application\Context\TenantContext;
use Illuminate\Http\JsonResponse;

final class RegisterShiftController extends Controller
{
    public function store(
        OpenRegisterShiftRequest $request,
        TenantContext $tenantContext,
        OpenRegisterShiftAction $action,
    ): JsonResponse {
        $shift=$action->execute(
            $tenantContext->tenantId(),
            $request->validated(),
            $request->user()->id,
            $request,
        );

        return response()->json([
            'data'=>$this->resource($shift,false),
        ],201);
    }

    public function show(
        string $shiftPublicId,
        TenantContext $tenantContext,
    ): JsonResponse {
        $shift=RegisterShift::query()
            ->with('entries')
            ->where('tenant_id',$tenantContext->tenantId())
            ->where('public_id',$shiftPublicId)
            ->firstOrFail();

        return response()->json([
            'data'=>$this->resource($shift,$shift->status==='closed'),
        ]);
    }

    public function movement(
        string $shiftPublicId,
        PostRegisterShiftMovementRequest $request,
        TenantContext $tenantContext,
        PostRegisterShiftMovementAction $action,
    ): JsonResponse {
        $shift=RegisterShift::query()
            ->where('tenant_id',$tenantContext->tenantId())
            ->where('public_id',$shiftPublicId)
            ->firstOrFail();

        return response()->json([
            'data'=>$action->execute(
                $shift,
                $request->validated(),
                $request->user()->id,
                $request,
            ),
        ],201);
    }

    public function close(
        string $shiftPublicId,
        CloseRegisterShiftRequest $request,
        TenantContext $tenantContext,
        CloseRegisterShiftAction $action,
    ): JsonResponse {
        $shift=RegisterShift::query()
            ->where('tenant_id',$tenantContext->tenantId())
            ->where('public_id',$shiftPublicId)
            ->firstOrFail();

        $shift=$action->execute(
            $shift,
            (string)$request->validated()['counted_cash_amount'],
            $request->user()->id,
            $request,
        );

        return response()->json([
            'data'=>$this->resource($shift,true),
        ]);
    }

    private function resource(RegisterShift $shift,bool $revealExpected): array
    {
        return [
            'id'=>$shift->public_id,
            'status'=>$shift->status,
            'business_date'=>$shift->business_date?->format('Y-m-d'),
            'opening_float_amount'=>$shift->opening_float_amount,
            'expected_cash_amount'=>$revealExpected ? $shift->expected_cash_amount : null,
            'counted_cash_amount'=>$shift->counted_cash_amount,
            'variance_amount'=>$shift->variance_amount,
            'opened_at'=>$shift->opened_at?->toISOString(),
            'closed_at'=>$shift->closed_at?->toISOString(),
        ];
    }
}

================================================================
FILE: .\app\Modules\Cash\Http\Controllers\CashDropController.php
================================================================
<?php

namespace App\Modules\Cash\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Modules\Cash\Application\Shifts\PostCashDropAction;
use App\Modules\Cash\Domain\Models\RegisterShift;
use App\Modules\Cash\Http\Requests\PostCashDropRequest;
use App\Modules\Core\Application\Context\TenantContext;
use Illuminate\Http\JsonResponse;

final class CashDropController extends Controller
{
    public function store(
        string $shiftPublicId,
        PostCashDropRequest $request,
        TenantContext $tenantContext,
        PostCashDropAction $action,
    ): JsonResponse {
        $shift=RegisterShift::query()
            ->where('tenant_id',$tenantContext->tenantId())
            ->where('public_id',$shiftPublicId)
            ->firstOrFail();

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

        return response()->json([
            'data'=>[
                'id'=>$transfer->public_id,
                'amount'=>$transfer->amount,
                'currency_code'=>$transfer->currency_code,
                'status'=>$transfer->status,
                'posted_at'=>$transfer->posted_at?->toISOString(),
            ],
        ],201);
    }
}

================================================================
FILE: .\app\Modules\Cash\Application\Permissions\CashPermissions.php
================================================================
<?php

namespace App\Modules\Cash\Application\Permissions;

final class CashPermissions
{
    public const ACCOUNT_VIEW = 'cash.account.view';
    public const ACCOUNT_MANAGE = 'cash.account.manage';
    public const MOVEMENT_VIEW = 'cash.movement.view';
    public const MOVEMENT_POST = 'cash.movement.post';
    public const REGISTER_LINK = 'cash.register.link';
    public const RECONCILE = 'cash.reconcile';

    public const SHIFT_VIEW = 'cash.shift.view';
    public const SHIFT_OPEN = 'cash.shift.open';
    public const SHIFT_MOVE = 'cash.shift.move';
    public const SHIFT_CLOSE = 'cash.shift.close';
    public const SALE_POST = 'cash.shift.sale.post';
    public const CASH_DROP = 'cash.shift.drop';
    public const CASH_REFUND = 'cash.shift.refund';

    private function __construct() {}
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Approvals\Application\Actions\CreateApprovalRequestAction.php
================================================================
<?php

namespace App\Modules\Approvals\Application\Actions;

use App\Modules\Approvals\Domain\Models\ApprovalPolicy;
use App\Modules\Approvals\Domain\Models\ApprovalRequest;
use App\Modules\Audit\Application\AuditRecorder;
use App\Modules\Core\Application\Context\OrganizationScopeContext;
use App\Modules\Core\Application\Context\TenantContext;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

final readonly class CreateApprovalRequestAction
{
    public function __construct(
        private TenantContext $tenantContext,
        private OrganizationScopeContext $scopeContext,
        private AuditRecorder $auditRecorder,
    ) {}

    public function execute(
        string $policyPublicId,
        string $actionCode,
        int $requestedByUserId,
        ?string $subjectType = null,
        ?string $subjectPublicId = null,
        ?array $payload = null,
        ?Request $request = null,
    ): ApprovalRequest {
        $policy = ApprovalPolicy::query()
            ->where('tenant_id', $this->tenantContext->tenantId())
            ->where('public_id', $policyPublicId)
            ->where('status', 'active')
            ->first();

        if ($policy === null) {
            throw ValidationException::withMessages([
                'policy_id' => ['Approval policy was not found in the active tenant.'],
            ]);
        }

        if ($policy->action_code !== $actionCode) {
            throw ValidationException::withMessages([
                'action_code' => ['Action code does not match the selected approval policy.'],
            ]);
        }

        $this->assertPolicyScopeMatches($policy);

        return DB::transaction(function () use (
            $policy,
            $actionCode,
            $requestedByUserId,
            $subjectType,
            $subjectPublicId,
            $payload,
            $request,
        ): ApprovalRequest {
            $approvalRequest = ApprovalRequest::query()->create([
                'tenant_id' => $this->tenantContext->tenantId(),
                'policy_id' => $policy->id,
                'company_id' => $this->scopeContext->companyId(),
                'branch_id' => $this->scopeContext->branchId(),
                'action_code' => $actionCode,
                'subject_type' => $subjectType,
                'subject_public_id' => $subjectPublicId,
                'payload' => $payload,
                'requested_by_user_id' => $requestedByUserId,
                'status' => 'pending',
                'requested_at' => now(),
                'expires_at' => $policy->expires_after_minutes
                    ? now()->addMinutes($policy->expires_after_minutes)
                    : null,
            ]);

            $this->auditRecorder->record(
                eventType: 'approvals.request.created',
                tenantId: $this->tenantContext->tenantId(),
                actorUserId: $requestedByUserId,
                subjectType: 'approvals.request',
                subjectPublicId: $approvalRequest->public_id,
                after: [
                    'policy_id' => $policy->public_id,
                    'action_code' => $actionCode,
                    'subject_type' => $subjectType,
                    'subject_public_id' => $subjectPublicId,
                    'status' => 'pending',
                ],
                request: $request,
            );

            return $approvalRequest;
        });
    }

    private function assertPolicyScopeMatches(ApprovalPolicy $policy): void
    {
        if ($policy->scope_type === 'tenant') {
            return;
        }

        if ($policy->scope_type === 'company') {
            if ($this->scopeContext->companyId() === null || $policy->company_id !== $this->scopeContext->companyId()) {
                throw ValidationException::withMessages([
                    'policy_id' => ['Approval policy does not match the selected company scope.'],
                ]);
            }

            return;
        }

        if ($policy->scope_type === 'branch') {
            if ($this->scopeContext->branchId() === null || $policy->branch_id !== $this->scopeContext->branchId()) {
                throw ValidationException::withMessages([
                    'policy_id' => ['Approval policy does not match the selected branch scope.'],
                ]);
            }

            return;
        }

        throw ValidationException::withMessages([
            'policy_id' => ['Unsupported approval policy scope.'],
        ]);
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Approvals\Application\Actions\DecideApprovalRequestAction.php
================================================================
<?php

namespace App\Modules\Approvals\Application\Actions;

use App\Modules\Approvals\Domain\Models\ApprovalDecision;
use App\Modules\Approvals\Domain\Models\ApprovalRequest;
use App\Modules\Audit\Application\AuditRecorder;
use App\Modules\Core\Application\Context\TenantContext;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

final readonly class DecideApprovalRequestAction
{
    public function __construct(
        private TenantContext $tenantContext,
        private AuditRecorder $auditRecorder,
    ) {}

    public function execute(
        ApprovalRequest $approvalRequest,
        string $decision,
        int $decidedByUserId,
        ?string $comment = null,
        ?Request $request = null,
    ): ApprovalRequest {
        return DB::transaction(function () use (
            $approvalRequest,
            $decision,
            $decidedByUserId,
            $comment,
            $request,
        ): ApprovalRequest {
            $locked = ApprovalRequest::query()
                ->with('policy')
                ->where('tenant_id', $this->tenantContext->tenantId())
                ->whereKey($approvalRequest->id)
                ->lockForUpdate()
                ->firstOrFail();

            if ($locked->status !== 'pending') {
                throw ValidationException::withMessages([
                    'decision' => ['Only pending approval requests can be decided.'],
                ]);
            }

            if ($locked->expires_at !== null && $locked->expires_at->isPast()) {
                $locked->status = 'expired';
                $locked->decided_at = now();
                $locked->save();

                throw ValidationException::withMessages([
                    'decision' => ['Approval request has expired.'],
                ]);
            }

            if (! $locked->policy->allow_self_approval && $locked->requested_by_user_id === $decidedByUserId) {
                throw ValidationException::withMessages([
                    'decision' => ['Self-approval is not allowed by this policy.'],
                ]);
            }

            $alreadyDecided = ApprovalDecision::query()
                ->where('request_id', $locked->id)
                ->where('decided_by_user_id', $decidedByUserId)
                ->exists();

            if ($alreadyDecided) {
                throw ValidationException::withMessages([
                    'decision' => ['This user has already decided this approval request.'],
                ]);
            }

            ApprovalDecision::query()->create([
                'tenant_id' => $this->tenantContext->tenantId(),
                'request_id' => $locked->id,
                'decided_by_user_id' => $decidedByUserId,
                'decision' => $decision,
                'comment' => $comment,
                'decided_at' => now(),
            ]);

            if ($decision === 'reject') {
                $locked->status = 'rejected';
                $locked->decided_at = now();
            } else {
                $approvalCount = ApprovalDecision::query()
                    ->where('request_id', $locked->id)
                    ->where('decision', 'approve')
                    ->count();

                if ($approvalCount >= $locked->policy->required_approvals) {
                    $locked->status = 'approved';
                    $locked->decided_at = now();
                }
            }

            $locked->save();

            $this->auditRecorder->record(
                eventType: 'approvals.request.decided',
                tenantId: $this->tenantContext->tenantId(),
                actorUserId: $decidedByUserId,
                subjectType: 'approvals.request',
                subjectPublicId: $locked->public_id,
                after: [
                    'decision' => $decision,
                    'status' => $locked->status,
                    'comment' => $comment,
                ],
                request: $request,
            );

            return $locked->fresh(['policy', 'decisions']);
        });
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Approvals\Application\Permissions\ApprovalPermissions.php
================================================================
<?php

namespace App\Modules\Approvals\Application\Permissions;

final class ApprovalPermissions
{
    public const VIEW = 'approvals.view';
    public const REQUEST = 'approvals.request';
    public const DECIDE = 'approvals.decide';
    public const MANAGE = 'approvals.manage';

    private function __construct() {}
}

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

namespace App\Modules\Approvals\Domain\Models;

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

final class ApprovalDecision extends Model
{
    use HasPublicUlid;

    protected $table = 'approvals.decisions';

    protected $fillable = [
        'tenant_id',
        'request_id',
        'decided_by_user_id',
        'decision',
        'comment',
        'decided_at',
    ];

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

    public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); }
    public function request(): BelongsTo { return $this->belongsTo(ApprovalRequest::class, 'request_id'); }
    public function decidedBy(): BelongsTo { return $this->belongsTo(User::class, 'decided_by_user_id'); }
}

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

namespace App\Modules\Approvals\Domain\Models;

use App\Modules\Core\Domain\Concerns\HasPublicUlid;
use App\Modules\Core\Domain\Models\Branch;
use App\Modules\Core\Domain\Models\Company;
use App\Modules\Core\Domain\Models\Tenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

final class ApprovalPolicy extends Model
{
    use HasPublicUlid;

    protected $table = 'approvals.policies';

    protected $fillable = [
        'tenant_id',
        'name',
        'code',
        'action_code',
        'scope_type',
        'company_id',
        'branch_id',
        'conditions',
        'required_approvals',
        'allow_self_approval',
        'expires_after_minutes',
        'status',
    ];

    protected function casts(): array
    {
        return [
            'conditions' => 'array',
            'required_approvals' => 'integer',
            'allow_self_approval' => 'boolean',
            'expires_after_minutes' => 'integer',
        ];
    }

    public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); }
    public function company(): BelongsTo { return $this->belongsTo(Company::class); }
    public function branch(): BelongsTo { return $this->belongsTo(Branch::class); }
    public function requests(): HasMany { return $this->hasMany(ApprovalRequest::class, 'policy_id'); }
}

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

namespace App\Modules\Approvals\Domain\Models;

use App\Models\User;
use App\Modules\Core\Domain\Concerns\HasPublicUlid;
use App\Modules\Core\Domain\Models\Branch;
use App\Modules\Core\Domain\Models\Company;
use App\Modules\Core\Domain\Models\Tenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

final class ApprovalRequest extends Model
{
    use HasPublicUlid;

    protected $table = 'approvals.requests';

    protected $fillable = [
        'tenant_id',
        'policy_id',
        'company_id',
        'branch_id',
        'action_code',
        'subject_type',
        'subject_public_id',
        'payload',
        'requested_by_user_id',
        'status',
        'requested_at',
        'decided_at',
        'expires_at',
    ];

    protected function casts(): array
    {
        return [
            'payload' => 'array',
            'requested_at' => 'immutable_datetime',
            'decided_at' => 'immutable_datetime',
            'expires_at' => 'immutable_datetime',
        ];
    }

    public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); }
    public function policy(): BelongsTo { return $this->belongsTo(ApprovalPolicy::class, 'policy_id'); }
    public function company(): BelongsTo { return $this->belongsTo(Company::class); }
    public function branch(): BelongsTo { return $this->belongsTo(Branch::class); }
    public function requestedBy(): BelongsTo { return $this->belongsTo(User::class, 'requested_by_user_id'); }
    public function decisions(): HasMany { return $this->hasMany(ApprovalDecision::class, 'request_id'); }
}

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

namespace App\Modules\Approvals\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Modules\Approvals\Application\Actions\CreateApprovalRequestAction;
use App\Modules\Approvals\Application\Actions\DecideApprovalRequestAction;
use App\Modules\Approvals\Domain\Models\ApprovalRequest;
use App\Modules\Approvals\Http\Requests\DecideApprovalRequestRequest;
use App\Modules\Approvals\Http\Requests\StoreApprovalRequestRequest;
use App\Modules\Approvals\Http\Resources\ApprovalRequestResource;
use App\Modules\Core\Application\Context\OrganizationScopeContext;
use App\Modules\Core\Application\Context\TenantContext;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;

final class ApprovalRequestController extends Controller
{
    public function index(
        TenantContext $tenantContext,
        OrganizationScopeContext $scopeContext,
    ): AnonymousResourceCollection {
        $query = ApprovalRequest::query()
            ->with(['policy', 'decisions'])
            ->where('tenant_id', $tenantContext->tenantId());

        if ($scopeContext->branchId() !== null) {
            $query->where('branch_id', $scopeContext->branchId());
        } elseif ($scopeContext->companyId() !== null) {
            $query->where('company_id', $scopeContext->companyId());
        }

        return ApprovalRequestResource::collection(
            $query->latest('requested_at')->get()
        );
    }

    public function store(
        StoreApprovalRequestRequest $request,
        CreateApprovalRequestAction $action,
    ): Response {
        $validated = $request->validated();

        $approvalRequest = $action->execute(
            policyPublicId: $validated['policy_id'],
            actionCode: $validated['action_code'],
            requestedByUserId: $request->user()->id,
            subjectType: $validated['subject_type'] ?? null,
            subjectPublicId: $validated['subject_public_id'] ?? null,
            payload: $validated['payload'] ?? null,
            request: $request,
        );

        return (new ApprovalRequestResource($approvalRequest->load('policy', 'decisions')))
            ->response()
            ->setStatusCode(201);
    }

    public function decide(
        string $approvalRequestPublicId,
        DecideApprovalRequestRequest $request,
        TenantContext $tenantContext,
        DecideApprovalRequestAction $action,
    ): ApprovalRequestResource {
        $approvalRequest = ApprovalRequest::query()
            ->where('tenant_id', $tenantContext->tenantId())
            ->where('public_id', $approvalRequestPublicId)
            ->firstOrFail();

        $validated = $request->validated();

        $approvalRequest = $action->execute(
            approvalRequest: $approvalRequest,
            decision: $validated['decision'],
            decidedByUserId: $request->user()->id,
            comment: $validated['comment'] ?? null,
            request: $request,
        );

        return new ApprovalRequestResource($approvalRequest);
    }
}

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

namespace App\Modules\Approvals\Http\Requests;

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

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

    public function rules(): array
    {
        return [
            'decision' => ['required', Rule::in(['approve', 'reject'])],
            'comment' => ['nullable', 'string', 'max:2000'],
        ];
    }
}

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

namespace App\Modules\Approvals\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    public function rules(): array
    {
        return [
            'policy_id' => ['required', 'string', 'size:26'],
            'action_code' => ['required', 'string', 'max:180'],
            'subject_type' => ['nullable', 'string', 'max:180'],
            'subject_public_id' => ['nullable', 'string', 'max:64'],
            'payload' => ['nullable', 'array'],
        ];
    }
}

================================================================
FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Approvals\Http\Resources\ApprovalRequestResource.php
================================================================
<?php

namespace App\Modules\Approvals\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

final class ApprovalRequestResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->public_id,
            'action_code' => $this->action_code,
            'subject_type' => $this->subject_type,
            'subject_public_id' => $this->subject_public_id,
            'payload' => $this->payload,
            'status' => $this->status,
            'requested_at' => $this->requested_at?->toISOString(),
            'decided_at' => $this->decided_at?->toISOString(),
            'expires_at' => $this->expires_at?->toISOString(),
            'policy' => $this->whenLoaded('policy', fn () => [
                'id' => $this->policy->public_id,
                'name' => $this->policy->name,
                'code' => $this->policy->code,
                'required_approvals' => $this->policy->required_approvals,
                'allow_self_approval' => (bool) $this->policy->allow_self_approval,
            ]),
            'decisions' => $this->whenLoaded('decisions', fn () =>
                $this->decisions->map(fn ($decision) => [
                    'id' => $decision->public_id,
                    'decision' => $decision->decision,
                    'comment' => $decision->comment,
                    'decided_at' => $decision->decided_at?->toISOString(),
                ])->values()->all()
            ),
        ];
    }
}

