Add PayPal support for add-on and gift voucher checkout
This commit is contained in:
@@ -36,7 +36,7 @@ class GiftVoucherCheckoutController extends Controller
|
||||
|
||||
if (! $checkout['checkout_url']) {
|
||||
throw ValidationException::withMessages([
|
||||
'tier_key' => __('Unable to create Lemon Squeezy checkout.'),
|
||||
'tier_key' => __('Unable to create checkout.'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -51,19 +51,38 @@ class GiftVoucherCheckoutController extends Controller
|
||||
'code' => ['nullable', 'string', 'required_without_all:checkout_id,order_id'],
|
||||
]);
|
||||
|
||||
$voucherQuery = GiftVoucher::query();
|
||||
$voucherQuery = GiftVoucher::query()
|
||||
->where('status', '!=', GiftVoucher::STATUS_PENDING)
|
||||
->where(function ($query) use ($data) {
|
||||
$hasCondition = false;
|
||||
|
||||
if (! empty($data['checkout_id'])) {
|
||||
$voucherQuery->where('lemonsqueezy_checkout_id', $data['checkout_id']);
|
||||
}
|
||||
if (! empty($data['checkout_id'])) {
|
||||
$query->where(function ($inner) use ($data) {
|
||||
$inner->where('lemonsqueezy_checkout_id', $data['checkout_id'])
|
||||
->orWhere('paypal_order_id', $data['checkout_id']);
|
||||
});
|
||||
|
||||
if (! empty($data['order_id'])) {
|
||||
$voucherQuery->orWhere('lemonsqueezy_order_id', $data['order_id']);
|
||||
}
|
||||
$hasCondition = true;
|
||||
}
|
||||
|
||||
if (! empty($data['code'])) {
|
||||
$voucherQuery->orWhere('code', strtoupper($data['code']));
|
||||
}
|
||||
if (! empty($data['order_id'])) {
|
||||
$method = $hasCondition ? 'orWhere' : 'where';
|
||||
|
||||
$query->{$method}(function ($inner) use ($data) {
|
||||
$inner->where('lemonsqueezy_order_id', $data['order_id'])
|
||||
->orWhere('paypal_capture_id', $data['order_id'])
|
||||
->orWhere('paypal_order_id', $data['order_id']);
|
||||
});
|
||||
|
||||
$hasCondition = true;
|
||||
}
|
||||
|
||||
if (! empty($data['code'])) {
|
||||
$method = $hasCondition ? 'orWhere' : 'where';
|
||||
|
||||
$query->{$method}('code', strtoupper($data['code']));
|
||||
}
|
||||
});
|
||||
|
||||
$voucher = $voucherQuery->latest()->firstOrFail();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Services\Addons\EventAddonCatalog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
@@ -12,9 +13,25 @@ class EventAddonCatalogController extends Controller
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$provider = config('package-addons.provider')
|
||||
?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL);
|
||||
|
||||
$addons = collect($this->catalog->all())
|
||||
->filter(fn (array $addon) => ! empty($addon['variant_id']))
|
||||
->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key]))
|
||||
->map(function (array $addon, string $key) use ($provider): array {
|
||||
$priceId = $provider === CheckoutSession::PROVIDER_PAYPAL
|
||||
? ($addon['price'] ?? null ? 'paypal' : null)
|
||||
: ($addon['variant_id'] ?? null);
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $addon['label'] ?? null,
|
||||
'price_id' => $priceId,
|
||||
'increments' => $addon['increments'] ?? [],
|
||||
'price' => $addon['price'] ?? null,
|
||||
'currency' => $addon['currency'] ?? 'EUR',
|
||||
];
|
||||
})
|
||||
->filter(fn (array $addon) => ! empty($addon['price_id']))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
|
||||
150
app/Http/Controllers/PayPalAddonReturnController.php
Normal file
150
app/Http/Controllers/PayPalAddonReturnController.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Services\Addons\EventAddonPurchaseService;
|
||||
use App\Services\PayPal\Exceptions\PayPalException;
|
||||
use App\Services\PayPal\PayPalOrderService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PayPalAddonReturnController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PayPalOrderService $orders,
|
||||
private readonly EventAddonPurchaseService $addons,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
$orderId = $this->resolveOrderId($request);
|
||||
$fallback = $this->resolveFallbackUrl();
|
||||
|
||||
if (! $orderId) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
$addon = EventPackageAddon::query()
|
||||
->where('checkout_id', $orderId)
|
||||
->first();
|
||||
|
||||
if (! $addon) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
$successUrl = Arr::get($addon->metadata ?? [], 'paypal_success_url')
|
||||
?? Arr::get($addon->metadata ?? [], 'success_url');
|
||||
$cancelUrl = Arr::get($addon->metadata ?? [], 'paypal_cancel_url')
|
||||
?? Arr::get($addon->metadata ?? [], 'cancel_url');
|
||||
|
||||
if ($addon->status === 'completed') {
|
||||
return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback));
|
||||
}
|
||||
|
||||
try {
|
||||
$capture = $this->orders->captureOrder($orderId, [
|
||||
'request_id' => 'addon-'.$addon->id,
|
||||
]);
|
||||
} catch (PayPalException $exception) {
|
||||
$this->addons->fail($addon, 'paypal_capture_failed', [
|
||||
'message' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
]);
|
||||
|
||||
return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback));
|
||||
}
|
||||
|
||||
$captureId = $this->resolveCaptureId($capture);
|
||||
$totals = $this->resolveTotals($capture);
|
||||
|
||||
$this->addons->complete(
|
||||
$addon,
|
||||
$capture,
|
||||
$captureId,
|
||||
$orderId,
|
||||
$totals['total'] ?? null,
|
||||
$totals['currency'] ?? null,
|
||||
[
|
||||
'paypal_order_id' => $orderId,
|
||||
'paypal_capture_id' => $captureId,
|
||||
'paypal_status' => $capture['status'] ?? null,
|
||||
'paypal_totals' => $totals ?: null,
|
||||
'paypal_captured_at' => now()->toIso8601String(),
|
||||
],
|
||||
);
|
||||
|
||||
return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback));
|
||||
}
|
||||
|
||||
protected function resolveOrderId(Request $request): ?string
|
||||
{
|
||||
$candidate = $request->query('token') ?? $request->query('order_id');
|
||||
|
||||
if (! is_string($candidate) || $candidate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
protected function resolveFallbackUrl(): string
|
||||
{
|
||||
return rtrim((string) config('app.url', url('/')), '/') ?: url('/');
|
||||
}
|
||||
|
||||
protected function resolveSafeRedirect(?string $target, string $fallback): string
|
||||
{
|
||||
if (! $target) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if (Str::startsWith($target, ['/'])) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
$appHost = parse_url($fallback, PHP_URL_HOST);
|
||||
$targetHost = parse_url($target, PHP_URL_HOST);
|
||||
|
||||
if ($appHost && $targetHost && Str::lower($appHost) === Str::lower($targetHost)) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $capture
|
||||
*/
|
||||
protected function resolveCaptureId(array $capture): ?string
|
||||
{
|
||||
$captureId = Arr::get($capture, 'purchase_units.0.payments.captures.0.id')
|
||||
?? Arr::get($capture, 'id');
|
||||
|
||||
return is_string($captureId) && $captureId !== '' ? $captureId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $capture
|
||||
* @return array{currency?: string, total?: float}
|
||||
*/
|
||||
protected function resolveTotals(array $capture): array
|
||||
{
|
||||
$amount = Arr::get($capture, 'purchase_units.0.payments.captures.0.amount')
|
||||
?? Arr::get($capture, 'purchase_units.0.amount');
|
||||
|
||||
if (! is_array($amount)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$currency = Arr::get($amount, 'currency_code');
|
||||
$total = Arr::get($amount, 'value');
|
||||
|
||||
return array_filter([
|
||||
'currency' => is_string($currency) ? strtoupper($currency) : null,
|
||||
'total' => is_numeric($total) ? (float) $total : null,
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
}
|
||||
129
app/Http/Controllers/PayPalGiftVoucherReturnController.php
Normal file
129
app/Http/Controllers/PayPalGiftVoucherReturnController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Services\GiftVouchers\GiftVoucherService;
|
||||
use App\Services\PayPal\Exceptions\PayPalException;
|
||||
use App\Services\PayPal\PayPalOrderService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PayPalGiftVoucherReturnController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PayPalOrderService $orders,
|
||||
private readonly GiftVoucherService $vouchers,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
$orderId = $this->resolveOrderId($request);
|
||||
$fallback = $this->resolveFallbackUrl();
|
||||
|
||||
if (! $orderId) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
$voucher = GiftVoucher::query()
|
||||
->where('paypal_order_id', $orderId)
|
||||
->first();
|
||||
|
||||
if (! $voucher) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
$successUrl = Arr::get($voucher->metadata ?? [], 'paypal_success_url')
|
||||
?? Arr::get($voucher->metadata ?? [], 'success_url');
|
||||
$cancelUrl = Arr::get($voucher->metadata ?? [], 'paypal_cancel_url')
|
||||
?? Arr::get($voucher->metadata ?? [], 'return_url');
|
||||
|
||||
if (in_array($voucher->status, [GiftVoucher::STATUS_ISSUED, GiftVoucher::STATUS_REDEEMED], true)) {
|
||||
return redirect()->to($this->resolveSafeRedirect($this->appendOrderId($successUrl, $orderId), $fallback));
|
||||
}
|
||||
|
||||
try {
|
||||
$capture = $this->orders->captureOrder($orderId, [
|
||||
'request_id' => 'gift-voucher-'.$voucher->id,
|
||||
]);
|
||||
} catch (PayPalException $exception) {
|
||||
return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback));
|
||||
}
|
||||
|
||||
$this->vouchers->issueFromPayPal($voucher, $capture, $orderId);
|
||||
|
||||
return redirect()->to($this->resolveSafeRedirect($this->appendOrderId($successUrl, $orderId), $fallback));
|
||||
}
|
||||
|
||||
protected function resolveOrderId(Request $request): ?string
|
||||
{
|
||||
$candidate = $request->query('token') ?? $request->query('order_id');
|
||||
|
||||
if (! is_string($candidate) || $candidate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
protected function resolveFallbackUrl(): string
|
||||
{
|
||||
return rtrim((string) config('app.url', url('/')), '/') ?: url('/');
|
||||
}
|
||||
|
||||
protected function resolveSafeRedirect(?string $target, string $fallback): string
|
||||
{
|
||||
if (! $target) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if (Str::startsWith($target, ['/'])) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
$appHost = parse_url($fallback, PHP_URL_HOST);
|
||||
$targetHost = parse_url($target, PHP_URL_HOST);
|
||||
|
||||
if ($appHost && $targetHost && Str::lower($appHost) === Str::lower($targetHost)) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
protected function appendOrderId(?string $url, string $orderId): ?string
|
||||
{
|
||||
if (! $url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
if (! $parts) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$query = [];
|
||||
if (! empty($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
}
|
||||
|
||||
if (! isset($query['order_id'])) {
|
||||
$query['order_id'] = $orderId;
|
||||
}
|
||||
|
||||
$scheme = $parts['scheme'] ?? null;
|
||||
$host = $parts['host'] ?? null;
|
||||
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
|
||||
$path = $parts['path'] ?? '';
|
||||
$fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : '';
|
||||
$queryString = $query ? '?'.http_build_query($query) : '';
|
||||
|
||||
if ($scheme && $host) {
|
||||
return $scheme.'://'.$host.$port.$path.$queryString.$fragment;
|
||||
}
|
||||
|
||||
return $path.$queryString.$fragment;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Integrations\IntegrationWebhookRecorder;
|
||||
use App\Services\PayPal\PayPalAddonWebhookService;
|
||||
use App\Services\PayPal\PayPalGiftVoucherWebhookService;
|
||||
use App\Services\PayPal\PayPalWebhookService;
|
||||
use App\Services\PayPal\PayPalWebhookVerifier;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -15,6 +17,8 @@ class PayPalWebhookController extends Controller
|
||||
public function __construct(
|
||||
private readonly PayPalWebhookVerifier $verifier,
|
||||
private readonly PayPalWebhookService $webhooks,
|
||||
private readonly PayPalAddonWebhookService $addonWebhooks,
|
||||
private readonly PayPalGiftVoucherWebhookService $giftVoucherWebhooks,
|
||||
private readonly IntegrationWebhookRecorder $recorder,
|
||||
) {}
|
||||
|
||||
@@ -42,7 +46,13 @@ class PayPalWebhookController extends Controller
|
||||
is_string($eventType) ? $eventType : null,
|
||||
);
|
||||
|
||||
$handled = is_string($eventType) ? $this->webhooks->handle($payload) : false;
|
||||
$handled = false;
|
||||
|
||||
if (is_string($eventType)) {
|
||||
$handled = $this->webhooks->handle($payload) || $handled;
|
||||
$handled = $this->addonWebhooks->handle($payload) || $handled;
|
||||
$handled = $this->giftVoucherWebhooks->handle($payload) || $handled;
|
||||
}
|
||||
|
||||
Log::info('PayPal webhook processed', [
|
||||
'event_type' => $eventType,
|
||||
|
||||
@@ -15,6 +15,8 @@ class GiftVoucher extends Model
|
||||
|
||||
use SoftDeletes;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_ISSUED = 'issued';
|
||||
|
||||
public const STATUS_REDEEMED = 'redeemed';
|
||||
@@ -35,6 +37,8 @@ class GiftVoucher extends Model
|
||||
'lemonsqueezy_order_id',
|
||||
'lemonsqueezy_checkout_id',
|
||||
'lemonsqueezy_variant_id',
|
||||
'paypal_order_id',
|
||||
'paypal_capture_id',
|
||||
'coupon_id',
|
||||
'expires_at',
|
||||
'redeemed_at',
|
||||
@@ -89,6 +93,10 @@ class GiftVoucher extends Model
|
||||
|
||||
public function canBeRedeemed(): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_ISSUED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $this->isRedeemed() && ! $this->isRefunded() && ! $this->isExpired();
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ class EventAddonCatalog
|
||||
'label' => $addon->label,
|
||||
'variant_id' => $addon->variant_id,
|
||||
'increments' => $addon->increments,
|
||||
'price' => $this->resolveMetadataPrice($addon->metadata ?? []),
|
||||
'currency' => 'EUR',
|
||||
]];
|
||||
})
|
||||
->all();
|
||||
@@ -46,6 +48,26 @@ class EventAddonCatalog
|
||||
return $addon['variant_id'] ?? null;
|
||||
}
|
||||
|
||||
public function resolvePrice(string $key): ?float
|
||||
{
|
||||
$addon = $this->find($key);
|
||||
|
||||
if (! $addon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$price = $addon['price'] ?? $addon['price_eur'] ?? null;
|
||||
|
||||
return is_numeric($price) ? (float) $price : null;
|
||||
}
|
||||
|
||||
protected function resolveMetadataPrice(array $metadata): ?float
|
||||
{
|
||||
$price = $metadata['price_eur'] ?? null;
|
||||
|
||||
return is_numeric($price) ? (float) $price : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Services\Addons;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
|
||||
use App\Services\PayPal\Exceptions\PayPalException;
|
||||
use App\Services\PayPal\PayPalOrderService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -15,6 +18,7 @@ class EventAddonCheckoutService
|
||||
public function __construct(
|
||||
private readonly EventAddonCatalog $catalog,
|
||||
private readonly LemonSqueezyCheckoutService $checkout,
|
||||
private readonly PayPalOrderService $paypalOrders,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -34,24 +38,45 @@ class EventAddonCheckoutService
|
||||
]);
|
||||
}
|
||||
|
||||
$variantId = $this->catalog->resolveVariantId($addonKey);
|
||||
|
||||
if (! $variantId) {
|
||||
throw ValidationException::withMessages([
|
||||
'addon_key' => __('Für dieses Add-on ist kein Lemon Squeezy Variant hinterlegt.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$event->loadMissing('eventPackage');
|
||||
|
||||
if (! $event->eventPackage) {
|
||||
throw ValidationException::withMessages([
|
||||
'event' => __('Kein Paket für dieses Event hinterlegt.'),
|
||||
'event' => __('Kein Paket für dieses Event hinterlegt.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$provider = $this->resolveProvider();
|
||||
|
||||
if ($provider === CheckoutSession::PROVIDER_PAYPAL) {
|
||||
return $this->createPayPalCheckout($tenant, $event, $addonKey, $quantity, $acceptedTerms, $acceptedWaiver, $payload);
|
||||
}
|
||||
|
||||
return $this->createLemonSqueezyCheckout($tenant, $event, $addonKey, $quantity, $acceptedTerms, $acceptedWaiver, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{addon_key: string, quantity?: int, success_url?: string|null, cancel_url?: string|null} $payload
|
||||
* @return array{checkout_url: string|null, id: string|null, expires_at: string|null}
|
||||
*/
|
||||
protected function createLemonSqueezyCheckout(
|
||||
Tenant $tenant,
|
||||
Event $event,
|
||||
string $addonKey,
|
||||
int $quantity,
|
||||
bool $acceptedTerms,
|
||||
bool $acceptedWaiver,
|
||||
array $payload,
|
||||
): array {
|
||||
$variantId = $this->catalog->resolveVariantId($addonKey);
|
||||
|
||||
if (! $variantId) {
|
||||
throw ValidationException::withMessages([
|
||||
'addon_key' => __('Für dieses Add-on ist kein Lemon Squeezy Variant hinterlegt.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$addonIntent = (string) Str::uuid();
|
||||
|
||||
$increments = $this->catalog->resolveIncrements($addonKey);
|
||||
|
||||
$metadata = array_filter([
|
||||
@@ -77,7 +102,6 @@ class EventAddonCheckoutService
|
||||
|
||||
$checkoutUrl = $response['checkout_url'] ?? null;
|
||||
$checkoutId = $response['id'] ?? null;
|
||||
$transactionId = null;
|
||||
|
||||
if (! $checkoutUrl) {
|
||||
Log::warning('Lemon Squeezy addon checkout response missing url', ['response' => $response]);
|
||||
@@ -91,7 +115,7 @@ class EventAddonCheckoutService
|
||||
'quantity' => $quantity,
|
||||
'variant_id' => $variantId,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'transaction_id' => null,
|
||||
'status' => 'pending',
|
||||
'metadata' => array_merge($metadata, [
|
||||
'increments' => $increments,
|
||||
@@ -114,6 +138,155 @@ class EventAddonCheckoutService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{addon_key: string, quantity?: int, success_url?: string|null, cancel_url?: string|null} $payload
|
||||
* @return array{checkout_url: string|null, id: string|null, expires_at: string|null}
|
||||
*/
|
||||
protected function createPayPalCheckout(
|
||||
Tenant $tenant,
|
||||
Event $event,
|
||||
string $addonKey,
|
||||
int $quantity,
|
||||
bool $acceptedTerms,
|
||||
bool $acceptedWaiver,
|
||||
array $payload,
|
||||
): array {
|
||||
$price = $this->catalog->resolvePrice($addonKey);
|
||||
$addonLabel = $this->catalog->find($addonKey)['label'] ?? $addonKey;
|
||||
|
||||
if (! $price) {
|
||||
throw ValidationException::withMessages([
|
||||
'addon_key' => __('Dieses Add-on ist aktuell nicht verfügbar.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$addonIntent = (string) Str::uuid();
|
||||
$increments = $this->catalog->resolveIncrements($addonKey);
|
||||
$amount = round($price * $quantity, 2);
|
||||
$currency = strtoupper((string) config('checkout.currency', 'EUR'));
|
||||
|
||||
$metadata = array_filter([
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'event_id' => (string) $event->id,
|
||||
'event_package_id' => (string) $event->eventPackage->id,
|
||||
'addon_key' => $addonKey,
|
||||
'addon_intent' => $addonIntent,
|
||||
'quantity' => $quantity,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'legal_version' => $this->resolveLegalVersion(),
|
||||
'accepted_terms' => $acceptedTerms ? '1' : '0',
|
||||
'accepted_waiver' => $acceptedWaiver ? '1' : '0',
|
||||
'success_url' => $payload['success_url'] ?? null,
|
||||
'cancel_url' => $payload['cancel_url'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$addon = EventPackageAddon::create([
|
||||
'event_package_id' => $event->eventPackage->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'addon_key' => $addonKey,
|
||||
'quantity' => $quantity,
|
||||
'variant_id' => null,
|
||||
'checkout_id' => null,
|
||||
'transaction_id' => null,
|
||||
'status' => 'pending',
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'metadata' => array_merge($metadata, [
|
||||
'increments' => $increments,
|
||||
'consents' => [
|
||||
'legal_version' => $metadata['legal_version'],
|
||||
'accepted_terms_at' => $acceptedTerms ? now()->toIso8601String() : null,
|
||||
'digital_content_waiver_at' => $acceptedWaiver ? now()->toIso8601String() : null,
|
||||
],
|
||||
]),
|
||||
'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity,
|
||||
'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity,
|
||||
'extra_gallery_days' => ($increments['extra_gallery_days'] ?? 0) * $quantity,
|
||||
]);
|
||||
|
||||
$successUrl = $payload['success_url'] ?? null;
|
||||
$cancelUrl = $payload['cancel_url'] ?? $successUrl;
|
||||
$paypalReturnUrl = route('paypal.addon.return', absolute: true);
|
||||
|
||||
try {
|
||||
$order = $this->paypalOrders->createSimpleOrder(
|
||||
referenceId: 'addon-'.$addon->id,
|
||||
description: $addonLabel,
|
||||
amount: $amount,
|
||||
currency: $currency,
|
||||
options: [
|
||||
'custom_id' => 'addon_'.$addon->id,
|
||||
'return_url' => $paypalReturnUrl,
|
||||
'cancel_url' => $paypalReturnUrl,
|
||||
'locale' => $tenant->user?->preferred_locale ?? app()->getLocale(),
|
||||
'request_id' => 'addon-'.$addon->id,
|
||||
],
|
||||
);
|
||||
} catch (PayPalException $exception) {
|
||||
Log::warning('PayPal addon checkout creation failed', [
|
||||
'addon_id' => $addon->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
]);
|
||||
|
||||
$addon->forceFill([
|
||||
'status' => 'failed',
|
||||
'error' => $exception->getMessage(),
|
||||
])->save();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'addon_key' => __('Add-on checkout failed.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$orderId = $order['id'] ?? null;
|
||||
|
||||
if (! is_string($orderId) || $orderId === '') {
|
||||
$addon->forceFill([
|
||||
'status' => 'failed',
|
||||
'error' => 'PayPal order ID missing.',
|
||||
])->save();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'addon_key' => __('Add-on checkout failed.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$approveUrl = $this->paypalOrders->resolveApproveUrl($order);
|
||||
|
||||
$addon->forceFill([
|
||||
'checkout_id' => $orderId,
|
||||
'metadata' => array_merge($addon->metadata ?? [], array_filter([
|
||||
'paypal_order_id' => $orderId,
|
||||
'paypal_approve_url' => $approveUrl,
|
||||
'paypal_success_url' => $successUrl,
|
||||
'paypal_cancel_url' => $cancelUrl,
|
||||
'paypal_created_at' => now()->toIso8601String(),
|
||||
])),
|
||||
])->save();
|
||||
|
||||
return [
|
||||
'checkout_url' => $approveUrl,
|
||||
'expires_at' => null,
|
||||
'id' => $orderId,
|
||||
];
|
||||
}
|
||||
|
||||
protected function resolveProvider(): ?string
|
||||
{
|
||||
$provider = config('package-addons.provider');
|
||||
|
||||
if (is_string($provider) && $provider !== '') {
|
||||
return $provider;
|
||||
}
|
||||
|
||||
$default = config('checkout.default_provider');
|
||||
|
||||
return is_string($default) && $default !== '' ? $default : null;
|
||||
}
|
||||
|
||||
protected function resolveLegalVersion(): string
|
||||
{
|
||||
return config('app.legal_version', now()->toDateString());
|
||||
|
||||
117
app/Services/Addons/EventAddonPurchaseService.php
Normal file
117
app/Services/Addons/EventAddonPurchaseService.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Addons;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Notifications\Addons\AddonPurchaseReceipt;
|
||||
use App\Notifications\Ops\AddonPurchased;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class EventAddonPurchaseService
|
||||
{
|
||||
public function __construct(private readonly EventAddonCatalog $catalog) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function complete(
|
||||
EventPackageAddon $addon,
|
||||
array $payload,
|
||||
?string $transactionId,
|
||||
?string $checkoutId,
|
||||
?float $amount,
|
||||
?string $currency,
|
||||
array $metadata = [],
|
||||
): void {
|
||||
if ($addon->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$increments = $this->resolveAddonIncrements($addon, $addon->addon_key);
|
||||
|
||||
DB::transaction(function () use ($addon, $payload, $transactionId, $checkoutId, $amount, $currency, $metadata, $increments) {
|
||||
$addon->forceFill([
|
||||
'transaction_id' => $transactionId ?? $addon->transaction_id,
|
||||
'checkout_id' => $addon->checkout_id ?: $checkoutId,
|
||||
'status' => 'completed',
|
||||
'amount' => $amount ?? $addon->amount,
|
||||
'currency' => $currency ?? $addon->currency,
|
||||
'metadata' => array_merge($addon->metadata ?? [], array_filter([
|
||||
'payment_payload' => $payload,
|
||||
]), $metadata),
|
||||
'purchased_at' => now(),
|
||||
])->save();
|
||||
|
||||
/** @var EventPackage $eventPackage */
|
||||
$eventPackage = EventPackage::query()
|
||||
->lockForUpdate()
|
||||
->find($addon->event_package_id);
|
||||
|
||||
if (! $eventPackage) {
|
||||
return;
|
||||
}
|
||||
|
||||
$eventPackage->forceFill([
|
||||
'extra_photos' => ($eventPackage->extra_photos ?? 0) + (int) ($increments['extra_photos'] ?? 0) * $addon->quantity,
|
||||
'extra_guests' => ($eventPackage->extra_guests ?? 0) + (int) ($increments['extra_guests'] ?? 0) * $addon->quantity,
|
||||
'extra_gallery_days' => ($eventPackage->extra_gallery_days ?? 0) + (int) ($increments['extra_gallery_days'] ?? 0) * $addon->quantity,
|
||||
]);
|
||||
|
||||
if (($increments['extra_gallery_days'] ?? 0) > 0) {
|
||||
$base = $eventPackage->gallery_expires_at ?? now();
|
||||
$eventPackage->gallery_expires_at = $base->copy()->addDays((int) ($increments['extra_gallery_days'] ?? 0) * $addon->quantity);
|
||||
}
|
||||
|
||||
$eventPackage->save();
|
||||
|
||||
$tenant = $addon->tenant;
|
||||
if ($tenant) {
|
||||
Notification::route('mail', [$tenant->contact_email ?? $tenant->user?->email])
|
||||
->notify(new AddonPurchaseReceipt($addon));
|
||||
|
||||
$opsEmail = config('mail.ops_address');
|
||||
if ($opsEmail) {
|
||||
Notification::route('mail', $opsEmail)->notify(new AddonPurchased($addon));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function fail(EventPackageAddon $addon, string $reason, array $payload = []): void
|
||||
{
|
||||
$addon->forceFill([
|
||||
'status' => 'failed',
|
||||
'error' => $reason,
|
||||
'metadata' => array_merge($addon->metadata ?? [], array_filter([
|
||||
'payment_payload' => $payload,
|
||||
])),
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function resolveAddonIncrements(EventPackageAddon $addon, string $addonKey): array
|
||||
{
|
||||
$stored = Arr::get($addon->metadata ?? [], 'increments', []);
|
||||
|
||||
if (is_array($stored) && $stored !== []) {
|
||||
$filtered = collect($stored)
|
||||
->map(fn ($value) => is_numeric($value) ? (int) $value : 0)
|
||||
->filter(fn ($value) => $value > 0)
|
||||
->all();
|
||||
|
||||
if ($filtered !== []) {
|
||||
return $filtered;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->catalog->resolveIncrements($addonKey);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
166
app/Services/PayPal/PayPalAddonWebhookService.php
Normal file
166
app/Services/PayPal/PayPalAddonWebhookService.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\PayPal;
|
||||
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Services\Addons\EventAddonPurchaseService;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PayPalAddonWebhookService
|
||||
{
|
||||
public function __construct(private readonly EventAddonPurchaseService $addons) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
public function handle(array $event): bool
|
||||
{
|
||||
$eventType = $event['event_type'] ?? null;
|
||||
$resource = $event['resource'] ?? null;
|
||||
|
||||
if (! is_string($eventType) || ! is_array($resource)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalized = strtoupper($eventType);
|
||||
|
||||
if (! in_array($normalized, [
|
||||
'PAYMENT.CAPTURE.COMPLETED',
|
||||
'PAYMENT.CAPTURE.PENDING',
|
||||
'PAYMENT.CAPTURE.DENIED',
|
||||
'PAYMENT.CAPTURE.REFUNDED',
|
||||
'CHECKOUT.ORDER.COMPLETED',
|
||||
'CHECKOUT.ORDER.VOIDED',
|
||||
], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$orderId = $this->resolveOrderId($normalized, $resource);
|
||||
|
||||
if (! $orderId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$addon = EventPackageAddon::query()
|
||||
->where('checkout_id', $orderId)
|
||||
->first();
|
||||
|
||||
if (! $addon) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lock = Cache::lock('addon:webhook:paypal:'.$orderId, 30);
|
||||
|
||||
if (! $lock->get()) {
|
||||
Log::info('[PayPalAddonWebhook] lock busy', [
|
||||
'order_id' => $orderId,
|
||||
'addon_id' => $addon->id,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($addon->status === 'completed' && $normalized === 'PAYMENT.CAPTURE.COMPLETED') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($normalized, ['PAYMENT.CAPTURE.COMPLETED', 'CHECKOUT.ORDER.COMPLETED'], true)) {
|
||||
$captureId = $this->resolveCaptureId($resource, $normalized);
|
||||
$totals = $this->resolveTotals($resource);
|
||||
|
||||
$this->addons->complete(
|
||||
$addon,
|
||||
$resource,
|
||||
$captureId,
|
||||
$orderId,
|
||||
$totals['total'] ?? null,
|
||||
$totals['currency'] ?? null,
|
||||
[
|
||||
'paypal_order_id' => $orderId,
|
||||
'paypal_capture_id' => $captureId,
|
||||
'paypal_status' => $resource['status'] ?? null,
|
||||
'paypal_totals' => $totals ?: null,
|
||||
'paypal_captured_at' => now()->toIso8601String(),
|
||||
],
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($normalized, ['PAYMENT.CAPTURE.DENIED', 'PAYMENT.CAPTURE.REFUNDED', 'CHECKOUT.ORDER.VOIDED'], true)) {
|
||||
$reason = match ($normalized) {
|
||||
'PAYMENT.CAPTURE.DENIED' => 'paypal_capture_denied',
|
||||
'PAYMENT.CAPTURE.REFUNDED' => 'paypal_refunded',
|
||||
'CHECKOUT.ORDER.VOIDED' => 'paypal_voided',
|
||||
default => 'paypal_failed',
|
||||
};
|
||||
|
||||
$this->addons->fail($addon, $reason, $resource);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $resource
|
||||
*/
|
||||
protected function resolveOrderId(string $eventType, array $resource): ?string
|
||||
{
|
||||
if (str_starts_with($eventType, 'PAYMENT.CAPTURE.')) {
|
||||
$relatedOrderId = Arr::get($resource, 'supplementary_data.related_ids.order_id');
|
||||
|
||||
return is_string($relatedOrderId) && $relatedOrderId !== '' ? $relatedOrderId : null;
|
||||
}
|
||||
|
||||
$orderId = $resource['id'] ?? null;
|
||||
|
||||
return is_string($orderId) && $orderId !== '' ? $orderId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $resource
|
||||
*/
|
||||
protected function resolveCaptureId(array $resource, string $eventType): ?string
|
||||
{
|
||||
if (str_starts_with($eventType, 'PAYMENT.CAPTURE.')) {
|
||||
$captureId = $resource['id'] ?? null;
|
||||
|
||||
return is_string($captureId) && $captureId !== '' ? $captureId : null;
|
||||
}
|
||||
|
||||
$captureId = Arr::get($resource, 'purchase_units.0.payments.captures.0.id');
|
||||
|
||||
return is_string($captureId) && $captureId !== '' ? $captureId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $resource
|
||||
* @return array{currency?: string, total?: float}
|
||||
*/
|
||||
protected function resolveTotals(array $resource): array
|
||||
{
|
||||
$amount = Arr::get($resource, 'amount')
|
||||
?? Arr::get($resource, 'purchase_units.0.payments.captures.0.amount')
|
||||
?? Arr::get($resource, 'purchase_units.0.amount');
|
||||
|
||||
if (! is_array($amount)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$currency = Arr::get($amount, 'currency_code');
|
||||
$total = Arr::get($amount, 'value');
|
||||
|
||||
return array_filter([
|
||||
'currency' => is_string($currency) ? strtoupper($currency) : null,
|
||||
'total' => is_numeric($total) ? (float) $total : null,
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
}
|
||||
79
app/Services/PayPal/PayPalGiftVoucherWebhookService.php
Normal file
79
app/Services/PayPal/PayPalGiftVoucherWebhookService.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\PayPal;
|
||||
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Services\GiftVouchers\GiftVoucherService;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class PayPalGiftVoucherWebhookService
|
||||
{
|
||||
public function __construct(private readonly GiftVoucherService $vouchers) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
public function handle(array $event): bool
|
||||
{
|
||||
$eventType = $event['event_type'] ?? null;
|
||||
$resource = $event['resource'] ?? null;
|
||||
|
||||
if (! is_string($eventType) || ! is_array($resource)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalized = strtoupper($eventType);
|
||||
|
||||
if (! in_array($normalized, [
|
||||
'PAYMENT.CAPTURE.COMPLETED',
|
||||
'PAYMENT.CAPTURE.REFUNDED',
|
||||
'CHECKOUT.ORDER.COMPLETED',
|
||||
], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$orderId = $this->resolveOrderId($normalized, $resource);
|
||||
|
||||
if (! $orderId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$voucher = GiftVoucher::query()
|
||||
->where('paypal_order_id', $orderId)
|
||||
->first();
|
||||
|
||||
if (! $voucher) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array($normalized, ['PAYMENT.CAPTURE.COMPLETED', 'CHECKOUT.ORDER.COMPLETED'], true)) {
|
||||
$this->vouchers->issueFromPayPal($voucher, $resource, $orderId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($normalized === 'PAYMENT.CAPTURE.REFUNDED') {
|
||||
$this->vouchers->markRefundedFromPayPal($voucher, $resource);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $resource
|
||||
*/
|
||||
protected function resolveOrderId(string $eventType, array $resource): ?string
|
||||
{
|
||||
if (str_starts_with($eventType, 'PAYMENT.CAPTURE.')) {
|
||||
$relatedOrderId = Arr::get($resource, 'supplementary_data.related_ids.order_id');
|
||||
|
||||
return is_string($relatedOrderId) && $relatedOrderId !== '' ? $relatedOrderId : null;
|
||||
}
|
||||
|
||||
$orderId = $resource['id'] ?? null;
|
||||
|
||||
return is_string($orderId) && $orderId !== '' ? $orderId : null;
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,61 @@ class PayPalOrderService
|
||||
return $this->client->post('/v2/checkout/orders', $payload, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* custom_id?: string|null,
|
||||
* return_url?: string|null,
|
||||
* cancel_url?: string|null,
|
||||
* locale?: string|null,
|
||||
* request_id?: string|null
|
||||
* } $options
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createSimpleOrder(
|
||||
string $referenceId,
|
||||
string $description,
|
||||
float $amount,
|
||||
string $currency,
|
||||
array $options = [],
|
||||
): array {
|
||||
$formattedAmount = $this->formatAmount($amount);
|
||||
$currency = strtoupper($currency);
|
||||
|
||||
$purchaseUnit = array_filter([
|
||||
'reference_id' => Str::limit($referenceId, 127, ''),
|
||||
'description' => Str::limit($description, 127, ''),
|
||||
'custom_id' => $options['custom_id'] ?? null,
|
||||
'amount' => [
|
||||
'currency_code' => $currency,
|
||||
'value' => $formattedAmount,
|
||||
],
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$applicationContext = array_filter([
|
||||
'brand_name' => config('app.name', 'Fotospiel'),
|
||||
'landing_page' => 'NO_PREFERENCE',
|
||||
'user_action' => 'PAY_NOW',
|
||||
'shipping_preference' => 'NO_SHIPPING',
|
||||
'locale' => $this->resolveLocale($options['locale'] ?? null),
|
||||
'return_url' => $options['return_url'] ?? null,
|
||||
'cancel_url' => $options['cancel_url'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$payload = [
|
||||
'intent' => 'CAPTURE',
|
||||
'purchase_units' => [$purchaseUnit],
|
||||
'application_context' => $applicationContext,
|
||||
];
|
||||
|
||||
$headers = [];
|
||||
$requestId = $options['request_id'] ?? null;
|
||||
if (is_string($requestId) && $requestId !== '') {
|
||||
$headers['PayPal-Request-Id'] = $requestId;
|
||||
}
|
||||
|
||||
return $this->client->post('/v2/checkout/orders', $payload, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{request_id?: string|null} $options
|
||||
* @return array<string, mixed>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'provider' => env('GIFT_VOUCHER_PROVIDER', env('CHECKOUT_DEFAULT_PROVIDER', 'paypal')),
|
||||
'default_valid_years' => 5,
|
||||
'reminder_days' => 7,
|
||||
'expiry_reminder_days' => 14,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'provider' => env('ADDON_CHECKOUT_PROVIDER', env('CHECKOUT_DEFAULT_PROVIDER', 'paypal')),
|
||||
|
||||
// Keyed add-ons with display and Lemon Squeezy mapping. Amounts are base increments; multiply by quantity.
|
||||
'extra_photos_small' => [
|
||||
'label' => 'Extra photos (500)',
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('gift_vouchers', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('gift_vouchers', 'paypal_order_id')) {
|
||||
$table->string('paypal_order_id')->nullable()->after('lemonsqueezy_variant_id');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('gift_vouchers', 'paypal_capture_id')) {
|
||||
$table->string('paypal_capture_id')->nullable()->after('paypal_order_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('gift_vouchers', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('gift_vouchers', 'paypal_capture_id')) {
|
||||
$table->dropColumn('paypal_capture_id');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('gift_vouchers', 'paypal_order_id')) {
|
||||
$table->dropColumn('paypal_order_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -13,7 +13,9 @@ use App\Http\Controllers\LemonSqueezyWebhookController;
|
||||
use App\Http\Controllers\LocaleController;
|
||||
use App\Http\Controllers\Marketing\GiftVoucherPrintController;
|
||||
use App\Http\Controllers\MarketingController;
|
||||
use App\Http\Controllers\PayPalAddonReturnController;
|
||||
use App\Http\Controllers\PayPalCheckoutController;
|
||||
use App\Http\Controllers\PayPalGiftVoucherReturnController;
|
||||
use App\Http\Controllers\PayPalReturnController;
|
||||
use App\Http\Controllers\PayPalWebhookController;
|
||||
use App\Http\Controllers\ProfileAccountController;
|
||||
@@ -437,5 +439,11 @@ Route::post('/paypal/webhook', [PayPalWebhookController::class, 'handle'])
|
||||
->middleware('throttle:paypal-webhook')
|
||||
->name('paypal.webhook');
|
||||
|
||||
Route::get('/paypal/addon/return', PayPalAddonReturnController::class)
|
||||
->name('paypal.addon.return');
|
||||
|
||||
Route::get('/paypal/gift-voucher/return', PayPalGiftVoucherReturnController::class)
|
||||
->name('paypal.gift-voucher.return');
|
||||
|
||||
Route::get('/paypal/return', PayPalReturnController::class)
|
||||
->name('paypal.return');
|
||||
|
||||
@@ -28,6 +28,25 @@ class GiftVoucherLookupTest extends TestCase
|
||||
->assertJsonPath('data.currency', 'EUR');
|
||||
}
|
||||
|
||||
public function test_it_returns_voucher_by_paypal_order_id(): void
|
||||
{
|
||||
$voucher = GiftVoucher::factory()->create([
|
||||
'code' => 'GIFT-PAYPAL',
|
||||
'amount' => 59.00,
|
||||
'currency' => 'EUR',
|
||||
'paypal_order_id' => 'ORDER-PAYPAL-1',
|
||||
'paypal_capture_id' => 'CAPTURE-PAYPAL-1',
|
||||
'status' => GiftVoucher::STATUS_ISSUED,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/v1/marketing/gift-vouchers/lookup?order_id=CAPTURE-PAYPAL-1');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.code', $voucher->code)
|
||||
->assertJsonPath('data.amount', 59)
|
||||
->assertJsonPath('data.currency', 'EUR');
|
||||
}
|
||||
|
||||
public function test_it_returns_voucher_by_code(): void
|
||||
{
|
||||
$voucher = GiftVoucher::factory()->create([
|
||||
|
||||
@@ -5,6 +5,10 @@ namespace Tests\Feature;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\CouponRedemption;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Models\IntegrationWebhookEvent;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
@@ -16,6 +20,8 @@ use App\Services\Coupons\CouponService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PayPalWebhookControllerTest extends TestCase
|
||||
@@ -140,6 +146,149 @@ class PayPalWebhookControllerTest extends TestCase
|
||||
$response->assertStatus(400)->assertJson(['status' => 'invalid']);
|
||||
}
|
||||
|
||||
public function test_capture_completed_applies_addon_purchase(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
config([
|
||||
'services.paypal.client_id' => 'client',
|
||||
'services.paypal.secret' => 'secret',
|
||||
'services.paypal.sandbox' => true,
|
||||
'services.paypal.webhook_id' => 'wh_123',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([
|
||||
'access_token' => 'token',
|
||||
'expires_in' => 3600,
|
||||
]),
|
||||
'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([
|
||||
'verification_status' => 'SUCCESS',
|
||||
]),
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'contact_email' => 'tenant@example.com',
|
||||
]);
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'max_photos' => 100,
|
||||
]);
|
||||
$event = Event::factory()->for($tenant)->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$eventPackage = EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 0,
|
||||
'gallery_expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
$addon = EventPackageAddon::create([
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'addon_key' => 'extra_photos_small',
|
||||
'quantity' => 1,
|
||||
'extra_photos' => 0,
|
||||
'status' => 'pending',
|
||||
'checkout_id' => 'ORDER-ADDON-1',
|
||||
'metadata' => [
|
||||
'increments' => ['extra_photos' => 500],
|
||||
],
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'id' => 'WH-ADDON-1',
|
||||
'event_type' => 'PAYMENT.CAPTURE.COMPLETED',
|
||||
'resource' => [
|
||||
'id' => 'CAPTURE-ADDON-1',
|
||||
'status' => 'COMPLETED',
|
||||
'amount' => [
|
||||
'value' => '12.50',
|
||||
'currency_code' => 'EUR',
|
||||
],
|
||||
'supplementary_data' => [
|
||||
'related_ids' => [
|
||||
'order_id' => 'ORDER-ADDON-1',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->withHeaders($this->paypalHeaders())
|
||||
->postJson('/paypal/webhook', $payload);
|
||||
|
||||
$response->assertOk()->assertJson(['status' => 'processed']);
|
||||
|
||||
$addon->refresh();
|
||||
$eventPackage->refresh();
|
||||
|
||||
$this->assertSame('completed', $addon->status);
|
||||
$this->assertSame('CAPTURE-ADDON-1', $addon->transaction_id);
|
||||
$this->assertSame(500, $eventPackage->extra_photos);
|
||||
}
|
||||
|
||||
public function test_capture_completed_issues_gift_voucher(): void
|
||||
{
|
||||
Mail::fake();
|
||||
config()->set('gift-vouchers.reminder_days', 0);
|
||||
config()->set('gift-vouchers.expiry_reminder_days', 0);
|
||||
|
||||
config([
|
||||
'services.paypal.client_id' => 'client',
|
||||
'services.paypal.secret' => 'secret',
|
||||
'services.paypal.sandbox' => true,
|
||||
'services.paypal.webhook_id' => 'wh_123',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([
|
||||
'access_token' => 'token',
|
||||
'expires_in' => 3600,
|
||||
]),
|
||||
'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([
|
||||
'verification_status' => 'SUCCESS',
|
||||
]),
|
||||
]);
|
||||
|
||||
$voucher = GiftVoucher::factory()->create([
|
||||
'status' => GiftVoucher::STATUS_PENDING,
|
||||
'paypal_order_id' => 'ORDER-GIFT-1',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'id' => 'WH-GIFT-1',
|
||||
'event_type' => 'PAYMENT.CAPTURE.COMPLETED',
|
||||
'resource' => [
|
||||
'id' => 'CAPTURE-GIFT-1',
|
||||
'status' => 'COMPLETED',
|
||||
'amount' => [
|
||||
'value' => '29.00',
|
||||
'currency_code' => 'EUR',
|
||||
],
|
||||
'supplementary_data' => [
|
||||
'related_ids' => [
|
||||
'order_id' => 'ORDER-GIFT-1',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->withHeaders($this->paypalHeaders())
|
||||
->postJson('/paypal/webhook', $payload);
|
||||
|
||||
$response->assertOk()->assertJson(['status' => 'processed']);
|
||||
|
||||
$voucher->refresh();
|
||||
|
||||
$this->assertSame(GiftVoucher::STATUS_ISSUED, $voucher->status);
|
||||
$this->assertSame('CAPTURE-GIFT-1', $voucher->paypal_capture_id);
|
||||
$this->assertNotNull($voucher->coupon_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* 0: \App\Models\Tenant,
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Package;
|
||||
use App\Services\PayPal\PayPalOrderService;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Mockery;
|
||||
|
||||
class EventAddonCheckoutTest extends TenantTestCase
|
||||
{
|
||||
@@ -20,14 +23,15 @@ class EventAddonCheckoutTest extends TenantTestCase
|
||||
'variant_id' => 'var_addon_photos',
|
||||
'increments' => ['extra_photos' => 500],
|
||||
]);
|
||||
Config::set('package-addons.provider', CheckoutSession::PROVIDER_LEMONSQUEEZY);
|
||||
|
||||
Config::set('lemonsqueezy.api_key', 'test_key');
|
||||
Config::set('lemonsqueezy.base_url', 'https://lemonsqueezy.test');
|
||||
Config::set('lemonsqueezy.base_url', 'https://api.lemonsqueezy.com/v1');
|
||||
Config::set('lemonsqueezy.store_id', 'store_123');
|
||||
|
||||
// Fake Lemon Squeezy response
|
||||
Http::fake([
|
||||
'https://lemonsqueezy.test/checkouts' => Http::response([
|
||||
'https://api.lemonsqueezy.com/v1/checkouts' => Http::response([
|
||||
'data' => [
|
||||
'id' => 'chk_addon_123',
|
||||
'attributes' => [
|
||||
@@ -81,4 +85,69 @@ class EventAddonCheckoutTest extends TenantTestCase
|
||||
$addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first();
|
||||
$this->assertSame(1000, $addon->extra_photos); // increments * quantity
|
||||
}
|
||||
|
||||
public function test_paypal_checkout_creates_pending_addon_record(): void
|
||||
{
|
||||
Config::set('package-addons.provider', CheckoutSession::PROVIDER_PAYPAL);
|
||||
Config::set('checkout.currency', 'EUR');
|
||||
Config::set('package-addons.extra_photos_small.price', 12.50);
|
||||
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'max_photos' => 100,
|
||||
'max_guests' => 50,
|
||||
'gallery_days' => 7,
|
||||
]);
|
||||
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
|
||||
$eventPackage = EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 0,
|
||||
'gallery_expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
$orders = Mockery::mock(PayPalOrderService::class);
|
||||
$orders->shouldReceive('createSimpleOrder')
|
||||
->once()
|
||||
->andReturn([
|
||||
'id' => 'ORDER-ADDON-1',
|
||||
'links' => [
|
||||
['rel' => 'approve', 'href' => 'https://paypal.test/approve'],
|
||||
],
|
||||
]);
|
||||
$orders->shouldReceive('resolveApproveUrl')
|
||||
->once()
|
||||
->andReturn('https://paypal.test/approve');
|
||||
$this->app->instance(PayPalOrderService::class, $orders);
|
||||
|
||||
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [
|
||||
'addon_key' => 'extra_photos_small',
|
||||
'quantity' => 2,
|
||||
'accepted_terms' => true,
|
||||
'accepted_waiver' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('checkout_id', 'ORDER-ADDON-1');
|
||||
$response->assertJsonPath('checkout_url', 'https://paypal.test/approve');
|
||||
|
||||
$this->assertDatabaseHas('event_package_addons', [
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'addon_key' => 'extra_photos_small',
|
||||
'status' => 'pending',
|
||||
'quantity' => 2,
|
||||
'checkout_id' => 'ORDER-ADDON-1',
|
||||
'amount' => 25.00,
|
||||
'currency' => 'EUR',
|
||||
]);
|
||||
|
||||
$addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first();
|
||||
$this->assertSame(1000, $addon->extra_photos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
|
||||
use App\Services\PayPal\PayPalOrderService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
@@ -14,6 +16,7 @@ class GiftVoucherCheckoutServiceTest extends TestCase
|
||||
|
||||
public function test_it_lists_tiers_with_checkout_flag(): void
|
||||
{
|
||||
config()->set('gift-vouchers.provider', CheckoutSession::PROVIDER_LEMONSQUEEZY);
|
||||
config()->set('gift-vouchers.tiers', [
|
||||
['key' => 'gift-a', 'label' => 'A', 'amount' => 10, 'currency' => 'EUR', 'lemonsqueezy_variant_id' => 'pri_a'],
|
||||
['key' => 'gift-b', 'label' => 'B', 'amount' => 20, 'currency' => 'EUR', 'lemonsqueezy_variant_id' => null],
|
||||
@@ -28,8 +31,27 @@ class GiftVoucherCheckoutServiceTest extends TestCase
|
||||
$this->assertFalse($tiers[1]['can_checkout']);
|
||||
}
|
||||
|
||||
public function test_it_lists_tiers_for_paypal_currency_only(): void
|
||||
{
|
||||
config()->set('gift-vouchers.provider', CheckoutSession::PROVIDER_PAYPAL);
|
||||
config()->set('checkout.currency', 'EUR');
|
||||
config()->set('gift-vouchers.tiers', [
|
||||
['key' => 'gift-eur', 'label' => 'EUR', 'amount' => 10, 'currency' => 'EUR', 'lemonsqueezy_variant_id' => null],
|
||||
['key' => 'gift-usd', 'label' => 'USD', 'amount' => 20, 'currency' => 'USD', 'lemonsqueezy_variant_id' => null],
|
||||
]);
|
||||
|
||||
$service = $this->app->make(GiftVoucherCheckoutService::class);
|
||||
|
||||
$tiers = $service->tiers();
|
||||
|
||||
$this->assertCount(2, $tiers);
|
||||
$this->assertTrue($tiers[0]['can_checkout']);
|
||||
$this->assertFalse($tiers[1]['can_checkout']);
|
||||
}
|
||||
|
||||
public function test_it_creates_checkout_link_with_metadata(): void
|
||||
{
|
||||
config()->set('gift-vouchers.provider', CheckoutSession::PROVIDER_LEMONSQUEEZY);
|
||||
config()->set('gift-vouchers.tiers', [
|
||||
['key' => 'gift-a', 'label' => 'A', 'amount' => 10, 'currency' => 'EUR', 'lemonsqueezy_variant_id' => 'pri_a'],
|
||||
]);
|
||||
@@ -62,4 +84,46 @@ class GiftVoucherCheckoutServiceTest extends TestCase
|
||||
$this->assertSame('https://lemonsqueezy.test/checkout/123', $checkout['checkout_url']);
|
||||
$this->assertSame('chk_123', $checkout['id']);
|
||||
}
|
||||
|
||||
public function test_it_creates_paypal_checkout(): void
|
||||
{
|
||||
config()->set('gift-vouchers.provider', CheckoutSession::PROVIDER_PAYPAL);
|
||||
config()->set('checkout.currency', 'EUR');
|
||||
config()->set('gift-vouchers.tiers', [
|
||||
['key' => 'gift-a', 'label' => 'A', 'amount' => 10, 'currency' => 'EUR', 'lemonsqueezy_variant_id' => null],
|
||||
]);
|
||||
|
||||
$orders = Mockery::mock(PayPalOrderService::class);
|
||||
$orders->shouldReceive('createSimpleOrder')
|
||||
->once()
|
||||
->andReturn([
|
||||
'id' => 'ORDER-123',
|
||||
'links' => [
|
||||
['rel' => 'approve', 'href' => 'https://paypal.test/approve'],
|
||||
],
|
||||
]);
|
||||
$orders->shouldReceive('resolveApproveUrl')
|
||||
->once()
|
||||
->andReturn('https://paypal.test/approve');
|
||||
|
||||
$this->app->instance(PayPalOrderService::class, $orders);
|
||||
|
||||
$service = $this->app->make(GiftVoucherCheckoutService::class);
|
||||
|
||||
$checkout = $service->create([
|
||||
'tier_key' => 'gift-a',
|
||||
'purchaser_email' => 'buyer@example.com',
|
||||
'recipient_email' => 'friend@example.com',
|
||||
'recipient_name' => 'Friend',
|
||||
'message' => 'Hi',
|
||||
]);
|
||||
|
||||
$this->assertSame('https://paypal.test/approve', $checkout['checkout_url']);
|
||||
$this->assertSame('ORDER-123', $checkout['id']);
|
||||
|
||||
$this->assertDatabaseHas('gift_vouchers', [
|
||||
'paypal_order_id' => 'ORDER-123',
|
||||
'status' => \App\Models\GiftVoucher::STATUS_PENDING,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use App\Mail\GiftVoucherIssued;
|
||||
use App\Models\Coupon;
|
||||
@@ -156,4 +157,65 @@ class GiftVoucherServiceTest extends TestCase
|
||||
$this->assertSame(65.00, (float) $voucher->amount);
|
||||
$this->assertSame('USD', $voucher->currency);
|
||||
}
|
||||
|
||||
public function test_it_issues_voucher_from_paypal_payload(): void
|
||||
{
|
||||
Mail::fake();
|
||||
config()->set('gift-vouchers.reminder_days', 0);
|
||||
config()->set('gift-vouchers.expiry_reminder_days', 0);
|
||||
|
||||
$voucher = GiftVoucher::factory()->create([
|
||||
'status' => GiftVoucher::STATUS_PENDING,
|
||||
'paypal_order_id' => 'ORDER-123',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'id' => 'CAPTURE-123',
|
||||
'status' => 'COMPLETED',
|
||||
'purchase_units' => [
|
||||
[
|
||||
'payments' => [
|
||||
'captures' => [
|
||||
['id' => 'CAPTURE-123'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$service = $this->app->make(GiftVoucherService::class);
|
||||
$service->issueFromPayPal($voucher, $payload, 'ORDER-123');
|
||||
|
||||
$voucher->refresh();
|
||||
|
||||
$this->assertSame(GiftVoucher::STATUS_ISSUED, $voucher->status);
|
||||
$this->assertSame('ORDER-123', $voucher->paypal_order_id);
|
||||
$this->assertSame('CAPTURE-123', $voucher->paypal_capture_id);
|
||||
$this->assertNotNull($voucher->coupon_id);
|
||||
|
||||
Mail::assertQueued(GiftVoucherIssued::class, 2);
|
||||
}
|
||||
|
||||
public function test_it_marks_voucher_refunded_from_paypal(): void
|
||||
{
|
||||
$coupon = Coupon::factory()->create([
|
||||
'status' => CouponStatus::ACTIVE,
|
||||
'enabled_for_checkout' => true,
|
||||
]);
|
||||
|
||||
$voucher = GiftVoucher::factory()->create([
|
||||
'status' => GiftVoucher::STATUS_ISSUED,
|
||||
'coupon_id' => $coupon->id,
|
||||
]);
|
||||
|
||||
$service = $this->app->make(GiftVoucherService::class);
|
||||
$service->markRefundedFromPayPal($voucher, ['id' => 'REFUND-1']);
|
||||
|
||||
$voucher->refresh();
|
||||
$coupon->refresh();
|
||||
|
||||
$this->assertSame(GiftVoucher::STATUS_REFUNDED, $voucher->status);
|
||||
$this->assertSame(CouponStatus::ARCHIVED, $coupon->status);
|
||||
$this->assertFalse($coupon->enabled_for_checkout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class EventAddonCatalogTest extends TestCase
|
||||
Config::set('package-addons', [
|
||||
'extra_photos_small' => [
|
||||
'label' => 'Config Photos',
|
||||
'price_id' => 'pri_config',
|
||||
'variant_id' => 'var_config',
|
||||
'increments' => ['extra_photos' => 100],
|
||||
],
|
||||
]);
|
||||
@@ -25,10 +25,13 @@ class EventAddonCatalogTest extends TestCase
|
||||
PackageAddon::create([
|
||||
'key' => 'extra_photos_small',
|
||||
'label' => 'DB Photos',
|
||||
'price_id' => 'pri_db',
|
||||
'variant_id' => 'var_db',
|
||||
'extra_photos' => 200,
|
||||
'active' => true,
|
||||
'sort' => 1,
|
||||
'metadata' => [
|
||||
'price_eur' => 12,
|
||||
],
|
||||
]);
|
||||
|
||||
$catalog = $this->app->make(EventAddonCatalog::class);
|
||||
@@ -37,7 +40,9 @@ class EventAddonCatalogTest extends TestCase
|
||||
|
||||
$this->assertNotNull($addon);
|
||||
$this->assertSame('DB Photos', $addon['label']);
|
||||
$this->assertSame('pri_db', $addon['price_id']);
|
||||
$this->assertSame('var_db', $addon['variant_id']);
|
||||
$this->assertSame(200, $addon['increments']['extra_photos']);
|
||||
$this->assertSame(12.0, $addon['price']);
|
||||
$this->assertSame('EUR', $addon['currency']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user