, source: string} */ public function preview(string $code, Package $package, ?Tenant $tenant = null): array { $coupon = $this->findCouponForCode($code); $this->ensureCouponCanBeApplied($coupon, $package, $tenant); $pricing = $this->buildPricingBreakdown($coupon, $package, $tenant); return [ 'coupon' => $coupon, 'pricing' => $pricing['pricing'], 'source' => $pricing['source'], ]; } public function ensureCouponCanBeApplied(Coupon $coupon, Package $package, ?Tenant $tenant = null): void { if (! $coupon->paddle_discount_id) { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.not_synced'), ]); } if (! $coupon->enabled_for_checkout) { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.disabled'), ]); } if (! $coupon->isCurrentlyActive()) { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.inactive'), ]); } if (! $coupon->appliesToPackage($package)) { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.not_applicable'), ]); } if ($tenant) { $usage = $this->usageForTenant($coupon, $tenant); $remaining = $coupon->remainingUsages($usage); if ($remaining !== null && $remaining <= 0) { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.limit_reached'), ]); } } elseif ($coupon->per_customer_limit !== null) { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.login_required'), ]); } } protected function findCouponForCode(string $code): Coupon { $normalized = Str::upper(trim($code)); if ($normalized === '') { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.required'), ]); } $coupon = Coupon::query() ->where('code', $normalized) ->first(); if (! $coupon) { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.not_found'), ]); } if (in_array($coupon->status, [CouponStatus::PAUSED, CouponStatus::ARCHIVED, CouponStatus::DRAFT], true)) { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.inactive'), ]); } return $coupon; } protected function usageForTenant(Coupon $coupon, Tenant $tenant): int { return $coupon->redemptions() ->where('tenant_id', $tenant->id) ->whereNot('status', CouponRedemption::STATUS_FAILED) ->count(); } /** * @return array{pricing: array, source: string} */ protected function buildPricingBreakdown(Coupon $coupon, Package $package, ?Tenant $tenant = null): array { $currency = Str::upper($package->currency ?? 'EUR'); $subtotal = (float) $package->price; if ($package->paddle_price_id) { try { $preview = $this->paddleDiscounts->previewDiscount( $coupon, [ [ 'price_id' => $package->paddle_price_id, 'quantity' => 1, ], ], array_filter([ 'currency' => $currency, 'customer_id' => $tenant?->paddle_customer_id, ]) ); $mapped = $this->mapPaddlePreview($preview, $currency, $subtotal); return [ 'pricing' => $mapped, 'source' => 'paddle', ]; } catch (PaddleException $exception) { Log::warning('Paddle preview failed, falling back to manual pricing', [ 'coupon_id' => $coupon->id, 'package_id' => $package->id, 'message' => $exception->getMessage(), ]); } } return [ 'pricing' => $this->manualPricing($coupon, $currency, $subtotal), 'source' => 'manual', ]; } protected function mapPaddlePreview(array $preview, string $currency, float $fallbackSubtotal): array { $totals = $this->extractTotals($preview); $subtotal = $totals['subtotal'] ?? $fallbackSubtotal; $discount = $totals['discount'] ?? 0.0; $tax = $totals['tax'] ?? 0.0; $total = $totals['total'] ?? max($subtotal - $discount + $tax, 0); return $this->formatPricing($currency, $subtotal, $discount, $tax, $total, [ 'raw' => $preview, 'breakdown' => $totals['breakdown'] ?? [], ]); } protected function manualPricing(Coupon $coupon, string $currency, float $subtotal): array { $discount = match ($coupon->type) { CouponType::PERCENTAGE => round($subtotal * ((float) $coupon->amount) / 100, 2), default => (float) $coupon->amount, }; if ($coupon->type !== CouponType::PERCENTAGE && $coupon->currency && Str::upper($coupon->currency) !== $currency) { throw ValidationException::withMessages([ 'code' => __('marketing.coupon.errors.currency_mismatch'), ]); } $discount = min($discount, $subtotal); $total = max($subtotal - $discount, 0); return $this->formatPricing($currency, $subtotal, $discount, 0, $total, [ 'breakdown' => [ ['type' => 'coupon', 'amount' => $discount], ], ]); } protected function extractTotals(array $preview): array { $totals = Arr::get($preview, 'totals', Arr::get($preview, 'details.totals', [])); $subtotal = $this->convertMinorAmount($totals['subtotal'] ?? ($totals['subtotal']['amount'] ?? null)); $discount = $this->convertMinorAmount($totals['discount'] ?? ($totals['discount']['amount'] ?? null)); $tax = $this->convertMinorAmount($totals['tax'] ?? ($totals['tax']['amount'] ?? null)); $total = $this->convertMinorAmount($totals['total'] ?? ($totals['total']['amount'] ?? null)); return array_filter([ 'currency' => $totals['currency_code'] ?? Arr::get($preview, 'currency_code'), 'subtotal' => $subtotal, 'discount' => $discount, 'tax' => $tax, 'total' => $total, 'breakdown' => Arr::get($preview, 'discounts', []), ], static fn ($value) => $value !== null && $value !== ''); } protected function convertMinorAmount(mixed $value): ?float { if ($value === null || $value === '') { return null; } if (is_array($value) && isset($value['amount'])) { $value = $value['amount']; } if (! is_numeric($value)) { return null; } return round(((float) $value) / 100, 2); } protected function formatPricing(string $currency, float $subtotal, float $discount, float $tax, float $total, array $extra = []): array { $locale = $this->mapLocale(app()->getLocale()); $formatter = class_exists(\NumberFormatter::class) ? new \NumberFormatter($locale, \NumberFormatter::CURRENCY) : null; $format = function (float $amount, bool $allowNegative = false) use ($currency, $formatter): string { $value = $allowNegative ? $amount : max($amount, 0); if ($formatter) { $formatted = $formatter->formatCurrency($value, $currency); if ($formatted !== false) { return $formatted; } } $symbol = match ($currency) { 'EUR' => '€', 'USD' => '$', default => $currency.' ', }; return $symbol.number_format($value, 2, ',', '.'); }; return array_merge([ 'currency' => $currency, 'subtotal' => round($subtotal, 2), 'discount' => round($discount, 2), 'tax' => round($tax, 2), 'total' => round($total, 2), 'formatted' => [ 'subtotal' => $format($subtotal), 'discount' => $format(-1 * abs($discount), true), 'tax' => $format($tax), 'total' => $format($total), ], ], $extra); } protected function mapLocale(?string $locale): string { return match ($locale) { 'de', 'de_DE' => 'de_DE', 'en', 'en_GB', 'en_US' => 'en_US', default => 'en_US', }; } }