Files
fotospiel-app/app/Services/GiftVouchers/GiftVoucherService.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

330 lines
11 KiB
PHP

<?php
namespace App\Services\GiftVouchers;
use App\Enums\CouponStatus;
use App\Enums\CouponType;
use App\Jobs\NotifyGiftVoucherReminder;
use App\Mail\GiftVoucherIssued;
use App\Models\Coupon;
use App\Models\GiftVoucher;
use App\Models\Package;
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GiftVoucherService
{
public function __construct(private readonly LemonSqueezyOrderService $orders) {}
/**
* Create a voucher from a Lemon Squeezy order payload.
*/
public function issueFromLemonSqueezy(array $payload): GiftVoucher
{
$metadata = $this->extractCustomData($payload);
$variantId = $this->resolveVariantId($payload);
$amount = $this->resolveAmount($payload);
$currency = Str::upper($this->resolveCurrency($payload));
$locale = $metadata['app_locale'] ?? app()->getLocale();
$existing = null;
$expiresAt = now()->addYears((int) config('gift-vouchers.default_valid_years', 5));
$orderId = data_get($payload, 'data.id');
if ($orderId) {
$existing = GiftVoucher::query()
->where('lemonsqueezy_order_id', $orderId)
->first();
}
$mergedMetadata = array_merge($existing?->metadata ?? [], $metadata);
$voucher = GiftVoucher::query()->updateOrCreate(
[
'lemonsqueezy_order_id' => $orderId,
],
[
'code' => $metadata['gift_code'] ?? $this->generateCode(),
'amount' => $amount,
'currency' => $currency,
'status' => GiftVoucher::STATUS_ISSUED,
'purchaser_email' => $metadata['purchaser_email'] ?? data_get($payload, 'data.attributes.user_email'),
'recipient_email' => $metadata['recipient_email'] ?? null,
'recipient_name' => $metadata['recipient_name'] ?? null,
'message' => $metadata['message'] ?? null,
'lemonsqueezy_checkout_id' => data_get($payload, 'data.attributes.checkout_id'),
'lemonsqueezy_variant_id' => $variantId,
'metadata' => $mergedMetadata,
'expires_at' => $expiresAt,
'refunded_at' => null,
'redeemed_at' => null,
]
);
if (! $voucher->coupon_id) {
$coupon = $this->createCouponForVoucher($voucher);
$voucher->forceFill(['coupon_id' => $coupon->id])->save();
}
$notificationsSent = (bool) Arr::get($voucher->metadata ?? [], 'notifications_sent', false);
if (! $notificationsSent) {
$this->sendNotifications($voucher, locale: $locale);
}
return $voucher;
}
public function resend(GiftVoucher $voucher, ?string $locale = null, ?bool $recipientOnly = null): void
{
$this->sendNotifications($voucher, force: true, locale: $locale, recipientOnly: $recipientOnly);
}
public function scheduleRecipientDelivery(GiftVoucher $voucher, Carbon $when, ?string $locale = null): void
{
$voucher->forceFill([
'recipient_delivery_scheduled_at' => $when,
])->save();
$this->sendNotifications($voucher, force: true, when: $when, locale: $locale, recipientOnly: true);
}
public function markRedeemed(?Coupon $coupon, ?string $transactionId = null): void
{
if (! $coupon?->giftVoucher) {
return;
}
$voucher = $coupon->giftVoucher;
if ($voucher->isRedeemed()) {
return;
}
$voucher->forceFill([
'status' => GiftVoucher::STATUS_REDEEMED,
'redeemed_at' => now(),
'metadata' => array_merge($voucher->metadata ?? [], array_filter([
'redeemed_transaction_id' => $transactionId,
])),
])->save();
}
/**
* @return array<string, mixed>
*/
public function refund(GiftVoucher $voucher, ?string $reason = null): array
{
if (! $voucher->canBeRefunded()) {
throw ValidationException::withMessages([
'voucher' => __('Voucher cannot be refunded after redemption or refund.'),
]);
}
if (! $voucher->lemonsqueezy_order_id) {
throw ValidationException::withMessages([
'voucher' => __('Missing Lemon Squeezy order for refund.'),
]);
}
$response = $this->orders->refund($voucher->lemonsqueezy_order_id, array_filter([
'reason' => $reason,
]));
$voucher->forceFill([
'status' => GiftVoucher::STATUS_REFUNDED,
'refunded_at' => now(),
])->save();
if ($voucher->coupon) {
$voucher->coupon->forceFill([
'status' => CouponStatus::ARCHIVED,
'enabled_for_checkout' => false,
])->save();
}
return $response;
}
protected function createCouponForVoucher(GiftVoucher $voucher): Coupon
{
$packages = $this->eligiblePackages();
$coupon = Coupon::create([
'name' => 'Gutschein '.$voucher->code,
'code' => $voucher->code,
'type' => CouponType::FLAT,
'amount' => $voucher->amount,
'currency' => $voucher->currency,
'status' => CouponStatus::ACTIVE,
'enabled_for_checkout' => true,
'is_stackable' => false,
'usage_limit' => 1,
'per_customer_limit' => 1,
'auto_apply' => false,
'description' => 'Geschenkgutschein '.number_format((float) $voucher->amount, 2).' '.$voucher->currency.' für Endkunden-Pakete.',
'starts_at' => now(),
'ends_at' => $voucher->expires_at,
'lemonsqueezy_discount_id' => $voucher->code,
]);
if ($packages->isNotEmpty()) {
$coupon->packages()->sync($packages->pluck('id'));
}
return $coupon;
}
protected function eligiblePackages(): Collection
{
$types = (array) config('gift-vouchers.package_types', ['endcustomer']);
return Package::query()
->whereIn('type', $types)
->whereNotNull('lemonsqueezy_variant_id')
->get(['id']);
}
protected function resolveVariantId(array $payload): ?string
{
$metadata = $this->extractCustomData($payload);
if (is_array($metadata) && ! empty($metadata['lemonsqueezy_variant_id'])) {
return $metadata['lemonsqueezy_variant_id'];
}
return data_get($payload, 'data.attributes.variant_id');
}
protected function resolveAmount(array $payload): float
{
$tiers = Collection::make(config('gift-vouchers.tiers', []))
->keyBy(fn ($tier) => $tier['lemonsqueezy_variant_id'] ?? null);
$variantId = $this->resolveVariantId($payload);
if ($variantId && $tiers->has($variantId)) {
return (float) $tiers->get($variantId)['amount'];
}
$amount = data_get($payload, 'data.attributes.total');
if (is_numeric($amount)) {
$value = (float) $amount;
return $value >= 100 ? round($value / 100, 2) : round($value, 2);
}
Log::warning('[GiftVoucher] Unable to resolve amount, defaulting to 0', ['payload' => $payload]);
return 0.0;
}
protected function resolveCurrency(array $payload): string
{
return (string) (data_get($payload, 'data.attributes.currency') ?? 'EUR');
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected function extractCustomData(array $payload): array
{
$customData = [];
if (isset($payload['meta']['custom_data']) && is_array($payload['meta']['custom_data'])) {
$customData = $payload['meta']['custom_data'];
}
if (isset($payload['attributes']['custom_data']) && is_array($payload['attributes']['custom_data'])) {
$customData = array_merge($customData, $payload['attributes']['custom_data']);
}
if (isset($payload['custom_data']) && is_array($payload['custom_data'])) {
$customData = array_merge($customData, $payload['custom_data']);
}
if (isset($payload['customData']) && is_array($payload['customData'])) {
$customData = array_merge($customData, $payload['customData']);
}
if (isset($payload['metadata']) && is_array($payload['metadata'])) {
$customData = array_merge($customData, $payload['metadata']);
}
return $customData;
}
protected function generateCode(): string
{
return 'GIFT-'.Str::upper(Str::random(8));
}
protected function sendNotifications(
GiftVoucher $voucher,
bool $force = false,
?Carbon $when = null,
?string $locale = null,
?bool $recipientOnly = null
): void {
$alreadySent = (bool) Arr::get($voucher->metadata ?? [], 'notifications_sent', false);
if ($alreadySent && ! $force) {
return;
}
$purchaserMail = $voucher->purchaser_email ? Mail::to($voucher->purchaser_email) : null;
$recipientMail = $voucher->recipient_email && $voucher->recipient_email !== $voucher->purchaser_email
? Mail::to($voucher->recipient_email)
: null;
if (! $recipientOnly && $purchaserMail) {
$mailable = (new GiftVoucherIssued($voucher, false))->locale($locale);
$when ? $purchaserMail->later($when, $mailable) : $purchaserMail->queue($mailable);
}
if ($recipientMail) {
$mailable = (new GiftVoucherIssued($voucher, true))->locale($locale);
$when ? $recipientMail->later($when, $mailable) : $recipientMail->queue($mailable);
}
$metadata = $voucher->metadata ?? [];
if (! $recipientOnly) {
$metadata['notifications_sent'] = true;
}
$voucher->forceFill([
'metadata' => $metadata,
'recipient_delivery_sent_at' => $when ? $voucher->recipient_delivery_sent_at : ($recipientMail ? now() : $voucher->recipient_delivery_sent_at),
])->save();
$this->scheduleReminders($voucher);
}
protected function scheduleReminders(GiftVoucher $voucher): void
{
if ($voucher->isRedeemed() || $voucher->isRefunded()) {
return;
}
$reminderDays = (int) config('gift-vouchers.reminder_days', 7);
$expiryReminderDays = (int) config('gift-vouchers.expiry_reminder_days', 14);
if ($reminderDays > 0) {
NotifyGiftVoucherReminder::dispatch($voucher)->delay(now()->addDays($reminderDays));
}
if ($voucher->expires_at && $expiryReminderDays > 0) {
$when = $voucher->expires_at->copy()->subDays($expiryReminderDays);
if ($when->isFuture()) {
NotifyGiftVoucherReminder::dispatch($voucher, true)->delay($when);
}
}
}
}