================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Pricing\Application\Permissions\PricingPermissions.php ================================================================ where('tenant_id',$tenantId)->where('status','active') ->where(fn($q)=>$q->whereNull('effective_from')->orWhere('effective_from','<=',$now)) ->where(fn($q)=>$q->whereNull('effective_to')->orWhere('effective_to','>',$now))->get(); $candidates=[]; foreach($lists as $list){ $scopes=PriceListScope::query()->where('tenant_id',$tenantId)->where('price_list_id',$list->id)->get(); $best=null; foreach($scopes as $s){ $score=match($s->scope_type){ 'customer' => $customer!==null && $s->customer_public_id===$customer ? 500:null, 'customer_group' => $group!==null && $s->customer_group_key===$group ? 400:null, 'branch' => $branchId!==null && $s->branch_id===$branchId ? 300:null, 'company' => $companyId!==null && $s->company_id===$companyId ? 200:null, 'tenant' => 100, default => null, }; if($score!==null) $best=max($best??$score,$score); } if($best===null && !$list->is_default) continue; $candidates[]=['list'=>$list,'score'=>$best??0]; } usort($candidates,fn($a,$b)=>$a['score']===$b['score'] ? $a['list']->priority<=>$b['list']->priority : $b['score']<=>$a['score']); foreach($candidates as $c){ $entry=PriceEntry::query()->where('tenant_id',$tenantId)->where('price_list_id',$c['list']->id)->where('variant_id',$variantId)->where('status','active') ->where(fn($q)=>$q->whereNull('effective_from')->orWhere('effective_from','<=',$now)) ->where(fn($q)=>$q->whereNull('effective_to')->orWhere('effective_to','>',$now)) ->when($variantUnitId===null,fn($q)=>$q->whereNull('variant_unit_id'),fn($q)=>$q->where('variant_unit_id',$variantUnitId))->first(); if($entry) return ['price_list_id'=>$c['list']->public_id,'price_list_code'=>$c['list']->code,'currency_code'=>$c['list']->currency_code,'price_amount'=>$entry->price_amount,'min_price_amount'=>$entry->min_price_amount,'specificity'=>$c['score']]; } return null; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Pricing\Domain\Models\MarginGuard.php ================================================================ 'decimal:4','minimum_markup_percent'=>'decimal:4']; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Pricing\Domain\Models\PriceEntry.php ================================================================ 'decimal:6','min_price_amount'=>'decimal:6','effective_from'=>'immutable_datetime','effective_to'=>'immutable_datetime','metadata'=>'array']; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Pricing\Domain\Models\PriceList.php ================================================================ 'integer','is_default'=>'boolean','effective_from'=>'immutable_datetime','effective_to'=>'immutable_datetime','metadata'=>'array']; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Pricing\Domain\Models\PriceListScope.php ================================================================ where('tenant_id',$t->tenantId())->where('public_id',$id)->firstOrFail();$vu=null; if($r->query('variant_unit_id')){$u=VariantUnit::query()->where('tenant_id',$t->tenantId())->where('variant_id',$v->id)->where('public_id',$r->query('variant_unit_id'))->first();if(!$u)throw ValidationException::withMessages(['variant_unit_id'=>['Variant unit was not found.']]);$vu=$u->id;} $p=$x->resolve($t->tenantId(),$v->id,$vu,$o->companyId(),$o->branchId(),$r->query('customer_group_key'),$r->query('customer_public_id')); return $p?response()->json(['data'=>$p]):response()->json(['message'=>'No effective price was found.','code'=>'PRICE_NOT_FOUND'],404); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Pricing\Http\Controllers\MarginGuardController.php ================================================================ validated(); if ( ! isset($validated['minimum_margin_percent']) && ! isset($validated['minimum_markup_percent']) ) { throw ValidationException::withMessages([ 'minimum_margin_percent' => ['Provide margin or markup.'], ]); } if ( $validated['violation_action'] === 'approval' && empty($validated['approval_action_code']) ) { throw ValidationException::withMessages([ 'approval_action_code' => ['Approval action code is required.'], ]); } $companyId = null; $branchId = null; if ($validated['scope_type'] === 'company') { if ($scopeContext->companyId() === null) { throw ValidationException::withMessages([ 'scope_type' => ['X-Company-ID is required.'], ]); } $companyId = $scopeContext->companyId(); } elseif ($validated['scope_type'] === 'branch') { if ($scopeContext->companyId() === null || $scopeContext->branchId() === null) { throw ValidationException::withMessages([ 'scope_type' => ['X-Branch-ID is required.'], ]); } $companyId = $scopeContext->companyId(); $branchId = $scopeContext->branchId(); } $duplicate = MarginGuard::query() ->where('tenant_id', $tenantContext->tenantId()) ->where('scope_type', $validated['scope_type']) ->where('status', 'active') ->when($validated['scope_type'] === 'company', fn ($q) => $q->where('company_id', $companyId)) ->when($validated['scope_type'] === 'branch', fn ($q) => $q->where('branch_id', $branchId)) ->exists(); if ($duplicate) { throw ValidationException::withMessages([ 'scope_type' => ['An active margin guard already exists for this scope.'], ]); } $guard = MarginGuard::query()->create([ 'tenant_id' => $tenantContext->tenantId(), 'scope_type' => $validated['scope_type'], 'company_id' => $companyId, 'branch_id' => $branchId, 'minimum_margin_percent' => $validated['minimum_margin_percent'] ?? null, 'minimum_markup_percent' => $validated['minimum_markup_percent'] ?? null, 'violation_action' => $validated['violation_action'], 'approval_action_code' => $validated['approval_action_code'] ?? null, 'status' => 'active', ]); $auditRecorder->record( 'pricing.margin_guard.created', $tenantContext->tenantId(), $request->user()->id, 'pricing.margin_guard', $guard->public_id, after: [ 'scope_type' => $guard->scope_type, 'violation_action' => $guard->violation_action, ], request: $request, ); return response()->json([ 'data' => [ 'id' => $guard->public_id, 'scope_type' => $guard->scope_type, 'violation_action' => $guard->violation_action, ], ], 201); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Pricing\Http\Controllers\PriceEntryController.php ================================================================ tenantId(); $priceList = PriceList::query() ->where('tenant_id', $tenantId) ->where('public_id', $priceListPublicId) ->firstOrFail(); $validated = $request->validated(); $variant = Variant::query() ->where('tenant_id', $tenantId) ->where('public_id', $validated['variant_id']) ->first(); if ($variant === null) { throw ValidationException::withMessages([ 'variant_id' => ['Variant was not found.'], ]); } $variantUnitId = null; if (! empty($validated['variant_unit_id'])) { $variantUnit = VariantUnit::query() ->where('tenant_id', $tenantId) ->where('variant_id', $variant->id) ->where('public_id', $validated['variant_unit_id']) ->first(); if ($variantUnit === null) { throw ValidationException::withMessages([ 'variant_unit_id' => ['Variant unit was not found.'], ]); } $variantUnitId = $variantUnit->id; } $duplicate = PriceEntry::query() ->where('tenant_id', $tenantId) ->where('price_list_id', $priceList->id) ->where('variant_id', $variant->id) ->where('status', 'active') ->when( $variantUnitId === null, fn ($q) => $q->whereNull('variant_unit_id'), fn ($q) => $q->where('variant_unit_id', $variantUnitId) ) ->exists(); if ($duplicate) { throw ValidationException::withMessages([ 'variant_id' => ['An active price already exists for this list, variant, and unit.'], ]); } $entry = PriceEntry::query()->create([ 'tenant_id' => $tenantId, 'price_list_id' => $priceList->id, 'variant_id' => $variant->id, 'variant_unit_id' => $variantUnitId, 'price_amount' => $validated['price_amount'], 'min_price_amount' => $validated['min_price_amount'] ?? null, 'effective_from' => $validated['effective_from'] ?? null, 'effective_to' => $validated['effective_to'] ?? null, 'status' => 'active', 'metadata' => $validated['metadata'] ?? null, ]); $auditRecorder->record( 'pricing.price_entry.created', $tenantId, $request->user()->id, 'pricing.price_entry', $entry->public_id, after: [ 'price_list_id' => $priceList->public_id, 'variant_id' => $variant->public_id, 'price_amount' => $entry->price_amount, 'min_price_amount' => $entry->min_price_amount, ], request: $request, ); return response()->json([ 'data' => [ 'id' => $entry->public_id, 'price_amount' => $entry->price_amount, 'min_price_amount' => $entry->min_price_amount, ], ], 201); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Pricing\Http\Controllers\PriceListController.php ================================================================ json([ 'data' => PriceList::query() ->where('tenant_id', $tenantContext->tenantId()) ->orderBy('priority') ->get() ->map(fn ($list) => [ 'id' => $list->public_id, 'name' => $list->name, 'code' => $list->code, 'currency_code' => $list->currency_code, 'priority' => $list->priority, 'is_default' => (bool) $list->is_default, 'status' => $list->status, ]) ->values(), ]); } public function store( StorePriceListRequest $request, TenantContext $tenantContext, AuditRecorder $auditRecorder, ): JsonResponse { $validated = $request->validated(); $tenantId = $tenantContext->tenantId(); $currencyCode = strtoupper($validated['currency_code'] ?? 'EGP'); $isDefault = (bool) ($validated['is_default'] ?? false); if ( PriceList::query() ->where('tenant_id', $tenantId) ->where('code', $validated['code']) ->exists() ) { throw ValidationException::withMessages([ 'code' => ['Price list code already exists.'], ]); } /* * PostgreSQL marks the whole current transaction as failed after a * unique-constraint violation. PHPUnit RefreshDatabase keeps the test * inside one transaction, so catching the exception alone is not enough: * the next query would fail with SQLSTATE[25P02]. * * Validate the predictable conflict before INSERT, while keeping the * database unique index as the final race-condition guard. */ if ( $isDefault && PriceList::query() ->where('tenant_id', $tenantId) ->where('currency_code', $currencyCode) ->where('is_default', true) ->where('status', 'active') ->exists() ) { throw ValidationException::withMessages([ 'is_default' => [ 'Only one active default price list is allowed per tenant and currency.', ], ]); } try { $priceList = PriceList::query()->create([ 'tenant_id' => $tenantId, 'name' => $validated['name'], 'code' => $validated['code'], 'currency_code' => $currencyCode, 'priority' => $validated['priority'] ?? 100, 'is_default' => $isDefault, 'effective_from' => $validated['effective_from'] ?? null, 'effective_to' => $validated['effective_to'] ?? null, 'status' => 'active', 'metadata' => $validated['metadata'] ?? null, ]); } catch (QueryException $exception) { /* * Keep the DB constraint as a concurrency backstop. * In normal HTTP execution this safely returns a validation error. */ if ($exception->getCode() === '23505') { throw ValidationException::withMessages([ 'is_default' => [ 'A conflicting active default price list or duplicate code already exists.', ], ]); } throw $exception; } $auditRecorder->record( 'pricing.price_list.created', $tenantId, $request->user()->id, 'pricing.price_list', $priceList->public_id, after: [ 'code' => $priceList->code, 'currency_code' => $priceList->currency_code, 'priority' => $priceList->priority, 'is_default' => $priceList->is_default, ], request: $request, ); return response()->json([ 'data' => [ 'id' => $priceList->public_id, 'name' => $priceList->name, 'code' => $priceList->code, 'currency_code' => $priceList->currency_code, 'priority' => $priceList->priority, 'is_default' => (bool) $priceList->is_default, 'status' => $priceList->status, ], ], 201); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Pricing\Http\Controllers\PriceListScopeController.php ================================================================ tenantId(); $priceList = PriceList::query() ->where('tenant_id', $tenantId) ->where('public_id', $priceListPublicId) ->firstOrFail(); $validated = $request->validated(); $scopeType = $validated['scope_type']; $companyId = null; $branchId = null; $customerGroupKey = null; $customerPublicId = null; if ($scopeType === 'company') { if ($scopeContext->companyId() === null) { throw ValidationException::withMessages([ 'scope_type' => ['X-Company-ID is required.'], ]); } $companyId = $scopeContext->companyId(); } elseif ($scopeType === 'branch') { if ($scopeContext->companyId() === null || $scopeContext->branchId() === null) { throw ValidationException::withMessages([ 'scope_type' => ['X-Branch-ID is required.'], ]); } $companyId = $scopeContext->companyId(); $branchId = $scopeContext->branchId(); } elseif ($scopeType === 'customer_group') { if (empty($validated['customer_group_key'])) { throw ValidationException::withMessages([ 'customer_group_key' => ['Customer group key is required.'], ]); } $customerGroupKey = $validated['customer_group_key']; } elseif ($scopeType === 'customer') { if (empty($validated['customer_public_id'])) { throw ValidationException::withMessages([ 'customer_public_id' => ['Customer public ID is required.'], ]); } $customerPublicId = $validated['customer_public_id']; } $duplicate = PriceListScope::query() ->where('tenant_id', $tenantId) ->where('price_list_id', $priceList->id) ->where('scope_type', $scopeType) ->when($scopeType === 'company', fn ($q) => $q->where('company_id', $companyId)) ->when($scopeType === 'branch', fn ($q) => $q->where('branch_id', $branchId)) ->when($scopeType === 'customer_group', fn ($q) => $q->where('customer_group_key', $customerGroupKey)) ->when($scopeType === 'customer', fn ($q) => $q->where('customer_public_id', $customerPublicId)) ->exists(); if ($duplicate) { throw ValidationException::withMessages([ 'scope_type' => ['This scope is already attached to the price list.'], ]); } $scope = PriceListScope::query()->create([ 'tenant_id' => $tenantId, 'price_list_id' => $priceList->id, 'scope_type' => $scopeType, 'company_id' => $companyId, 'branch_id' => $branchId, 'customer_group_key' => $customerGroupKey, 'customer_public_id' => $customerPublicId, ]); $auditRecorder->record( 'pricing.price_list.scope_attached', $tenantId, $request->user()->id, 'pricing.price_list_scope', $scope->public_id, after: [ 'price_list_id' => $priceList->public_id, 'scope_type' => $scopeType, ], request: $request, ); return response()->json([ 'data' => [ 'id' => $scope->public_id, 'scope_type' => $scopeType, ], ], 201); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Pricing\Http\Requests\AttachPriceListScopeRequest.php ================================================================ ['required',Rule::in(['tenant','company','branch','customer_group','customer'])],'customer_group_key'=>['nullable','string','max:100'],'customer_public_id'=>['nullable','string','max:64']];} } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Pricing\Http\Requests\StoreMarginGuardRequest.php ================================================================ ['required',Rule::in(['tenant','company','branch'])],'minimum_margin_percent'=>['nullable','numeric'],'minimum_markup_percent'=>['nullable','numeric'],'violation_action'=>['required',Rule::in(['block','approval'])],'approval_action_code'=>['nullable','string','max:180']];} } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Pricing\Http\Requests\StorePriceEntryRequest.php ================================================================ ['required','string','size:26'],'variant_unit_id'=>['nullable','string','size:26'],'price_amount'=>['required','numeric','min:0'],'min_price_amount'=>['nullable','numeric','min:0','lte:price_amount'],'effective_from'=>['nullable','date'],'effective_to'=>['nullable','date','after:effective_from'],'metadata'=>['nullable','array']];} } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Pricing\Http\Requests\StorePriceListRequest.php ================================================================ ['required','string','max:180'],'code'=>['required','string','max:100'],'currency_code'=>['nullable','string','size:3'],'priority'=>['nullable','integer','min:0'],'is_default'=>['nullable','boolean'],'effective_from'=>['nullable','date'],'effective_to'=>['nullable','date','after:effective_from'],'metadata'=>['nullable','array']];} } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Promotions\Application\Evaluation\PromotionEligibilityEvaluator.php ================================================================ status !== 'active') { return ['eligible'=>false,'reason'=>'INACTIVE']; } if ($campaign->effective_from && $campaign->effective_from->isFuture()) { return ['eligible'=>false,'reason'=>'NOT_STARTED']; } if ($campaign->effective_to && !$campaign->effective_to->isFuture()) { return ['eligible'=>false,'reason'=>'EXPIRED']; } $campaign->loadMissing(['scopes','targets','condition','reward']); if (!$this->scopeMatches($campaign, $companyId, $branchId, $customerGroupKey, $customerPublicId)) { return ['eligible'=>false,'reason'=>'SCOPE_MISMATCH']; } if (!$this->targetMatches($campaign, $productFamilyId, $variantId, $categoryId)) { return ['eligible'=>false,'reason'=>'TARGET_MISMATCH']; } if ($campaign->condition) { if ($campaign->condition->minimum_quantity !== null && bccomp($quantity, (string)$campaign->condition->minimum_quantity, 6) < 0) { return ['eligible'=>false,'reason'=>'MINIMUM_QUANTITY']; } if ($campaign->condition->minimum_basket_amount !== null && bccomp($basketAmount, (string)$campaign->condition->minimum_basket_amount, 6) < 0) { return ['eligible'=>false,'reason'=>'MINIMUM_BASKET']; } } if ($campaign->max_total_uses !== null) { $total = PromotionUsageCounter::query() ->where('tenant_id',$campaign->tenant_id) ->where('campaign_id',$campaign->id) ->whereNull('customer_public_id') ->value('usage_count') ?? 0; if ($total >= $campaign->max_total_uses) { return ['eligible'=>false,'reason'=>'TOTAL_USAGE_LIMIT']; } } if ($campaign->max_uses_per_customer !== null && $customerPublicId !== null) { $customer = PromotionUsageCounter::query() ->where('tenant_id',$campaign->tenant_id) ->where('campaign_id',$campaign->id) ->where('customer_public_id',$customerPublicId) ->value('usage_count') ?? 0; if ($customer >= $campaign->max_uses_per_customer) { return ['eligible'=>false,'reason'=>'CUSTOMER_USAGE_LIMIT']; } } return [ 'eligible'=>true, 'reason'=>null, 'reward_type'=>$campaign->reward?->reward_type, 'exclusive'=>(bool)$campaign->is_exclusive, 'priority'=>$campaign->priority, ]; } private function scopeMatches( PromotionCampaign $campaign, ?int $companyId, ?int $branchId, ?string $group, ?string $customer ): bool { if ($campaign->scopes->isEmpty()) return true; foreach ($campaign->scopes as $scope) { if ($scope->scope_type === 'tenant') return true; if ($scope->scope_type === 'company' && $companyId !== null && $scope->company_id === $companyId) return true; if ($scope->scope_type === 'branch' && $branchId !== null && $scope->branch_id === $branchId) return true; if ($scope->scope_type === 'customer_group' && $group !== null && $scope->customer_group_key === $group) return true; if ($scope->scope_type === 'customer' && $customer !== null && $scope->customer_public_id === $customer) return true; } return false; } private function targetMatches( PromotionCampaign $campaign, ?int $familyId, ?int $variantId, ?int $categoryId ): bool { if ($campaign->targets->isEmpty()) return true; foreach ($campaign->targets as $target) { if ($target->target_type === 'all') return true; if ($target->target_type === 'product_family' && $familyId !== null && $target->product_family_id === $familyId) return true; if ($target->target_type === 'variant' && $variantId !== null && $target->variant_id === $variantId) return true; if ($target->target_type === 'category' && $categoryId !== null && $target->category_id === $categoryId) return true; } return false; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Promotions\Application\Permissions\PromotionPermissions.php ================================================================ loadMissing(['scopes','targets','condition','reward']); return [ 'version' => 1, 'campaign_id' => $campaign->public_id, 'priority' => $campaign->priority, 'exclusive' => (bool) $campaign->is_exclusive, 'scopes' => $campaign->scopes->map(fn ($scope) => [ 'type' => $scope->scope_type, 'company_id' => $scope->company_id, 'branch_id' => $scope->branch_id, 'customer_group_key' => $scope->customer_group_key, 'customer_public_id' => $scope->customer_public_id, ])->values()->all(), 'targets' => $campaign->targets->map(fn ($target) => [ 'type' => $target->target_type, 'product_family_id' => $target->product_family_id, 'variant_id' => $target->variant_id, 'category_id' => $target->category_id, ])->values()->all(), 'condition' => $campaign->condition ? [ 'minimum_quantity' => $campaign->condition->minimum_quantity, 'minimum_basket_amount' => $campaign->condition->minimum_basket_amount, ] : null, 'reward' => $campaign->reward ? [ 'type' => $campaign->reward->reward_type, 'percentage_value' => $campaign->reward->percentage_value, 'fixed_amount' => $campaign->reward->fixed_amount, 'buy_quantity' => $campaign->reward->buy_quantity, 'get_quantity' => $campaign->reward->get_quantity, 'reward_variant_id' => $campaign->reward->reward_variant_id, ] : null, 'usage' => [ 'max_total_uses' => $campaign->max_total_uses, 'max_uses_per_customer' => $campaign->max_uses_per_customer, ], ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Promotions\Domain\Models\PromotionCampaign.php ================================================================ 'integer', 'is_exclusive'=>'boolean', 'effective_from'=>'immutable_datetime', 'effective_to'=>'immutable_datetime', 'max_total_uses'=>'integer', 'max_uses_per_customer'=>'integer', 'compiled_rule'=>'array', 'metadata'=>'array', ]; } public function scopes(): HasMany { return $this->hasMany(PromotionScope::class, 'campaign_id'); } public function targets(): HasMany { return $this->hasMany(PromotionTarget::class, 'campaign_id'); } public function condition(): HasOne { return $this->hasOne(PromotionCondition::class, 'campaign_id'); } public function reward(): HasOne { return $this->hasOne(PromotionReward::class, 'campaign_id'); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Promotions\Domain\Models\PromotionCondition.php ================================================================ 'decimal:6','minimum_basket_amount'=>'decimal:6']; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Promotions\Domain\Models\PromotionReward.php ================================================================ 'decimal:4','fixed_amount'=>'decimal:6', 'buy_quantity'=>'decimal:6','get_quantity'=>'decimal:6' ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Promotions\Domain\Models\PromotionScope.php ================================================================ 'integer','last_used_at'=>'immutable_datetime']; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Promotions\Http\Controllers\PromotionCampaignController.php ================================================================ json([ 'data'=>PromotionCampaign::query() ->where('tenant_id',$tenantContext->tenantId()) ->orderBy('priority') ->orderBy('name') ->get() ->map(fn ($campaign)=>[ 'id'=>$campaign->public_id, 'name'=>$campaign->name, 'code'=>$campaign->code, 'priority'=>$campaign->priority, 'is_exclusive'=>(bool)$campaign->is_exclusive, 'status'=>$campaign->status, ])->values(), ]); } public function store( StorePromotionCampaignRequest $request, TenantContext $tenantContext, PromotionCompiler $compiler, AuditRecorder $auditRecorder, ): JsonResponse { $v=$request->validated(); $tenantId=$tenantContext->tenantId(); if (PromotionCampaign::query()->where('tenant_id',$tenantId)->where('code',$v['code'])->exists()) { throw ValidationException::withMessages(['code'=>['Promotion code already exists.']]); } $reward=$v['reward']; if ($reward['reward_type']==='percentage_discount' && !isset($reward['percentage_value'])) { throw ValidationException::withMessages(['reward.percentage_value'=>['Percentage value is required.']]); } if ($reward['reward_type']==='fixed_discount' && !isset($reward['fixed_amount'])) { throw ValidationException::withMessages(['reward.fixed_amount'=>['Fixed amount is required.']]); } $rewardVariantId=null; if ($reward['reward_type']==='buy_x_get_y') { if (!isset($reward['buy_quantity'],$reward['get_quantity'],$reward['reward_variant_id'])) { throw ValidationException::withMessages(['reward.reward_variant_id'=>['Buy X Get Y requires quantities and reward variant.']]); } $variant=Variant::query() ->where('tenant_id',$tenantId) ->where('public_id',$reward['reward_variant_id']) ->first(); if ($variant===null) { throw ValidationException::withMessages(['reward.reward_variant_id'=>['Reward variant was not found.']]); } $rewardVariantId=$variant->id; } $campaign=DB::transaction(function() use ($v,$reward,$rewardVariantId,$tenantId,$request,$compiler,$auditRecorder) { $campaign=PromotionCampaign::query()->create([ 'tenant_id'=>$tenantId, 'name'=>$v['name'], 'code'=>$v['code'], 'priority'=>$v['priority']??100, 'is_exclusive'=>$v['is_exclusive']??false, 'effective_from'=>$v['effective_from']??null, 'effective_to'=>$v['effective_to']??null, 'max_total_uses'=>$v['max_total_uses']??null, 'max_uses_per_customer'=>$v['max_uses_per_customer']??null, 'status'=>'active', 'metadata'=>$v['metadata']??null, ]); if (!empty($v['condition'])) { PromotionCondition::query()->create([ 'tenant_id'=>$tenantId, 'campaign_id'=>$campaign->id, 'minimum_quantity'=>$v['condition']['minimum_quantity']??null, 'minimum_basket_amount'=>$v['condition']['minimum_basket_amount']??null, ]); } PromotionReward::query()->create([ 'tenant_id'=>$tenantId, 'campaign_id'=>$campaign->id, 'reward_type'=>$reward['reward_type'], 'percentage_value'=>$reward['percentage_value']??null, 'fixed_amount'=>$reward['fixed_amount']??null, 'buy_quantity'=>$reward['buy_quantity']??null, 'get_quantity'=>$reward['get_quantity']??null, 'reward_variant_id'=>$rewardVariantId, ]); $campaign->load(['scopes','targets','condition','reward']); $campaign->compiled_rule=$compiler->compile($campaign); $campaign->save(); $auditRecorder->record( 'promotions.campaign.created', $tenantId, $request->user()->id, 'promotions.campaign', $campaign->public_id, after:[ 'code'=>$campaign->code, 'priority'=>$campaign->priority, 'is_exclusive'=>$campaign->is_exclusive, 'reward_type'=>$campaign->reward->reward_type, ], request:$request, ); return $campaign; }); return response()->json(['data'=>[ 'id'=>$campaign->public_id, 'name'=>$campaign->name, 'code'=>$campaign->code, 'priority'=>$campaign->priority, 'is_exclusive'=>(bool)$campaign->is_exclusive, 'compiled_rule'=>$campaign->compiled_rule, ]],201); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Promotions\Http\Controllers\PromotionEvaluateController.php ================================================================ where('tenant_id',$tenantContext->tenantId()) ->where('public_id',$campaignPublicId) ->firstOrFail(); $variantPublicId=$request->query('variant_id'); $variant=null; if ($variantPublicId) { $variant=Variant::query() ->with('family') ->where('tenant_id',$tenantContext->tenantId()) ->where('public_id',$variantPublicId) ->first(); if (!$variant) throw ValidationException::withMessages(['variant_id'=>['Variant was not found.']]); } $result=$evaluator->evaluate( campaign:$campaign, companyId:$scopeContext->companyId(), branchId:$scopeContext->branchId(), customerGroupKey:$request->query('customer_group_key'), customerPublicId:$request->query('customer_public_id'), productFamilyId:$variant?->product_family_id, variantId:$variant?->id, categoryId:$variant?->family?->category_id, quantity:(string)($request->query('quantity','1')), basketAmount:(string)($request->query('basket_amount','0')), ); return response()->json(['data'=>$result]); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Promotions\Http\Controllers\PromotionScopeController.php ================================================================ tenantId(); $campaign=PromotionCampaign::query() ->where('tenant_id',$tenantId) ->where('public_id',$campaignPublicId) ->firstOrFail(); $v=$request->validated(); $type=$v['scope_type']; $companyId=null; $branchId=null; $group=null; $customer=null; if ($type==='company') { if ($scopeContext->companyId()===null) throw ValidationException::withMessages(['scope_type'=>['X-Company-ID is required.']]); $companyId=$scopeContext->companyId(); } elseif ($type==='branch') { if ($scopeContext->companyId()===null || $scopeContext->branchId()===null) throw ValidationException::withMessages(['scope_type'=>['X-Branch-ID is required.']]); $companyId=$scopeContext->companyId(); $branchId=$scopeContext->branchId(); } elseif ($type==='customer_group') { if (empty($v['customer_group_key'])) throw ValidationException::withMessages(['customer_group_key'=>['Customer group key is required.']]); $group=$v['customer_group_key']; } elseif ($type==='customer') { if (empty($v['customer_public_id'])) throw ValidationException::withMessages(['customer_public_id'=>['Customer public ID is required.']]); $customer=$v['customer_public_id']; } $duplicate=PromotionScope::query() ->where('tenant_id',$tenantId) ->where('campaign_id',$campaign->id) ->where('scope_type',$type) ->when($type==='company',fn($q)=>$q->where('company_id',$companyId)) ->when($type==='branch',fn($q)=>$q->where('branch_id',$branchId)) ->when($type==='customer_group',fn($q)=>$q->where('customer_group_key',$group)) ->when($type==='customer',fn($q)=>$q->where('customer_public_id',$customer)) ->exists(); if ($duplicate) throw ValidationException::withMessages(['scope_type'=>['This scope is already attached.']]); $scope=PromotionScope::query()->create([ 'tenant_id'=>$tenantId,'campaign_id'=>$campaign->id,'scope_type'=>$type, 'company_id'=>$companyId,'branch_id'=>$branchId, 'customer_group_key'=>$group,'customer_public_id'=>$customer, ]); $campaign->load(['scopes','targets','condition','reward']); $campaign->compiled_rule=$compiler->compile($campaign); $campaign->save(); $auditRecorder->record( 'promotions.scope.attached',$tenantId,$request->user()->id, 'promotions.scope',$scope->public_id, after:['campaign_id'=>$campaign->public_id,'scope_type'=>$type], request:$request ); return response()->json(['data'=>['id'=>$scope->public_id,'scope_type'=>$type]],201); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Promotions\Http\Controllers\PromotionTargetController.php ================================================================ tenantId(); $campaign=PromotionCampaign::query() ->where('tenant_id',$tenantId) ->where('public_id',$campaignPublicId) ->firstOrFail(); $v=$request->validated(); $type=$v['target_type']; $familyId=null; $variantId=null; $categoryId=null; if ($type==='product_family') { if (empty($v['product_family_id'])) throw ValidationException::withMessages(['product_family_id'=>['Product family is required.']]); $m=ProductFamily::query()->where('tenant_id',$tenantId)->where('public_id',$v['product_family_id'])->first(); if (!$m) throw ValidationException::withMessages(['product_family_id'=>['Product family was not found.']]); $familyId=$m->id; } elseif ($type==='variant') { if (empty($v['variant_id'])) throw ValidationException::withMessages(['variant_id'=>['Variant is required.']]); $m=Variant::query()->where('tenant_id',$tenantId)->where('public_id',$v['variant_id'])->first(); if (!$m) throw ValidationException::withMessages(['variant_id'=>['Variant was not found.']]); $variantId=$m->id; } elseif ($type==='category') { if (empty($v['category_id'])) throw ValidationException::withMessages(['category_id'=>['Category is required.']]); $m=Category::query()->where('tenant_id',$tenantId)->where('public_id',$v['category_id'])->first(); if (!$m) throw ValidationException::withMessages(['category_id'=>['Category was not found.']]); $categoryId=$m->id; } $target=PromotionTarget::query()->create([ 'tenant_id'=>$tenantId,'campaign_id'=>$campaign->id,'target_type'=>$type, 'product_family_id'=>$familyId,'variant_id'=>$variantId,'category_id'=>$categoryId, ]); $campaign->load(['scopes','targets','condition','reward']); $campaign->compiled_rule=$compiler->compile($campaign); $campaign->save(); $auditRecorder->record( 'promotions.target.attached',$tenantId,$request->user()->id, 'promotions.target',$target->public_id, after:['campaign_id'=>$campaign->public_id,'target_type'=>$type], request:$request ); return response()->json(['data'=>['id'=>$target->public_id,'target_type'=>$type]],201); } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Promotions\Http\Requests\AttachPromotionScopeRequest.php ================================================================ ['required',Rule::in(['tenant','company','branch','customer_group','customer'])], 'customer_group_key'=>['nullable','string','max:100'], 'customer_public_id'=>['nullable','string','max:64'], ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Promotions\Http\Requests\AttachPromotionTargetRequest.php ================================================================ ['required',Rule::in(['all','product_family','variant','category'])], 'product_family_id'=>['nullable','string','size:26'], 'variant_id'=>['nullable','string','size:26'], 'category_id'=>['nullable','string','size:26'], ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\app\Modules\Promotions\Http\Requests\StorePromotionCampaignRequest.php ================================================================ ['required','string','max:180'], 'code'=>['required','string','max:100'], 'priority'=>['nullable','integer','min:0'], 'is_exclusive'=>['nullable','boolean'], 'effective_from'=>['nullable','date'], 'effective_to'=>['nullable','date','after:effective_from'], 'max_total_uses'=>['nullable','integer','min:1'], 'max_uses_per_customer'=>['nullable','integer','min:1'], 'metadata'=>['nullable','array'], 'condition'=>['nullable','array'], 'condition.minimum_quantity'=>['nullable','numeric','gt:0'], 'condition.minimum_basket_amount'=>['nullable','numeric','min:0'], 'reward'=>['required','array'], 'reward.reward_type'=>['required',Rule::in(['percentage_discount','fixed_discount','buy_x_get_y'])], 'reward.percentage_value'=>['nullable','numeric','gt:0','lte:100'], 'reward.fixed_amount'=>['nullable','numeric','gt:0'], 'reward.buy_quantity'=>['nullable','numeric','gt:0'], 'reward.get_quantity'=>['nullable','numeric','gt:0'], 'reward.reward_variant_id'=>['nullable','string','size:26'], ]; } } ================================================================ FILE: .\app\Modules\Sales\Application\Cart\CartRecalculator.php ================================================================ load(['lines']); $subtotal='0.000000'; $discountTotal='0.000000'; foreach ($cart->lines as $line) { $variant=Variant::query() ->with('family') ->where('tenant_id',$cart->tenant_id) ->whereKey($line->variant_id) ->firstOrFail(); /* * Use positional arguments here deliberately. * * EffectivePriceResolver was introduced earlier with parameter names * that changed during the Pricing repair cycle. Named arguments made * Sales depend on those internal parameter names and caused: * * Unknown named parameter $customerGroupKey * * The resolver contract is the argument ORDER / semantic contract, * not its private PHP parameter labels. */ $price=$this->priceResolver->resolve( $cart->tenant_id, $line->variant_id, $line->variant_unit_id, $cart->company_id, $cart->branch_id, $cart->customer_group_key, $cart->customer_public_id, ); if ($price === null) { throw ValidationException::withMessages([ 'price'=>['No effective price was found for one of the cart lines.'], ]); } $unitPrice=(string)$price['price_amount']; $gross=bcmul($unitPrice,(string)$line->quantity,6); $promotions=$this->promotionResolver->resolve( tenantId:$cart->tenant_id, variant:$variant, companyId:$cart->company_id, branchId:$cart->branch_id, customerGroupKey:$cart->customer_group_key, customerPublicId:$cart->customer_public_id, quantity:(string)$line->quantity, basketAmount:bcadd($subtotal,$gross,6), grossAmount:$gross, ); $lineDiscount=$promotions['discount_amount']; $net=bcsub($gross,$lineDiscount,6); $line->fill([ 'unit_price_amount'=>$unitPrice, 'gross_amount'=>$gross, 'discount_amount'=>$lineDiscount, 'net_amount'=>$net, 'price_snapshot'=>$price, 'promotion_snapshot'=>$promotions['promotions'], ]); $line->save(); $subtotal=bcadd($subtotal,$gross,6); $discountTotal=bcadd($discountTotal,$lineDiscount,6); } $cart->fill([ 'subtotal_amount'=>$subtotal, 'discount_amount'=>$discountTotal, 'total_amount'=>bcsub($subtotal,$discountTotal,6), ]); $cart->save(); return $cart->fresh('lines'); } } ================================================================ FILE: .\app\Modules\Sales\Application\Checkout\CheckoutCartAction.php ================================================================ where('tenant_id',$cart->tenant_id) ->where('idempotency_key',$idempotencyKey) ->first(); if ($existing?->sale_id) { return Sale::query()->with('lines')->findOrFail($existing->sale_id); } return DB::transaction(function () use ($cart,$expectedVersion,$idempotencyKey,$actorUserId,$request) { $locked=Cart::query() ->with('lines') ->whereKey($cart->id) ->lockForUpdate() ->firstOrFail(); $existing=CheckoutCommand::query() ->where('tenant_id',$locked->tenant_id) ->where('idempotency_key',$idempotencyKey) ->lockForUpdate() ->first(); if ($existing?->sale_id) { return Sale::query()->with('lines')->findOrFail($existing->sale_id); } if ($locked->status === 'converted') { $sale=Sale::query() ->with('lines') ->where('tenant_id',$locked->tenant_id) ->where('cart_id',$locked->id) ->first(); if ($sale) return $sale; throw new ConflictHttpException('Cart was already converted.'); } if ($locked->status !== 'open') { throw new ConflictHttpException('Cart is not open for checkout.'); } if ($locked->version !== $expectedVersion) { throw new ConflictHttpException('Cart version conflict.'); } if ($locked->lines->isEmpty()) { throw ValidationException::withMessages([ 'cart'=>['Cannot checkout an empty cart.'], ]); } if (bccomp((string)$locked->total_amount,'0.000000',6) < 0) { throw ValidationException::withMessages([ 'cart'=>['Cart total is invalid.'], ]); } $command=$existing ?? CheckoutCommand::query()->create([ 'tenant_id'=>$locked->tenant_id, 'cart_id'=>$locked->id, 'idempotency_key'=>$idempotencyKey, 'status'=>'started', 'created_at'=>now(), ]); $occurredAt=CarbonImmutable::now('UTC'); $number=$this->numberGenerator->next( $locked->tenant_id, $locked->branch_id, $occurredAt, ); $sale=Sale::query()->create([ 'tenant_id'=>$locked->tenant_id, 'company_id'=>$locked->company_id, 'branch_id'=>$locked->branch_id, 'register_id'=>$locked->register_id, 'cart_id'=>$locked->id, 'created_by_user_id'=>$actorUserId, 'sale_number'=>$number['sale_number'], 'business_date'=>$number['business_date'], 'occurred_at'=>$occurredAt, 'currency_code'=>$locked->currency_code, 'customer_public_id'=>$locked->customer_public_id, 'customer_group_key'=>$locked->customer_group_key, 'subtotal_amount'=>$locked->subtotal_amount, 'discount_amount'=>$locked->discount_amount, 'total_amount'=>$locked->total_amount, 'status'=>'pending_payment', 'payment_status'=>'unpaid', 'pricing_snapshot'=>[ 'source'=>'server_cart', 'cart_version'=>$locked->version, ], 'promotion_snapshot'=>[ 'line_promotions'=>$locked->lines ->filter(fn($line)=>!empty($line->promotion_snapshot)) ->map(fn($line)=>[ 'cart_line_id'=>$line->public_id, 'promotions'=>$line->promotion_snapshot, ])->values()->all(), ], ]); foreach ($locked->lines as $line) { $variant=Variant::query() ->where('tenant_id',$locked->tenant_id) ->whereKey($line->variant_id) ->firstOrFail(); SaleLine::query()->create([ 'tenant_id'=>$locked->tenant_id, 'sale_id'=>$sale->id, 'variant_id'=>$line->variant_id, 'variant_unit_id'=>$line->variant_unit_id, 'sku_snapshot'=>$variant->sku, 'name_snapshot'=>$variant->name, 'quantity'=>$line->quantity, 'unit_price_amount'=>$line->unit_price_amount, 'gross_amount'=>$line->gross_amount, 'discount_amount'=>$line->discount_amount, 'net_amount'=>$line->net_amount, 'price_snapshot'=>$line->price_snapshot, 'promotion_snapshot'=>$line->promotion_snapshot, 'metadata'=>$line->metadata, 'created_at'=>$occurredAt, ]); } $locked->status='converted'; $locked->version++; $locked->save(); $command->sale_id=$sale->id; $command->status='completed'; $command->response_snapshot=[ 'sale_id'=>$sale->public_id, 'sale_number'=>$sale->sale_number, 'status'=>$sale->status, 'payment_status'=>$sale->payment_status, 'total_amount'=>$sale->total_amount, ]; $command->completed_at=now(); $command->save(); $this->auditRecorder->record( 'sales.checkout.completed', $locked->tenant_id, $actorUserId, 'sales.sale', $sale->public_id, after:[ 'cart_id'=>$locked->public_id, 'sale_number'=>$sale->sale_number, 'total_amount'=>$sale->total_amount, 'status'=>$sale->status, 'payment_status'=>$sale->payment_status, ], request:$request, ); return $sale->fresh('lines'); }); } } ================================================================ FILE: .\app\Modules\Sales\Application\Numbering\SaleNumberGenerator.php ================================================================ toDateString(); $value=DB::transaction(function () use ($tenantId,$branchId,$businessDate) { /* * Serialize first-use and increment for this tenant/branch/day. * Advisory locks avoid the classic "both sessions see no sequence row" * race before lockForUpdate can lock an existing row. */ $branchKey=$branchId ?? 0; $lockKey=crc32("sales-number:$tenantId:$branchKey:$businessDate"); DB::select('SELECT pg_advisory_xact_lock(?)', [$lockKey]); $sequence=SaleNumberSequence::query() ->where('tenant_id',$tenantId) ->where('business_date',$businessDate) ->when($branchId===null,fn($q)=>$q->whereNull('branch_id'),fn($q)=>$q->where('branch_id',$branchId)) ->lockForUpdate() ->first(); if ($sequence===null) { $sequence=SaleNumberSequence::query()->create([ 'tenant_id'=>$tenantId, 'branch_id'=>$branchId, 'business_date'=>$businessDate, 'last_value'=>0, ]); } $sequence->last_value++; $sequence->save(); return $sequence->last_value; }); $branchPart=$branchId===null ? 'C' : 'B'.$branchId; return [ 'business_date'=>$businessDate, 'sale_number'=>sprintf( '%s-%s-%06d', $occurredAt->format('Ymd'), $branchPart, $value ), ]; } } ================================================================ FILE: .\app\Modules\Sales\Domain\Models\CheckoutCommand.php ================================================================ 'array', 'created_at'=>'immutable_datetime', 'completed_at'=>'immutable_datetime', ]; } } ================================================================ FILE: .\app\Modules\Sales\Domain\Models\Cart.php ================================================================ 'decimal:6', 'discount_amount'=>'decimal:6', 'total_amount'=>'decimal:6', 'version'=>'integer', 'expires_at'=>'immutable_datetime', 'metadata'=>'array', ]; } public function lines(): HasMany { return $this->hasMany(CartLine::class, 'cart_id'); } } ================================================================ FILE: .\app\Modules\Sales\Domain\Models\CartLine.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', ]; } } ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_01_073000_create_pricing_foundation_tables.php ================================================================ bigIncrements('id'); $t->ulid('public_id')->unique(); $t->unsignedBigInteger('tenant_id'); $t->string('name',180); $t->string('code',100); $t->string('currency_code',3)->default('EGP'); $t->unsignedInteger('priority')->default(100); $t->boolean('is_default')->default(false); $t->timestampTz('effective_from')->nullable(); $t->timestampTz('effective_to')->nullable(); $t->string('status',30)->default('active'); $t->jsonb('metadata')->nullable(); $t->timestampsTz(); $t->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $t->unique(['tenant_id','code']); $t->index(['tenant_id','status','priority']); }); DB::statement("ALTER TABLE pricing.price_lists ADD CONSTRAINT pricing_price_lists_range_check CHECK (effective_to IS NULL OR effective_from IS NULL OR effective_to > effective_from)"); DB::statement("CREATE UNIQUE INDEX pricing_price_lists_one_default_per_currency ON pricing.price_lists (tenant_id,currency_code) WHERE is_default=true AND status='active'"); Schema::create('pricing.price_list_scopes', function (Blueprint $t) { $t->bigIncrements('id'); $t->ulid('public_id')->unique(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('price_list_id'); $t->string('scope_type',30); $t->unsignedBigInteger('company_id')->nullable(); $t->unsignedBigInteger('branch_id')->nullable(); $t->string('customer_group_key',100)->nullable(); $t->string('customer_public_id',64)->nullable(); $t->timestampsTz(); $t->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $t->foreign('price_list_id')->references('id')->on('pricing.price_lists')->restrictOnDelete(); $t->foreign('company_id')->references('id')->on('core.companies')->restrictOnDelete(); $t->foreign('branch_id')->references('id')->on('core.branches')->restrictOnDelete(); $t->index(['tenant_id','scope_type']); }); DB::statement("ALTER TABLE pricing.price_list_scopes ADD CONSTRAINT pricing_scope_type_check CHECK (scope_type IN ('tenant','company','branch','customer_group','customer'))"); DB::statement("ALTER TABLE pricing.price_list_scopes ADD CONSTRAINT pricing_scope_shape_check CHECK ( (scope_type='tenant' AND company_id IS NULL AND branch_id IS NULL AND customer_group_key IS NULL AND customer_public_id IS NULL) OR (scope_type='company' AND company_id IS NOT NULL AND branch_id IS NULL AND customer_group_key IS NULL AND customer_public_id IS NULL) OR (scope_type='branch' AND company_id IS NOT NULL AND branch_id IS NOT NULL AND customer_group_key IS NULL AND customer_public_id IS NULL) OR (scope_type='customer_group' AND company_id IS NULL AND branch_id IS NULL AND customer_group_key IS NOT NULL AND customer_public_id IS NULL) OR (scope_type='customer' AND company_id IS NULL AND branch_id IS NULL AND customer_group_key IS NULL AND customer_public_id IS NOT NULL) )"); DB::statement("CREATE UNIQUE INDEX pricing_scope_tenant_uq ON pricing.price_list_scopes(tenant_id,price_list_id) WHERE scope_type='tenant'"); DB::statement("CREATE UNIQUE INDEX pricing_scope_company_uq ON pricing.price_list_scopes(tenant_id,price_list_id,company_id) WHERE scope_type='company'"); DB::statement("CREATE UNIQUE INDEX pricing_scope_branch_uq ON pricing.price_list_scopes(tenant_id,price_list_id,branch_id) WHERE scope_type='branch'"); DB::statement("CREATE UNIQUE INDEX pricing_scope_group_uq ON pricing.price_list_scopes(tenant_id,price_list_id,customer_group_key) WHERE scope_type='customer_group'"); DB::statement("CREATE UNIQUE INDEX pricing_scope_customer_uq ON pricing.price_list_scopes(tenant_id,price_list_id,customer_public_id) WHERE scope_type='customer'"); Schema::create('pricing.price_entries', function (Blueprint $t) { $t->bigIncrements('id'); $t->ulid('public_id')->unique(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('price_list_id'); $t->unsignedBigInteger('variant_id'); $t->unsignedBigInteger('variant_unit_id')->nullable(); $t->decimal('price_amount',18,6); $t->decimal('min_price_amount',18,6)->nullable(); $t->timestampTz('effective_from')->nullable(); $t->timestampTz('effective_to')->nullable(); $t->string('status',30)->default('active'); $t->jsonb('metadata')->nullable(); $t->timestampsTz(); $t->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $t->foreign('price_list_id')->references('id')->on('pricing.price_lists')->restrictOnDelete(); $t->foreign('variant_id')->references('id')->on('catalog.variants')->restrictOnDelete(); $t->foreign('variant_unit_id')->references('id')->on('catalog.variant_units')->restrictOnDelete(); $t->index(['tenant_id','variant_id','status']); }); DB::statement("ALTER TABLE pricing.price_entries ADD CONSTRAINT pricing_entry_amount_check CHECK (price_amount>=0 AND (min_price_amount IS NULL OR (min_price_amount>=0 AND min_price_amount<=price_amount)))"); DB::statement("ALTER TABLE pricing.price_entries ADD CONSTRAINT pricing_entry_range_check CHECK (effective_to IS NULL OR effective_from IS NULL OR effective_to > effective_from)"); DB::statement("CREATE UNIQUE INDEX pricing_entry_variant_uq ON pricing.price_entries(tenant_id,price_list_id,variant_id) WHERE variant_unit_id IS NULL AND status='active'"); DB::statement("CREATE UNIQUE INDEX pricing_entry_unit_uq ON pricing.price_entries(tenant_id,price_list_id,variant_id,variant_unit_id) WHERE variant_unit_id IS NOT NULL AND status='active'"); Schema::create('pricing.margin_guards', function (Blueprint $t) { $t->bigIncrements('id'); $t->ulid('public_id')->unique(); $t->unsignedBigInteger('tenant_id'); $t->string('scope_type',30); $t->unsignedBigInteger('company_id')->nullable(); $t->unsignedBigInteger('branch_id')->nullable(); $t->decimal('minimum_margin_percent',9,4)->nullable(); $t->decimal('minimum_markup_percent',9,4)->nullable(); $t->string('violation_action',30)->default('block'); $t->string('approval_action_code',180)->nullable(); $t->string('status',30)->default('active'); $t->timestampsTz(); $t->foreign('tenant_id')->references('id')->on('core.tenants')->restrictOnDelete(); $t->foreign('company_id')->references('id')->on('core.companies')->restrictOnDelete(); $t->foreign('branch_id')->references('id')->on('core.branches')->restrictOnDelete(); }); DB::statement("ALTER TABLE pricing.margin_guards ADD CONSTRAINT pricing_margin_scope_check CHECK (scope_type IN ('tenant','company','branch'))"); DB::statement("ALTER TABLE pricing.margin_guards ADD CONSTRAINT pricing_margin_action_check CHECK (violation_action IN ('block','approval'))"); DB::statement("ALTER TABLE pricing.margin_guards ADD CONSTRAINT pricing_margin_values_check CHECK (minimum_margin_percent IS NOT NULL OR minimum_markup_percent IS NOT NULL)"); DB::statement("ALTER TABLE pricing.margin_guards ADD CONSTRAINT pricing_margin_shape_check CHECK ( (scope_type='tenant' AND company_id IS NULL AND branch_id IS NULL) OR (scope_type='company' AND company_id IS NOT NULL AND branch_id IS NULL) OR (scope_type='branch' AND company_id IS NOT NULL AND branch_id IS NOT NULL) )"); DB::statement("CREATE UNIQUE INDEX pricing_margin_tenant_uq ON pricing.margin_guards(tenant_id) WHERE scope_type='tenant' AND status='active'"); DB::statement("CREATE UNIQUE INDEX pricing_margin_company_uq ON pricing.margin_guards(tenant_id,company_id) WHERE scope_type='company' AND status='active'"); DB::statement("CREATE UNIQUE INDEX pricing_margin_branch_uq ON pricing.margin_guards(tenant_id,branch_id) WHERE scope_type='branch' AND status='active'"); } public function down(): void { Schema::dropIfExists('pricing.margin_guards'); Schema::dropIfExists('pricing.price_entries'); Schema::dropIfExists('pricing.price_list_scopes'); Schema::dropIfExists('pricing.price_lists'); } }; ================================================================ FILE: F:\POS 2026\retail-platform\apps\api\database\migrations\2026_09_01_082900_cleanup_partial_promotions_v16.php ================================================================ bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->string('name', 180); $table->string('code', 100); $table->unsignedInteger('priority')->default(100); $table->boolean('is_exclusive')->default(false); $table->timestampTz('effective_from')->nullable(); $table->timestampTz('effective_to')->nullable(); $table->unsignedBigInteger('max_total_uses')->nullable(); $table->unsignedBigInteger('max_uses_per_customer')->nullable(); $table->string('status', 30)->default('active'); $table->jsonb('compiled_rule')->nullable(); $table->jsonb('metadata')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id') ->references('id')->on('core.tenants') ->restrictOnDelete(); $table->unique(['tenant_id', 'code']); $table->index(['tenant_id', 'status', 'priority']); }); DB::statement(" ALTER TABLE promotions.campaigns ADD CONSTRAINT promotions_campaigns_effective_range_check CHECK (effective_to IS NULL OR effective_from IS NULL OR effective_to > effective_from) "); Schema::create('promotions.scopes', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('campaign_id'); $table->string('scope_type', 30); $table->unsignedBigInteger('company_id')->nullable(); $table->unsignedBigInteger('branch_id')->nullable(); $table->string('customer_group_key', 100)->nullable(); $table->string('customer_public_id', 64)->nullable(); $table->timestampsTz(); $table->foreign('tenant_id') ->references('id')->on('core.tenants') ->restrictOnDelete(); $table->foreign('campaign_id') ->references('id')->on('promotions.campaigns') ->restrictOnDelete(); $table->foreign('company_id') ->references('id')->on('core.companies') ->restrictOnDelete(); $table->foreign('branch_id') ->references('id')->on('core.branches') ->restrictOnDelete(); $table->index(['tenant_id', 'scope_type']); }); DB::statement(" ALTER TABLE promotions.scopes ADD CONSTRAINT promotions_scopes_type_check CHECK (scope_type IN ('tenant','company','branch','customer_group','customer')) "); DB::statement(" ALTER TABLE promotions.scopes ADD CONSTRAINT promotions_scopes_shape_check CHECK ( (scope_type='tenant' AND company_id IS NULL AND branch_id IS NULL AND customer_group_key IS NULL AND customer_public_id IS NULL) OR (scope_type='company' AND company_id IS NOT NULL AND branch_id IS NULL AND customer_group_key IS NULL AND customer_public_id IS NULL) OR (scope_type='branch' AND company_id IS NOT NULL AND branch_id IS NOT NULL AND customer_group_key IS NULL AND customer_public_id IS NULL) OR (scope_type='customer_group' AND company_id IS NULL AND branch_id IS NULL AND customer_group_key IS NOT NULL AND customer_public_id IS NULL) OR (scope_type='customer' AND company_id IS NULL AND branch_id IS NULL AND customer_group_key IS NULL AND customer_public_id IS NOT NULL) ) "); DB::statement("CREATE UNIQUE INDEX promotions_scope_tenant_uq ON promotions.scopes(tenant_id,campaign_id) WHERE scope_type='tenant'"); DB::statement("CREATE UNIQUE INDEX promotions_scope_company_uq ON promotions.scopes(tenant_id,campaign_id,company_id) WHERE scope_type='company'"); DB::statement("CREATE UNIQUE INDEX promotions_scope_branch_uq ON promotions.scopes(tenant_id,campaign_id,branch_id) WHERE scope_type='branch'"); DB::statement("CREATE UNIQUE INDEX promotions_scope_group_uq ON promotions.scopes(tenant_id,campaign_id,customer_group_key) WHERE scope_type='customer_group'"); DB::statement("CREATE UNIQUE INDEX promotions_scope_customer_uq ON promotions.scopes(tenant_id,campaign_id,customer_public_id) WHERE scope_type='customer'"); Schema::create('promotions.targets', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('campaign_id'); $table->string('target_type', 30); $table->unsignedBigInteger('product_family_id')->nullable(); $table->unsignedBigInteger('variant_id')->nullable(); $table->unsignedBigInteger('category_id')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id') ->references('id')->on('core.tenants') ->restrictOnDelete(); $table->foreign('campaign_id') ->references('id')->on('promotions.campaigns') ->restrictOnDelete(); $table->foreign('product_family_id') ->references('id')->on('catalog.product_families') ->restrictOnDelete(); $table->foreign('variant_id') ->references('id')->on('catalog.variants') ->restrictOnDelete(); $table->foreign('category_id') ->references('id')->on('catalog.categories') ->restrictOnDelete(); $table->index(['tenant_id', 'campaign_id', 'target_type']); }); DB::statement(" ALTER TABLE promotions.targets ADD CONSTRAINT promotions_targets_type_check CHECK (target_type IN ('all','product_family','variant','category')) "); DB::statement(" ALTER TABLE promotions.targets ADD CONSTRAINT promotions_targets_shape_check CHECK ( (target_type='all' AND product_family_id IS NULL AND variant_id IS NULL AND category_id IS NULL) OR (target_type='product_family' AND product_family_id IS NOT NULL AND variant_id IS NULL AND category_id IS NULL) OR (target_type='variant' AND product_family_id IS NULL AND variant_id IS NOT NULL AND category_id IS NULL) OR (target_type='category' AND product_family_id IS NULL AND variant_id IS NULL AND category_id IS NOT NULL) ) "); Schema::create('promotions.conditions', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('campaign_id'); $table->decimal('minimum_quantity', 18, 6)->nullable(); $table->decimal('minimum_basket_amount', 18, 6)->nullable(); $table->timestampsTz(); $table->foreign('tenant_id') ->references('id')->on('core.tenants') ->restrictOnDelete(); $table->foreign('campaign_id') ->references('id')->on('promotions.campaigns') ->restrictOnDelete(); $table->unique(['campaign_id']); }); DB::statement(" ALTER TABLE promotions.conditions ADD CONSTRAINT promotions_conditions_values_check CHECK ( (minimum_quantity IS NULL OR minimum_quantity > 0) AND (minimum_basket_amount IS NULL OR minimum_basket_amount >= 0) ) "); Schema::create('promotions.rewards', function (Blueprint $table) { $table->bigIncrements('id'); $table->ulid('public_id')->unique(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('campaign_id'); $table->string('reward_type', 30); $table->decimal('percentage_value', 9, 4)->nullable(); $table->decimal('fixed_amount', 18, 6)->nullable(); $table->decimal('buy_quantity', 18, 6)->nullable(); $table->decimal('get_quantity', 18, 6)->nullable(); $table->unsignedBigInteger('reward_variant_id')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id') ->references('id')->on('core.tenants') ->restrictOnDelete(); $table->foreign('campaign_id') ->references('id')->on('promotions.campaigns') ->restrictOnDelete(); $table->foreign('reward_variant_id') ->references('id')->on('catalog.variants') ->restrictOnDelete(); $table->unique(['campaign_id']); }); DB::statement(" ALTER TABLE promotions.rewards ADD CONSTRAINT promotions_rewards_type_check CHECK (reward_type IN ('percentage_discount','fixed_discount','buy_x_get_y')) "); DB::statement(" ALTER TABLE promotions.rewards ADD CONSTRAINT promotions_rewards_shape_check CHECK ( (reward_type='percentage_discount' AND percentage_value IS NOT NULL AND percentage_value > 0 AND percentage_value <= 100 AND fixed_amount IS NULL AND buy_quantity IS NULL AND get_quantity IS NULL AND reward_variant_id IS NULL) OR (reward_type='fixed_discount' AND fixed_amount IS NOT NULL AND fixed_amount > 0 AND percentage_value IS NULL AND buy_quantity IS NULL AND get_quantity IS NULL AND reward_variant_id IS NULL) OR (reward_type='buy_x_get_y' AND buy_quantity IS NOT NULL AND buy_quantity > 0 AND get_quantity IS NOT NULL AND get_quantity > 0 AND reward_variant_id IS NOT NULL AND percentage_value IS NULL AND fixed_amount IS NULL) ) "); Schema::create('promotions.usage_counters', function (Blueprint $table) { $table->bigIncrements('id'); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('campaign_id'); $table->string('customer_public_id', 64)->nullable(); $table->unsignedBigInteger('usage_count')->default(0); $table->timestampTz('last_used_at')->nullable(); $table->timestampsTz(); $table->foreign('tenant_id') ->references('id')->on('core.tenants') ->restrictOnDelete(); $table->foreign('campaign_id') ->references('id')->on('promotions.campaigns') ->restrictOnDelete(); $table->index(['tenant_id', 'campaign_id']); $table->index(['tenant_id', 'customer_public_id']); }); DB::statement("CREATE UNIQUE INDEX promotions_usage_total_uq ON promotions.usage_counters(tenant_id,campaign_id) WHERE customer_public_id IS NULL"); DB::statement("CREATE UNIQUE INDEX promotions_usage_customer_uq ON promotions.usage_counters(tenant_id,campaign_id,customer_public_id) WHERE customer_public_id IS NOT NULL"); } public function down(): void { Schema::dropIfExists('promotions.usage_counters'); Schema::dropIfExists('promotions.rewards'); Schema::dropIfExists('promotions.conditions'); Schema::dropIfExists('promotions.targets'); Schema::dropIfExists('promotions.scopes'); Schema::dropIfExists('promotions.campaigns'); } }; ================================================================ 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'); } };