# Cycle 11: Pricing Engine Alignment

## Goal
Add `billing_cycle`, auto-applied promotions, `coupon_codes` table, `stackable_with_rules`, PriceNotFoundException, `min_final_amount`, and fix cascading behavior.

---

## Migration: `2024_01_01_000068_pricing_engine_alignment.php`

### On `base_prices`:
```php
$table->string('billing_cycle', 20)->default('one_time')->after('amount');
```
CHECK: `billing_cycle IN ('one_time', 'monthly', 'quarterly', 'semi_annual', 'annual', 'per_session')`

### On `pricing_rules`:
```php
$table->bigInteger('min_final_amount')->nullable()->after('adjustment_value');
// Minimum final price after this rule applies — floor per-rule
```

### On `promotions`:
```php
$table->boolean('stackable_with_rules')->default(false)->after('is_active');
// If true: promotion discount ADDS to rule discounts
// If false: promotion REPLACES rule discounts (takes whichever is larger)
$table->boolean('requires_coupon')->default(true)->after('stackable_with_rules');
// If false: auto-applied when conditions match (no code needed)
```

---

## New Migration: `2024_01_01_000069_create_coupon_codes_table.php`

```sql
CREATE TABLE coupon_codes (
    id              BIGSERIAL PRIMARY KEY,
    promotion_id    BIGINT NOT NULL REFERENCES promotions(id) ON DELETE CASCADE,
    code            VARCHAR(50) NOT NULL,
    max_uses        INTEGER,                    -- NULL = unlimited
    current_uses    INTEGER NOT NULL DEFAULT 0,
    assigned_to_type VARCHAR(100),              -- polymorphic (Participant, User)
    assigned_to_id  BIGINT,
    expires_at      TIMESTAMP,
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,
    created_at      TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX coupon_codes_promotion_code ON coupon_codes(promotion_id, code);
CREATE INDEX coupon_codes_code_active ON coupon_codes(code) WHERE is_active = TRUE;
```

### Model: `app/Domain/Pricing/Models/CouponCode.php`
```php
class CouponCode extends Model
{
    public $timestamps = false;
    const UPDATED_AT = null;

    protected $fillable = [
        'promotion_id', 'code', 'max_uses', 'current_uses',
        'assigned_to_type', 'assigned_to_id', 'expires_at', 'is_active',
    ];

    protected $casts = [
        'expires_at' => 'datetime',
        'is_active' => 'boolean',
    ];

    public function promotion(): BelongsTo { return $this->belongsTo(Promotion::class); }
    public function assignedTo(): MorphTo { return $this->morphTo('assigned_to'); }

    public function isValid(): bool
    {
        if (!$this->is_active) return false;
        if ($this->expires_at && $this->expires_at->isPast()) return false;
        if ($this->max_uses && $this->current_uses >= $this->max_uses) return false;
        return true;
    }
}
```

---

## PriceNotFoundException

```php
namespace App\Domain\Pricing\Exceptions;

use App\Domain\Shared\Exceptions\DomainException;

class PriceNotFoundException extends DomainException
{
    public function __construct(string $itemType, int $itemId, ?int $branchId = null)
    {
        $branchInfo = $branchId ? " (فرع: {$branchId})" : '';
        parent::__construct("لا يوجد سعر محدد لـ {$itemType}#{$itemId}{$branchInfo}");
    }
}
```

Replace in PricingService:
```php
// Before:
throw new DomainException('لا يوجد سعر محدد');

// After:
throw new PriceNotFoundException($itemType, $itemId, $branchId);
```

---

## PricingService Fixes

### Fix 1: Non-Cascading Percentage Application

Current (wrong — cascading):
```php
// Each percentage rule applies to $currentAmount (which changes)
$currentAmount -= (int) round($currentAmount * $rule->adjustment_value / 10000);
```

Correct (per doc — apply to ORIGINAL base):
```php
// Step 5: Apply rules in priority order
$baseForPercentage = $baseAmount; // ORIGINAL base, not current
$totalFixedDiscount = 0;
$totalPercentageDiscount = 0;

foreach ($applicableRules as $rule) {
    $discount = match ($rule->adjustment_type) {
        'percentage_discount' => (int) round($baseForPercentage * $rule->adjustment_value / 10000),
        'fixed_discount' => $rule->adjustment_value,
        'fixed_price' => $baseAmount - $rule->adjustment_value, // difference becomes the "discount"
        'percentage_increase' => -(int) round($baseForPercentage * $rule->adjustment_value / 10000), // negative = increase
    };

    // Step 6: Per-rule cap
    if ($rule->max_discount_percent && $discount > 0) {
        $maxForThisRule = (int) round($baseAmount * $rule->max_discount_percent / 100);
        $discount = min($discount, $maxForThisRule);
    }

    // Step 6b: min_final_amount per rule
    if ($rule->min_final_amount) {
        $maxDiscount = $baseAmount - $rule->min_final_amount;
        $discount = min($discount, max(0, $maxDiscount));
    }

    $totalDiscount += $discount;
    $appliedRules[] = [...];
}

$currentAmount = $baseAmount - $totalDiscount;
```

### Fix 2: Auto-Applied Promotions

Currently PricingService only applies promotions when a coupon code is explicitly provided. The doc says promotions with `requires_coupon = false` should auto-apply.

