extractCustomData($payload); $priceId = $this->resolvePriceId($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)); if (! empty($payload['id'])) { $existing = GiftVoucher::query() ->where('paddle_transaction_id', $payload['id']) ->first(); } $mergedMetadata = array_merge($existing?->metadata ?? [], $metadata); $voucher = GiftVoucher::query()->updateOrCreate( [ 'paddle_transaction_id' => $payload['id'] ?? null, ], [ 'code' => $metadata['gift_code'] ?? $this->generateCode(), 'amount' => $amount, 'currency' => $currency, 'status' => GiftVoucher::STATUS_ISSUED, 'purchaser_email' => $metadata['purchaser_email'] ?? Arr::get($payload, 'customer.email'), 'recipient_email' => $metadata['recipient_email'] ?? null, 'recipient_name' => $metadata['recipient_name'] ?? null, 'message' => $metadata['message'] ?? null, 'paddle_checkout_id' => $payload['checkout_id'] ?? Arr::get($payload, 'details.checkout_id'), 'paddle_price_id' => $priceId, '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(); SyncCouponToPaddle::dispatch($coupon); } $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->paddle_transaction_id) { throw ValidationException::withMessages([ 'voucher' => __('Missing Paddle transaction for refund.'), ]); } $response = $this->transactions->refund($voucher->paddle_transaction_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, ]); 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('paddle_price_id') ->get(['id']); } protected function resolvePriceId(array $payload): ?string { $metadata = $this->extractCustomData($payload); if (is_array($metadata) && ! empty($metadata['paddle_price_id'])) { return $metadata['paddle_price_id']; } $items = Arr::get($payload, 'items', Arr::get($payload, 'details.items', [])); if (is_array($items) && isset($items[0]['price_id'])) { return $items[0]['price_id']; } return $payload['price_id'] ?? null; } protected function resolveAmount(array $payload): float { $tiers = Collection::make(config('gift-vouchers.tiers', [])) ->keyBy(fn ($tier) => $tier['paddle_price_id'] ?? null); $priceId = $this->resolvePriceId($payload); if ($priceId && $tiers->has($priceId)) { return (float) $tiers->get($priceId)['amount']; } $amount = Arr::get($payload, 'totals.grand_total.amount') ?? Arr::get($payload, 'totals.grand_total') ?? Arr::get($payload, 'details.totals.grand_total.amount') ?? Arr::get($payload, 'details.totals.grand_total') ?? Arr::get($payload, 'amount'); 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 $payload['currency_code'] ?? Arr::get($payload, 'details.totals.currency_code') ?? Arr::get($payload, 'currency') ?? 'EUR'; } /** * @param array $payload * @return array */ protected function extractCustomData(array $payload): array { $customData = []; if (isset($payload['custom_data']) && is_array($payload['custom_data'])) { $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); } } } }