================================================================ FILE: .\app\Modules\Cash\Domain\Models\RegisterShift.php ================================================================ '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 ================================================================ '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 ================================================================ 'decimal:6', 'posted_at'=>'immutable_datetime', 'metadata'=>'array', 'created_at'=>'immutable_datetime', ]; } } ================================================================ FILE: .\app\Modules\Cash\Application\Shifts\OpenRegisterShiftAction.php ================================================================ 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 ================================================================ 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 ================================================================ 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 ================================================================ 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 ================================================================ 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 ================================================================ 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 ================================================================ 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 ================================================================ 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 ================================================================ '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 ================================================================ '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 ================================================================ '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 ================================================================ 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 ================================================================ ['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 ================================================================ ['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 ================================================================ $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() ), ]; } }