Migrate billing from Paddle to Lemon Squeezy
This commit is contained in:
@@ -19,7 +19,7 @@ class EventAddonCatalog
|
||||
->mapWithKeys(function (PackageAddon $addon) {
|
||||
return [$addon->key => [
|
||||
'label' => $addon->label,
|
||||
'price_id' => $addon->price_id,
|
||||
'variant_id' => $addon->variant_id,
|
||||
'increments' => $addon->increments,
|
||||
]];
|
||||
})
|
||||
@@ -39,11 +39,11 @@ class EventAddonCatalog
|
||||
return $this->all()[$key] ?? null;
|
||||
}
|
||||
|
||||
public function resolvePriceId(string $key): ?string
|
||||
public function resolveVariantId(string $key): ?string
|
||||
{
|
||||
$addon = $this->find($key);
|
||||
|
||||
return $addon['price_id'] ?? null;
|
||||
return $addon['variant_id'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,20 +5,16 @@ namespace App\Services\Addons;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Paddle\PaddleClient;
|
||||
use App\Services\Paddle\PaddleCustomerService;
|
||||
use Illuminate\Support\Arr;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class EventAddonCheckoutService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventAddonCatalog $catalog,
|
||||
private readonly PaddleClient $paddle,
|
||||
private readonly PaddleCustomerService $customers,
|
||||
private readonly LemonSqueezyCheckoutService $checkout,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -32,25 +28,17 @@ class EventAddonCheckoutService
|
||||
$acceptedWaiver = (bool) ($payload['accepted_waiver'] ?? false);
|
||||
$acceptedTerms = (bool) ($payload['accepted_terms'] ?? false);
|
||||
|
||||
try {
|
||||
$customerId = $this->customers->ensureCustomerId($tenant);
|
||||
} catch (Throwable $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'customer' => __('Konnte Paddle-Kundenkonto nicht anlegen: :message', ['message' => $exception->getMessage()]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $addonKey || ! $this->catalog->find($addonKey)) {
|
||||
throw ValidationException::withMessages([
|
||||
'addon_key' => __('Unbekanntes Add-on.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$priceId = $this->catalog->resolvePriceId($addonKey);
|
||||
$variantId = $this->catalog->resolveVariantId($addonKey);
|
||||
|
||||
if (! $priceId) {
|
||||
if (! $variantId) {
|
||||
throw ValidationException::withMessages([
|
||||
'addon_key' => __('Für dieses Add-on ist kein Paddle-Preis hinterlegt.'),
|
||||
'addon_key' => __('Für dieses Add-on ist kein Lemon Squeezy Variant hinterlegt.'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -73,6 +61,7 @@ class EventAddonCheckoutService
|
||||
'addon_key' => $addonKey,
|
||||
'addon_intent' => $addonIntent,
|
||||
'quantity' => $quantity,
|
||||
'lemonsqueezy_variant_id' => $variantId,
|
||||
'legal_version' => $this->resolveLegalVersion(),
|
||||
'accepted_terms' => $acceptedTerms ? '1' : '0',
|
||||
'accepted_waiver' => $acceptedWaiver ? '1' : '0',
|
||||
@@ -80,31 +69,18 @@ class EventAddonCheckoutService
|
||||
'cancel_url' => $payload['cancel_url'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$requestPayload = array_filter([
|
||||
'customer_id' => $customerId,
|
||||
'items' => [
|
||||
[
|
||||
'price_id' => $priceId,
|
||||
'quantity' => $quantity,
|
||||
],
|
||||
],
|
||||
'custom_data' => $metadata,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
$response = $this->checkout->createVariantCheckout($variantId, $metadata, [
|
||||
'success_url' => $payload['success_url'] ?? null,
|
||||
'return_url' => $payload['cancel_url'] ?? null,
|
||||
'customer_email' => $tenant->contact_email ?? $tenant->user?->email,
|
||||
]);
|
||||
|
||||
$response = $this->paddle->post('/transactions', $requestPayload);
|
||||
|
||||
$checkoutUrl = Arr::get($response, 'data.checkout.url')
|
||||
?? Arr::get($response, 'checkout.url')
|
||||
?? Arr::get($response, 'data.url')
|
||||
?? Arr::get($response, 'url');
|
||||
$checkoutId = Arr::get($response, 'data.checkout_id')
|
||||
?? Arr::get($response, 'data.checkout.id')
|
||||
?? Arr::get($response, 'checkout_id')
|
||||
?? Arr::get($response, 'checkout.id');
|
||||
$transactionId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
|
||||
$checkoutUrl = $response['checkout_url'] ?? null;
|
||||
$checkoutId = $response['id'] ?? null;
|
||||
$transactionId = null;
|
||||
|
||||
if (! $checkoutUrl) {
|
||||
Log::warning('Paddle addon checkout response missing url', ['response' => $response]);
|
||||
Log::warning('Lemon Squeezy addon checkout response missing url', ['response' => $response]);
|
||||
}
|
||||
|
||||
EventPackageAddon::create([
|
||||
@@ -113,7 +89,7 @@ class EventAddonCheckoutService
|
||||
'tenant_id' => $tenant->id,
|
||||
'addon_key' => $addonKey,
|
||||
'quantity' => $quantity,
|
||||
'price_id' => $priceId,
|
||||
'variant_id' => $variantId,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'status' => 'pending',
|
||||
@@ -133,10 +109,8 @@ class EventAddonCheckoutService
|
||||
|
||||
return [
|
||||
'checkout_url' => $checkoutUrl,
|
||||
'expires_at' => Arr::get($response, 'data.checkout.expires_at')
|
||||
?? Arr::get($response, 'data.expires_at')
|
||||
?? Arr::get($response, 'expires_at'),
|
||||
'id' => $transactionId ?? $checkoutId,
|
||||
'expires_at' => $response['expires_at'] ?? null,
|
||||
'id' => $checkoutId,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -17,14 +17,19 @@ class EventAddonWebhookService
|
||||
|
||||
public function handle(array $payload): bool
|
||||
{
|
||||
$eventType = $payload['event_type'] ?? null;
|
||||
$eventType = $payload['meta']['event_name'] ?? null;
|
||||
$data = $payload['data'] ?? [];
|
||||
|
||||
if ($eventType !== 'transaction.completed') {
|
||||
if (! in_array($eventType, ['order_created', 'order_updated'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$metadata = $this->extractMetadata($data);
|
||||
$status = strtolower((string) data_get($data, 'attributes.status', ''));
|
||||
if ($status !== 'paid') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$metadata = $this->extractMetadata($payload);
|
||||
$intentId = $metadata['addon_intent'] ?? null;
|
||||
$addonKey = $metadata['addon_key'] ?? null;
|
||||
|
||||
@@ -32,8 +37,8 @@ class EventAddonWebhookService
|
||||
return false;
|
||||
}
|
||||
|
||||
$transactionId = $data['id'] ?? $data['transaction_id'] ?? null;
|
||||
$checkoutId = $data['checkout_id'] ?? null;
|
||||
$transactionId = $data['id'] ?? null;
|
||||
$checkoutId = data_get($data, 'attributes.checkout_id') ?? null;
|
||||
|
||||
$addon = EventPackageAddon::query()
|
||||
->where('addon_key', $addonKey)
|
||||
@@ -66,10 +71,12 @@ class EventAddonWebhookService
|
||||
'transaction_id' => $transactionId,
|
||||
'checkout_id' => $addon->checkout_id ?: $checkoutId,
|
||||
'status' => 'completed',
|
||||
'amount' => Arr::get($data, 'totals.grand_total') ?? Arr::get($data, 'amount'),
|
||||
'currency' => Arr::get($data, 'currency_code') ?? Arr::get($data, 'currency'),
|
||||
'amount' => $this->resolveAmount($data),
|
||||
'currency' => Arr::get($data, 'attributes.currency') ?? Arr::get($data, 'currency'),
|
||||
'metadata' => array_merge($addon->metadata ?? [], ['webhook_payload' => $data]),
|
||||
'receipt_payload' => Arr::get($data, 'receipt_url') ? ['receipt_url' => Arr::get($data, 'receipt_url')] : null,
|
||||
'receipt_payload' => Arr::get($data, 'attributes.urls.receipt')
|
||||
? ['receipt_url' => Arr::get($data, 'attributes.urls.receipt')]
|
||||
: null,
|
||||
'purchased_at' => now(),
|
||||
])->save();
|
||||
|
||||
@@ -118,17 +125,36 @@ class EventAddonWebhookService
|
||||
{
|
||||
$metadata = [];
|
||||
|
||||
if (isset($data['metadata']) && is_array($data['metadata'])) {
|
||||
$metadata = $data['metadata'];
|
||||
if (isset($data['meta']['custom_data']) && is_array($data['meta']['custom_data'])) {
|
||||
$metadata = $data['meta']['custom_data'];
|
||||
}
|
||||
|
||||
if (isset($data['custom_data']) && is_array($data['custom_data'])) {
|
||||
$metadata = array_merge($metadata, $data['custom_data']);
|
||||
if (isset($data['metadata']) && is_array($data['metadata'])) {
|
||||
$metadata = array_merge($metadata, $data['metadata']);
|
||||
}
|
||||
|
||||
if (isset($data['attributes']['custom_data']) && is_array($data['attributes']['custom_data'])) {
|
||||
$metadata = array_merge($metadata, $data['attributes']['custom_data']);
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
private function resolveAmount(array $data): ?float
|
||||
{
|
||||
$total = Arr::get($data, 'attributes.total');
|
||||
|
||||
if ($total === null || $total === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_numeric($total)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(((float) $total) / 100, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
|
||||
@@ -60,16 +60,16 @@ class CheckoutAssignmentService
|
||||
$consents = array_filter($consents);
|
||||
|
||||
$providerReference = $options['provider_reference']
|
||||
?? $metadata['paddle_transaction_id'] ?? null
|
||||
?? $metadata['paddle_checkout_id'] ?? null
|
||||
?? $metadata['lemonsqueezy_order_id'] ?? null
|
||||
?? $metadata['lemonsqueezy_checkout_id'] ?? null
|
||||
?? CheckoutSession::PROVIDER_FREE;
|
||||
|
||||
$providerName = $options['provider']
|
||||
?? $session->provider
|
||||
?? ($metadata['paddle_transaction_id'] ?? $metadata['paddle_checkout_id'] ? CheckoutSession::PROVIDER_PADDLE : null)
|
||||
?? ($metadata['lemonsqueezy_order_id'] ?? $metadata['lemonsqueezy_checkout_id'] ? CheckoutSession::PROVIDER_LEMONSQUEEZY : null)
|
||||
?? CheckoutSession::PROVIDER_FREE;
|
||||
|
||||
$totals = $this->resolvePaddleTotals($session, $options['payload'] ?? []);
|
||||
$totals = $this->resolveLemonSqueezyTotals($session, $options['payload'] ?? []);
|
||||
$currency = $totals['currency'] ?? $session->currency ?? $package->currency ?? 'EUR';
|
||||
$price = array_key_exists('total', $totals) ? $totals['total'] : (float) $session->amount_total;
|
||||
|
||||
@@ -88,7 +88,7 @@ class CheckoutAssignmentService
|
||||
'payload' => $options['payload'] ?? null,
|
||||
'checkout_session_id' => $session->id,
|
||||
'consents' => $consents ?: null,
|
||||
'paddle_totals' => $totals !== [] ? $totals : null,
|
||||
'lemonsqueezy_totals' => $totals !== [] ? $totals : null,
|
||||
'currency' => $currency,
|
||||
], static fn ($value) => $value !== null && $value !== ''),
|
||||
]
|
||||
@@ -223,34 +223,25 @@ class CheckoutAssignmentService
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float}
|
||||
*/
|
||||
protected function resolvePaddleTotals(CheckoutSession $session, array $payload): array
|
||||
protected function resolveLemonSqueezyTotals(CheckoutSession $session, array $payload): array
|
||||
{
|
||||
$metadataTotals = $session->provider_metadata['paddle_totals'] ?? null;
|
||||
$metadataTotals = $session->provider_metadata['lemonsqueezy_totals'] ?? null;
|
||||
|
||||
if (is_array($metadataTotals) && $metadataTotals !== []) {
|
||||
return $metadataTotals;
|
||||
}
|
||||
|
||||
$totals = Arr::get($payload, 'details.totals', Arr::get($payload, 'totals', []));
|
||||
if (! is_array($totals) || $totals === []) {
|
||||
$attributes = Arr::get($payload, 'attributes', []);
|
||||
if (! is_array($attributes) || $attributes === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$currency = Arr::get($totals, 'currency_code')
|
||||
?? Arr::get($payload, 'currency_code')
|
||||
?? Arr::get($totals, 'currency')
|
||||
?? Arr::get($payload, 'currency');
|
||||
$currency = Arr::get($attributes, 'currency');
|
||||
|
||||
$subtotal = $this->convertMinorAmount(Arr::get($totals, 'subtotal.amount', $totals['subtotal'] ?? null));
|
||||
$discount = $this->convertMinorAmount(Arr::get($totals, 'discount.amount', $totals['discount'] ?? null));
|
||||
$tax = $this->convertMinorAmount(Arr::get($totals, 'tax.amount', $totals['tax'] ?? null));
|
||||
$total = $this->convertMinorAmount(
|
||||
Arr::get(
|
||||
$totals,
|
||||
'total.amount',
|
||||
$totals['total'] ?? Arr::get($totals, 'grand_total.amount', $totals['grand_total'] ?? null)
|
||||
)
|
||||
);
|
||||
$subtotal = $this->convertMinorAmount(Arr::get($attributes, 'subtotal'));
|
||||
$discount = $this->convertMinorAmount(Arr::get($attributes, 'discount_total'));
|
||||
$tax = $this->convertMinorAmount(Arr::get($attributes, 'tax'));
|
||||
$total = $this->convertMinorAmount(Arr::get($attributes, 'total'));
|
||||
|
||||
return array_filter([
|
||||
'currency' => $currency ? strtoupper((string) $currency) : null,
|
||||
|
||||
@@ -72,8 +72,8 @@ class CheckoutSessionService
|
||||
$session->amount_discount = 0;
|
||||
$session->provider = CheckoutSession::PROVIDER_NONE;
|
||||
$session->status = CheckoutSession::STATUS_DRAFT;
|
||||
$session->paddle_checkout_id = null;
|
||||
$session->paddle_transaction_id = null;
|
||||
$session->lemonsqueezy_checkout_id = null;
|
||||
$session->lemonsqueezy_order_id = null;
|
||||
$session->provider_metadata = [];
|
||||
$session->failure_reason = null;
|
||||
$session->coupon()->dissociate();
|
||||
@@ -118,7 +118,7 @@ class CheckoutSessionService
|
||||
$provider = strtolower($provider);
|
||||
|
||||
if (! in_array($provider, [
|
||||
CheckoutSession::PROVIDER_PADDLE,
|
||||
CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
||||
CheckoutSession::PROVIDER_FREE,
|
||||
], true)) {
|
||||
throw new RuntimeException("Unsupported checkout provider [{$provider}]");
|
||||
|
||||
@@ -8,7 +8,7 @@ use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Coupons\CouponRedemptionService;
|
||||
use App\Services\GiftVouchers\GiftVoucherService;
|
||||
use App\Services\Paddle\PaddleSubscriptionService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -20,52 +20,52 @@ class CheckoutWebhookService
|
||||
public function __construct(
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CheckoutAssignmentService $assignment,
|
||||
private readonly PaddleSubscriptionService $paddleSubscriptions,
|
||||
private readonly LemonSqueezySubscriptionService $lemonsqueezySubscriptions,
|
||||
private readonly CouponRedemptionService $couponRedemptions,
|
||||
private readonly GiftVoucherService $giftVouchers,
|
||||
) {}
|
||||
|
||||
public function handlePaddleEvent(array $event): bool
|
||||
public function handleLemonSqueezyEvent(array $event): bool
|
||||
{
|
||||
$eventType = $event['event_type'] ?? null;
|
||||
$data = $event['data'] ?? [];
|
||||
$eventType = $event['meta']['event_name'] ?? $event['event_name'] ?? null;
|
||||
$data = $event['data'] ?? null;
|
||||
|
||||
if (! $eventType || ! is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Str::startsWith($eventType, 'subscription.')) {
|
||||
return $this->handlePaddleSubscriptionEvent($eventType, $data);
|
||||
if (Str::startsWith($eventType, 'subscription_')) {
|
||||
return $this->handleLemonSqueezySubscriptionEvent($eventType, $data, $event);
|
||||
}
|
||||
|
||||
if ($this->isGiftVoucherEvent($data)) {
|
||||
if ($eventType === 'transaction.completed') {
|
||||
$this->giftVouchers->issueFromPaddle($data);
|
||||
if ($this->isGiftVoucherEvent($event)) {
|
||||
if ($eventType === 'order_created') {
|
||||
$this->giftVouchers->issueFromLemonSqueezy($event);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($eventType, ['transaction.processing', 'transaction.created', 'transaction.failed', 'transaction.cancelled'], true);
|
||||
return in_array($eventType, ['order_created', 'order_refunded', 'order_payment_failed', 'order_updated'], true);
|
||||
}
|
||||
|
||||
$session = $this->locatePaddleSession($data);
|
||||
$session = $this->locateLemonSqueezySession($event);
|
||||
|
||||
if (! $session) {
|
||||
Log::info('[CheckoutWebhook] Paddle session not resolved', [
|
||||
Log::info('[CheckoutWebhook] Lemon Squeezy session not resolved', [
|
||||
'event_type' => $eventType,
|
||||
'transaction_id' => $data['id'] ?? null,
|
||||
'order_id' => $data['id'] ?? null,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$transactionId = $data['id'] ?? $data['transaction_id'] ?? null;
|
||||
$lockKey = 'checkout:webhook:paddle:'.($transactionId ?: $session->id);
|
||||
$orderId = $data['id'] ?? null;
|
||||
$lockKey = 'checkout:webhook:lemonsqueezy:'.($orderId ?: $session->id);
|
||||
$lock = Cache::lock($lockKey, 30);
|
||||
|
||||
if (! $lock->get()) {
|
||||
Log::info('[CheckoutWebhook] Paddle lock busy', [
|
||||
'transaction_id' => $transactionId,
|
||||
Log::info('[CheckoutWebhook] Lemon Squeezy lock busy', [
|
||||
'order_id' => $orderId,
|
||||
'session_id' => $session->id,
|
||||
]);
|
||||
|
||||
@@ -73,75 +73,90 @@ class CheckoutWebhookService
|
||||
}
|
||||
|
||||
try {
|
||||
if ($transactionId) {
|
||||
if ($orderId) {
|
||||
$session->forceFill([
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'lemonsqueezy_order_id' => $orderId,
|
||||
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
||||
])->save();
|
||||
} elseif ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
|
||||
$session->forceFill(['provider' => CheckoutSession::PROVIDER_PADDLE])->save();
|
||||
} elseif ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) {
|
||||
$session->forceFill(['provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY])->save();
|
||||
}
|
||||
|
||||
$metadata = [
|
||||
'paddle_last_event' => $eventType,
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'paddle_status' => $data['status'] ?? null,
|
||||
'paddle_last_update_at' => now()->toIso8601String(),
|
||||
'lemonsqueezy_last_event' => $eventType,
|
||||
'lemonsqueezy_order_id' => $orderId,
|
||||
'lemonsqueezy_status' => data_get($data, 'attributes.status'),
|
||||
'lemonsqueezy_last_update_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
if (! empty($data['checkout_id'])) {
|
||||
$metadata['paddle_checkout_id'] = $data['checkout_id'];
|
||||
$checkoutId = data_get($data, 'attributes.checkout_id') ?? data_get($event, 'meta.custom_data.checkout_id');
|
||||
if (! empty($checkoutId)) {
|
||||
$metadata['lemonsqueezy_checkout_id'] = $checkoutId;
|
||||
}
|
||||
|
||||
$this->mergeProviderMetadata($session, $metadata);
|
||||
|
||||
return $this->applyPaddleEvent($session, $eventType, $data);
|
||||
$customerId = data_get($data, 'attributes.customer_id')
|
||||
?? data_get($data, 'relationships.customer.data.id');
|
||||
|
||||
if ($customerId && $session->tenant && ! $session->tenant->lemonsqueezy_customer_id) {
|
||||
$session->tenant->forceFill([
|
||||
'lemonsqueezy_customer_id' => (string) $customerId,
|
||||
])->save();
|
||||
}
|
||||
|
||||
return $this->applyLemonSqueezyEvent($session, $eventType, $data, $event);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
protected function applyPaddleEvent(CheckoutSession $session, string $eventType, array $data): bool
|
||||
protected function applyLemonSqueezyEvent(CheckoutSession $session, string $eventType, array $data, array $event): bool
|
||||
{
|
||||
$status = strtolower((string) ($data['status'] ?? ''));
|
||||
$status = Str::lower((string) data_get($data, 'attributes.status', ''));
|
||||
|
||||
switch ($eventType) {
|
||||
case 'transaction.created':
|
||||
case 'transaction.processing':
|
||||
$this->sessions->markProcessing($session, [
|
||||
'paddle_status' => $status ?: null,
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
case 'transaction.completed':
|
||||
case 'order_created':
|
||||
case 'order_updated':
|
||||
$this->syncSessionTotals($session, $data);
|
||||
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
||||
|
||||
if ($status === 'paid') {
|
||||
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
||||
$this->sessions->markProcessing($session, [
|
||||
'lemonsqueezy_status' => $status ?: 'paid',
|
||||
]);
|
||||
|
||||
$this->assignment->finalise($session, [
|
||||
'source' => 'lemonsqueezy_webhook',
|
||||
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
||||
'provider_reference' => $data['id'] ?? null,
|
||||
'payload' => $data,
|
||||
]);
|
||||
|
||||
$this->sessions->markCompleted($session, now());
|
||||
$this->couponRedemptions->recordSuccess($session, $data);
|
||||
}
|
||||
} else {
|
||||
$this->sessions->markProcessing($session, [
|
||||
'paddle_status' => $status ?: 'completed',
|
||||
'lemonsqueezy_status' => $status ?: null,
|
||||
]);
|
||||
|
||||
$this->assignment->finalise($session, [
|
||||
'source' => 'paddle_webhook',
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'provider_reference' => $data['id'] ?? null,
|
||||
'payload' => $data,
|
||||
]);
|
||||
|
||||
$this->sessions->markCompleted($session, now());
|
||||
$this->couponRedemptions->recordSuccess($session, $data);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
case 'transaction.failed':
|
||||
case 'transaction.cancelled':
|
||||
$reason = $status ?: ($eventType === 'transaction.failed' ? 'paddle_failed' : 'paddle_cancelled');
|
||||
case 'order_payment_failed':
|
||||
$reason = $status ?: 'lemonsqueezy_failed';
|
||||
$this->sessions->markFailed($session, $reason);
|
||||
$this->couponRedemptions->recordFailure($session, $reason);
|
||||
|
||||
return true;
|
||||
|
||||
case 'order_refunded':
|
||||
$this->sessions->markFailed($session, 'lemonsqueezy_refunded');
|
||||
$this->couponRedemptions->recordFailure($session, 'lemonsqueezy_refunded');
|
||||
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -149,7 +164,7 @@ class CheckoutWebhookService
|
||||
|
||||
protected function syncSessionTotals(CheckoutSession $session, array $data): void
|
||||
{
|
||||
$totals = $this->normalizePaddleTotals($data);
|
||||
$totals = $this->normalizeLemonSqueezyTotals($data);
|
||||
|
||||
if ($totals === []) {
|
||||
return;
|
||||
@@ -178,29 +193,22 @@ class CheckoutWebhookService
|
||||
}
|
||||
|
||||
$this->mergeProviderMetadata($session, [
|
||||
'paddle_totals' => $totals,
|
||||
'lemonsqueezy_totals' => $totals,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float}
|
||||
*/
|
||||
protected function normalizePaddleTotals(array $data): array
|
||||
protected function normalizeLemonSqueezyTotals(array $data): array
|
||||
{
|
||||
$totals = Arr::get($data, 'details.totals', Arr::get($data, 'totals', []));
|
||||
$currency = Arr::get($totals, 'currency_code')
|
||||
?? $data['currency_code'] ?? Arr::get($totals, 'currency') ?? Arr::get($data, 'currency');
|
||||
$attributes = Arr::get($data, 'attributes', []);
|
||||
$currency = Arr::get($attributes, 'currency');
|
||||
|
||||
$subtotal = $this->convertMinorAmount(Arr::get($totals, 'subtotal.amount', $totals['subtotal'] ?? null));
|
||||
$discount = $this->convertMinorAmount(Arr::get($totals, 'discount.amount', $totals['discount'] ?? null));
|
||||
$tax = $this->convertMinorAmount(Arr::get($totals, 'tax.amount', $totals['tax'] ?? null));
|
||||
$total = $this->convertMinorAmount(
|
||||
Arr::get(
|
||||
$totals,
|
||||
'total.amount',
|
||||
$totals['total'] ?? Arr::get($totals, 'grand_total.amount', $totals['grand_total'] ?? null)
|
||||
)
|
||||
);
|
||||
$subtotal = $this->convertMinorAmount(Arr::get($attributes, 'subtotal'));
|
||||
$discount = $this->convertMinorAmount(Arr::get($attributes, 'discount_total'));
|
||||
$tax = $this->convertMinorAmount(Arr::get($attributes, 'tax'));
|
||||
$total = $this->convertMinorAmount(Arr::get($attributes, 'total'));
|
||||
|
||||
return array_filter([
|
||||
'currency' => $currency ? strtoupper((string) $currency) : null,
|
||||
@@ -228,7 +236,7 @@ class CheckoutWebhookService
|
||||
return round(((float) $value) / 100, 2);
|
||||
}
|
||||
|
||||
protected function handlePaddleSubscriptionEvent(string $eventType, array $data): bool
|
||||
protected function handleLemonSqueezySubscriptionEvent(string $eventType, array $data, array $event): bool
|
||||
{
|
||||
$subscriptionId = $data['id'] ?? null;
|
||||
|
||||
@@ -236,11 +244,11 @@ class CheckoutWebhookService
|
||||
return false;
|
||||
}
|
||||
|
||||
$customData = $this->extractCustomData($data);
|
||||
$customData = $this->extractCustomData($event);
|
||||
$tenant = $this->resolveTenantFromSubscription($data, $customData, $subscriptionId);
|
||||
|
||||
if (! $tenant) {
|
||||
Log::info('[CheckoutWebhook] Paddle subscription tenant not resolved', [
|
||||
Log::info('[CheckoutWebhook] Lemon Squeezy subscription tenant not resolved', [
|
||||
'subscription_id' => $subscriptionId,
|
||||
]);
|
||||
|
||||
@@ -250,14 +258,14 @@ class CheckoutWebhookService
|
||||
$package = $this->resolvePackageFromSubscription($data, $customData, $subscriptionId);
|
||||
|
||||
if (! $package) {
|
||||
Log::info('[CheckoutWebhook] Paddle subscription package not resolved', [
|
||||
Log::info('[CheckoutWebhook] Lemon Squeezy subscription package not resolved', [
|
||||
'subscription_id' => $subscriptionId,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = strtolower((string) ($data['status'] ?? ''));
|
||||
$status = Str::lower((string) Arr::get($data, 'attributes.status', ''));
|
||||
$expiresAt = $this->resolveSubscriptionExpiry($data);
|
||||
$startedAt = $this->resolveSubscriptionStart($data);
|
||||
|
||||
@@ -267,7 +275,7 @@ class CheckoutWebhookService
|
||||
]);
|
||||
|
||||
$tenantPackage->fill([
|
||||
'paddle_subscription_id' => $subscriptionId,
|
||||
'lemonsqueezy_subscription_id' => $subscriptionId,
|
||||
'price' => $package->price,
|
||||
]);
|
||||
|
||||
@@ -279,17 +287,17 @@ class CheckoutWebhookService
|
||||
$tenantPackage->active = $this->isSubscriptionActive($status);
|
||||
$tenantPackage->save();
|
||||
|
||||
if ($eventType === 'subscription.cancelled' || $eventType === 'subscription.paused') {
|
||||
if (in_array($eventType, ['subscription_cancelled', 'subscription_expired', 'subscription_paused'], true)) {
|
||||
$tenantPackage->forceFill(['active' => false])->save();
|
||||
}
|
||||
|
||||
$tenant->forceFill([
|
||||
'subscription_status' => $this->mapSubscriptionStatus($status),
|
||||
'subscription_expires_at' => $expiresAt,
|
||||
'paddle_customer_id' => $tenant->paddle_customer_id ?: ($data['customer_id'] ?? null),
|
||||
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id ?: Arr::get($data, 'attributes.customer_id'),
|
||||
])->save();
|
||||
|
||||
Log::info('[CheckoutWebhook] Paddle subscription event processed', [
|
||||
Log::info('[CheckoutWebhook] Lemon Squeezy subscription event processed', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'subscription_id' => $subscriptionId,
|
||||
@@ -309,20 +317,20 @@ class CheckoutWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
$customerId = $data['customer_id'] ?? null;
|
||||
$customerId = Arr::get($data, 'attributes.customer_id') ?? Arr::get($data, 'relationships.customer.data.id');
|
||||
|
||||
if ($customerId) {
|
||||
$tenant = Tenant::where('paddle_customer_id', $customerId)->first();
|
||||
$tenant = Tenant::where('lemonsqueezy_customer_id', $customerId)->first();
|
||||
if ($tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
$subscription = $this->paddleSubscriptions->retrieve($subscriptionId);
|
||||
$customerId = Arr::get($subscription, 'data.customer_id');
|
||||
$subscription = $this->lemonsqueezySubscriptions->retrieve($subscriptionId);
|
||||
$customerId = Arr::get($subscription, 'attributes.customer_id') ?? Arr::get($subscription, 'relationships.customer.data.id');
|
||||
|
||||
if ($customerId) {
|
||||
return Tenant::where('paddle_customer_id', $customerId)->first();
|
||||
return Tenant::where('lemonsqueezy_customer_id', $customerId)->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -337,20 +345,20 @@ class CheckoutWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
$priceId = Arr::get($data, 'items.0.price_id') ?? Arr::get($data, 'items.0.price.id');
|
||||
$variantId = Arr::get($data, 'attributes.variant_id') ?? Arr::get($data, 'relationships.variant.data.id');
|
||||
|
||||
if ($priceId) {
|
||||
$package = Package::withTrashed()->where('paddle_price_id', $priceId)->first();
|
||||
if ($variantId) {
|
||||
$package = Package::withTrashed()->where('lemonsqueezy_variant_id', $variantId)->first();
|
||||
if ($package) {
|
||||
return $package;
|
||||
}
|
||||
}
|
||||
|
||||
$subscription = $this->paddleSubscriptions->retrieve($subscriptionId);
|
||||
$priceId = Arr::get($subscription, 'data.items.0.price_id') ?? Arr::get($subscription, 'data.items.0.price.id');
|
||||
$subscription = $this->lemonsqueezySubscriptions->retrieve($subscriptionId);
|
||||
$variantId = Arr::get($subscription, 'attributes.variant_id') ?? Arr::get($subscription, 'relationships.variant.data.id');
|
||||
|
||||
if ($priceId) {
|
||||
return Package::withTrashed()->where('paddle_price_id', $priceId)->first();
|
||||
if ($variantId) {
|
||||
return Package::withTrashed()->where('lemonsqueezy_variant_id', $variantId)->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -358,35 +366,35 @@ class CheckoutWebhookService
|
||||
|
||||
protected function resolveSubscriptionExpiry(array $data): ?Carbon
|
||||
{
|
||||
$nextBilling = Arr::get($data, 'next_billing_date') ?? Arr::get($data, 'next_payment_date');
|
||||
$nextBilling = Arr::get($data, 'attributes.renews_at');
|
||||
|
||||
if ($nextBilling) {
|
||||
return Carbon::parse($nextBilling);
|
||||
}
|
||||
|
||||
$endsAt = Arr::get($data, 'billing_period_ends_at') ?? Arr::get($data, 'pays_out_at');
|
||||
$endsAt = Arr::get($data, 'attributes.ends_at');
|
||||
|
||||
return $endsAt ? Carbon::parse($endsAt) : null;
|
||||
}
|
||||
|
||||
protected function resolveSubscriptionStart(array $data): Carbon
|
||||
{
|
||||
$created = Arr::get($data, 'created_at') ?? Arr::get($data, 'activated_at');
|
||||
$created = Arr::get($data, 'attributes.created_at');
|
||||
|
||||
return $created ? Carbon::parse($created) : now();
|
||||
}
|
||||
|
||||
protected function isSubscriptionActive(string $status): bool
|
||||
{
|
||||
return in_array($status, ['active', 'trialing'], true);
|
||||
return in_array($status, ['active', 'on_trial'], true);
|
||||
}
|
||||
|
||||
protected function mapSubscriptionStatus(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'active', 'trialing' => 'active',
|
||||
'paused' => 'suspended',
|
||||
'cancelled', 'past_due', 'halted' => 'expired',
|
||||
'active', 'on_trial' => 'active',
|
||||
'past_due', 'unpaid', 'paused' => 'suspended',
|
||||
'cancelled', 'expired' => 'expired',
|
||||
default => 'free',
|
||||
};
|
||||
}
|
||||
@@ -397,9 +405,9 @@ class CheckoutWebhookService
|
||||
$session->save();
|
||||
}
|
||||
|
||||
protected function isGiftVoucherEvent(array $data): bool
|
||||
protected function isGiftVoucherEvent(array $event): bool
|
||||
{
|
||||
$metadata = $this->extractCustomData($data);
|
||||
$metadata = $this->extractCustomData($event);
|
||||
|
||||
$type = is_array($metadata) ? ($metadata['type'] ?? $metadata['kind'] ?? $metadata['category'] ?? null) : null;
|
||||
|
||||
@@ -407,18 +415,19 @@ class CheckoutWebhookService
|
||||
return true;
|
||||
}
|
||||
|
||||
$priceId = $data['price_id'] ?? Arr::get($metadata, 'paddle_price_id');
|
||||
$variantId = data_get($event, 'data.attributes.variant_id')
|
||||
?? Arr::get($metadata, 'lemonsqueezy_variant_id');
|
||||
$tiers = collect(config('gift-vouchers.tiers', []))
|
||||
->pluck('paddle_price_id')
|
||||
->pluck('lemonsqueezy_variant_id')
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
return $priceId && in_array($priceId, $tiers, true);
|
||||
return $variantId && in_array($variantId, $tiers, true);
|
||||
}
|
||||
|
||||
protected function locatePaddleSession(array $data): ?CheckoutSession
|
||||
protected function locateLemonSqueezySession(array $event): ?CheckoutSession
|
||||
{
|
||||
$metadata = $this->extractCustomData($data);
|
||||
$metadata = $this->extractCustomData($event);
|
||||
|
||||
if (is_array($metadata)) {
|
||||
$sessionId = $metadata['checkout_session_id'] ?? null;
|
||||
@@ -444,11 +453,13 @@ class CheckoutWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
$checkoutId = $data['checkout_id'] ?? Arr::get($data, 'details.checkout_id');
|
||||
$checkoutId = data_get($event, 'data.attributes.checkout_id')
|
||||
?? Arr::get($metadata, 'lemonsqueezy_checkout_id')
|
||||
?? Arr::get($metadata, 'checkout_id');
|
||||
|
||||
if ($checkoutId) {
|
||||
return CheckoutSession::query()
|
||||
->where('provider_metadata->paddle_checkout_id', $checkoutId)
|
||||
->where('provider_metadata->lemonsqueezy_checkout_id', $checkoutId)
|
||||
->first();
|
||||
}
|
||||
|
||||
@@ -463,8 +474,16 @@ class CheckoutWebhookService
|
||||
{
|
||||
$customData = [];
|
||||
|
||||
if (isset($data['meta']['custom_data']) && is_array($data['meta']['custom_data'])) {
|
||||
$customData = $data['meta']['custom_data'];
|
||||
}
|
||||
|
||||
if (isset($data['attributes']['custom_data']) && is_array($data['attributes']['custom_data'])) {
|
||||
$customData = array_merge($customData, $data['attributes']['custom_data']);
|
||||
}
|
||||
|
||||
if (isset($data['custom_data']) && is_array($data['custom_data'])) {
|
||||
$customData = $data['custom_data'];
|
||||
$customData = array_merge($customData, $data['custom_data']);
|
||||
}
|
||||
|
||||
if (isset($data['customData']) && is_array($data['customData'])) {
|
||||
|
||||
@@ -31,7 +31,7 @@ class CouponRedemptionService
|
||||
return;
|
||||
}
|
||||
|
||||
$transactionId = Arr::get($payload, 'id') ?? $session->paddle_transaction_id;
|
||||
$transactionId = Arr::get($payload, 'id') ?? $session->lemonsqueezy_order_id;
|
||||
|
||||
$context = $this->resolveRequestContext($session);
|
||||
$fraudSnapshot = $this->buildFraudSnapshot($context);
|
||||
@@ -40,7 +40,7 @@ class CouponRedemptionService
|
||||
'tenant_id' => $session->tenant_id,
|
||||
'user_id' => $session->user_id,
|
||||
'package_id' => $session->package_id,
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'lemonsqueezy_order_id' => $transactionId,
|
||||
'status' => CouponRedemption::STATUS_SUCCESS,
|
||||
'failure_reason' => null,
|
||||
'amount_discounted' => $session->amount_discount,
|
||||
@@ -84,7 +84,7 @@ class CouponRedemptionService
|
||||
'tenant_id' => $session->tenant_id,
|
||||
'user_id' => $session->user_id,
|
||||
'package_id' => $session->package_id,
|
||||
'paddle_transaction_id' => $session->paddle_transaction_id,
|
||||
'lemonsqueezy_order_id' => $session->lemonsqueezy_order_id,
|
||||
'status' => CouponRedemption::STATUS_FAILED,
|
||||
'failure_reason' => $reason,
|
||||
'amount_discounted' => $session->amount_discount,
|
||||
|
||||
@@ -8,16 +8,12 @@ use App\Models\Coupon;
|
||||
use App\Models\CouponRedemption;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleDiscountService;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CouponService
|
||||
{
|
||||
public function __construct(private readonly PaddleDiscountService $paddleDiscounts) {}
|
||||
public function __construct() {}
|
||||
|
||||
/**
|
||||
* @return array{coupon: Coupon, pricing: array<string, mixed>, source: string}
|
||||
@@ -39,7 +35,7 @@ class CouponService
|
||||
|
||||
public function ensureCouponCanBeApplied(Coupon $coupon, Package $package, ?Tenant $tenant = null): void
|
||||
{
|
||||
if (! $coupon->paddle_discount_id) {
|
||||
if (! $coupon->lemonsqueezy_discount_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.not_synced'),
|
||||
]);
|
||||
@@ -124,58 +120,12 @@ class CouponService
|
||||
$currency = Str::upper($package->currency ?? 'EUR');
|
||||
$subtotal = (float) $package->price;
|
||||
|
||||
if ($package->paddle_price_id) {
|
||||
try {
|
||||
$preview = $this->paddleDiscounts->previewDiscount(
|
||||
$coupon,
|
||||
[
|
||||
[
|
||||
'price_id' => $package->paddle_price_id,
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
array_filter([
|
||||
'currency' => $currency,
|
||||
'customer_id' => $tenant?->paddle_customer_id,
|
||||
])
|
||||
);
|
||||
|
||||
$mapped = $this->mapPaddlePreview($preview, $currency, $subtotal);
|
||||
|
||||
return [
|
||||
'pricing' => $mapped,
|
||||
'source' => 'paddle',
|
||||
];
|
||||
} catch (PaddleException $exception) {
|
||||
Log::warning('Paddle preview failed, falling back to manual pricing', [
|
||||
'coupon_id' => $coupon->id,
|
||||
'package_id' => $package->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'pricing' => $this->manualPricing($coupon, $currency, $subtotal),
|
||||
'source' => 'manual',
|
||||
];
|
||||
}
|
||||
|
||||
protected function mapPaddlePreview(array $preview, string $currency, float $fallbackSubtotal): array
|
||||
{
|
||||
$totals = $this->extractTotals($preview);
|
||||
|
||||
$subtotal = $totals['subtotal'] ?? $fallbackSubtotal;
|
||||
$discount = $totals['discount'] ?? 0.0;
|
||||
$tax = $totals['tax'] ?? 0.0;
|
||||
$total = $totals['total'] ?? max($subtotal - $discount + $tax, 0);
|
||||
|
||||
return $this->formatPricing($currency, $subtotal, $discount, $tax, $total, [
|
||||
'raw' => $preview,
|
||||
'breakdown' => $totals['breakdown'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function manualPricing(Coupon $coupon, string $currency, float $subtotal): array
|
||||
{
|
||||
$discount = match ($coupon->type) {
|
||||
@@ -199,42 +149,6 @@ class CouponService
|
||||
]);
|
||||
}
|
||||
|
||||
protected function extractTotals(array $preview): array
|
||||
{
|
||||
$totals = Arr::get($preview, 'totals', Arr::get($preview, 'details.totals', []));
|
||||
|
||||
$subtotal = $this->convertMinorAmount($totals['subtotal'] ?? ($totals['subtotal']['amount'] ?? null));
|
||||
$discount = $this->convertMinorAmount($totals['discount'] ?? ($totals['discount']['amount'] ?? null));
|
||||
$tax = $this->convertMinorAmount($totals['tax'] ?? ($totals['tax']['amount'] ?? null));
|
||||
$total = $this->convertMinorAmount($totals['total'] ?? ($totals['total']['amount'] ?? null));
|
||||
|
||||
return array_filter([
|
||||
'currency' => $totals['currency_code'] ?? Arr::get($preview, 'currency_code'),
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $discount,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
'breakdown' => Arr::get($preview, 'discounts', []),
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
}
|
||||
|
||||
protected function convertMinorAmount(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value) && isset($value['amount'])) {
|
||||
$value = $value['amount'];
|
||||
}
|
||||
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(((float) $value) / 100, 2);
|
||||
}
|
||||
|
||||
protected function formatPricing(string $currency, float $subtotal, float $discount, float $tax, float $total, array $extra = []): array
|
||||
{
|
||||
$locale = $this->mapLocale(app()->getLocale());
|
||||
|
||||
@@ -2,34 +2,32 @@
|
||||
|
||||
namespace App\Services\GiftVouchers;
|
||||
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleClient;
|
||||
use Illuminate\Support\Arr;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GiftVoucherCheckoutService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
public function __construct(private readonly LemonSqueezyCheckoutService $checkout) {}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,label:string,amount:float,currency:string,paddle_price_id?:string|null,can_checkout:bool}>
|
||||
* @return array<int, array{key:string,label:string,amount:float,currency:string,lemonsqueezy_variant_id?:string|null,can_checkout:bool}>
|
||||
*/
|
||||
public function tiers(): array
|
||||
{
|
||||
return collect(config('gift-vouchers.tiers', []))
|
||||
->map(function (array $tier): array {
|
||||
$currency = Str::upper($tier['currency'] ?? 'EUR');
|
||||
$priceId = $tier['paddle_price_id'] ?? null;
|
||||
$variantId = $tier['lemonsqueezy_variant_id'] ?? null;
|
||||
|
||||
return [
|
||||
'key' => $tier['key'],
|
||||
'label' => $tier['label'],
|
||||
'amount' => (float) $tier['amount'],
|
||||
'currency' => $currency,
|
||||
'paddle_price_id' => $priceId,
|
||||
'can_checkout' => ! empty($priceId),
|
||||
'lemonsqueezy_variant_id' => $variantId,
|
||||
'can_checkout' => ! empty($variantId),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
@@ -44,47 +42,34 @@ class GiftVoucherCheckoutService
|
||||
{
|
||||
$tier = $this->findTier($data['tier_key']);
|
||||
|
||||
if (! $tier || empty($tier['paddle_price_id'])) {
|
||||
if (! $tier || empty($tier['lemonsqueezy_variant_id'])) {
|
||||
throw ValidationException::withMessages([
|
||||
'tier_key' => __('Gift voucher is not available right now.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$customerId = $this->ensureCustomerId($data['purchaser_email']);
|
||||
$customData = array_filter([
|
||||
'type' => 'gift_voucher',
|
||||
'tier_key' => $tier['key'],
|
||||
'purchaser_email' => $data['purchaser_email'],
|
||||
'recipient_email' => $data['recipient_email'] ?? null,
|
||||
'recipient_name' => $data['recipient_name'] ?? null,
|
||||
'message' => $data['message'] ?? null,
|
||||
'app_locale' => App::getLocale(),
|
||||
'success_url' => $data['success_url'] ?? null,
|
||||
'return_url' => $data['return_url'] ?? null,
|
||||
'lemonsqueezy_variant_id' => $tier['lemonsqueezy_variant_id'],
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$payload = [
|
||||
'items' => [
|
||||
[
|
||||
'price_id' => $tier['paddle_price_id'],
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
'customer_id' => $customerId,
|
||||
'custom_data' => array_filter([
|
||||
'type' => 'gift_voucher',
|
||||
'tier_key' => $tier['key'],
|
||||
'purchaser_email' => $data['purchaser_email'],
|
||||
'recipient_email' => $data['recipient_email'] ?? null,
|
||||
'recipient_name' => $data['recipient_name'] ?? null,
|
||||
'message' => $data['message'] ?? null,
|
||||
'app_locale' => App::getLocale(),
|
||||
return $this->checkout->createVariantCheckout(
|
||||
(string) $tier['lemonsqueezy_variant_id'],
|
||||
$customData,
|
||||
[
|
||||
'success_url' => $data['success_url'] ?? null,
|
||||
'cancel_url' => $data['return_url'] ?? null,
|
||||
]),
|
||||
];
|
||||
|
||||
$response = $this->client->post('/transactions', $payload);
|
||||
|
||||
return [
|
||||
'checkout_url' => Arr::get($response, 'data.checkout.url')
|
||||
?? Arr::get($response, 'checkout.url')
|
||||
?? Arr::get($response, 'data.url')
|
||||
?? Arr::get($response, 'url'),
|
||||
'expires_at' => Arr::get($response, 'data.checkout.expires_at')
|
||||
?? Arr::get($response, 'data.expires_at')
|
||||
?? Arr::get($response, 'expires_at'),
|
||||
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
|
||||
];
|
||||
'return_url' => $data['return_url'] ?? null,
|
||||
'customer_email' => $data['purchaser_email'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,43 +90,4 @@ class GiftVoucherCheckoutService
|
||||
|
||||
return $tier;
|
||||
}
|
||||
|
||||
protected function ensureCustomerId(string $email): string
|
||||
{
|
||||
$payload = ['email' => $email];
|
||||
|
||||
try {
|
||||
$response = $this->client->post('/customers', $payload);
|
||||
} catch (PaddleException $exception) {
|
||||
$customerId = $this->resolveExistingCustomerId($email, $exception);
|
||||
|
||||
if ($customerId) {
|
||||
return $customerId;
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$customerId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
|
||||
|
||||
if (! $customerId) {
|
||||
throw new PaddleException('Failed to create Paddle customer.');
|
||||
}
|
||||
|
||||
return $customerId;
|
||||
}
|
||||
|
||||
protected function resolveExistingCustomerId(string $email, PaddleException $exception): ?string
|
||||
{
|
||||
if ($exception->status() !== 409 || Arr::get($exception->context(), 'error.code') !== 'customer_already_exists') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = $this->client->get('/customers', [
|
||||
'email' => $email,
|
||||
'per_page' => 1,
|
||||
]);
|
||||
|
||||
return Arr::get($response, 'data.0.id') ?? Arr::get($response, 'data.0.customer_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ namespace App\Services\GiftVouchers;
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use App\Jobs\NotifyGiftVoucherReminder;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Mail\GiftVoucherIssued;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Models\Package;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -21,15 +20,15 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GiftVoucherService
|
||||
{
|
||||
public function __construct(private readonly PaddleTransactionService $transactions) {}
|
||||
public function __construct(private readonly LemonSqueezyOrderService $orders) {}
|
||||
|
||||
/**
|
||||
* Create a voucher from a Paddle transaction payload.
|
||||
* Create a voucher from a Lemon Squeezy order payload.
|
||||
*/
|
||||
public function issueFromPaddle(array $payload): GiftVoucher
|
||||
public function issueFromLemonSqueezy(array $payload): GiftVoucher
|
||||
{
|
||||
$metadata = $this->extractCustomData($payload);
|
||||
$priceId = $this->resolvePriceId($payload);
|
||||
$variantId = $this->resolveVariantId($payload);
|
||||
$amount = $this->resolveAmount($payload);
|
||||
$currency = Str::upper($this->resolveCurrency($payload));
|
||||
$locale = $metadata['app_locale'] ?? app()->getLocale();
|
||||
@@ -37,9 +36,10 @@ class GiftVoucherService
|
||||
|
||||
$expiresAt = now()->addYears((int) config('gift-vouchers.default_valid_years', 5));
|
||||
|
||||
if (! empty($payload['id'])) {
|
||||
$orderId = data_get($payload, 'data.id');
|
||||
if ($orderId) {
|
||||
$existing = GiftVoucher::query()
|
||||
->where('paddle_transaction_id', $payload['id'])
|
||||
->where('lemonsqueezy_order_id', $orderId)
|
||||
->first();
|
||||
}
|
||||
|
||||
@@ -47,19 +47,19 @@ class GiftVoucherService
|
||||
|
||||
$voucher = GiftVoucher::query()->updateOrCreate(
|
||||
[
|
||||
'paddle_transaction_id' => $payload['id'] ?? null,
|
||||
'lemonsqueezy_order_id' => $orderId,
|
||||
],
|
||||
[
|
||||
'code' => $metadata['gift_code'] ?? $this->generateCode(),
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'status' => GiftVoucher::STATUS_ISSUED,
|
||||
'purchaser_email' => $metadata['purchaser_email'] ?? Arr::get($payload, 'customer.email'),
|
||||
'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,
|
||||
'paddle_checkout_id' => $payload['checkout_id'] ?? Arr::get($payload, 'details.checkout_id'),
|
||||
'paddle_price_id' => $priceId,
|
||||
'lemonsqueezy_checkout_id' => data_get($payload, 'data.attributes.checkout_id'),
|
||||
'lemonsqueezy_variant_id' => $variantId,
|
||||
'metadata' => $mergedMetadata,
|
||||
'expires_at' => $expiresAt,
|
||||
'refunded_at' => null,
|
||||
@@ -70,7 +70,6 @@ class GiftVoucherService
|
||||
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);
|
||||
@@ -128,13 +127,13 @@ class GiftVoucherService
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $voucher->paddle_transaction_id) {
|
||||
if (! $voucher->lemonsqueezy_order_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'voucher' => __('Missing Paddle transaction for refund.'),
|
||||
'voucher' => __('Missing Lemon Squeezy order for refund.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->transactions->refund($voucher->paddle_transaction_id, array_filter([
|
||||
$response = $this->orders->refund($voucher->lemonsqueezy_order_id, array_filter([
|
||||
'reason' => $reason,
|
||||
]));
|
||||
|
||||
@@ -172,6 +171,7 @@ class GiftVoucherService
|
||||
'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()) {
|
||||
@@ -187,41 +187,32 @@ class GiftVoucherService
|
||||
|
||||
return Package::query()
|
||||
->whereIn('type', $types)
|
||||
->whereNotNull('paddle_price_id')
|
||||
->whereNotNull('lemonsqueezy_variant_id')
|
||||
->get(['id']);
|
||||
}
|
||||
|
||||
protected function resolvePriceId(array $payload): ?string
|
||||
protected function resolveVariantId(array $payload): ?string
|
||||
{
|
||||
$metadata = $this->extractCustomData($payload);
|
||||
|
||||
if (is_array($metadata) && ! empty($metadata['paddle_price_id'])) {
|
||||
return $metadata['paddle_price_id'];
|
||||
if (is_array($metadata) && ! empty($metadata['lemonsqueezy_variant_id'])) {
|
||||
return $metadata['lemonsqueezy_variant_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;
|
||||
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['paddle_price_id'] ?? null);
|
||||
->keyBy(fn ($tier) => $tier['lemonsqueezy_variant_id'] ?? null);
|
||||
|
||||
$priceId = $this->resolvePriceId($payload);
|
||||
if ($priceId && $tiers->has($priceId)) {
|
||||
return (float) $tiers->get($priceId)['amount'];
|
||||
$variantId = $this->resolveVariantId($payload);
|
||||
if ($variantId && $tiers->has($variantId)) {
|
||||
return (float) $tiers->get($variantId)['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');
|
||||
$amount = data_get($payload, 'data.attributes.total');
|
||||
|
||||
if (is_numeric($amount)) {
|
||||
$value = (float) $amount;
|
||||
@@ -236,10 +227,7 @@ class GiftVoucherService
|
||||
|
||||
protected function resolveCurrency(array $payload): string
|
||||
{
|
||||
return $payload['currency_code']
|
||||
?? Arr::get($payload, 'details.totals.currency_code')
|
||||
?? Arr::get($payload, 'currency')
|
||||
?? 'EUR';
|
||||
return (string) (data_get($payload, 'data.attributes.currency') ?? 'EUR');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,8 +238,16 @@ class GiftVoucherService
|
||||
{
|
||||
$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 = $payload['custom_data'];
|
||||
$customData = array_merge($customData, $payload['custom_data']);
|
||||
}
|
||||
|
||||
if (isset($payload['customData']) && is_array($payload['customData'])) {
|
||||
|
||||
@@ -15,8 +15,8 @@ class IntegrationHealthService
|
||||
public function providers(): array
|
||||
{
|
||||
return [
|
||||
$this->buildProvider('paddle', 'Paddle', [
|
||||
'is_configured' => filled(config('paddle.webhook_secret')),
|
||||
$this->buildProvider('lemonsqueezy', 'Lemon Squeezy', [
|
||||
'is_configured' => filled(config('lemonsqueezy.webhook_secret')),
|
||||
'label' => 'Webhook secret',
|
||||
]),
|
||||
$this->buildProvider('revenuecat', 'RevenueCat', [
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle\Exceptions;
|
||||
namespace App\Services\LemonSqueezy\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class PaddleException extends RuntimeException
|
||||
class LemonSqueezyException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, private readonly ?int $status = null, private readonly array $context = [])
|
||||
{
|
||||
@@ -1,14 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
namespace App\Services\LemonSqueezy;
|
||||
|
||||
use App\Models\PackageAddon;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class PaddleAddonCatalogService
|
||||
class LemonSqueezyAddonCatalogService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
public function __construct(private readonly LemonSqueezyClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
@@ -124,7 +124,7 @@ class PaddleAddonCatalogService
|
||||
$metaPrice = $addon->metadata['price_eur'] ?? null;
|
||||
|
||||
if (! is_numeric($metaPrice)) {
|
||||
throw new PaddleException('No unit price specified for addon. Provide metadata[price_eur] or overrides.unit_price.');
|
||||
throw new LemonSqueezyException('No unit price specified for addon. Provide metadata[price_eur] or overrides.unit_price.');
|
||||
}
|
||||
|
||||
$amountCents = (int) round(((float) $metaPrice) * 100);
|
||||
@@ -1,14 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
namespace App\Services\LemonSqueezy;
|
||||
|
||||
use App\Models\Package;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaddleCatalogService
|
||||
class LemonSqueezyCatalogService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
public function __construct(private readonly LemonSqueezyClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
@@ -63,7 +63,7 @@ class PaddleCatalogService
|
||||
{
|
||||
$payload = $this->buildPricePayload(
|
||||
$package,
|
||||
$overrides['product_id'] ?? $package->paddle_product_id,
|
||||
$overrides['product_id'] ?? $package->lemonsqueezy_product_id,
|
||||
$overrides,
|
||||
includeProduct: false
|
||||
);
|
||||
128
app/Services/LemonSqueezy/LemonSqueezyCheckoutService.php
Normal file
128
app/Services/LemonSqueezy/LemonSqueezyCheckoutService.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\LemonSqueezy;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class LemonSqueezyCheckoutService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LemonSqueezyClient $client,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{success_url?: string|null, return_url?: string|null, discount_code?: string|null, metadata?: array, custom_data?: array, customer_email?: string|null, customer_name?: string|null} $options
|
||||
*/
|
||||
public function createCheckout(Tenant $tenant, Package $package, array $options = []): array
|
||||
{
|
||||
$storeId = (string) config('lemonsqueezy.store_id');
|
||||
|
||||
$customData = $this->buildCustomData(
|
||||
$tenant,
|
||||
$package,
|
||||
array_merge(
|
||||
$options['metadata'] ?? [],
|
||||
$options['custom_data'] ?? [],
|
||||
array_filter([
|
||||
'success_url' => $options['success_url'] ?? null,
|
||||
'return_url' => $options['return_url'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== '')
|
||||
)
|
||||
);
|
||||
|
||||
return $this->createVariantCheckout((string) $package->lemonsqueezy_variant_id, $customData, $options + [
|
||||
'customer_email' => $options['customer_email'] ?? null,
|
||||
'customer_name' => $options['customer_name'] ?? null,
|
||||
'store_id' => $storeId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{success_url?: string|null, return_url?: string|null, discount_code?: string|null, customer_email?: string|null, customer_name?: string|null, store_id?: string|null} $options
|
||||
*/
|
||||
public function createVariantCheckout(string $variantId, array $customData, array $options = []): array
|
||||
{
|
||||
$storeId = $options['store_id'] ?? (string) config('lemonsqueezy.store_id');
|
||||
|
||||
$attributes = array_filter([
|
||||
'checkout_data' => array_filter([
|
||||
'custom' => $customData,
|
||||
'email' => $options['customer_email'] ?? null,
|
||||
'name' => $options['customer_name'] ?? null,
|
||||
'discount_code' => $options['discount_code'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== ''),
|
||||
'checkout_options' => [
|
||||
'embed' => true,
|
||||
],
|
||||
'product_options' => array_filter([
|
||||
'redirect_url' => $options['success_url'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== ''),
|
||||
'test_mode' => (bool) config('lemonsqueezy.test_mode', false),
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$payload = [
|
||||
'data' => [
|
||||
'type' => 'checkouts',
|
||||
'attributes' => $attributes,
|
||||
'relationships' => [
|
||||
'store' => [
|
||||
'data' => [
|
||||
'type' => 'stores',
|
||||
'id' => $storeId,
|
||||
],
|
||||
],
|
||||
'variant' => [
|
||||
'data' => [
|
||||
'type' => 'variants',
|
||||
'id' => $variantId,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->client->post('/checkouts', $payload);
|
||||
|
||||
$checkoutUrl = Arr::get($response, 'data.attributes.url')
|
||||
?? Arr::get($response, 'data.attributes.checkout_url')
|
||||
?? Arr::get($response, 'data.url')
|
||||
?? Arr::get($response, 'url');
|
||||
|
||||
if (! $checkoutUrl) {
|
||||
Log::warning('Lemon Squeezy checkout response missing url', ['response' => $response]);
|
||||
}
|
||||
|
||||
return [
|
||||
'checkout_url' => $checkoutUrl,
|
||||
'expires_at' => Arr::get($response, 'data.attributes.expires_at'),
|
||||
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function buildCustomData(Tenant $tenant, Package $package, array $extra = []): array
|
||||
{
|
||||
$metadata = [
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
];
|
||||
|
||||
foreach ($extra as $key => $value) {
|
||||
if (! is_string($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) {
|
||||
$metadata[$key] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
namespace App\Services\LemonSqueezy;
|
||||
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaddleClient
|
||||
class LemonSqueezyClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HttpFactory $http,
|
||||
@@ -42,16 +41,21 @@ class PaddleClient
|
||||
try {
|
||||
$response = $request->send(strtoupper($method), ltrim($endpoint, '/'), $options);
|
||||
} catch (RequestException $exception) {
|
||||
throw new PaddleException($exception->getMessage(), $exception->response?->status(), $exception->response?->json() ?? []);
|
||||
throw new LemonSqueezyException(
|
||||
$exception->getMessage(),
|
||||
$exception->response?->status(),
|
||||
$exception->response?->json() ?? []
|
||||
);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$body = $response->json() ?? [];
|
||||
$message = Arr::get($body, 'error.message')
|
||||
$message = Arr::get($body, 'errors.0.detail')
|
||||
?? Arr::get($body, 'error')
|
||||
?? Arr::get($body, 'message')
|
||||
?? sprintf('Paddle request failed with status %s', $response->status());
|
||||
?? sprintf('Lemon Squeezy request failed with status %s', $response->status());
|
||||
|
||||
throw new PaddleException($message, $response->status(), $body);
|
||||
throw new LemonSqueezyException($message, $response->status(), $body);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
@@ -59,23 +63,20 @@ class PaddleClient
|
||||
|
||||
protected function preparedRequest(): PendingRequest
|
||||
{
|
||||
$apiKey = config('paddle.api_key');
|
||||
$apiKey = config('lemonsqueezy.api_key');
|
||||
if (! $apiKey) {
|
||||
throw new PaddleException('Paddle API key is not configured.');
|
||||
throw new LemonSqueezyException('Lemon Squeezy API key is not configured.');
|
||||
}
|
||||
|
||||
$baseUrl = rtrim((string) config('paddle.base_url'), '/');
|
||||
$environment = (string) config('paddle.environment', 'production');
|
||||
|
||||
$headers = [
|
||||
'User-Agent' => sprintf('FotospielApp/%s PaddleClient', app()->version()),
|
||||
'Paddle-Environment' => Str::lower($environment) === 'sandbox' ? 'sandbox' : 'production',
|
||||
'Paddle-Version' => '1',
|
||||
];
|
||||
$baseUrl = rtrim((string) config('lemonsqueezy.base_url'), '/');
|
||||
|
||||
return $this->http
|
||||
->baseUrl($baseUrl)
|
||||
->withHeaders($headers)
|
||||
->withHeaders([
|
||||
'Accept' => 'application/vnd.api+json',
|
||||
'Content-Type' => 'application/vnd.api+json',
|
||||
'User-Agent' => sprintf('FotospielApp/%s LemonSqueezyClient', app()->version()),
|
||||
])
|
||||
->withToken($apiKey)
|
||||
->acceptJson()
|
||||
->asJson();
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
namespace App\Services\LemonSqueezy;
|
||||
|
||||
use App\Enums\CouponType;
|
||||
use App\Models\Coupon;
|
||||
@@ -8,9 +8,9 @@ use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaddleDiscountService
|
||||
class LemonSqueezyDiscountService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
public function __construct(private readonly LemonSqueezyClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
@@ -34,24 +34,24 @@ class PaddleDiscountService
|
||||
*/
|
||||
public function updateDiscount(Coupon $coupon): array
|
||||
{
|
||||
if (! $coupon->paddle_discount_id) {
|
||||
if (! $coupon->lemonsqueezy_discount_id) {
|
||||
return $this->createDiscount($coupon);
|
||||
}
|
||||
|
||||
$payload = $this->buildDiscountPayload($coupon);
|
||||
|
||||
$response = $this->client->patch('/discounts/'.$coupon->paddle_discount_id, $payload);
|
||||
$response = $this->client->patch('/discounts/'.$coupon->lemonsqueezy_discount_id, $payload);
|
||||
|
||||
return Arr::get($response, 'data', $response);
|
||||
}
|
||||
|
||||
public function archiveDiscount(Coupon $coupon): void
|
||||
{
|
||||
if (! $coupon->paddle_discount_id) {
|
||||
if (! $coupon->lemonsqueezy_discount_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->client->delete('/discounts/'.$coupon->paddle_discount_id);
|
||||
$this->client->delete('/discounts/'.$coupon->lemonsqueezy_discount_id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +63,7 @@ class PaddleDiscountService
|
||||
{
|
||||
$payload = [
|
||||
'items' => $items,
|
||||
'discount_id' => $coupon->paddle_discount_id,
|
||||
'discount_id' => $coupon->lemonsqueezy_discount_id,
|
||||
];
|
||||
|
||||
if (isset($context['currency'])) {
|
||||
@@ -128,7 +128,7 @@ class PaddleDiscountService
|
||||
'currency_code' => Str::upper((string) ($coupon->currency ?? config('app.currency', 'EUR'))),
|
||||
'enabled_for_checkout' => $coupon->enabled_for_checkout,
|
||||
'description' => $this->resolveDescription($coupon),
|
||||
'mode' => $coupon->paddle_mode ?? 'standard',
|
||||
'mode' => $coupon->lemonsqueezy_mode ?? 'standard',
|
||||
'usage_limit' => $coupon->usage_limit,
|
||||
'maximum_recurring_intervals' => null,
|
||||
'recur' => false,
|
||||
@@ -168,13 +168,13 @@ class PaddleDiscountService
|
||||
$packages = ($coupon->relationLoaded('packages')
|
||||
? $coupon->packages
|
||||
: $coupon->packages()->get())
|
||||
->whereNotNull('paddle_price_id');
|
||||
->whereNotNull('lemonsqueezy_variant_id');
|
||||
|
||||
if ($packages->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prices = $packages->pluck('paddle_price_id')->filter()->values();
|
||||
$prices = $packages->pluck('lemonsqueezy_variant_id')->filter()->values();
|
||||
|
||||
return $prices->isEmpty() ? null : $prices->all();
|
||||
}
|
||||
@@ -1,34 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
namespace App\Services\LemonSqueezy;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaddleGiftVoucherCatalogService
|
||||
class LemonSqueezyGiftVoucherCatalogService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
public function __construct(private readonly LemonSqueezyClient $client) {}
|
||||
|
||||
/**
|
||||
* @param array{key:string,label:string,amount:float,currency?:string,paddle_product_id?:string|null,paddle_price_id?:string|null} $tier
|
||||
* @return array{product_id:string,price_id:string}
|
||||
* @param array{key:string,label:string,amount:float,currency?:string,lemonsqueezy_product_id?:string|null,lemonsqueezy_variant_id?:string|null} $tier
|
||||
* @return array{product_id:string,variant_id:string}
|
||||
*/
|
||||
public function ensureTier(array $tier): array
|
||||
{
|
||||
$product = $tier['paddle_product_id'] ?? null;
|
||||
$price = $tier['paddle_price_id'] ?? null;
|
||||
$product = $tier['lemonsqueezy_product_id'] ?? null;
|
||||
$variant = $tier['lemonsqueezy_variant_id'] ?? null;
|
||||
|
||||
if (! $product) {
|
||||
$product = $this->createProduct($tier)['id'];
|
||||
}
|
||||
|
||||
if (! $price) {
|
||||
$price = $this->createPrice($tier, $product)['id'];
|
||||
if (! $variant) {
|
||||
$variant = $this->createPrice($tier, $product)['id'];
|
||||
}
|
||||
|
||||
return [
|
||||
'product_id' => $product,
|
||||
'price_id' => $price,
|
||||
'variant_id' => $variant,
|
||||
];
|
||||
}
|
||||
|
||||
151
app/Services/LemonSqueezy/LemonSqueezyOrderService.php
Normal file
151
app/Services/LemonSqueezy/LemonSqueezyOrderService.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\LemonSqueezy;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class LemonSqueezyOrderService
|
||||
{
|
||||
public function __construct(private readonly LemonSqueezyClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
|
||||
*/
|
||||
public function listForCustomer(string $customerId, array $query = []): array
|
||||
{
|
||||
$payload = array_filter(array_merge([
|
||||
'filter[customer_id]' => $customerId,
|
||||
'sort' => '-created_at',
|
||||
], $query), static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$response = $this->client->get('/orders', $payload);
|
||||
|
||||
$orders = Arr::get($response, 'data', []);
|
||||
$meta = Arr::get($response, 'meta', []);
|
||||
|
||||
if (! is_array($orders)) {
|
||||
$orders = [];
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => array_map([$this, 'mapOrder'], $orders),
|
||||
'meta' => $this->mapPagination($meta),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function retrieve(string $orderId): array
|
||||
{
|
||||
$response = $this->client->get("/orders/{$orderId}");
|
||||
$order = Arr::get($response, 'data');
|
||||
|
||||
return is_array($order) ? $order : (is_array($response) ? $response : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findByCheckoutId(string $checkoutId): ?array
|
||||
{
|
||||
$response = $this->client->get("/checkouts/{$checkoutId}");
|
||||
$checkout = Arr::get($response, 'data');
|
||||
|
||||
if (! is_array($checkout)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$orderId = Arr::get($checkout, 'attributes.order_id');
|
||||
if (! $orderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->retrieve((string) $orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a refund for a Lemon Squeezy order.
|
||||
*
|
||||
* @param array{reason?: string|null} $options
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function refund(string $orderId, array $options = []): array
|
||||
{
|
||||
$payload = [
|
||||
'data' => [
|
||||
'type' => 'refunds',
|
||||
'attributes' => array_filter([
|
||||
'order_id' => $orderId,
|
||||
'reason' => $options['reason'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== ''),
|
||||
],
|
||||
];
|
||||
|
||||
return $this->client->post('/refunds', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mapOrder(array $order): array
|
||||
{
|
||||
$attributes = Arr::get($order, 'attributes', []);
|
||||
|
||||
return [
|
||||
'id' => $order['id'] ?? null,
|
||||
'order_number' => $attributes['order_number'] ?? null,
|
||||
'status' => $attributes['status'] ?? null,
|
||||
'amount' => $this->convertAmount($attributes['subtotal'] ?? null),
|
||||
'currency' => $attributes['currency'] ?? 'EUR',
|
||||
'origin' => 'lemonsqueezy',
|
||||
'checkout_id' => $attributes['checkout_id'] ?? null,
|
||||
'created_at' => $attributes['created_at'] ?? null,
|
||||
'updated_at' => $attributes['updated_at'] ?? null,
|
||||
'receipt_url' => Arr::get($attributes, 'urls.receipt'),
|
||||
'tax' => $this->convertAmount($attributes['tax'] ?? null),
|
||||
'grand_total' => $this->convertAmount($attributes['total'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mapPagination(array $meta): array
|
||||
{
|
||||
$page = Arr::get($meta, 'page', []);
|
||||
$current = (int) ($page['currentPage'] ?? $page['current_page'] ?? 1);
|
||||
$totalPages = (int) ($page['totalPages'] ?? $page['total_pages'] ?? 1);
|
||||
|
||||
return [
|
||||
'next' => $current < $totalPages ? (string) ($current + 1) : null,
|
||||
'previous' => $current > 1 ? (string) ($current - 1) : null,
|
||||
'has_more' => $current < $totalPages,
|
||||
];
|
||||
}
|
||||
|
||||
protected function convertAmount(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value) && isset($value['amount'])) {
|
||||
$value = $value['amount'];
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$value = preg_replace('/[^0-9.-]/', '', $value);
|
||||
}
|
||||
|
||||
if ($value === '' || $value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$amount = (float) $value;
|
||||
|
||||
return $amount / 100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\LemonSqueezy;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class LemonSqueezySubscriptionService
|
||||
{
|
||||
public function __construct(private readonly LemonSqueezyClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function retrieve(string $subscriptionId): array
|
||||
{
|
||||
$response = $this->client->get("/subscriptions/{$subscriptionId}");
|
||||
|
||||
return Arr::get($response, 'data', is_array($response) ? $response : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $subscription
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function customData(array $subscription): array
|
||||
{
|
||||
$attributes = Arr::get($subscription, 'attributes', []);
|
||||
|
||||
$custom = Arr::get($attributes, 'custom_data', Arr::get($attributes, 'custom', []));
|
||||
|
||||
return is_array($custom) ? $custom : [];
|
||||
}
|
||||
|
||||
public function portalUrl(array $subscription): ?string
|
||||
{
|
||||
return Arr::get($subscription, 'attributes.urls.customer_portal')
|
||||
?? Arr::get($subscription, 'attributes.urls.customer_portal_url');
|
||||
}
|
||||
|
||||
public function updatePaymentMethodUrl(array $subscription): ?string
|
||||
{
|
||||
return Arr::get($subscription, 'attributes.urls.update_payment_method')
|
||||
?? Arr::get($subscription, 'attributes.urls.update_payment_method_url');
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PaddleCheckoutService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaddleClient $client,
|
||||
private readonly PaddleCustomerService $customers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{success_url?: string|null, return_url?: string|null, discount_id?: string|null, metadata?: array, custom_data?: array} $options
|
||||
*/
|
||||
public function createCheckout(Tenant $tenant, Package $package, array $options = []): array
|
||||
{
|
||||
$customerId = $this->customers->ensureCustomerId($tenant);
|
||||
|
||||
$customData = $this->buildMetadata(
|
||||
$tenant,
|
||||
$package,
|
||||
array_merge(
|
||||
$options['metadata'] ?? [],
|
||||
$options['custom_data'] ?? [],
|
||||
array_filter([
|
||||
'success_url' => $options['success_url'] ?? null,
|
||||
'cancel_url' => $options['return_url'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== '')
|
||||
)
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'customer_id' => $customerId,
|
||||
'items' => [
|
||||
[
|
||||
'price_id' => $package->paddle_price_id,
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
'custom_data' => $customData,
|
||||
];
|
||||
|
||||
if (! empty($options['discount_id'])) {
|
||||
$payload['discount_id'] = $options['discount_id'];
|
||||
}
|
||||
|
||||
$response = $this->client->post('/transactions', $payload);
|
||||
|
||||
$checkoutUrl = Arr::get($response, 'data.checkout.url')
|
||||
?? Arr::get($response, 'checkout.url')
|
||||
?? Arr::get($response, 'data.url')
|
||||
?? Arr::get($response, 'url');
|
||||
|
||||
if (! $checkoutUrl) {
|
||||
Log::warning('Paddle checkout response missing url', ['response' => $response]);
|
||||
}
|
||||
|
||||
return [
|
||||
'checkout_url' => $checkoutUrl,
|
||||
'expires_at' => Arr::get($response, 'data.checkout.expires_at')
|
||||
?? Arr::get($response, 'data.expires_at')
|
||||
?? Arr::get($response, 'expires_at'),
|
||||
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function buildMetadata(Tenant $tenant, Package $package, array $extra = []): array
|
||||
{
|
||||
$metadata = [
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
];
|
||||
|
||||
foreach ($extra as $key => $value) {
|
||||
if (! is_string($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) {
|
||||
$metadata[$key] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
|
||||
class PaddleCustomerPortalService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
|
||||
/**
|
||||
* @param array{subscription_ids?: array<int, string>} $options
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createSession(string $customerId, array $options = []): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
if (! empty($options['subscription_ids'])) {
|
||||
$payload['subscription_ids'] = array_values(
|
||||
array_filter($options['subscription_ids'], 'is_string')
|
||||
);
|
||||
}
|
||||
|
||||
if ($payload === []) {
|
||||
$payload = (object) [];
|
||||
}
|
||||
|
||||
return $this->client->post("/customers/{$customerId}/portal-sessions", $payload);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PaddleCustomerService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
|
||||
public function ensureCustomerId(Tenant $tenant): string
|
||||
{
|
||||
if ($tenant->paddle_customer_id) {
|
||||
return $tenant->paddle_customer_id;
|
||||
}
|
||||
|
||||
$email = $tenant->contact_email ?: ($tenant->user?->email ?? null);
|
||||
|
||||
$payload = [
|
||||
'email' => $email,
|
||||
'name' => $tenant->name,
|
||||
];
|
||||
|
||||
if (! $payload['email']) {
|
||||
throw new PaddleException('Tenant email address required to create Paddle customer.');
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->post('/customers', $payload);
|
||||
} catch (PaddleException $exception) {
|
||||
$existingCustomerId = $this->resolveExistingCustomerId($tenant, $email, $exception);
|
||||
if ($existingCustomerId) {
|
||||
$tenant->forceFill(['paddle_customer_id' => $existingCustomerId])->save();
|
||||
|
||||
return $existingCustomerId;
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$customerId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
|
||||
|
||||
if (! $customerId) {
|
||||
Log::error('Paddle customer creation returned no id', ['tenant' => $tenant->id, 'response' => $response]);
|
||||
throw new PaddleException('Failed to create Paddle customer.');
|
||||
}
|
||||
|
||||
$tenant->forceFill(['paddle_customer_id' => $customerId])->save();
|
||||
|
||||
return $customerId;
|
||||
}
|
||||
|
||||
protected function resolveExistingCustomerId(Tenant $tenant, string $email, PaddleException $exception): ?string
|
||||
{
|
||||
if ($exception->status() !== 409 || Arr::get($exception->context(), 'error.code') !== 'customer_already_exists') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = $this->client->get('/customers', [
|
||||
'email' => $email,
|
||||
'per_page' => 1,
|
||||
]);
|
||||
|
||||
$customerId = Arr::get($response, 'data.0.id') ?? Arr::get($response, 'data.0.customer_id');
|
||||
|
||||
if (! $customerId) {
|
||||
Log::warning('Paddle customer lookup by email returned no id', [
|
||||
'tenant' => $tenant->id,
|
||||
'error_code' => Arr::get($exception->context(), 'error.code'),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $customerId;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class PaddleSubscriptionService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
|
||||
/**
|
||||
* Retrieve a subscription record directly from Paddle.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function retrieve(string $subscriptionId): array
|
||||
{
|
||||
$response = $this->client->get("/subscriptions/{$subscriptionId}");
|
||||
|
||||
return is_array($response) ? $response : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper to extract metadata from the subscription response.
|
||||
*
|
||||
* @param array<string, mixed> $subscription
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function metadata(array $subscription): array
|
||||
{
|
||||
$customData = Arr::get($subscription, 'data.custom_data');
|
||||
|
||||
if (is_array($customData)) {
|
||||
return $customData;
|
||||
}
|
||||
|
||||
$metadata = Arr::get($subscription, 'data.metadata');
|
||||
|
||||
return is_array($metadata) ? $metadata : [];
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class PaddleTransactionService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
|
||||
*/
|
||||
public function listForCustomer(string $customerId, array $query = []): array
|
||||
{
|
||||
$payload = array_filter(array_merge([
|
||||
'customer_id' => $customerId,
|
||||
'order_by' => 'created_at[desc]',
|
||||
], $query), static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$response = $this->client->get('/transactions', $payload);
|
||||
|
||||
$transactions = Arr::get($response, 'data', []);
|
||||
$meta = Arr::get($response, 'meta.pagination', []);
|
||||
|
||||
if (! is_array($transactions)) {
|
||||
$transactions = [];
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => array_map([$this, 'mapTransaction'], $transactions),
|
||||
'meta' => $this->mapPagination($meta),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function retrieve(string $transactionId): array
|
||||
{
|
||||
$response = $this->client->get("/transactions/{$transactionId}");
|
||||
$transaction = Arr::get($response, 'data');
|
||||
|
||||
return is_array($transaction) ? $transaction : (is_array($response) ? $response : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findByCheckoutId(string $checkoutId): ?array
|
||||
{
|
||||
$response = $this->client->get('/transactions', [
|
||||
'checkout_id' => $checkoutId,
|
||||
'order_by' => 'created_at[desc]',
|
||||
]);
|
||||
|
||||
$transactions = Arr::get($response, 'data', []);
|
||||
|
||||
if (! is_array($transactions) || $transactions === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$first = $transactions[0] ?? null;
|
||||
|
||||
return is_array($first) ? $first : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|int|null> $criteria
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findByCustomData(array $criteria, int $limit = 20): ?array
|
||||
{
|
||||
$payload = array_filter([
|
||||
'order_by' => 'created_at[desc]',
|
||||
'per_page' => max(1, min($limit, 50)),
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$response = $this->client->get('/transactions', $payload);
|
||||
$transactions = Arr::get($response, 'data', []);
|
||||
|
||||
if (! is_array($transactions) || $transactions === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
if (! is_array($transaction)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$customData = Arr::get($transaction, 'custom_data', Arr::get($transaction, 'customData', []));
|
||||
if (! is_array($customData) || $customData === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matches = true;
|
||||
foreach ($criteria as $key => $value) {
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = $customData[$key] ?? null;
|
||||
if ((string) $candidate !== (string) $value) {
|
||||
$matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matches) {
|
||||
return $transaction;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a refund for a Paddle transaction.
|
||||
*
|
||||
* @param array{reason?: string|null} $options
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function refund(string $transactionId, array $options = []): array
|
||||
{
|
||||
$payload = array_filter([
|
||||
'reason' => $options['reason'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
return $this->client->post("/transactions/{$transactionId}/refunds", $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $transaction
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mapTransaction(array $transaction): array
|
||||
{
|
||||
$totals = Arr::get($transaction, 'totals', []);
|
||||
|
||||
return [
|
||||
'id' => $transaction['id'] ?? null,
|
||||
'status' => $transaction['status'] ?? null,
|
||||
'amount' => $this->resolveAmount($transaction, $totals),
|
||||
'currency' => $transaction['currency_code'] ?? Arr::get($transaction, 'currency') ?? 'EUR',
|
||||
'origin' => $transaction['origin'] ?? null,
|
||||
'checkout_id' => $transaction['checkout_id'] ?? Arr::get($transaction, 'details.checkout_id'),
|
||||
'created_at' => $transaction['created_at'] ?? null,
|
||||
'updated_at' => $transaction['updated_at'] ?? null,
|
||||
'receipt_url' => Arr::get($transaction, 'invoice_url') ?? Arr::get($transaction, 'receipt_url'),
|
||||
'tax' => Arr::get($totals, 'tax_total') ?? null,
|
||||
'grand_total' => Arr::get($totals, 'grand_total') ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $transaction
|
||||
* @param array<string, mixed>|null $totals
|
||||
*/
|
||||
protected function resolveAmount(array $transaction, $totals): ?float
|
||||
{
|
||||
$amount = Arr::get($totals ?? [], 'subtotal') ?? Arr::get($totals ?? [], 'grand_total');
|
||||
|
||||
if ($amount !== null) {
|
||||
return (float) $amount;
|
||||
}
|
||||
|
||||
$raw = $transaction['amount'] ?? null;
|
||||
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (float) $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $pagination
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mapPagination(array $pagination): array
|
||||
{
|
||||
return [
|
||||
'next' => $pagination['next'] ?? null,
|
||||
'previous' => $pagination['previous'] ?? null,
|
||||
'has_more' => (bool) ($pagination['has_more'] ?? false),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user