Add PayPal support for add-on and gift voucher checkout

This commit is contained in:
Codex Agent
2026-02-04 14:54:40 +01:00
parent 7025418d9e
commit 17025df47b
24 changed files with 1599 additions and 34 deletions

View File

@@ -2,24 +2,37 @@
namespace App\Services\GiftVouchers;
use App\Models\CheckoutSession;
use App\Models\GiftVoucher;
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use App\Services\PayPal\Exceptions\PayPalException;
use App\Services\PayPal\PayPalOrderService;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GiftVoucherCheckoutService
{
public function __construct(private readonly LemonSqueezyCheckoutService $checkout) {}
public function __construct(
private readonly LemonSqueezyCheckoutService $checkout,
private readonly PayPalOrderService $paypalOrders,
) {}
/**
* @return array<int, array{key:string,label:string,amount:float,currency:string,lemonsqueezy_variant_id?:string|null,can_checkout:bool}>
*/
public function tiers(): array
{
$provider = $this->resolveProvider();
$checkoutCurrency = Str::upper((string) config('checkout.currency', 'EUR'));
return collect(config('gift-vouchers.tiers', []))
->map(function (array $tier): array {
->map(function (array $tier) use ($provider, $checkoutCurrency): array {
$currency = Str::upper($tier['currency'] ?? 'EUR');
$variantId = $tier['lemonsqueezy_variant_id'] ?? null;
$canCheckout = $provider === CheckoutSession::PROVIDER_PAYPAL
? $currency === $checkoutCurrency
: ! empty($variantId);
return [
'key' => $tier['key'],
@@ -27,7 +40,7 @@ class GiftVoucherCheckoutService
'amount' => (float) $tier['amount'],
'currency' => $currency,
'lemonsqueezy_variant_id' => $variantId,
'can_checkout' => ! empty($variantId),
'can_checkout' => $canCheckout,
];
})
->values()
@@ -40,6 +53,12 @@ class GiftVoucherCheckoutService
*/
public function create(array $data): array
{
$provider = $this->resolveProvider();
if ($provider === CheckoutSession::PROVIDER_PAYPAL) {
return $this->createPayPalCheckout($data);
}
$tier = $this->findTier($data['tier_key']);
if (! $tier || empty($tier['lemonsqueezy_variant_id'])) {
@@ -90,4 +109,119 @@ class GiftVoucherCheckoutService
return $tier;
}
/**
* @param array{tier_key:string,purchaser_email:string,recipient_email?:string|null,recipient_name?:string|null,message?:string|null,success_url?:string|null,return_url?:string|null} $data
* @return array{checkout_url:?string,expires_at:?string,id:?string}
*/
protected function createPayPalCheckout(array $data): array
{
$tier = $this->findTier($data['tier_key']);
if (! $tier) {
throw ValidationException::withMessages([
'tier_key' => __('Gift voucher is not available right now.'),
]);
}
$currency = Str::upper($tier['currency'] ?? 'EUR');
$checkoutCurrency = Str::upper((string) config('checkout.currency', 'EUR'));
if ($currency !== $checkoutCurrency) {
throw ValidationException::withMessages([
'tier_key' => __('Gift voucher currency is not supported.'),
]);
}
$voucher = GiftVoucher::create([
'code' => $this->generateCode(),
'amount' => (float) $tier['amount'],
'currency' => $currency,
'status' => GiftVoucher::STATUS_PENDING,
'purchaser_email' => $data['purchaser_email'],
'recipient_email' => $data['recipient_email'] ?? null,
'recipient_name' => $data['recipient_name'] ?? null,
'message' => $data['message'] ?? null,
'metadata' => array_filter([
'tier_key' => $tier['key'],
'app_locale' => App::getLocale(),
'success_url' => $data['success_url'] ?? null,
'return_url' => $data['return_url'] ?? null,
], static fn ($value) => $value !== null && $value !== ''),
'expires_at' => now()->addYears((int) config('gift-vouchers.default_valid_years', 5)),
]);
$successUrl = $data['success_url'] ?? null;
$returnUrl = $data['return_url'] ?? $successUrl;
$paypalReturnUrl = route('paypal.gift-voucher.return', absolute: true);
try {
$order = $this->paypalOrders->createSimpleOrder(
referenceId: 'gift-voucher-'.$voucher->id,
description: $tier['label'] ?? 'Gift Voucher',
amount: (float) $tier['amount'],
currency: $currency,
options: [
'custom_id' => 'gift_voucher_'.$voucher->id,
'return_url' => $paypalReturnUrl,
'cancel_url' => $paypalReturnUrl,
'locale' => App::getLocale(),
'request_id' => 'gift-voucher-'.$voucher->id,
],
);
} catch (PayPalException) {
$voucher->delete();
throw ValidationException::withMessages([
'tier_key' => __('Unable to create PayPal checkout.'),
]);
}
$orderId = $order['id'] ?? null;
if (! is_string($orderId) || $orderId === '') {
$voucher->delete();
throw ValidationException::withMessages([
'tier_key' => __('PayPal order ID missing.'),
]);
}
$approveUrl = $this->paypalOrders->resolveApproveUrl($order);
$voucher->forceFill([
'paypal_order_id' => $orderId,
'metadata' => array_merge($voucher->metadata ?? [], array_filter([
'paypal_order_id' => $orderId,
'paypal_approve_url' => $approveUrl,
'paypal_success_url' => $successUrl,
'paypal_cancel_url' => $returnUrl,
'paypal_created_at' => now()->toIso8601String(),
])),
])->save();
return [
'checkout_url' => $approveUrl,
'expires_at' => null,
'id' => $orderId,
];
}
protected function resolveProvider(): ?string
{
$provider = config('gift-vouchers.provider');
if (is_string($provider) && $provider !== '') {
return $provider;
}
$default = config('checkout.default_provider');
return is_string($default) && $default !== '' ? $default : null;
}
protected function generateCode(): string
{
return 'GIFT-'.Str::upper(Str::random(8));
}
}

View File

@@ -81,6 +81,48 @@ class GiftVoucherService
return $voucher;
}
/**
* Create or finalize a voucher from a PayPal capture payload.
*
* @param array<string, mixed> $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);
@@ -152,6 +194,31 @@ class GiftVoucherService
return $response;
}
/**
* @param array<string, mixed> $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();