```php
// NEW Step 7a: Auto-apply promotions (no code needed)
$autoPromotions = Promotion::where('academy_id', $academyId)
    ->where('type', 'automatic')
    ->where('requires_coupon', false)
    ->where('is_active', true)
    ->where('start_date', '<=', today())
    ->where(fn ($q) => $q->whereNull('end_date')->orWhere('end_date', '>=', today()))
    ->where(fn ($q) => $q->whereNull('usage_limit')->orWhereColumn('usage_count', '<', 'usage_limit'))
    ->orderByDesc('priority')
    ->get();

foreach ($autoPromotions as $promo) {
    if ($this->promotionMatchesTarget($promo, $itemType, $itemId)) {
        $promoDiscount = $this->calculatePromotionDiscount($promo, $currentAmount);

        if ($promo->stackable_with_rules) {
            // Stack: add promotion discount to rule discounts
            $currentAmount -= $promoDiscount;
        } else {
            // Replace: use whichever is larger (rule discounts vs promotion)
            $promotionAloneAmount = $baseAmount - $promoDiscount;
            $currentAmount = min($currentAmount, $promotionAloneAmount);
        }
        break; // Only one auto-promotion applies
    }
}

// Step 7b: Coupon code (existing logic, but also check coupon_codes table)
if ($couponCode) {
    $this->validateAndApplyCoupon($couponCode, ...);
}
```

### Fix 3: Coupon Validation Enhanced

When validating a coupon code, check BOTH:
1. `promotions.code` (current — single code per promotion)
2. `coupon_codes.code` (new — multiple codes per promotion)

```php
private function findPromotionByCode(string $code, int $academyId): ?Promotion
{
    // Check promotion.code directly
    $promo = Promotion::where('academy_id', $academyId)
        ->where('code', strtoupper($code))
        ->where('is_active', true)
        ->first();

    if ($promo) return $promo;

    // Check coupon_codes table
    $couponCode = CouponCode::where('code', strtoupper($code))
        ->where('is_active', true)
        ->whereHas('promotion', fn ($q) => $q->where('academy_id', $academyId)->where('is_active', true))
        ->first();

    if ($couponCode && $couponCode->isValid()) {
        return $couponCode->promotion;
    }

    return null;
}
```

### Fix 4: incrementUsage Enhanced

After successful sale, increment both `promotion.usage_count` AND `coupon_code.current_uses`:
```php
public function incrementUsage(Promotion $promotion, ?string $code, int $participantId, int $invoiceId): void
{
    $promotion->increment('usage_count');

    // Increment coupon_code if it was from the codes table
    if ($code) {
        CouponCode::where('promotion_id', $promotion->id)
            ->where('code', strtoupper($code))
            ->increment('current_uses');
    }

    PromotionUsage::create([
        'promotion_id' => $promotion->id,
        'participant_id' => $participantId,
        'invoice_id' => $invoiceId,
        'discount_amount' => 0, // set by caller
        'used_at' => now(),
    ]);
}
```

---

## PriceResult Enhancement

Add missing fields:
```php
class PriceResult
{
    public function __construct(
        public int $baseAmount,
        public int $finalAmount,
        public int $totalDiscount,
        public array $appliedRules,
        public ?array $appliedPromotion,
        public ?array $appliedCoupon,       // NEW: separate from promotion
        public ?int $basePriceId,
        public string $currency = 'EGP',    // NEW
        public ?string $billingCycle = null, // NEW
    ) {}

    public function breakdown(): array
    {
        $lines = [['label' => 'السعر الأساسي', 'amount' => $this->baseAmount]];

        foreach ($this->appliedRules as $rule) {
            $lines[] = ['label' => $rule['name'], 'amount' => -$rule['discount']];
        }

        if ($this->appliedPromotion) {
            $lines[] = ['label' => $this->appliedPromotion['name'], 'amount' => -$this->appliedPromotion['discount']];
        }

        $lines[] = ['label' => 'الإجمالي', 'amount' => $this->finalAmount];
        return $lines;
    }

    public function toInvoiceItemMeta(): array
    {
        return [
            'base_amount' => $this->baseAmount,
            'final_amount' => $this->finalAmount,
            'discount' => $this->totalDiscount,
            'discount_percentage' => $this->discountPercentage(),
            'rules_applied' => $this->appliedRules,
            'promotion_applied' => $this->appliedPromotion,
            'coupon_applied' => $this->appliedCoupon,
            'billing_cycle' => $this->billingCycle,
            'calculated_at' => now()->toIso8601String(),
        ];
    }
}
```

---

## Livewire: Coupon Code Management

### `app/Livewire/Pricing/CouponCodeManager.php`
- Nested within PromotionForm (show when type = 'coupon')
- Generate N random codes (batch generation)
- Assign specific codes to participants
- Deactivate individual codes
- View usage per code

---

## Verification

- [ ] `billing_cycle` appears on base price form and saves correctly
- [ ] Auto-applied promotion triggers without code when conditions match
- [ ] `requires_coupon = false` promotion auto-applies at POS
- [ ] Multiple coupon codes per promotion work (batch generate + validate)
- [ ] Customer-specific code: only assigned participant can use it
- [ ] `min_final_amount`: rule cannot discount below this floor
- [ ] Percentage discounts apply to ORIGINAL base (not cascading)
- [ ] `stackable_with_rules = true`: promotion adds to rule discounts
- [ ] `stackable_with_rules = false`: takes larger of (rules discount) vs (promotion discount)
- [ ] PriceNotFoundException thrown with descriptive Arabic message
- [ ] PriceResult.breakdown() returns readable line items
- [ ] PriceResult.toInvoiceItemMeta() snapshot saved on invoice_items.metadata
