================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Application\Customers\CreateCustomerAction.php ================================================================ resolveGroupId($tenantId,$data['customer_group_id']??null); $customer=Customer::query()->create([ 'tenant_id'=>$tenantId, 'customer_group_id'=>$groupId, 'display_name'=>$data['display_name'], 'first_name'=>$data['first_name']??null, 'last_name'=>$data['last_name']??null, 'phone'=>$data['phone']??null, 'phone_normalized'=>$this->normalizer->phone($data['phone']??null), 'email'=>$data['email']??null, 'email_normalized'=>$this->normalizer->email($data['email']??null), 'status'=>'active', 'notes'=>$data['notes']??null, 'metadata'=>$data['metadata']??null, 'created_by_user_id'=>$actorUserId, ]); $this->auditRecorder->record( 'crm.customer.created', $tenantId, $actorUserId, 'crm.customer', $customer->public_id, after:[ 'display_name'=>$customer->display_name, 'customer_group_id'=>$data['customer_group_id']??null, 'status'=>$customer->status, ], request:$request, ); return $customer->fresh('group'); }); } private function resolveGroupId(int $tenantId,?string $publicId): ?int { if($publicId===null) return null; $group=CustomerGroup::query() ->where('tenant_id',$tenantId) ->where('public_id',$publicId) ->where('status','active') ->first(); if(!$group) { throw ValidationException::withMessages([ 'customer_group_id'=>['Active customer group was not found in this tenant.'], ]); } return $group->id; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Application\Customers\UpdateCustomerAction.php ================================================================ where('tenant_id',$customer->tenant_id) ->whereKey($customer->id) ->lockForUpdate() ->firstOrFail(); $before=[ 'display_name'=>$locked->display_name, 'status'=>$locked->status, 'customer_group_id'=>$locked->customer_group_id, ]; if(array_key_exists('customer_group_id',$data)) { $locked->customer_group_id=$this->resolveGroupId( $locked->tenant_id, $data['customer_group_id'] ); } foreach(['display_name','first_name','last_name','phone','email','status','notes','metadata'] as $field) { if(array_key_exists($field,$data)) { $locked->{$field}=$data[$field]; } } if(array_key_exists('phone',$data)) { $locked->phone_normalized=$this->normalizer->phone($data['phone']); } if(array_key_exists('email',$data)) { $locked->email_normalized=$this->normalizer->email($data['email']); } $locked->updated_by_user_id=$actorUserId; $locked->save(); $this->auditRecorder->record( 'crm.customer.updated', $locked->tenant_id, $actorUserId, 'crm.customer', $locked->public_id, before:$before, after:[ 'display_name'=>$locked->display_name, 'status'=>$locked->status, 'customer_group_id'=>$locked->customer_group_id, ], request:$request, ); return $locked->fresh('group'); }); } private function resolveGroupId(int $tenantId,?string $publicId): ?int { if($publicId===null) return null; $group=CustomerGroup::query() ->where('tenant_id',$tenantId) ->where('public_id',$publicId) ->where('status','active') ->first(); if(!$group) { throw ValidationException::withMessages([ 'customer_group_id'=>['Active customer group was not found in this tenant.'], ]); } return $group->id; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Application\Normalization\CustomerContactNormalizer.php ================================================================ loadMissing('group'); return [ 'id'=>$customer->public_id, 'display_name'=>$customer->display_name, 'first_name'=>$customer->first_name, 'last_name'=>$customer->last_name, 'phone'=>$this->maskPhone($customer->phone), 'email'=>$this->maskEmail($customer->email), 'group'=>$customer->group ? [ 'id'=>$customer->group->public_id, 'key'=>$customer->group->group_key, 'name'=>$customer->group->name, ] : null, 'status'=>$customer->status, 'metadata'=>$customer->metadata, 'created_at'=>$customer->created_at?->toISOString(), 'updated_at'=>$customer->updated_at?->toISOString(), ]; } public function sensitive(Customer $customer): array { $data=$this->masked($customer); $data['phone']=$customer->phone; $data['email']=$customer->email; $data['notes']=$customer->notes; return $data; } private function maskPhone(?string $value): ?string { if($value===null || $value==='') return null; $length=mb_strlen($value); if($length<=4) return str_repeat('*',$length); return str_repeat('*',max(0,$length-4)).mb_substr($value,-4); } private function maskEmail(?string $value): ?string { if($value===null || $value==='') return null; [$local,$domain]=array_pad(explode('@',$value,2),2,null); if($domain===null) { return mb_substr($value,0,1).'***'; } $visible=mb_substr($local,0,1); return $visible.'***@'.$domain; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Application\Resolution\CustomerSnapshotResolver.php ================================================================ with('group') ->where('tenant_id',$tenantId) ->where('public_id',$customerPublicId) ->where('status','active') ->first(); if(!$customer) { throw ValidationException::withMessages([ 'customer_id'=>['Active customer was not found in this tenant.'], ]); } return $customer; } public function snapshot(?int $tenantId,?string $customerPublicId): ?array { if($tenantId===null || $customerPublicId===null) return null; $customer=Customer::query() ->with('group') ->where('tenant_id',$tenantId) ->where('public_id',$customerPublicId) ->first(); if(!$customer) return null; return [ 'customer_id'=>$customer->public_id, 'display_name'=>$customer->display_name, 'phone_normalized'=>$customer->phone_normalized, 'email_normalized'=>$customer->email_normalized, 'customer_group_key'=>$customer->group?->group_key, ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Application\Timeline\CustomerTimelineService.php ================================================================ where('tenant_id',$customer->tenant_id) ->where(function($q) use ($customer) { $q->where('customer_id',$customer->id) ->orWhere('customer_public_id',$customer->public_id); }) ->orderByDesc('occurred_at') ->get(); $saleIds=$sales->pluck('id'); $returns=$saleIds->isEmpty() ? collect() : SaleReturn::query() ->where('tenant_id',$customer->tenant_id) ->whereIn('sale_id',$saleIds) ->where('status','posted') ->orderByDesc('posted_at') ->get(); $payments=$saleIds->isEmpty() ? collect() : Payment::query() ->where('tenant_id',$customer->tenant_id) ->whereIn('sale_id',$saleIds) ->orderByDesc('id') ->get(); $paymentIds=$payments->pluck('id'); $refunds=$paymentIds->isEmpty() ? collect() : PaymentRefund::query() ->where('tenant_id',$customer->tenant_id) ->whereIn('payment_id',$paymentIds) ->where('status','completed') ->orderByDesc('resolved_at') ->get(); $completedSales=$sales->where('status','completed'); $grossSales='0.000000'; $returned='0.000000'; $captured='0.000000'; $refunded='0.000000'; foreach($completedSales as $sale) { $grossSales=bcadd($grossSales,(string)$sale->total_amount,6); } foreach($returns as $return) { $returned=bcadd($returned,(string)$return->total_amount,6); } foreach($payments->where('status','captured') as $payment) { $captured=bcadd($captured,(string)$payment->amount,6); } foreach($refunds as $refund) { $refunded=bcadd($refunded,(string)$refund->amount,6); } $events=[]; foreach($sales as $sale) { $events[]=[ 'type'=>'sale', 'occurred_at'=>$sale->occurred_at?->toISOString(), 'reference'=>$sale->public_id, 'number'=>$sale->sale_number, 'amount'=>$sale->total_amount, 'status'=>$sale->status, ]; } foreach($returns as $return) { $events[]=[ 'type'=>'return', 'occurred_at'=>$return->posted_at?->toISOString(), 'reference'=>$return->public_id, 'number'=>$return->return_number, 'amount'=>$return->total_amount, 'status'=>$return->refund_status, ]; } foreach($payments as $payment) { $events[]=[ 'type'=>'payment', 'occurred_at'=>$payment->resolved_at?->toISOString() ?? $payment->created_at?->toISOString(), 'reference'=>$payment->public_id, 'method'=>$payment->method_type, 'amount'=>$payment->amount, 'status'=>$payment->status, ]; } foreach($refunds as $refund) { $events[]=[ 'type'=>'payment_refund', 'occurred_at'=>$refund->resolved_at?->toISOString(), 'reference'=>$refund->public_id, 'method'=>$refund->method_type, 'amount'=>$refund->amount, 'status'=>$refund->status, ]; } usort($events,fn($a,$b)=>strcmp((string)($b['occurred_at']??''),(string)($a['occurred_at']??''))); return [ 'customer_id'=>$customer->public_id, 'summary'=>[ 'completed_sales_count'=>$completedSales->count(), 'gross_sales_amount'=>$grossSales, 'posted_return_amount'=>$returned, 'net_sales_amount'=>bcsub($grossSales,$returned,6), 'captured_payment_amount'=>$captured, 'completed_refund_amount'=>$refunded, 'last_purchase_at'=>$completedSales->sortByDesc('occurred_at')->first()?->occurred_at?->toISOString(), ], 'events'=>$events, ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Domain\Models\Customer.php ================================================================ 'array']; } public function group(): BelongsTo { return $this->belongsTo(CustomerGroup::class,'customer_group_id'); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Domain\Models\CustomerGroup.php ================================================================ 'array']; } public function customers(): HasMany { return $this->hasMany(Customer::class,'customer_group_id'); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Http\Controllers\CustomerController.php ================================================================ with('group') ->where('tenant_id',$tenantContext->tenantId()); if($request->filled('status')) { $query->where('status',$request->string('status')->toString()); } if($request->filled('group_key')) { $groupKey=$request->string('group_key')->toString(); $query->whereHas('group',fn($q)=>$q->where('group_key',$groupKey)); } if($request->filled('q')) { $term=trim($request->string('q')->toString()); $phone=$normalizer->phone($term); $email=$normalizer->email($term); $query->where(function($q) use ($term,$phone,$email) { $q->where('display_name','ilike','%'.$term.'%') ->orWhere('first_name','ilike','%'.$term.'%') ->orWhere('last_name','ilike','%'.$term.'%'); if($phone!==null) { $q->orWhere('phone_normalized','like','%'.$phone.'%'); } if($email!==null && str_contains($email,'@')) { $q->orWhere('email_normalized','like','%'.$email.'%'); } }); } $limit=max(1,min((int)$request->integer('limit',50),200)); return response()->json([ 'data'=>$query ->orderBy('display_name') ->limit($limit) ->get() ->map(fn(Customer $customer)=>$presenter->masked($customer)) ->values(), ]); } public function store( StoreCustomerRequest $request, TenantContext $tenantContext, CreateCustomerAction $action, CustomerPresenter $presenter, ): JsonResponse { $customer=$action->execute( $tenantContext->tenantId(), $request->validated(), $request->user()->id, $request, ); return response()->json(['data'=>$presenter->masked($customer)],201); } public function show( string $customerPublicId, TenantContext $tenantContext, CustomerPresenter $presenter, ): JsonResponse { $customer=$this->customer($tenantContext->tenantId(),$customerPublicId); return response()->json(['data'=>$presenter->masked($customer)]); } public function sensitive( string $customerPublicId, TenantContext $tenantContext, CustomerPresenter $presenter, ): JsonResponse { $customer=$this->customer($tenantContext->tenantId(),$customerPublicId); return response()->json(['data'=>$presenter->sensitive($customer)]); } public function update( string $customerPublicId, UpdateCustomerRequest $request, TenantContext $tenantContext, UpdateCustomerAction $action, CustomerPresenter $presenter, ): JsonResponse { $customer=$this->customer($tenantContext->tenantId(),$customerPublicId); $customer=$action->execute( $customer, $request->validated(), $request->user()->id, $request, ); return response()->json(['data'=>$presenter->masked($customer)]); } private function customer(int $tenantId,string $publicId): Customer { return Customer::query() ->with('group') ->where('tenant_id',$tenantId) ->where('public_id',$publicId) ->firstOrFail(); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Http\Controllers\CustomerGroupController.php ================================================================ json([ 'data'=>CustomerGroup::query() ->where('tenant_id',$tenantContext->tenantId()) ->orderBy('name') ->get() ->map(fn($group)=>[ 'id'=>$group->public_id, 'key'=>$group->group_key, 'name'=>$group->name, 'status'=>$group->status, 'metadata'=>$group->metadata, ])->values(), ]); } public function store( StoreCustomerGroupRequest $request, TenantContext $tenantContext, AuditRecorder $auditRecorder, ): JsonResponse { $tenantId=$tenantContext->tenantId(); $data=$request->validated(); $key=strtolower($data['group_key']); if(CustomerGroup::query()->where('tenant_id',$tenantId)->where('group_key',$key)->exists()) { throw ValidationException::withMessages([ 'group_key'=>['Customer group key already exists in this tenant.'], ]); } $group=CustomerGroup::query()->create([ 'tenant_id'=>$tenantId, 'group_key'=>$key, 'name'=>$data['name'], 'status'=>'active', 'metadata'=>$data['metadata']??null, ]); $auditRecorder->record( 'crm.customer_group.created', $tenantId, $request->user()->id, 'crm.customer_group', $group->public_id, after:['group_key'=>$group->group_key,'name'=>$group->name], request:$request, ); return response()->json([ 'data'=>[ 'id'=>$group->public_id, 'key'=>$group->group_key, 'name'=>$group->name, 'status'=>$group->status, ], ],201); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Http\Controllers\CustomerTimelineController.php ================================================================ where('tenant_id',$tenantContext->tenantId()) ->where('public_id',$customerPublicId) ->firstOrFail(); return response()->json(['data'=>$timeline->build($customer)]); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Http\Requests\StoreCustomerGroupRequest.php ================================================================ ['required','string','max:100','regex:/^[A-Za-z0-9._-]+$/'], 'name'=>['required','string','max:180'], 'metadata'=>['nullable','array'], ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Http\Requests\StoreCustomerRequest.php ================================================================ ['required','string','max:220'], 'first_name'=>['nullable','string','max:120'], 'last_name'=>['nullable','string','max:120'], 'phone'=>['nullable','string','max:80'], 'email'=>['nullable','email','max:254'], 'customer_group_id'=>['nullable','string','size:26'], 'notes'=>['nullable','string','max:5000'], 'metadata'=>['nullable','array'], ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\CRM\Http\Requests\UpdateCustomerRequest.php ================================================================ ['sometimes','required','string','max:220'], 'first_name'=>['sometimes','nullable','string','max:120'], 'last_name'=>['sometimes','nullable','string','max:120'], 'phone'=>['sometimes','nullable','string','max:80'], 'email'=>['sometimes','nullable','email','max:254'], 'customer_group_id'=>['sometimes','nullable','string','size:26'], 'status'=>['sometimes','required',Rule::in(['active','inactive'])], 'notes'=>['sometimes','nullable','string','max:5000'], 'metadata'=>['sometimes','nullable','array'], ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Application\Checkout\CheckoutCartAction.php ================================================================ fingerprint($cart,$expectedVersion); $existing=CheckoutCommand::query() ->where('tenant_id',$cart->tenant_id) ->where('idempotency_key',$idempotencyKey) ->first(); if($existing) { $this->assertReplayMatches($existing,$cart,$fingerprint); if($existing->sale_id) { return Sale::query()->with('lines')->findOrFail($existing->sale_id); } } return DB::transaction(function () use ( $cart,$expectedVersion,$idempotencyKey,$actorUserId,$request,$fingerprint ) { $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) { $this->assertReplayMatches($existing,$locked,$fingerprint); 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, 'request_fingerprint'=>$fingerprint, 'status'=>'started', 'created_at'=>now(), ]); /* * Reserve/consume promotion usage before materializing the sale. * It is inside the same DB transaction, so any later checkout failure * rolls counters and consumption records back. */ $this->promotionUsage->execute($locked,$command); $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, 'customer_id'=>$locked->customer_id, '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, 'customer_snapshot'=>$this->customerSnapshotResolver->snapshot( $locked->tenant_id, $locked->customer_public_id, ), '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, ]); } $this->promotionUsage->execute($locked,$command,$sale); $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, 'request_fingerprint'=>$fingerprint, ]; $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_fingerprint'=>$fingerprint, ], request:$request, ); return $sale->fresh('lines'); }); } private function fingerprint(Cart $cart,int $expectedVersion): string { return hash('sha256',json_encode([ 'tenant_id'=>$cart->tenant_id, 'cart_id'=>$cart->id, 'expected_version'=>$expectedVersion, ],JSON_THROW_ON_ERROR)); } private function assertReplayMatches( CheckoutCommand $command, Cart $cart, string $fingerprint, ): void { if($command->cart_id!==$cart->id) { throw new ConflictHttpException( 'Idempotency key already belongs to another cart.' ); } /* * Pre-v40 commands have no fingerprint. They remain replayable only for * their original cart; new commands are strict. */ if( $command->request_fingerprint!==null && !hash_equals($command->request_fingerprint,$fingerprint) ) { throw new ConflictHttpException( 'Idempotency key was already used with different checkout input.' ); } } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Application\Customers\AttachCustomerToCartAction.php ================================================================ where('tenant_id',$cart->tenant_id) ->whereKey($cart->id) ->lockForUpdate() ->firstOrFail(); if($locked->status!=='open') { throw new ConflictHttpException('Customer can be changed only on an open cart.'); } if($locked->version!==$expectedVersion) { throw new ConflictHttpException('Cart version conflict.'); } $before=[ 'customer_public_id'=>$locked->customer_public_id, 'customer_group_key'=>$locked->customer_group_key, 'version'=>$locked->version, ]; if($customerPublicId===null) { $locked->customer_id=null; $locked->customer_public_id=null; $locked->customer_group_key=null; } else { $customer=$this->customerResolver->findActive( $locked->tenant_id, $customerPublicId ); $locked->customer_id=$customer->id; $locked->customer_public_id=$customer->public_id; $locked->customer_group_key=$customer->group?->group_key; } $locked->save(); /* * Customer/group scopes can change price lists and promotions, so this * is not a cosmetic assignment. Reprice the complete cart. */ $recalculated=$this->recalculator->recalculate($locked); $recalculated->version++; $recalculated->save(); $this->auditRecorder->record( 'sales.cart.customer_changed', $locked->tenant_id, $actorUserId, 'sales.cart', $locked->public_id, before:$before, after:[ 'customer_public_id'=>$recalculated->customer_public_id, 'customer_group_key'=>$recalculated->customer_group_key, 'version'=>$recalculated->version, ], request:$request, ); return $recalculated->fresh('lines'); }); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Domain\Models\CheckoutCommand.php ================================================================ 'array', 'created_at'=>'immutable_datetime', 'completed_at'=>'immutable_datetime', ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Http\Controllers\CartCustomerController.php ================================================================ where('tenant_id',$tenantContext->tenantId()) ->where('public_id',$cartPublicId) ->firstOrFail(); $cart=$action->execute( $cart, $request->validated()['customer_id']??null, (int)$request->validated()['expected_version'], $request->user()->id, $request, ); return response()->json([ 'data'=>[ 'id'=>$cart->public_id, 'customer_id'=>$cart->customer_public_id, 'customer_group_key'=>$cart->customer_group_key, 'subtotal_amount'=>$cart->subtotal_amount, 'discount_amount'=>$cart->discount_amount, 'total_amount'=>$cart->total_amount, 'version'=>$cart->version, ], ]); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Http\Controllers\CheckoutController.php ================================================================ where('tenant_id',$tenantContext->tenantId()) ->where('public_id',$cartPublicId) ->firstOrFail(); $v=$request->validated(); $sale=$action->execute( cart:$cart, expectedVersion:(int)$v['expected_version'], idempotencyKey:$v['idempotency_key'], actorUserId:$request->user()->id, request:$request, ); return new SaleResource($sale); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Http\Requests\AttachCartCustomerRequest.php ================================================================ ['nullable','string','size:26'], 'expected_version'=>['required','integer','min:1'], ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Sales\Http\Requests\CheckoutCartRequest.php ================================================================ ['required','integer','min:1'], 'idempotency_key'=>['required','string','max:120'], ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\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: F:\POS 2026\retail-platform\apps\api\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: F:\POS 2026\retail-platform\apps\api\app\Modules\Payments\Application\Refunds\SaleRefundStatusUpdater.php ================================================================ where('tenant_id',$return->tenant_id) ->where('sale_return_id',$return->id) ->get(); if($refunds->isEmpty()) { $status='none'; } else { $completed='0.000000'; $pending=false; $failed=false; foreach($refunds as $refund) { if($refund->status==='completed') { $completed=bcadd($completed,(string)$refund->amount,6); } elseif($refund->status==='pending') { $pending=true; } elseif($refund->status==='failed') { $failed=true; } } if(bccomp($completed,(string)$return->total_amount,6)>=0) { $status='completed'; } elseif(bccomp($completed,'0.000000',6)>0) { $status='partial'; } elseif($pending) { $status='pending'; } elseif($failed) { $status='failed'; } else { $status='none'; } } $return->refund_status=$status; $return->save(); return $return->fresh(); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\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: F:\POS 2026\retail-platform\apps\api\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: F:\POS 2026\retail-platform\apps\api\app\Modules\Payments\Domain\Models\PaymentRefund.php ================================================================ 'decimal:6', 'resolved_at'=>'immutable_datetime', 'metadata'=>'array', 'created_at'=>'immutable_datetime', ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Payments\Http\Controllers\PaymentRefundResolutionController.php ================================================================ where('tenant_id',$tenantContext->tenantId()) ->where('public_id',$refundPublicId) ->firstOrFail(); $refund=$action->execute( $refund, $request->validated(), $request->user()->id, $request, ); return response()->json([ 'data'=>[ 'id'=>$refund->public_id, 'payment_id'=>$refund->payment_id, 'method_type'=>$refund->method_type, 'amount'=>$refund->amount, 'status'=>$refund->status, 'provider_reference'=>$refund->provider_reference, 'resolved_at'=>$refund->resolved_at?->toISOString(), ], ]); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Payments\Http\Requests\ResolvePaymentRefundRequest.php ================================================================ ['required',Rule::in(['succeeded','failed'])], 'provider_reference'=>['nullable','string','max:255'], 'metadata'=>['nullable','array'], ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_01_093000_create_sales_cart_foundation_tables.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_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_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_083000_create_crm_customer_foundation.php ================================================================ bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->string('group_key', 100); $table->string('name', 180); $table->string('status', 30)->default('active'); $table->jsonb('metadata')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->unique(['tenant_id','group_key']); $table->index(['tenant_id','status','name']); }); DB::statement(" ALTER TABLE crm.customer_groups ADD CONSTRAINT crm_customer_groups_status_check CHECK (status IN ('active','inactive')) "); Schema::create('crm.customers', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('customer_group_id')->nullable(); $table->string('display_name', 220); $table->string('first_name', 120)->nullable(); $table->string('last_name', 120)->nullable(); $table->string('phone', 80)->nullable(); $table->string('phone_normalized', 80)->nullable(); $table->string('email', 254)->nullable(); $table->string('email_normalized', 254)->nullable(); $table->string('status', 30)->default('active'); $table->text('notes')->nullable(); $table->jsonb('metadata')->nullable(); $table->unsignedBigInteger('created_by_user_id'); $table->unsignedBigInteger('updated_by_user_id')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $table->foreign('customer_group_id')->references('id')->on('crm.customer_groups')->restrictOnDelete(); $table->foreign('created_by_user_id')->references('id')->on('users')->restrictOnDelete(); $table->foreign('updated_by_user_id')->references('id')->on('users')->restrictOnDelete(); $table->index(['tenant_id','status','display_name']); $table->index(['tenant_id','phone_normalized']); $table->index(['tenant_id','email_normalized']); $table->index(['tenant_id','customer_group_id','status']); }); DB::statement(" ALTER TABLE crm.customers ADD CONSTRAINT crm_customers_status_check CHECK (status IN ('active','inactive')) "); Schema::table('sales.carts', function (Blueprint $table) { $table->unsignedBigInteger('customer_id')->nullable()->after('created_by_user_id'); $table->foreign('customer_id')->references('id')->on('crm.customers')->restrictOnDelete(); $table->index(['tenant_id','customer_id','status']); }); Schema::table('sales.sales', function (Blueprint $table) { $table->unsignedBigInteger('customer_id')->nullable()->after('created_by_user_id'); $table->jsonb('customer_snapshot')->nullable()->after('customer_group_key'); $table->foreign('customer_id')->references('id')->on('crm.customers')->restrictOnDelete(); $table->index(['tenant_id','customer_id','business_date']); }); } public function down(): void { Schema::table('sales.sales', function (Blueprint $table) { $table->dropForeign(['customer_id']); $table->dropIndex(['tenant_id','customer_id','business_date']); $table->dropColumn(['customer_id','customer_snapshot']); }); Schema::table('sales.carts', function (Blueprint $table) { $table->dropForeign(['customer_id']); $table->dropIndex(['tenant_id','customer_id','status']); $table->dropColumn('customer_id'); }); Schema::dropIfExists('crm.customers'); Schema::dropIfExists('crm.customer_groups'); } };