Migrate billing from Paddle to Lemon Squeezy

This commit is contained in:
Codex Agent
2026-02-03 10:59:54 +01:00
parent 2f4ebfefd4
commit a0ef90e13a
228 changed files with 4369 additions and 4067 deletions

View File

@@ -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;
}
/**

View File

@@ -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,
];
}

View File

@@ -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>
*/

View File

@@ -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,

View File

@@ -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}]");

View File

@@ -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'])) {

View File

@@ -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,

View File

@@ -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());

View File

@@ -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');
}
}

View File

@@ -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'])) {

View File

@@ -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', [

View File

@@ -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 = [])
{

View File

@@ -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);

View File

@@ -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
);

View 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;
}
}

View File

@@ -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();

View File

@@ -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();
}

View File

@@ -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,
];
}

View 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;
}
}

View File

@@ -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');
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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 : [];
}
}

View File

@@ -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),
];
}
}