================================================================ 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\SaleLine.php ================================================================ 'decimal:6', 'unit_price_amount'=>'decimal:6', 'gross_amount'=>'decimal:6', 'discount_amount'=>'decimal:6', 'net_amount'=>'decimal:6', 'price_snapshot'=>'array', 'promotion_snapshot'=>'array', 'metadata'=>'array', 'created_at'=>'immutable_datetime', ]; } public function save(array $options = []): bool { if ($this->exists) { throw new LogicException('Sale lines are immutable.'); } return parent::save($options); } public function delete(): ?bool { throw new LogicException('Sale lines are immutable.'); } } ================================================================ 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\Payments\Domain\Models\Payment.php ================================================================ 'decimal:6', 'resolved_at'=>'immutable_datetime', 'metadata'=>'array', ]; } public function attempts(): HasMany { return $this->hasMany(PaymentAttempt::class, 'payment_id'); } } ================================================================ 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.'); } /* * Recovery behavior: * an old captured cash payment may predate automatic posting. * A safe retry can complete the missing shift posting exactly once. */ if ($existing->status==='captured' && $existing->method_type==='cash') { $saleFresh=Sale::query()->findOrFail($existing->sale_id); $this->autoPostCashShift->execute($existing,$saleFresh,$actorUserId); } 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.'); } if ($existing->status==='captured' && $existing->method_type==='cash') { $this->autoPostCashShift->execute($existing,$lockedSale,$actorUserId); } 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); /* * Same database transaction: * if a registered cash sale has no open shift, this throws and the * payment capture rolls back. No captured cash can escape the shift ledger. */ if ($payment->status==='captured' && $payment->method_type==='cash') { $this->autoPostCashShift->execute($payment,$saleAfter,$actorUserId); } $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.'); } $fresh=$payment->fresh(); if ($fresh->status==='captured' && $fresh->method_type==='cash') { $sale=Sale::query()->findOrFail($fresh->sale_id); $this->autoPostCashShift->execute($fresh,$sale,$actorUserId); } return $fresh->load('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.'); } $sale=Sale::query()->whereKey($locked->sale_id)->lockForUpdate()->firstOrFail(); if ($locked->status==='captured' && $locked->method_type==='cash') { $this->autoPostCashShift->execute($locked,$sale,$actorUserId); } 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); if ($locked->status==='captured' && $locked->method_type==='cash') { $this->autoPostCashShift->execute($locked,$saleAfter,$actorUserId); } $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\Cash\Application\Shifts\PostCashRefundToShiftAction.php ================================================================ where('tenant_id',$payment->tenant_id) ->where('idempotency_key',$data['idempotency_key']) ->first(); if($existing) return $existing; return DB::transaction(function () use ($payment,$data,$actorUserId,$request) { $lockedPayment=Payment::query() ->where('tenant_id',$payment->tenant_id) ->whereKey($payment->id) ->lockForUpdate() ->firstOrFail(); if($lockedPayment->method_type!=='cash') { throw ValidationException::withMessages([ 'payment'=>['Only cash payments can be refunded from a cash shift.'], ]); } if($lockedPayment->status!=='captured') { throw new ConflictHttpException('Only captured cash payments can be refunded.'); } $existing=CashSaleRefund::query() ->where('tenant_id',$lockedPayment->tenant_id) ->where('idempotency_key',$data['idempotency_key']) ->lockForUpdate() ->first(); if($existing) return $existing; $register=Register::query() ->where('tenant_id',$lockedPayment->tenant_id) ->where('public_id',$data['register_id']) ->where('status','active') ->first(); if(!$register) { throw ValidationException::withMessages([ 'register_id'=>['Active register was not found in this tenant.'], ]); } $shift=RegisterShift::query() ->where('tenant_id',$lockedPayment->tenant_id) ->where('register_id',$register->id) ->where('status','open') ->lockForUpdate() ->first(); if(!$shift) { throw new ConflictHttpException('Register has no open shift.'); } $refundBalance=CashPaymentRefundBalance::query() ->where('tenant_id',$lockedPayment->tenant_id) ->where('payment_id',$lockedPayment->id) ->lockForUpdate() ->first(); if(!$refundBalance) { $refundBalance=CashPaymentRefundBalance::query()->create([ 'tenant_id'=>$lockedPayment->tenant_id, 'payment_id'=>$lockedPayment->id, 'refunded_amount'=>'0.000000', ]); } $amount=(string)$data['amount']; $afterRefunded=bcadd((string)$refundBalance->refunded_amount,$amount,6); if(bccomp($afterRefunded,(string)$lockedPayment->amount,6)>0) { throw ValidationException::withMessages([ 'amount'=>['Refund exceeds remaining refundable cash payment amount.'], ]); } if(bccomp($amount,(string)$shift->expected_cash_amount,6)>0) { throw ValidationException::withMessages([ 'amount'=>['Refund exceeds shift expected cash.'], ]); } $at=CarbonImmutable::now('UTC'); $refund=CashSaleRefund::query()->create([ 'tenant_id'=>$lockedPayment->tenant_id, 'payment_id'=>$lockedPayment->id, 'shift_id'=>$shift->id, 'amount'=>$amount, 'reason'=>$data['reason'], 'idempotency_key'=>$data['idempotency_key'], 'posted_at'=>$at, 'posted_by_user_id'=>$actorUserId, 'metadata'=>$data['metadata']??null, 'created_at'=>now(), ]); $movement=$this->cashLedger->post( $lockedPayment->tenant_id, $shift->cash_account_id, 'shift_cash_out', bcmul($amount,'-1',6), 'sale_cash_refund', $refund->public_id, $at, $shift->business_date->format('Y-m-d'), $actorUserId, [ 'payment_id'=>$lockedPayment->public_id, 'refund_id'=>$refund->public_id, 'register_id'=>$register->public_id, 'reason'=>$data['reason'], ], ); RegisterShiftEntry::query()->create([ 'tenant_id'=>$lockedPayment->tenant_id, 'shift_id'=>$shift->id, 'entry_type'=>'refund_cash', 'amount_delta'=>bcmul($amount,'-1',6), 'source_type'=>'sale_cash_refund', 'source_public_id'=>$refund->public_id, 'occurred_at'=>$at, 'created_by_user_id'=>$actorUserId, 'metadata'=>[ 'payment_id'=>$lockedPayment->public_id, 'refund_id'=>$refund->public_id, 'cash_movement_id'=>$movement->public_id, 'reason'=>$data['reason'], ], 'created_at'=>now(), ]); $shift->expected_cash_amount=bcsub((string)$shift->expected_cash_amount,$amount,6); $shift->save(); $refundBalance->refunded_amount=$afterRefunded; $refundBalance->last_refund_at=$at; $refundBalance->save(); $this->auditRecorder->record( 'cash.shift.refund.posted', $lockedPayment->tenant_id, $actorUserId, 'cash.sale_refund', $refund->public_id, after:[ 'payment_id'=>$lockedPayment->public_id, 'register_id'=>$register->public_id, 'amount'=>$amount, 'refunded_total'=>$afterRefunded, 'reason'=>$data['reason'], ], request:$request, ); return $refund; }); } } ================================================================ FILE: .\app\Modules\Cash\Domain\Models\CashSaleRefund.php ================================================================ 'decimal:6', 'posted_at'=>'immutable_datetime', 'metadata'=>'array', 'created_at'=>'immutable_datetime', ]; } } ================================================================ FILE: .\app\Modules\Cash\Domain\Models\CashPaymentRefundBalance.php ================================================================ 'decimal:6', 'last_refund_at'=>'immutable_datetime', ]; } } ================================================================ FILE: .\app\Modules\Inventory\Application\Ledger\InventoryLedgerService.php ================================================================ freezeGuard->assertNotFrozen($tenantId,$warehouseId); } $lot=null; if ($lotId!==null) { $lot=InventoryLot::query() ->where('tenant_id',$tenantId) ->where('variant_id',$variantId) ->whereKey($lotId) ->first(); if (!$lot) { throw ValidationException::withMessages([ 'lot_id'=>['Lot was not found for this tenant and variant.'], ]); } if ($lot->variant_unit_id!==null && $variantUnitId!==$lot->variant_unit_id) { throw ValidationException::withMessages([ 'variant_unit_id'=>['Movement unit does not match lot unit.'], ]); } } $lockKey=crc32("inventory:$tenantId:$warehouseId:$variantId:".($variantUnitId ?? 0)); DB::select('SELECT pg_advisory_xact_lock(?)',[$lockKey]); if ($lotId!==null) { $lotLock=crc32("inventory-lot:$tenantId:$warehouseId:$lotId"); DB::select('SELECT pg_advisory_xact_lock(?)',[$lotLock]); } $existing=InventoryMovement::query() ->where('tenant_id',$tenantId) ->where('source_type',$sourceType) ->where('source_public_id',$sourcePublicId) ->when( $sourceLinePublicId===null, fn($q)=>$q->whereNull('source_line_public_id'), fn($q)=>$q->where('source_line_public_id',$sourceLinePublicId) ) ->first(); if ($existing) return $existing; $balance=InventoryBalance::query() ->where('tenant_id',$tenantId) ->where('warehouse_id',$warehouseId) ->where('variant_id',$variantId) ->when( $variantUnitId===null, fn($q)=>$q->whereNull('variant_unit_id'), fn($q)=>$q->where('variant_unit_id',$variantUnitId) ) ->lockForUpdate() ->first(); if ($balance===null) { $balance=InventoryBalance::query()->create([ 'tenant_id'=>$tenantId, 'warehouse_id'=>$warehouseId, 'variant_id'=>$variantId, 'variant_unit_id'=>$variantUnitId, 'quantity_on_hand'=>'0.000000', ]); } $after=bcadd((string)$balance->quantity_on_hand,$quantityDelta,6); if (bccomp($after,'0.000000',6)<0) { throw ValidationException::withMessages([ 'quantity'=>['Insufficient stock. Negative inventory is disabled.'], ]); } $lotBalance=null; $lotAfter=null; if ($lotId!==null) { $lotBalance=InventoryLotBalance::query() ->where('tenant_id',$tenantId) ->where('warehouse_id',$warehouseId) ->where('lot_id',$lotId) ->lockForUpdate() ->first(); if ($lotBalance===null) { $lotBalance=InventoryLotBalance::query()->create([ 'tenant_id'=>$tenantId, 'warehouse_id'=>$warehouseId, 'lot_id'=>$lotId, 'quantity_on_hand'=>'0.000000', ]); } $lotAfter=bcadd((string)$lotBalance->quantity_on_hand,$quantityDelta,6); if (bccomp($lotAfter,'0.000000',6)<0) { throw ValidationException::withMessages([ 'quantity'=>['Insufficient stock in selected lot.'], ]); } } $valuation=$this->valuation->apply( tenantId:$tenantId, warehouseId:$warehouseId, variantId:$variantId, variantUnitId:$variantUnitId, quantityDelta:$quantityDelta, incomingUnitCost:$unitCostAmount, occurredAt:$occurredAt, ); $movement=InventoryMovement::query()->create([ 'tenant_id'=>$tenantId, 'warehouse_id'=>$warehouseId, 'variant_id'=>$variantId, 'variant_unit_id'=>$variantUnitId, 'lot_id'=>$lotId, 'movement_type'=>$movementType, 'quantity_delta'=>$quantityDelta, 'unit_cost_amount'=>$valuation['unit_cost_amount'], 'value_delta'=>$valuation['value_delta'], 'source_type'=>$sourceType, 'source_public_id'=>$sourcePublicId, 'source_line_public_id'=>$sourceLinePublicId, 'occurred_at'=>$occurredAt, 'business_date'=>$businessDate, 'created_by_user_id'=>$actorUserId, 'metadata'=>$metadata, 'created_at'=>now(), ]); $balance->quantity_on_hand=$after; $balance->last_movement_at=$occurredAt; $balance->save(); if ($lotBalance!==null) { $lotBalance->quantity_on_hand=$lotAfter; $lotBalance->last_movement_at=$occurredAt; $lotBalance->save(); } return $movement; }); } } ================================================================ FILE: .\app\Modules\Inventory\Domain\Models\InventoryMovement.php ================================================================ 'decimal:6', 'unit_cost_amount'=>'decimal:6', 'value_delta'=>'decimal:6', 'occurred_at'=>'immutable_datetime', 'business_date'=>'date:Y-m-d', 'metadata'=>'array', 'created_at'=>'immutable_datetime', ]; } public function save(array $options = []): bool { if ($this->exists) { throw new LogicException('Inventory movements are immutable.'); } return parent::save($options); } public function delete(): ?bool { throw new LogicException('Inventory movements are immutable.'); } } ================================================================ FILE: .\app\Modules\Inventory\Domain\Models\InventoryBalance.php ================================================================ 'decimal:6', 'last_movement_at'=>'immutable_datetime', ]; } } ================================================================ FILE: .\app\Modules\Sales\Application\Permissions\SalesPermissions.php ================================================================ bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('company_id')->nullable(); $table->unsignedBigInteger('branch_id')->nullable(); $table->unsignedBigInteger('register_id')->nullable(); $table->unsignedBigInteger('created_by_user_id'); $table->string('customer_public_id', 64)->nullable(); $table->string('customer_group_key', 100)->nullable(); $table->string('currency_code', 3)->default('EGP'); $table->string('status', 30)->default('open'); $table->decimal('subtotal_amount', 18, 6)->default(0); $table->decimal('discount_amount', 18, 6)->default(0); $table->decimal('total_amount', 18, 6)->default(0); $table->unsignedBigInteger('version')->default(1); $table->timestampTz('expires_at')->nullable(); $table->jsonb('metadata')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('company_id')->references('id')->on('core.companies')->restrictOnDelete(); $table->foreign('branch_id')->references('id')->on('core.branches')->restrictOnDelete(); $table->foreign('register_id')->references('id')->on('core.registers')->restrictOnDelete(); $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete(); $table->index(['tenant_id', 'status', 'updated_at']); $table->index(['tenant_id', 'branch_id', 'status']); $table->index(['tenant_id', 'created_by_user_id', 'status']); }); DB::statement(" ALTER TABLE sales.carts ADD CONSTRAINT sales_carts_status_check CHECK (status IN ('open','converted','abandoned','cancelled')) "); DB::statement(" ALTER TABLE sales.carts ADD CONSTRAINT sales_carts_amounts_check CHECK ( subtotal_amount >= 0 AND discount_amount >= 0 AND total_amount >= 0 AND discount_amount <= subtotal_amount AND total_amount = subtotal_amount - discount_amount ) "); Schema::create('sales.cart_lines', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('cart_id'); $table->unsignedBigInteger('variant_id'); $table->unsignedBigInteger('variant_unit_id')->nullable(); $table->decimal('quantity', 18, 6); $table->decimal('unit_price_amount', 18, 6); $table->decimal('gross_amount', 18, 6); $table->decimal('discount_amount', 18, 6)->default(0); $table->decimal('net_amount', 18, 6); $table->jsonb('price_snapshot'); $table->jsonb('promotion_snapshot')->nullable(); $table->jsonb('metadata')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('cart_id')->references('id')->on('sales.carts')->restrictOnDelete(); $table->foreign('variant_id')->references('id')->on('catalog.variants')->restrictOnDelete(); $table->foreign('variant_unit_id')->references('id')->on('catalog.variant_units')->restrictOnDelete(); $table->index(['tenant_id', 'cart_id']); $table->index(['tenant_id', 'variant_id']); }); DB::statement(" ALTER TABLE sales.cart_lines ADD CONSTRAINT sales_cart_lines_amounts_check CHECK ( quantity > 0 AND unit_price_amount >= 0 AND gross_amount >= 0 AND discount_amount >= 0 AND net_amount >= 0 AND discount_amount <= gross_amount AND net_amount = gross_amount - discount_amount ) "); Schema::create('sales.cart_operations', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('cart_id'); $table->string('client_operation_id', 100); $table->string('operation_type', 40); $table->jsonb('response_snapshot')->nullable(); $table->timestampTz('created_at')->useCurrent(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('cart_id')->references('id')->on('sales.carts')->restrictOnDelete(); $table->unique(['tenant_id', 'client_operation_id']); $table->index(['tenant_id', 'cart_id', 'created_at']); }); } public function down(): void { Schema::dropIfExists('sales.cart_operations'); Schema::dropIfExists('sales.cart_lines'); Schema::dropIfExists('sales.carts'); } }; ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_01_103000_create_sales_checkout_foundation_tables.php ================================================================ bigIncrements('id'); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('branch_id')->nullable(); $table->date('business_date'); $table->unsignedBigInteger('last_value')->default(0); $table->timestampsTz(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('branch_id')->references('id')->on('core.branches')->restrictOnDelete(); $table->unique(['tenant_id','branch_id','business_date']); }); Schema::create('sales.sales', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('company_id')->nullable(); $table->unsignedBigInteger('branch_id')->nullable(); $table->unsignedBigInteger('register_id')->nullable(); $table->unsignedBigInteger('cart_id'); $table->unsignedBigInteger('created_by_user_id'); $table->string('sale_number', 80); $table->date('business_date'); $table->timestampTz('occurred_at'); $table->string('currency_code', 3)->default('EGP'); $table->string('customer_public_id', 64)->nullable(); $table->string('customer_group_key', 100)->nullable(); $table->decimal('subtotal_amount', 18, 6); $table->decimal('discount_amount', 18, 6); $table->decimal('total_amount', 18, 6); $table->string('status', 30)->default('pending_payment'); $table->string('payment_status', 30)->default('unpaid'); $table->jsonb('pricing_snapshot')->nullable(); $table->jsonb('promotion_snapshot')->nullable(); $table->jsonb('metadata')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('company_id')->references('id')->on('core.companies')->restrictOnDelete(); $table->foreign('branch_id')->references('id')->on('core.branches')->restrictOnDelete(); $table->foreign('register_id')->references('id')->on('core.registers')->restrictOnDelete(); $table->foreign('cart_id')->references('id')->on('sales.carts')->restrictOnDelete(); $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete(); $table->unique(['tenant_id','sale_number']); $table->unique(['tenant_id','cart_id']); $table->index(['tenant_id','business_date','status']); $table->index(['tenant_id','branch_id','business_date']); $table->index(['tenant_id','payment_status','created_at']); }); DB::statement(" ALTER TABLE sales.sales ADD CONSTRAINT sales_sales_amounts_check CHECK ( subtotal_amount >= 0 AND discount_amount >= 0 AND total_amount >= 0 AND discount_amount <= subtotal_amount AND total_amount = subtotal_amount - discount_amount ) "); DB::statement(" ALTER TABLE sales.sales ADD CONSTRAINT sales_sales_status_check CHECK (status IN ('pending_payment','completed','voided','reversed')) "); DB::statement(" ALTER TABLE sales.sales ADD CONSTRAINT sales_sales_payment_status_check CHECK (payment_status IN ('unpaid','partial','paid','refunded','partially_refunded')) "); Schema::create('sales.sale_lines', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('sale_id'); $table->unsignedBigInteger('variant_id'); $table->unsignedBigInteger('variant_unit_id')->nullable(); $table->string('sku_snapshot', 160)->nullable(); $table->string('name_snapshot', 220); $table->decimal('quantity', 18, 6); $table->decimal('unit_price_amount', 18, 6); $table->decimal('gross_amount', 18, 6); $table->decimal('discount_amount', 18, 6); $table->decimal('net_amount', 18, 6); $table->jsonb('price_snapshot'); $table->jsonb('promotion_snapshot')->nullable(); $table->jsonb('metadata')->nullable(); $table->timestampTz('created_at')->useCurrent(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('sale_id')->references('id')->on('sales.sales')->restrictOnDelete(); $table->foreign('variant_id')->references('id')->on('catalog.variants')->restrictOnDelete(); $table->foreign('variant_unit_id')->references('id')->on('catalog.variant_units')->restrictOnDelete(); $table->index(['tenant_id','sale_id']); $table->index(['tenant_id','variant_id']); }); DB::statement(" ALTER TABLE sales.sale_lines ADD CONSTRAINT sales_sale_lines_amounts_check CHECK ( quantity > 0 AND unit_price_amount >= 0 AND gross_amount >= 0 AND discount_amount >= 0 AND net_amount >= 0 AND discount_amount <= gross_amount AND net_amount = gross_amount - discount_amount ) "); Schema::create('sales.checkout_commands', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('cart_id'); $table->unsignedBigInteger('sale_id')->nullable(); $table->string('idempotency_key', 120); $table->string('status', 30)->default('started'); $table->jsonb('response_snapshot')->nullable(); $table->text('failure_code')->nullable(); $table->timestampTz('created_at')->useCurrent(); $table->timestampTz('completed_at')->nullable(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('cart_id')->references('id')->on('sales.carts')->restrictOnDelete(); $table->foreign('sale_id')->references('id')->on('sales.sales')->restrictOnDelete(); $table->unique(['tenant_id','idempotency_key']); $table->index(['tenant_id','cart_id','status']); }); DB::statement(" ALTER TABLE sales.checkout_commands ADD CONSTRAINT sales_checkout_commands_status_check CHECK (status IN ('started','completed','failed','unknown')) "); DB::statement(<<<'SQL' CREATE OR REPLACE FUNCTION sales.prevent_sale_line_mutation() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'sales.sale_lines are immutable'; END; $$ SQL); DB::statement(<<<'SQL' CREATE TRIGGER sales_sale_lines_immutable_update BEFORE UPDATE ON sales.sale_lines FOR EACH ROW EXECUTE FUNCTION sales.prevent_sale_line_mutation() SQL); DB::statement(<<<'SQL' CREATE TRIGGER sales_sale_lines_immutable_delete BEFORE DELETE ON sales.sale_lines FOR EACH ROW EXECUTE FUNCTION sales.prevent_sale_line_mutation() SQL); } public function down(): void { DB::statement('DROP TRIGGER IF EXISTS sales_sale_lines_immutable_delete ON sales.sale_lines'); DB::statement('DROP TRIGGER IF EXISTS sales_sale_lines_immutable_update ON sales.sale_lines'); DB::statement('DROP FUNCTION IF EXISTS sales.prevent_sale_line_mutation()'); Schema::dropIfExists('sales.checkout_commands'); Schema::dropIfExists('sales.sale_lines'); Schema::dropIfExists('sales.sales'); Schema::dropIfExists('sales.number_sequences'); } }; ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_04_013000_create_cash_sale_shift_posting_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->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','payment_id']); $table->index(['tenant_id','shift_id','posted_at']); }); } public function down(): void { Schema::dropIfExists('cash.sale_payment_postings'); } };