================================================================ FILE: .\app\Modules\Payments\Application\Settlement\CollectPaymentAction.php ================================================================ where('tenant_id',$sale->tenant_id) ->where('idempotency_key',$data['idempotency_key']) ->first(); if ($existing) { if ($existing->sale_id !== $sale->id) { throw new ConflictHttpException('Idempotency key already belongs to another sale.'); } return $existing->load('attempts'); } return DB::transaction(function () use ($sale,$data,$actorUserId,$request) { $lockedSale=Sale::query() ->whereKey($sale->id) ->lockForUpdate() ->firstOrFail(); $existing=Payment::query() ->where('tenant_id',$lockedSale->tenant_id) ->where('idempotency_key',$data['idempotency_key']) ->lockForUpdate() ->first(); if ($existing) { if ($existing->sale_id !== $lockedSale->id) { throw new ConflictHttpException('Idempotency key already belongs to another sale.'); } return $existing->load('attempts'); } if (in_array($lockedSale->status,['voided','reversed'],true)) { throw new ConflictHttpException('Sale cannot accept payments.'); } if ($lockedSale->payment_status === 'paid') { throw new ConflictHttpException('Sale is already fully paid.'); } $captured=$this->statusUpdater->capturedAmount($lockedSale); $remaining=bcsub((string)$lockedSale->total_amount,$captured,6); $amount=(string)$data['amount']; if (bccomp($amount,$remaining,6) > 0) { throw ValidationException::withMessages([ 'amount'=>['Payment amount cannot exceed the remaining sale balance.'], ]); } $method=$data['method_type']; $outcome=$method === 'cash' ? 'succeeded' : ($data['outcome'] ?? null); if ($method !== 'cash' && $outcome === null) { throw ValidationException::withMessages([ 'outcome'=>['Card and external payments require succeeded, failed, or unknown outcome.'], ]); } $status=match($outcome) { 'succeeded'=>'captured', 'failed'=>'failed', 'unknown'=>'unknown', default=>'pending', }; $payment=Payment::query()->create([ 'tenant_id'=>$lockedSale->tenant_id, 'sale_id'=>$lockedSale->id, 'method_type'=>$method, 'amount'=>$amount, 'currency_code'=>$lockedSale->currency_code, 'status'=>$status, 'idempotency_key'=>$data['idempotency_key'], 'provider_reference'=>$data['provider_reference']??null, 'created_by_user_id'=>$actorUserId, 'resolved_at'=>in_array($status,['captured','failed'],true) ? now() : null, 'metadata'=>$data['metadata']??null, ]); PaymentAttempt::query()->create([ 'tenant_id'=>$lockedSale->tenant_id, 'payment_id'=>$payment->id, 'operation_key'=>$data['idempotency_key'], 'attempt_type'=>'collect', 'status'=>match($status) { 'captured'=>'succeeded', 'failed'=>'failed', 'unknown'=>'unknown', default=>'started', }, 'provider_reference'=>$data['provider_reference']??null, 'request_snapshot'=>[ 'method_type'=>$method, 'amount'=>$amount, ], 'response_snapshot'=>[ 'outcome'=>$outcome, ], 'started_at'=>now(), 'completed_at'=>$status==='pending' ? null : now(), ]); $saleAfter=$this->statusUpdater->refresh($lockedSale); $this->auditRecorder->record( 'payments.payment.collected', $lockedSale->tenant_id, $actorUserId, 'payments.payment', $payment->public_id, after:[ 'sale_id'=>$lockedSale->public_id, 'method_type'=>$payment->method_type, 'amount'=>$payment->amount, 'status'=>$payment->status, 'sale_payment_status'=>$saleAfter->payment_status, ], request:$request, ); return $payment->fresh('attempts'); }); } } ================================================================ FILE: .\app\Modules\Payments\Application\Settlement\ResolvePaymentAction.php ================================================================ where('tenant_id',$payment->tenant_id) ->where('operation_key',$data['resolution_key']) ->first(); if ($existingAttempt) { if ($existingAttempt->payment_id !== $payment->id) { throw new ConflictHttpException('Resolution key already belongs to another payment.'); } return $payment->fresh('attempts'); } return DB::transaction(function () use ($payment,$data,$actorUserId,$request) { $locked=Payment::query()->whereKey($payment->id)->lockForUpdate()->firstOrFail(); $existingAttempt=PaymentAttempt::query() ->where('tenant_id',$locked->tenant_id) ->where('operation_key',$data['resolution_key']) ->lockForUpdate() ->first(); if ($existingAttempt) { if ($existingAttempt->payment_id !== $locked->id) { throw new ConflictHttpException('Resolution key already belongs to another payment.'); } return $locked->fresh('attempts'); } if ($locked->status !== 'unknown') { throw new ConflictHttpException('Only UNKNOWN payments can be resolved.'); } $before=$locked->status; $locked->status=$data['outcome']==='succeeded' ? 'captured' : 'failed'; $locked->provider_reference=$data['provider_reference'] ?? $locked->provider_reference; $locked->resolved_at=now(); if (!empty($data['metadata'])) { $locked->metadata=array_merge($locked->metadata ?? [],$data['metadata']); } $locked->save(); PaymentAttempt::query()->create([ 'tenant_id'=>$locked->tenant_id, 'payment_id'=>$locked->id, 'operation_key'=>$data['resolution_key'], 'attempt_type'=>'resolve', 'status'=>$data['outcome']==='succeeded' ? 'succeeded' : 'failed', 'provider_reference'=>$data['provider_reference']??$locked->provider_reference, 'request_snapshot'=>[ 'outcome'=>$data['outcome'], ], 'response_snapshot'=>[ 'payment_status'=>$locked->status, ], 'started_at'=>now(), 'completed_at'=>now(), ]); $sale=Sale::query()->whereKey($locked->sale_id)->lockForUpdate()->firstOrFail(); $saleAfter=$this->statusUpdater->refresh($sale); $this->auditRecorder->record( 'payments.payment.resolved', $locked->tenant_id, $actorUserId, 'payments.payment', $locked->public_id, before:['status'=>$before], after:[ 'status'=>$locked->status, 'sale_id'=>$sale->public_id, 'sale_payment_status'=>$saleAfter->payment_status, ], request:$request, ); return $locked->fresh('attempts'); }); } } ================================================================ FILE: .\app\Modules\Payments\Application\Settlement\SalePaymentStatusUpdater.php ================================================================ where('tenant_id',$sale->tenant_id) ->where('sale_id',$sale->id) ->where('status','captured') ->orderBy('id') ->pluck('amount') ->each(function ($amount) use (&$captured) { $captured=bcadd($captured,(string)$amount,6); }); $hasUnknown=Payment::query() ->where('tenant_id',$sale->tenant_id) ->where('sale_id',$sale->id) ->where('status','unknown') ->exists(); if ($hasUnknown) { $paymentStatus='unknown'; $saleStatus='pending_payment'; } elseif (bccomp($captured,(string)$sale->total_amount,6) >= 0) { $paymentStatus='paid'; $saleStatus='completed'; } elseif (bccomp($captured,'0.000000',6) > 0) { $paymentStatus='partial'; $saleStatus='pending_payment'; } else { $paymentStatus='unpaid'; $saleStatus='pending_payment'; } $sale->payment_status=$paymentStatus; $sale->status=$saleStatus; $sale->save(); return $sale->fresh(); } public function capturedAmount(Sale $sale): string { $total='0.000000'; Payment::query() ->where('tenant_id',$sale->tenant_id) ->where('sale_id',$sale->id) ->where('status','captured') ->pluck('amount') ->each(function ($amount) use (&$total) { $total=bcadd($total,(string)$amount,6); }); return $total; } } ================================================================ FILE: .\app\Modules\Payments\Http\Controllers\SalePaymentController.php ================================================================ where('tenant_id',$tenantContext->tenantId()) ->where('public_id',$salePublicId) ->firstOrFail(); return response()->json([ 'data'=>PaymentResource::collection( Payment::query() ->with('attempts') ->where('tenant_id',$tenantContext->tenantId()) ->where('sale_id',$sale->id) ->orderBy('id') ->get() ), ]); } public function store( string $salePublicId, CollectPaymentRequest $request, TenantContext $tenantContext, CollectPaymentAction $action, ): PaymentResource { $sale=Sale::query() ->where('tenant_id',$tenantContext->tenantId()) ->where('public_id',$salePublicId) ->firstOrFail(); $payment=$action->execute( sale:$sale, data:$request->validated(), actorUserId:$request->user()->id, request:$request, ); return new PaymentResource($payment); } } ================================================================ FILE: .\app\Modules\Payments\Http\Controllers\PaymentResolutionController.php ================================================================ where('tenant_id',$tenantContext->tenantId()) ->where('public_id',$paymentPublicId) ->firstOrFail(); return new PaymentResource( $action->execute( payment:$payment, data:$request->validated(), actorUserId:$request->user()->id, request:$request, ) ); } } ================================================================ FILE: .\app\Modules\Sales\Application\Checkout\CheckoutCartAction.php ================================================================ where('tenant_id',$cart->tenant_id) ->where('idempotency_key',$idempotencyKey) ->first(); if ($existing?->sale_id) { return Sale::query()->with('lines')->findOrFail($existing->sale_id); } return DB::transaction(function () use ($cart,$expectedVersion,$idempotencyKey,$actorUserId,$request) { $locked=Cart::query() ->with('lines') ->whereKey($cart->id) ->lockForUpdate() ->firstOrFail(); $existing=CheckoutCommand::query() ->where('tenant_id',$locked->tenant_id) ->where('idempotency_key',$idempotencyKey) ->lockForUpdate() ->first(); if ($existing?->sale_id) { return Sale::query()->with('lines')->findOrFail($existing->sale_id); } if ($locked->status === 'converted') { $sale=Sale::query() ->with('lines') ->where('tenant_id',$locked->tenant_id) ->where('cart_id',$locked->id) ->first(); if ($sale) return $sale; throw new ConflictHttpException('Cart was already converted.'); } if ($locked->status !== 'open') { throw new ConflictHttpException('Cart is not open for checkout.'); } if ($locked->version !== $expectedVersion) { throw new ConflictHttpException('Cart version conflict.'); } if ($locked->lines->isEmpty()) { throw ValidationException::withMessages([ 'cart'=>['Cannot checkout an empty cart.'], ]); } if (bccomp((string)$locked->total_amount,'0.000000',6) < 0) { throw ValidationException::withMessages([ 'cart'=>['Cart total is invalid.'], ]); } $command=$existing ?? CheckoutCommand::query()->create([ 'tenant_id'=>$locked->tenant_id, 'cart_id'=>$locked->id, 'idempotency_key'=>$idempotencyKey, 'status'=>'started', 'created_at'=>now(), ]); $occurredAt=CarbonImmutable::now('UTC'); $number=$this->numberGenerator->next( $locked->tenant_id, $locked->branch_id, $occurredAt, ); $sale=Sale::query()->create([ 'tenant_id'=>$locked->tenant_id, 'company_id'=>$locked->company_id, 'branch_id'=>$locked->branch_id, 'register_id'=>$locked->register_id, 'cart_id'=>$locked->id, 'created_by_user_id'=>$actorUserId, 'sale_number'=>$number['sale_number'], 'business_date'=>$number['business_date'], 'occurred_at'=>$occurredAt, 'currency_code'=>$locked->currency_code, 'customer_public_id'=>$locked->customer_public_id, 'customer_group_key'=>$locked->customer_group_key, 'subtotal_amount'=>$locked->subtotal_amount, 'discount_amount'=>$locked->discount_amount, 'total_amount'=>$locked->total_amount, 'status'=>'pending_payment', 'payment_status'=>'unpaid', 'pricing_snapshot'=>[ 'source'=>'server_cart', 'cart_version'=>$locked->version, ], 'promotion_snapshot'=>[ 'line_promotions'=>$locked->lines ->filter(fn($line)=>!empty($line->promotion_snapshot)) ->map(fn($line)=>[ 'cart_line_id'=>$line->public_id, 'promotions'=>$line->promotion_snapshot, ])->values()->all(), ], ]); foreach ($locked->lines as $line) { $variant=Variant::query() ->where('tenant_id',$locked->tenant_id) ->whereKey($line->variant_id) ->firstOrFail(); SaleLine::query()->create([ 'tenant_id'=>$locked->tenant_id, 'sale_id'=>$sale->id, 'variant_id'=>$line->variant_id, 'variant_unit_id'=>$line->variant_unit_id, 'sku_snapshot'=>$variant->sku, 'name_snapshot'=>$variant->name, 'quantity'=>$line->quantity, 'unit_price_amount'=>$line->unit_price_amount, 'gross_amount'=>$line->gross_amount, 'discount_amount'=>$line->discount_amount, 'net_amount'=>$line->net_amount, 'price_snapshot'=>$line->price_snapshot, 'promotion_snapshot'=>$line->promotion_snapshot, 'metadata'=>$line->metadata, 'created_at'=>$occurredAt, ]); } $locked->status='converted'; $locked->version++; $locked->save(); $command->sale_id=$sale->id; $command->status='completed'; $command->response_snapshot=[ 'sale_id'=>$sale->public_id, 'sale_number'=>$sale->sale_number, 'status'=>$sale->status, 'payment_status'=>$sale->payment_status, 'total_amount'=>$sale->total_amount, ]; $command->completed_at=now(); $command->save(); $this->auditRecorder->record( 'sales.checkout.completed', $locked->tenant_id, $actorUserId, 'sales.sale', $sale->public_id, after:[ 'cart_id'=>$locked->public_id, 'sale_number'=>$sale->sale_number, 'total_amount'=>$sale->total_amount, 'status'=>$sale->status, 'payment_status'=>$sale->payment_status, ], request:$request, ); return $sale->fresh('lines'); }); } } ================================================================ FILE: .\app\Modules\Sales\Http\Requests\CheckoutCartRequest.php ================================================================ ['required','integer','min:1'], 'idempotency_key'=>['required','string','max:120'], ]; } } ================================================================ FILE: .\app\Modules\Sales\Domain\Models\Cart.php ================================================================ 'decimal:6', 'discount_amount'=>'decimal:6', 'total_amount'=>'decimal:6', 'version'=>'integer', 'expires_at'=>'immutable_datetime', 'metadata'=>'array', ]; } public function lines(): HasMany { return $this->hasMany(CartLine::class, 'cart_id'); } } ================================================================ FILE: .\app\Modules\Sales\Domain\Models\Sale.php ================================================================ 'date:Y-m-d', 'occurred_at'=>'immutable_datetime', 'subtotal_amount'=>'decimal:6', 'discount_amount'=>'decimal:6', 'total_amount'=>'decimal:6', 'pricing_snapshot'=>'array', 'promotion_snapshot'=>'array', 'metadata'=>'array', ]; } public function lines(): HasMany { return $this->hasMany(SaleLine::class, 'sale_id'); } } ================================================================ FILE: .\app\Modules\Sales\Domain\Models\CheckoutCommand.php ================================================================ 'array', 'created_at'=>'immutable_datetime', 'completed_at'=>'immutable_datetime', ]; } }