================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Loyalty\Application\Ledger\LoyaltyLedgerService.php ================================================================ where('tenant_id',$customer->tenant_id) ->where('customer_id',$customer->id) ->lockForUpdate() ->first(); if(!$account) { $account=LoyaltyAccount::query()->create([ 'tenant_id'=>$customer->tenant_id, 'customer_id'=>$customer->id, 'points_balance'=>0, 'lifetime_earned'=>0, 'lifetime_redeemed'=>0, ]); $account=LoyaltyAccount::query()->whereKey($account->id)->lockForUpdate()->firstOrFail(); } $existing=LoyaltyEntry::query() ->where('tenant_id',$customer->tenant_id) ->where('account_id',$account->id) ->where('source_type',$sourceType) ->where('source_public_id',$sourcePublicId) ->where('source_line_key',$sourceLineKey) ->first(); if($existing) return $existing; $newBalance=$account->points_balance+$pointsDelta; if($newBalance<0) throw new ConflictHttpException('Insufficient loyalty points.'); $entry=LoyaltyEntry::query()->create([ 'tenant_id'=>$customer->tenant_id, 'account_id'=>$account->id, 'entry_type'=>$entryType, 'points_delta'=>$pointsDelta, 'source_type'=>$sourceType, 'source_public_id'=>$sourcePublicId, 'source_line_key'=>$sourceLineKey, 'occurred_at'=>now(), 'expires_at'=>$expiresAt, 'created_by_user_id'=>$actorUserId, 'metadata'=>$metadata, 'created_at'=>now(), ]); $account->points_balance=$newBalance; if($pointsDelta>0 && $entryType==='earn') $account->lifetime_earned += $pointsDelta; if($pointsDelta<0 && $entryType==='redeem') $account->lifetime_redeemed += abs($pointsDelta); $account->last_activity_at=now(); $account->save(); return $entry; }); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Loyalty\Domain\Models\LoyaltyAccount.php ================================================================ 'integer','lifetime_earned'=>'integer','lifetime_redeemed'=>'integer','last_activity_at'=>'immutable_datetime']; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Loyalty\Domain\Models\LoyaltyEntry.php ================================================================ 'integer','occurred_at'=>'immutable_datetime','expires_at'=>'immutable_datetime','metadata'=>'array','created_at'=>'immutable_datetime']; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Wallet\Application\Ledger\WalletLedgerService.php ================================================================ where('tenant_id',$customer->tenant_id) ->where('idempotency_key',$idempotencyKey) ->first(); if($existing) return $existing; $account=WalletAccount::query() ->where('tenant_id',$customer->tenant_id) ->where('customer_id',$customer->id) ->where('currency_code',$currencyCode) ->lockForUpdate() ->first(); if(!$account) { $account=WalletAccount::query()->create([ 'tenant_id'=>$customer->tenant_id, 'customer_id'=>$customer->id, 'currency_code'=>$currencyCode, 'balance_amount'=>'0.000000', ]); $account=WalletAccount::query()->whereKey($account->id)->lockForUpdate()->firstOrFail(); } $newBalance=bcadd((string)$account->balance_amount,$amountDelta,6); if(bccomp($newBalance,'0.000000',6)<0) throw new ConflictHttpException('Insufficient wallet balance.'); $entry=WalletEntry::query()->create([ 'tenant_id'=>$customer->tenant_id, 'account_id'=>$account->id, 'entry_type'=>$entryType, 'amount_delta'=>$amountDelta, 'source_type'=>$sourceType, 'source_public_id'=>$sourcePublicId, 'idempotency_key'=>$idempotencyKey, 'occurred_at'=>now(), 'expires_at'=>$expiresAt, 'created_by_user_id'=>$actorUserId, 'metadata'=>$metadata, 'created_at'=>now(), ]); $account->balance_amount=$newBalance; $account->last_activity_at=now(); $account->save(); return $entry; }); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Wallet\Domain\Models\WalletAccount.php ================================================================ 'decimal:6','last_activity_at'=>'immutable_datetime']; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Wallet\Domain\Models\WalletEntry.php ================================================================ 'decimal:6','occurred_at'=>'immutable_datetime','expires_at'=>'immutable_datetime','metadata'=>'array','created_at'=>'immutable_datetime']; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Credit\Application\Ledger\CreditLedgerService.php ================================================================ firstOrCreate( [ 'tenant_id'=>$customer->tenant_id, 'customer_id'=>$customer->id, 'currency_code'=>$currencyCode, ], [ 'credit_limit_amount'=>'0.000000', 'outstanding_amount'=>'0.000000', 'status'=>'active', ] ); } public function setLimit(Customer $customer,string $currencyCode,string $limit): CreditAccount { return DB::transaction(function () use ($customer,$currencyCode,$limit) { $account=$this->account($customer,$currencyCode); $account=CreditAccount::query()->whereKey($account->id)->lockForUpdate()->firstOrFail(); if(bccomp($limit,(string)$account->outstanding_amount,6)<0) { throw new ConflictHttpException('Credit limit cannot be below current outstanding balance.'); } $account->credit_limit_amount=$limit; $account->save(); return $account->fresh(); }); } public function post( Customer $customer, string $currencyCode, string $entryType, string $amountDelta, string $sourceType, string $sourcePublicId, string $idempotencyKey, ?int $actorUserId, ?array $metadata=null, ): CreditEntry { return DB::transaction(function () use ( $customer,$currencyCode,$entryType,$amountDelta,$sourceType,$sourcePublicId,$idempotencyKey,$actorUserId,$metadata ) { $existing=CreditEntry::query() ->where('tenant_id',$customer->tenant_id) ->where('idempotency_key',$idempotencyKey) ->first(); if($existing) return $existing; $account=$this->account($customer,$currencyCode); $account=CreditAccount::query()->whereKey($account->id)->lockForUpdate()->firstOrFail(); if($account->status!=='active') throw new ConflictHttpException('Customer credit account is not active.'); $newOutstanding=bcadd((string)$account->outstanding_amount,$amountDelta,6); if(bccomp($newOutstanding,'0.000000',6)<0) { throw new ConflictHttpException('Credit settlement exceeds outstanding balance.'); } if(bccomp($newOutstanding,(string)$account->credit_limit_amount,6)>0) { throw new ConflictHttpException('Customer credit limit exceeded.'); } $entry=CreditEntry::query()->create([ 'tenant_id'=>$customer->tenant_id, 'account_id'=>$account->id, 'entry_type'=>$entryType, 'amount_delta'=>$amountDelta, 'source_type'=>$sourceType, 'source_public_id'=>$sourcePublicId, 'idempotency_key'=>$idempotencyKey, 'occurred_at'=>now(), 'created_by_user_id'=>$actorUserId, 'metadata'=>$metadata, 'created_at'=>now(), ]); $account->outstanding_amount=$newOutstanding; $account->last_activity_at=now(); $account->save(); return $entry; }); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Credit\Domain\Models\CreditAccount.php ================================================================ 'decimal:6','outstanding_amount'=>'decimal:6','last_activity_at'=>'immutable_datetime']; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Credit\Domain\Models\CreditEntry.php ================================================================ 'decimal:6','occurred_at'=>'immutable_datetime','metadata'=>'array','created_at'=>'immutable_datetime']; } } ================================================================ FILE: .\app\Modules\Sales\Application\Returns\PostSaleReturnAction.php ================================================================ with(['lines','sale']) ->where('tenant_id',$return->tenant_id) ->whereKey($return->id) ->lockForUpdate() ->firstOrFail(); if($locked->status==='posted') { return $locked; } if($locked->status!=='draft') { throw new ConflictHttpException('Only draft returns can be posted.'); } $sale=Sale::query() ->where('tenant_id',$locked->tenant_id) ->whereKey($locked->sale_id) ->lockForUpdate() ->firstOrFail(); $this->assertApproval($locked,$approvalRequestPublicId); $lines=$locked->lines->sortBy('sale_line_id')->values(); foreach($lines as $returnLine) { $saleLine=SaleLine::query() ->where('tenant_id',$locked->tenant_id) ->whereKey($returnLine->sale_line_id) ->lockForUpdate() ->firstOrFail(); $alreadyReturned='0.000000'; SaleReturnLine::query() ->from('sales.return_lines as rl') ->join('sales.returns as r','r.id','=','rl.sale_return_id') ->where('rl.tenant_id',$locked->tenant_id) ->where('rl.sale_line_id',$saleLine->id) ->where('rl.sale_return_id','<>',$locked->id) ->where('r.status','posted') ->pluck('rl.quantity') ->each(function($quantity) use (&$alreadyReturned) { $alreadyReturned=bcadd($alreadyReturned,(string)$quantity,6); }); $remaining=bcsub((string)$saleLine->quantity,$alreadyReturned,6); if(bccomp((string)$returnLine->quantity,$remaining,6)>0) { throw ValidationException::withMessages([ 'lines'=>['Cumulative returned quantity exceeds original sold quantity.'], ]); } } $at=CarbonImmutable::now('UTC'); foreach($lines as $returnLine) { if($returnLine->disposition!=='restock') { continue; } if($returnLine->warehouse_id===null) { throw new ConflictHttpException('Restock return line is missing warehouse.'); } $this->inventory->post( tenantId:$locked->tenant_id, warehouseId:$returnLine->warehouse_id, variantId:$returnLine->variant_id, variantUnitId:$returnLine->variant_unit_id, movementType:'sale_reversal', quantityDelta:(string)$returnLine->quantity, sourceType:'sales_return', sourcePublicId:$locked->public_id, sourceLinePublicId:$returnLine->public_id, occurredAt:$at, businessDate:$sale->business_date->format('Y-m-d'), actorUserId:$actorUserId, metadata:[ 'sale_id'=>$sale->public_id, 'sale_line_id'=>$returnLine->sale_line_id, 'disposition'=>'restock', ], ); } $instructions=$locked->metadata['refund_instructions'] ?? []; foreach($instructions as $instruction) { $payment=Payment::query() ->where('tenant_id',$locked->tenant_id) ->where('sale_id',$sale->id) ->where('public_id',$instruction['payment_id']) ->firstOrFail(); $this->refundPayment->execute( $payment, $locked, (string)$instruction['amount'], 'return-'.$locked->public_id.'-'.$instruction['sequence'], $actorUserId, $request, $instruction['metadata']??null, ); } $locked->status='posted'; $locked->approval_request_id=$approvalRequestPublicId ? ApprovalRequest::query() ->where('tenant_id',$locked->tenant_id) ->where('public_id',$approvalRequestPublicId) ->value('id') : null; $locked->posted_by_user_id=$actorUserId; $locked->posted_at=$at; $locked->save(); $locked=$this->returnRefundStatus->refresh($locked); $this->salePaymentStatus->refresh($sale); $this->auditRecorder->record( 'sales.return.posted', $locked->tenant_id, $actorUserId, 'sales.return', $locked->public_id, after:[ 'sale_id'=>$sale->public_id, 'return_number'=>$locked->return_number, 'total_amount'=>$locked->total_amount, 'refund_status'=>$locked->refund_status, ], request:$request, ); return $locked->fresh('lines'); }); } private function assertApproval( SaleReturn $return, ?string $approvalRequestPublicId, ): void { $policy=ApprovalPolicy::query() ->where('tenant_id',$return->tenant_id) ->where('action_code','sales.return.post') ->where('status','active') ->orderByDesc('id') ->get() ->first(function($candidate) use ($return) { $conditions=$candidate->conditions ?? []; $threshold=(string)($conditions['max_return_amount_without_approval'] ?? '0.000000'); return bccomp((string)$return->total_amount,$threshold,6)>0; }); if(!$policy) return; if(!$approvalRequestPublicId) { throw ValidationException::withMessages([ 'approval_request_id'=>['Approved request is required for this return amount.'], ]); } $approval=ApprovalRequest::query() ->where('tenant_id',$return->tenant_id) ->where('public_id',$approvalRequestPublicId) ->where('policy_id',$policy->id) ->where('action_code','sales.return.post') ->where('subject_type','sales.return') ->where('subject_public_id',$return->public_id) ->where('status','approved') ->first(); if(!$approval) { throw ValidationException::withMessages([ 'approval_request_id'=>[ 'Approval request is missing, not approved, or does not match this return.', ], ]); } $approvedAmount=(string)($approval->payload['return_total_amount'] ?? $return->total_amount); if(bccomp($approvedAmount,(string)$return->total_amount,6)!==0) { throw ValidationException::withMessages([ 'approval_request_id'=>['Approved return amount does not match current return total.'], ]); } } } ================================================================ FILE: .\app\Modules\Sales\Application\Returns\CreateSaleReturnAction.php ================================================================ where('tenant_id',$sale->tenant_id) ->where('idempotency_key',$data['idempotency_key']) ->first(); if($existing) return $existing->load('lines'); return DB::transaction(function () use ($sale,$data,$actorUserId,$request) { $lockedSale=Sale::query() ->with('lines') ->where('tenant_id',$sale->tenant_id) ->whereKey($sale->id) ->lockForUpdate() ->firstOrFail(); if(in_array($lockedSale->status,['voided'],true)) { throw new ConflictHttpException('Voided sale cannot be returned.'); } $existing=SaleReturn::query() ->where('tenant_id',$lockedSale->tenant_id) ->where('idempotency_key',$data['idempotency_key']) ->lockForUpdate() ->first(); if($existing) return $existing->load('lines'); $requestedByLine=[]; foreach($data['lines'] as $item) { $line=SaleLine::query() ->where('tenant_id',$lockedSale->tenant_id) ->where('sale_id',$lockedSale->id) ->where('public_id',$item['sale_line_id']) ->first(); if(!$line) { throw ValidationException::withMessages([ 'lines'=>['Return line does not belong to this sale.'], ]); } if(isset($requestedByLine[$line->id])) { throw ValidationException::withMessages([ 'lines'=>['Each sale line may appear only once in a return draft.'], ]); } $warehouseId=null; if($item['disposition']==='restock') { if(empty($item['warehouse_id'])) { throw ValidationException::withMessages([ 'lines'=>['Restock disposition requires warehouse_id.'], ]); } $warehouse=Warehouse::query() ->where('tenant_id',$lockedSale->tenant_id) ->where('public_id',$item['warehouse_id']) ->first(); if(!$warehouse) { throw ValidationException::withMessages([ 'lines'=>['Return warehouse was not found in this tenant.'], ]); } $warehouseId=$warehouse->id; } $requestedByLine[$line->id]=[ 'line'=>$line, 'quantity'=>(string)$item['quantity'], 'disposition'=>$item['disposition'], 'warehouse_id'=>$warehouseId, 'metadata'=>$item['metadata']??null, ]; } $total='0.000000'; foreach($requestedByLine as &$item) { /** @var SaleLine $line */ $line=$item['line']; if(bccomp($item['quantity'],(string)$line->quantity,6)>0) { throw ValidationException::withMessages([ 'lines'=>['Return quantity cannot exceed original sold quantity.'], ]); } $lineAmount=bcdiv( bcmul((string)$line->net_amount,$item['quantity'],12), (string)$line->quantity, 6 ); $item['return_amount']=$lineAmount; $total=bcadd($total,$lineAmount,6); } unset($item); $refundTotal='0.000000'; $refundInstructions=[]; foreach($data['refunds'] ?? [] as $index=>$refund) { $payment=Payment::query() ->where('tenant_id',$lockedSale->tenant_id) ->where('sale_id',$lockedSale->id) ->where('public_id',$refund['payment_id']) ->where('status','captured') ->first(); if(!$payment) { throw ValidationException::withMessages([ 'refunds'=>['Refund payment must be a captured payment belonging to this sale.'], ]); } $amount=(string)$refund['amount']; $refundTotal=bcadd($refundTotal,$amount,6); $refundInstructions[]=[ 'payment_id'=>$payment->public_id, 'amount'=>$amount, 'metadata'=>$refund['metadata']??null, 'sequence'=>$index+1, ]; } if(bccomp($refundTotal,$total,6)>0) { throw ValidationException::withMessages([ 'refunds'=>['Refund allocations cannot exceed the return total.'], ]); } $return=SaleReturn::query()->create([ 'tenant_id'=>$lockedSale->tenant_id, 'sale_id'=>$lockedSale->id, 'return_number'=>'RET-'.strtoupper(substr((string)str()->ulid(),-12)), 'status'=>'draft', 'refund_status'=>empty($refundInstructions) ? 'none' : 'pending', 'subtotal_amount'=>$total, 'total_amount'=>$total, 'reason'=>$data['reason'], 'idempotency_key'=>$data['idempotency_key'], 'created_by_user_id'=>$actorUserId, 'metadata'=>array_merge( $data['metadata']??[], ['refund_instructions'=>$refundInstructions] ), ]); foreach($requestedByLine as $item) { $line=$item['line']; SaleReturnLine::query()->create([ 'tenant_id'=>$lockedSale->tenant_id, 'sale_return_id'=>$return->id, 'sale_line_id'=>$line->id, 'variant_id'=>$line->variant_id, 'variant_unit_id'=>$line->variant_unit_id, 'warehouse_id'=>$item['warehouse_id'], 'quantity'=>$item['quantity'], 'return_amount'=>$item['return_amount'], 'disposition'=>$item['disposition'], 'metadata'=>$item['metadata'], 'created_at'=>now(), ]); } $this->auditRecorder->record( 'sales.return.created', $lockedSale->tenant_id, $actorUserId, 'sales.return', $return->public_id, after:[ 'sale_id'=>$lockedSale->public_id, 'return_number'=>$return->return_number, 'total_amount'=>$return->total_amount, 'status'=>$return->status, ], request:$request, ); return $return->fresh('lines'); }); } } ================================================================ FILE: .\app\Modules\Payments\Application\Refunds\RefundCapturedPaymentAction.php ================================================================ where('tenant_id',$payment->tenant_id) ->where('idempotency_key',$idempotencyKey) ->first(); if($existing) return $existing; return DB::transaction(function () use ( $payment,$saleReturn,$amount,$idempotencyKey,$actorUserId,$request,$metadata ) { $lockedPayment=Payment::query() ->where('tenant_id',$payment->tenant_id) ->whereKey($payment->id) ->lockForUpdate() ->firstOrFail(); if($lockedPayment->status!=='captured') { throw new ConflictHttpException('Only captured payments can be refunded.'); } if($lockedPayment->sale_id!==$saleReturn->sale_id) { throw ValidationException::withMessages([ 'payment_id'=>['Refund payment does not belong to the returned sale.'], ]); } $existing=PaymentRefund::query() ->where('tenant_id',$lockedPayment->tenant_id) ->where('idempotency_key',$idempotencyKey) ->lockForUpdate() ->first(); if($existing) return $existing; $allocated='0.000000'; PaymentRefund::query() ->where('tenant_id',$lockedPayment->tenant_id) ->where('payment_id',$lockedPayment->id) ->whereIn('status',['pending','completed']) ->pluck('amount') ->each(function($value) use (&$allocated) { $allocated=bcadd($allocated,(string)$value,6); }); if(bccomp(bcadd($allocated,$amount,6),(string)$lockedPayment->amount,6)>0) { throw ValidationException::withMessages([ 'amount'=>['Refund allocation exceeds remaining refundable payment amount.'], ]); } $refund=PaymentRefund::query()->create([ 'tenant_id'=>$lockedPayment->tenant_id, 'payment_id'=>$lockedPayment->id, 'sale_return_id'=>$saleReturn->id, 'method_type'=>$lockedPayment->method_type, 'amount'=>$amount, 'currency_code'=>$lockedPayment->currency_code, 'status'=>'pending', 'idempotency_key'=>$idempotencyKey, 'created_by_user_id'=>$actorUserId, 'metadata'=>$metadata, 'created_at'=>now(), ]); if($lockedPayment->method_type==='cash') { $sale=Sale::query() ->where('tenant_id',$lockedPayment->tenant_id) ->whereKey($lockedPayment->sale_id) ->firstOrFail(); if($sale->register_id===null) { throw new ConflictHttpException('Cash refund requires a register-linked sale.'); } $register=Register::query() ->where('tenant_id',$sale->tenant_id) ->whereKey($sale->register_id) ->firstOrFail(); $cash=$this->cashRefund->execute( $lockedPayment, [ 'register_id'=>$register->public_id, 'amount'=>$amount, 'idempotency_key'=>'return-cash-'.$refund->public_id, 'reason'=>'Sales return '.$saleReturn->return_number, 'metadata'=>[ 'sale_return_id'=>$saleReturn->public_id, 'payment_refund_id'=>$refund->public_id, ], ], $actorUserId, $request, ); $refund->status='completed'; $refund->resolved_by_user_id=$actorUserId; $refund->resolved_at=now(); $refund->metadata=array_merge( $refund->metadata ?? [], ['cash_sale_refund_id'=>$cash->public_id] ); $refund->save(); } return $refund->fresh(); }); } } ================================================================ FILE: .\app\Modules\Payments\Application\Refunds\ResolvePaymentRefundAction.php ================================================================ where('tenant_id',$refund->tenant_id) ->whereKey($refund->id) ->lockForUpdate() ->firstOrFail(); if($locked->method_type==='cash') { throw new ConflictHttpException('Cash refunds are resolved synchronously through the register shift.'); } if($locked->status!=='pending') { return $locked; } $before=$locked->status; $locked->status=$data['outcome']==='succeeded' ? 'completed' : 'failed'; $locked->provider_reference=$data['provider_reference'] ?? $locked->provider_reference; $locked->resolved_by_user_id=$actorUserId; $locked->resolved_at=now(); if(!empty($data['metadata'])) { $locked->metadata=array_merge($locked->metadata ?? [],$data['metadata']); } $locked->save(); if($locked->sale_return_id!==null) { $return=SaleReturn::query()->whereKey($locked->sale_return_id)->lockForUpdate()->firstOrFail(); $this->returnStatus->refresh($return); $sale=Sale::query()->whereKey($return->sale_id)->lockForUpdate()->firstOrFail(); $this->salePaymentStatus->refresh($sale); } $this->auditRecorder->record( 'payments.refund.resolved', $locked->tenant_id, $actorUserId, 'payments.refund', $locked->public_id, before:['status'=>$before], after:[ 'status'=>$locked->status, 'provider_reference'=>$locked->provider_reference, ], request:$request, ); return $locked->fresh(); }); } } ================================================================ FILE: .\app\Modules\Payments\Application\Settlement\SalePaymentStatusUpdater.php ================================================================ capturedAmount($sale); $refunded=$this->completedRefundAmount($sale); $hasUnknown=Payment::query() ->where('tenant_id',$sale->tenant_id) ->where('sale_id',$sale->id) ->where('status','unknown') ->exists(); if($hasUnknown) { $paymentStatus='unknown'; $saleStatus='pending_payment'; } elseif(bccomp($refunded,'0.000000',6)>0 && bccomp($refunded,$captured,6)>=0) { $paymentStatus='refunded'; $saleStatus=bccomp($captured,(string)$sale->total_amount,6)>=0 ? 'completed' : 'pending_payment'; } elseif(bccomp($refunded,'0.000000',6)>0) { $paymentStatus='partially_refunded'; $saleStatus=bccomp($captured,(string)$sale->total_amount,6)>=0 ? 'completed' : 'pending_payment'; } elseif(bccomp($captured,(string)$sale->total_amount,6)>=0) { $paymentStatus='paid'; $saleStatus='completed'; } elseif(bccomp($captured,'0.000000',6)>0) { $paymentStatus='partial'; $saleStatus='pending_payment'; } else { $paymentStatus='unpaid'; $saleStatus='pending_payment'; } $sale->payment_status=$paymentStatus; $sale->status=$saleStatus; $sale->save(); return $sale->fresh(); } public function capturedAmount(Sale $sale): string { $total='0.000000'; Payment::query() ->where('tenant_id',$sale->tenant_id) ->where('sale_id',$sale->id) ->where('status','captured') ->pluck('amount') ->each(function($amount) use (&$total) { $total=bcadd($total,(string)$amount,6); }); return $total; } public function completedRefundAmount(Sale $sale): string { $total='0.000000'; PaymentRefund::query() ->from('payments.refunds as r') ->join('payments.payments as p','p.id','=','r.payment_id') ->where('r.tenant_id',$sale->tenant_id) ->where('p.sale_id',$sale->id) ->where('r.status','completed') ->pluck('r.amount') ->each(function($amount) use (&$total) { $total=bcadd($total,(string)$amount,6); }); return $total; } } ================================================================ FILE: .\app\Modules\CRM\Application\Value\SettleSaleWithWalletAction.php ================================================================ whereKey($sale->id)->lockForUpdate()->firstOrFail(); if($locked->customer_id===null) throw new ConflictHttpException('Wallet settlement requires a linked customer.'); $existing=Payment::query() ->where('tenant_id',$locked->tenant_id) ->where('idempotency_key',$idempotencyKey) ->first(); if($existing) return $existing; $captured=$this->statusUpdater->capturedAmount($locked); $remaining=bcsub((string)$locked->total_amount,$captured,6); if(bccomp($amount,$remaining,6)>0) throw new ConflictHttpException('Wallet amount exceeds remaining sale balance.'); $customer=Customer::query() ->where('tenant_id',$locked->tenant_id) ->whereKey($locked->customer_id) ->firstOrFail(); $walletEntry=$this->wallet->post( $customer, $locked->currency_code, 'debit', bcmul($amount,'-1',6), 'sale', $locked->public_id, 'sale-wallet-'.$idempotencyKey, $actorUserId, null, array_merge($metadata??[],['sale_id'=>$locked->public_id]), ); $payment=Payment::query()->create([ 'tenant_id'=>$locked->tenant_id, 'sale_id'=>$locked->id, 'method_type'=>'wallet', 'amount'=>$amount, 'currency_code'=>$locked->currency_code, 'status'=>'captured', 'idempotency_key'=>$idempotencyKey, 'created_by_user_id'=>$actorUserId, 'resolved_at'=>now(), 'metadata'=>array_merge($metadata??[],['wallet_entry_id'=>$walletEntry->public_id]), ]); PaymentAttempt::query()->create([ 'tenant_id'=>$locked->tenant_id, 'payment_id'=>$payment->id, 'operation_key'=>$idempotencyKey, 'attempt_type'=>'collect', 'status'=>'succeeded', 'request_snapshot'=>['method_type'=>'wallet','amount'=>$amount], 'response_snapshot'=>['outcome'=>'succeeded'], 'started_at'=>now(), 'completed_at'=>now(), ]); $saleAfter=$this->statusUpdater->refresh($locked); $this->audit->record( 'wallet.sale.collected',$locked->tenant_id,$actorUserId, 'payments.payment',$payment->public_id, after:['sale_id'=>$locked->public_id,'amount'=>$amount,'sale_payment_status'=>$saleAfter->payment_status], request:$request, ); return $payment; }); } } ================================================================ FILE: .\app\Modules\CRM\Application\Value\ChargeSaleToCreditAction.php ================================================================ whereKey($sale->id)->lockForUpdate()->firstOrFail(); if($locked->customer_id===null) throw new ConflictHttpException('Credit sale requires a linked customer.'); $captured=$this->statusUpdater->capturedAmount($locked); $currentCredit=$this->creditChargeAmount($locked); $remaining=bcsub(bcsub((string)$locked->total_amount,$captured,6),$currentCredit,6); if(bccomp($amount,$remaining,6)>0) throw new ConflictHttpException('Credit charge exceeds remaining sale balance.'); $customer=Customer::query() ->where('tenant_id',$locked->tenant_id) ->whereKey($locked->customer_id) ->firstOrFail(); $entry=$this->credit->post( $customer, $locked->currency_code, 'charge', $amount, 'sale', $locked->public_id, $idempotencyKey, $actorUserId, array_merge($metadata??[],['sale_id'=>$locked->public_id]), ); $captured=$this->statusUpdater->capturedAmount($locked); $creditTotal=$this->creditChargeAmount($locked); $covered=bcadd($captured,$creditTotal,6); $locked->payment_status=bccomp($covered,(string)$locked->total_amount,6)>=0 ? 'on_account' : 'partially_on_account'; $locked->status=bccomp($covered,(string)$locked->total_amount,6)>=0 ? 'completed' : 'pending_payment'; $locked->save(); $this->audit->record( 'credit.sale.charged',$locked->tenant_id,$actorUserId, 'credit.entry',$entry->public_id, after:['sale_id'=>$locked->public_id,'amount'=>$amount,'sale_payment_status'=>$locked->payment_status], request:$request, ); return $locked->fresh(); }); } private function creditChargeAmount(Sale $sale): string { $total='0.000000'; \App\Modules\Credit\Domain\Models\CreditEntry::query() ->from('credit.entries as e') ->join('credit.accounts as a','a.id','=','e.account_id') ->where('e.tenant_id',$sale->tenant_id) ->where('e.source_type','sale') ->where('e.source_public_id',$sale->public_id) ->where('e.entry_type','charge') ->pluck('e.amount_delta') ->each(function($amount) use (&$total) { $total=bcadd($total,(string)$amount,6); }); return $total; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_01_113000_create_payments_foundation_tables.php ================================================================ bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('sale_id'); $table->string('method_type', 30); $table->decimal('amount', 18, 6); $table->string('currency_code', 3); $table->string('status', 30)->default('pending'); $table->string('idempotency_key', 120); $table->string('provider_reference', 180)->nullable(); $table->unsignedBigInteger('created_by_user_id'); $table->timestampTz('resolved_at')->nullable(); $table->jsonb('metadata')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('sale_id')->references('id')->on('sales.sales')->restrictOnDelete(); $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete(); $table->unique(['tenant_id','idempotency_key']); $table->index(['tenant_id','sale_id','status']); $table->index(['tenant_id','provider_reference']); }); DB::statement(" ALTER TABLE payments.payments ADD CONSTRAINT payments_payments_method_check CHECK (method_type IN ('cash','card','external')) "); DB::statement(" ALTER TABLE payments.payments ADD CONSTRAINT payments_payments_status_check CHECK (status IN ('pending','captured','failed','unknown','reversed')) "); DB::statement(" ALTER TABLE payments.payments ADD CONSTRAINT payments_payments_amount_check CHECK (amount > 0) "); Schema::create('payments.attempts', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('payment_id'); $table->string('operation_key', 120); $table->string('attempt_type', 30); $table->string('status', 30); $table->string('provider_reference', 180)->nullable(); $table->jsonb('request_snapshot')->nullable(); $table->jsonb('response_snapshot')->nullable(); $table->text('failure_code')->nullable(); $table->timestampTz('started_at')->useCurrent(); $table->timestampTz('completed_at')->nullable(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('payment_id')->references('id')->on('payments.payments')->restrictOnDelete(); $table->unique(['tenant_id','operation_key']); $table->index(['tenant_id','payment_id','started_at']); }); DB::statement(" ALTER TABLE payments.attempts ADD CONSTRAINT payments_attempts_type_check CHECK (attempt_type IN ('collect','resolve')) "); DB::statement(" ALTER TABLE payments.attempts ADD CONSTRAINT payments_attempts_status_check CHECK (status IN ('started','succeeded','failed','unknown')) "); } public function down(): void { Schema::dropIfExists('payments.attempts'); Schema::dropIfExists('payments.payments'); DB::statement('ALTER TABLE sales.sales DROP CONSTRAINT IF EXISTS sales_sales_payment_status_check'); DB::statement(" ALTER TABLE sales.sales ADD CONSTRAINT sales_sales_payment_status_check CHECK (payment_status IN ('unpaid','partial','paid','refunded','partially_refunded')) "); } }; ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_04_033000_create_cash_refund_shift_foundation.php ================================================================ bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('payment_id'); $table->unsignedBigInteger('shift_id'); $table->decimal('amount', 18, 6); $table->string('reason', 255); $table->string('idempotency_key', 120); $table->timestampTz('posted_at'); $table->unsignedBigInteger('posted_by_user_id'); $table->jsonb('metadata')->nullable(); $table->timestampTz('created_at')->useCurrent(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('payment_id')->references('id')->on('payments.payments')->restrictOnDelete(); $table->foreign('shift_id')->references('id')->on('cash.shifts')->restrictOnDelete(); $table->foreign('posted_by_user_id')->references('id')->on('users')->restrictOnDelete(); $table->unique(['tenant_id','idempotency_key']); $table->index(['tenant_id','payment_id','posted_at']); $table->index(['tenant_id','shift_id','posted_at']); }); } public function down(): void { Schema::dropIfExists('cash.sale_cash_refunds'); } }; ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_04_043000_harden_cash_payment_shift_integrity.php ================================================================ bigIncrements('id'); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('payment_id'); $table->decimal('refunded_amount', 18, 6)->default(0); $table->timestampTz('last_refund_at')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('payment_id')->references('id')->on('payments.payments')->restrictOnDelete(); $table->unique(['tenant_id','payment_id']); }); DB::statement(" INSERT INTO cash.payment_refund_balances (tenant_id, payment_id, refunded_amount, last_refund_at, created_at, updated_at) SELECT tenant_id, payment_id, COALESCE(SUM(amount), 0), MAX(posted_at), NOW(), NOW() FROM cash.sale_cash_refunds GROUP BY tenant_id, payment_id ON CONFLICT (tenant_id, payment_id) DO NOTHING "); DB::statement(" ALTER TABLE cash.payment_refund_balances ADD CONSTRAINT cash_payment_refund_balances_non_negative_check CHECK (refunded_amount >= 0) "); } public function down(): void { Schema::dropIfExists('cash.payment_refund_balances'); } }; ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_04_063000_create_sales_return_refund_bundle.php ================================================================ bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('sale_id'); $table->string('return_number', 90); $table->string('status', 30)->default('draft'); $table->string('refund_status', 30)->default('none'); $table->decimal('subtotal_amount', 18, 6)->default(0); $table->decimal('total_amount', 18, 6)->default(0); $table->string('reason', 255); $table->string('idempotency_key', 120); $table->unsignedBigInteger('approval_request_id')->nullable(); $table->unsignedBigInteger('created_by_user_id'); $table->unsignedBigInteger('posted_by_user_id')->nullable(); $table->timestampTz('posted_at')->nullable(); $table->jsonb('metadata')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('sale_id')->references('id')->on('sales.sales')->restrictOnDelete(); $table->foreign('approval_request_id')->references('id')->on('approvals.requests')->restrictOnDelete(); $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete(); $table->foreign('posted_by_user_id')->references('id')->on('users')->restrictOnDelete(); $table->unique(['tenant_id','return_number']); $table->unique(['tenant_id','idempotency_key']); $table->index(['tenant_id','sale_id','status']); $table->index(['tenant_id','refund_status','created_at']); }); DB::statement(" ALTER TABLE sales.returns ADD CONSTRAINT sales_returns_status_check CHECK (status IN ('draft','posted','cancelled')) "); DB::statement(" ALTER TABLE sales.returns ADD CONSTRAINT sales_returns_refund_status_check CHECK (refund_status IN ('none','pending','partial','completed','failed')) "); DB::statement(" ALTER TABLE sales.returns ADD CONSTRAINT sales_returns_amounts_check CHECK ( subtotal_amount >= 0 AND total_amount >= 0 AND total_amount = subtotal_amount ) "); Schema::create('sales.return_lines', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('sale_return_id'); $table->unsignedBigInteger('sale_line_id'); $table->unsignedBigInteger('variant_id'); $table->unsignedBigInteger('variant_unit_id')->nullable(); $table->unsignedBigInteger('warehouse_id')->nullable(); $table->decimal('quantity', 18, 6); $table->decimal('return_amount', 18, 6); $table->string('disposition', 30)->default('no_stock'); $table->jsonb('metadata')->nullable(); $table->timestampTz('created_at')->useCurrent(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('sale_return_id')->references('id')->on('sales.returns')->restrictOnDelete(); $table->foreign('sale_line_id')->references('id')->on('sales.sale_lines')->restrictOnDelete(); $table->foreign('variant_id')->references('id')->on('catalog.variants')->restrictOnDelete(); $table->foreign('variant_unit_id')->references('id')->on('catalog.variant_units')->restrictOnDelete(); $table->foreign('warehouse_id')->references('id')->on('core.warehouses')->restrictOnDelete(); $table->index(['tenant_id','sale_return_id']); $table->index(['tenant_id','sale_line_id']); $table->index(['tenant_id','warehouse_id']); }); DB::statement(" ALTER TABLE sales.return_lines ADD CONSTRAINT sales_return_lines_quantity_amount_check CHECK (quantity > 0 AND return_amount >= 0) "); DB::statement(" ALTER TABLE sales.return_lines ADD CONSTRAINT sales_return_lines_disposition_check CHECK (disposition IN ('restock','damaged','waste','no_stock')) "); Schema::create('payments.refunds', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('payment_id'); $table->unsignedBigInteger('sale_return_id')->nullable(); $table->string('method_type', 30); $table->decimal('amount', 18, 6); $table->string('currency_code', 3); $table->string('status', 30)->default('pending'); $table->string('idempotency_key', 140); $table->string('provider_reference')->nullable(); $table->unsignedBigInteger('created_by_user_id'); $table->unsignedBigInteger('resolved_by_user_id')->nullable(); $table->timestampTz('resolved_at')->nullable(); $table->jsonb('metadata')->nullable(); $table->timestampTz('created_at')->useCurrent(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('payment_id')->references('id')->on('payments.payments')->restrictOnDelete(); $table->foreign('sale_return_id')->references('id')->on('sales.returns')->restrictOnDelete(); $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete(); $table->foreign('resolved_by_user_id')->references('id')->on('users')->restrictOnDelete(); $table->unique(['tenant_id','idempotency_key']); $table->index(['tenant_id','payment_id','status']); $table->index(['tenant_id','sale_return_id','status']); }); DB::statement(" ALTER TABLE payments.refunds ADD CONSTRAINT payments_refunds_method_check CHECK (method_type IN ('cash','card','external')) "); DB::statement(" ALTER TABLE payments.refunds ADD CONSTRAINT payments_refunds_status_check CHECK (status IN ('pending','completed','failed')) "); DB::statement(" ALTER TABLE payments.refunds ADD CONSTRAINT payments_refunds_amount_check CHECK (amount > 0) "); DB::statement(<<<'SQL' CREATE OR REPLACE FUNCTION sales.prevent_posted_return_line_mutation() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE return_status text; BEGIN SELECT status INTO return_status FROM sales.returns WHERE id = OLD.sale_return_id; IF return_status = 'posted' THEN RAISE EXCEPTION 'posted sales.return_lines are immutable'; END IF; RETURN NEW; END; $$ SQL); DB::statement(<<<'SQL' CREATE TRIGGER sales_return_lines_guard_update BEFORE UPDATE ON sales.return_lines FOR EACH ROW EXECUTE FUNCTION sales.prevent_posted_return_line_mutation() SQL); DB::statement(<<<'SQL' CREATE TRIGGER sales_return_lines_guard_delete BEFORE DELETE ON sales.return_lines FOR EACH ROW EXECUTE FUNCTION sales.prevent_posted_return_line_mutation() SQL); } public function down(): void { DB::statement('DROP TRIGGER IF EXISTS sales_return_lines_guard_delete ON sales.return_lines'); DB::statement('DROP TRIGGER IF EXISTS sales_return_lines_guard_update ON sales.return_lines'); DB::statement('DROP FUNCTION IF EXISTS sales.prevent_posted_return_line_mutation()'); Schema::dropIfExists('payments.refunds'); Schema::dropIfExists('sales.return_lines'); Schema::dropIfExists('sales.returns'); } }; ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_04_093000_create_customer_value_credit_foundation.php ================================================================ bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('customer_id'); $table->bigInteger('points_balance')->default(0); $table->bigInteger('lifetime_earned')->default(0); $table->bigInteger('lifetime_redeemed')->default(0); $table->timestampTz('last_activity_at')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('customer_id')->references('id')->on('crm.customers')->restrictOnDelete(); $table->unique(['tenant_id','customer_id']); }); Schema::create('loyalty.entries', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('account_id'); $table->string('entry_type',40); $table->bigInteger('points_delta'); $table->string('source_type',80); $table->string('source_public_id',64); $table->string('source_line_key',120)->nullable(); $table->timestampTz('occurred_at'); $table->timestampTz('expires_at')->nullable(); $table->unsignedBigInteger('created_by_user_id')->nullable(); $table->jsonb('metadata')->nullable(); $table->timestampTz('created_at')->useCurrent(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('account_id')->references('id')->on('loyalty.accounts')->restrictOnDelete(); $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete(); $table->unique( ['tenant_id','account_id','source_type','source_public_id','source_line_key'], 'loyalty_entries_source_uq' ); $table->index(['tenant_id','account_id','occurred_at']); $table->index(['tenant_id','expires_at']); }); DB::statement(" ALTER TABLE loyalty.entries ADD CONSTRAINT loyalty_entries_type_check CHECK (entry_type IN ('earn','redeem','expire','reversal','adjustment')) "); DB::statement(" ALTER TABLE loyalty.entries ADD CONSTRAINT loyalty_entries_nonzero_check CHECK (points_delta <> 0) "); Schema::create('wallet.accounts', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('customer_id'); $table->string('currency_code',3)->default('EGP'); $table->decimal('balance_amount',18,6)->default(0); $table->timestampTz('last_activity_at')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('customer_id')->references('id')->on('crm.customers')->restrictOnDelete(); $table->unique(['tenant_id','customer_id','currency_code']); }); Schema::create('wallet.entries', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('account_id'); $table->string('entry_type',40); $table->decimal('amount_delta',18,6); $table->string('source_type',80); $table->string('source_public_id',64); $table->string('idempotency_key',140); $table->timestampTz('occurred_at'); $table->timestampTz('expires_at')->nullable(); $table->unsignedBigInteger('created_by_user_id')->nullable(); $table->jsonb('metadata')->nullable(); $table->timestampTz('created_at')->useCurrent(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('account_id')->references('id')->on('wallet.accounts')->restrictOnDelete(); $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete(); $table->unique(['tenant_id','idempotency_key']); $table->index(['tenant_id','account_id','occurred_at']); $table->index(['tenant_id','expires_at']); }); DB::statement(" ALTER TABLE wallet.entries ADD CONSTRAINT wallet_entries_type_check CHECK (entry_type IN ('credit','debit','cashback','refund','expire','reversal','adjustment')) "); DB::statement(" ALTER TABLE wallet.entries ADD CONSTRAINT wallet_entries_nonzero_check CHECK (amount_delta <> 0) "); Schema::create('credit.accounts', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('customer_id'); $table->string('currency_code',3)->default('EGP'); $table->decimal('credit_limit_amount',18,6)->default(0); $table->decimal('outstanding_amount',18,6)->default(0); $table->string('status',30)->default('active'); $table->timestampTz('last_activity_at')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('customer_id')->references('id')->on('crm.customers')->restrictOnDelete(); $table->unique(['tenant_id','customer_id','currency_code']); }); DB::statement(" ALTER TABLE credit.accounts ADD CONSTRAINT credit_accounts_status_check CHECK (status IN ('active','suspended','closed')) "); DB::statement(" ALTER TABLE credit.accounts ADD CONSTRAINT credit_accounts_amount_check CHECK ( credit_limit_amount >= 0 AND outstanding_amount >= 0 AND outstanding_amount <= credit_limit_amount ) "); Schema::create('credit.entries', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('account_id'); $table->string('entry_type',40); $table->decimal('amount_delta',18,6); $table->string('source_type',80); $table->string('source_public_id',64); $table->string('idempotency_key',140); $table->timestampTz('occurred_at'); $table->unsignedBigInteger('created_by_user_id')->nullable(); $table->jsonb('metadata')->nullable(); $table->timestampTz('created_at')->useCurrent(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('account_id')->references('id')->on('credit.accounts')->restrictOnDelete(); $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete(); $table->unique(['tenant_id','idempotency_key']); $table->index(['tenant_id','account_id','occurred_at']); }); DB::statement(" ALTER TABLE credit.entries ADD CONSTRAINT credit_entries_type_check CHECK (entry_type IN ('charge','payment','return_credit','reversal','adjustment')) "); DB::statement(" ALTER TABLE credit.entries ADD CONSTRAINT credit_entries_nonzero_check CHECK (amount_delta <> 0) "); /* * Credit is AR, not a fake payment. Extend the sale state so a sale can be * operationally completed while remaining on-account. */ DB::statement('ALTER TABLE sales.sales DROP CONSTRAINT IF EXISTS sales_sales_payment_status_check'); DB::statement(" ALTER TABLE sales.sales ADD CONSTRAINT sales_sales_payment_status_check CHECK ( payment_status IN ( 'unpaid','partial','paid','unknown', 'refunded','partially_refunded','on_account','partially_on_account' ) ) "); } public function down(): void { DB::statement('ALTER TABLE sales.sales DROP CONSTRAINT IF EXISTS sales_sales_payment_status_check'); DB::statement(" ALTER TABLE sales.sales ADD CONSTRAINT sales_sales_payment_status_check CHECK (payment_status IN ('unpaid','partial','paid','unknown','refunded','partially_refunded')) "); Schema::dropIfExists('credit.entries'); Schema::dropIfExists('credit.accounts'); Schema::dropIfExists('wallet.entries'); Schema::dropIfExists('wallet.accounts'); Schema::dropIfExists('loyalty.entries'); Schema::dropIfExists('loyalty.accounts'); } }; ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_04_093100_extend_payment_methods_for_wallet.php ================================================================