Files
fotospiel-app/app/Services/Coupons/CouponService.php
Codex Agent 10c99de1e2
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Migrate billing from Paddle to Lemon Squeezy
2026-02-03 10:59:54 +01:00

202 lines
6.4 KiB
PHP

<?php
namespace App\Services\Coupons;
use App\Enums\CouponStatus;
use App\Enums\CouponType;
use App\Models\Coupon;
use App\Models\CouponRedemption;
use App\Models\Package;
use App\Models\Tenant;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class CouponService
{
public function __construct() {}
/**
* @return array{coupon: Coupon, pricing: array<string, mixed>, 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->lemonsqueezy_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<string, mixed>, source: string}
*/
protected function buildPricingBreakdown(Coupon $coupon, Package $package, ?Tenant $tenant = null): array
{
$currency = Str::upper($package->currency ?? 'EUR');
$subtotal = (float) $package->price;
return [
'pricing' => $this->manualPricing($coupon, $currency, $subtotal),
'source' => 'manual',
];
}
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 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',
};
}
}