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; } /** * Create or finalize a voucher from a PayPal capture payload. * * @param array $payload */ public function issueFromPayPal(GiftVoucher $voucher, array $payload, ?string $orderId = null): GiftVoucher { if (in_array($voucher->status, [GiftVoucher::STATUS_ISSUED, GiftVoucher::STATUS_REDEEMED], true)) { return $voucher; } $captureId = Arr::get($payload, 'purchase_units.0.payments.captures.0.id') ?? Arr::get($payload, 'id'); $locale = Arr::get($voucher->metadata ?? [], 'app_locale') ?? app()->getLocale(); $voucher->forceFill([ 'status' => GiftVoucher::STATUS_ISSUED, 'paypal_order_id' => $orderId ?? $voucher->paypal_order_id, 'paypal_capture_id' => is_string($captureId) ? $captureId : $voucher->paypal_capture_id, 'metadata' => array_merge($voucher->metadata ?? [], array_filter([ 'paypal_order_id' => $orderId, 'paypal_capture_id' => is_string($captureId) ? $captureId : null, 'paypal_status' => $payload['status'] ?? null, 'paypal_captured_at' => now()->toIso8601String(), ])), ])->save(); 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 */ 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; } /** * @param array $payload */ public function markRefundedFromPayPal(GiftVoucher $voucher, array $payload = []): void { if ($voucher->isRefunded()) { return; } $voucher->forceFill([ 'status' => GiftVoucher::STATUS_REFUNDED, 'refunded_at' => now(), 'metadata' => array_merge($voucher->metadata ?? [], array_filter([ 'paypal_refund_payload' => $payload ?: null, ])), ])->save(); if ($voucher->coupon) { $voucher->coupon->forceFill([ 'status' => CouponStatus::ARCHIVED, 'enabled_for_checkout' => false, ])->save(); } } 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 $payload * @return array */ 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); } } } }