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

@@ -0,0 +1,23 @@
<?php
namespace App\Services\LemonSqueezy\Exceptions;
use RuntimeException;
class LemonSqueezyException extends RuntimeException
{
public function __construct(string $message, private readonly ?int $status = null, private readonly array $context = [])
{
parent::__construct($message, $status ?? 0);
}
public function status(): ?int
{
return $this->status;
}
public function context(): array
{
return $this->context;
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Services\LemonSqueezy;
use App\Models\PackageAddon;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use Illuminate\Support\Arr;
class LemonSqueezyAddonCatalogService
{
public function __construct(private readonly LemonSqueezyClient $client) {}
/**
* @return array<string, mixed>
*/
public function createProduct(PackageAddon $addon, array $overrides = []): array
{
$payload = $this->buildProductPayload($addon, $overrides);
return $this->extractEntity($this->client->post('/products', $payload));
}
/**
* @return array<string, mixed>
*/
public function updateProduct(string $productId, PackageAddon $addon, array $overrides = []): array
{
$payload = $this->buildProductPayload($addon, $overrides);
return $this->extractEntity($this->client->patch("/products/{$productId}", $payload));
}
/**
* @return array<string, mixed>
*/
public function createPrice(PackageAddon $addon, string $productId, array $overrides = []): array
{
$payload = $this->buildPricePayload($addon, $productId, $overrides);
return $this->extractEntity($this->client->post('/prices', $payload));
}
/**
* @return array<string, mixed>
*/
public function updatePrice(string $priceId, PackageAddon $addon, array $overrides = []): array
{
$payload = $this->buildPricePayload($addon, $overrides['product_id'] ?? '', $overrides);
return $this->extractEntity($this->client->patch("/prices/{$priceId}", $payload));
}
/**
* @return array<string, mixed>
*/
public function buildProductPayload(PackageAddon $addon, array $overrides = []): array
{
$payload = array_merge([
'name' => $overrides['name'] ?? $addon->label,
'description' => $overrides['description'] ?? sprintf('Fotospiel Add-on: %s', $addon->label),
'tax_category' => $overrides['tax_category'] ?? 'standard',
'type' => $overrides['type'] ?? 'standard',
'custom_data' => array_merge([
'addon_key' => $addon->key,
'increments' => $addon->increments,
], $overrides['custom_data'] ?? []),
], Arr::except($overrides, ['tax_category', 'type', 'custom_data', 'name', 'description']));
return $this->cleanPayload($payload);
}
/**
* @return array<string, mixed>
*/
public function buildPricePayload(PackageAddon $addon, string $productId, array $overrides = []): array
{
$unitPrice = $overrides['unit_price'] ?? $this->defaultUnitPrice($addon);
$payload = array_merge([
'product_id' => $productId,
'description' => $overrides['description'] ?? $addon->label,
'unit_price' => $unitPrice,
'custom_data' => array_merge([
'addon_key' => $addon->key,
], $overrides['custom_data'] ?? []),
], Arr::except($overrides, ['unit_price', 'description', 'custom_data', 'product_id']));
return $this->cleanPayload($payload);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected function extractEntity(array $payload): array
{
return Arr::get($payload, 'data', $payload);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected function cleanPayload(array $payload): array
{
$filtered = collect($payload)
->reject(static fn ($value) => $value === null || $value === '' || $value === [])
->all();
if (array_key_exists('custom_data', $filtered)) {
$filtered['custom_data'] = collect($filtered['custom_data'])
->reject(static fn ($value) => $value === null || $value === '' || $value === [])
->all();
}
return $filtered;
}
/**
* @return array<string, string>
*/
protected function defaultUnitPrice(PackageAddon $addon): array
{
$metaPrice = $addon->metadata['price_eur'] ?? null;
if (! is_numeric($metaPrice)) {
throw new LemonSqueezyException('No unit price specified for addon. Provide metadata[price_eur] or overrides.unit_price.');
}
$amountCents = (int) round(((float) $metaPrice) * 100);
return [
'amount' => (string) $amountCents,
'currency_code' => 'EUR',
];
}
}

View File

@@ -0,0 +1,255 @@
<?php
namespace App\Services\LemonSqueezy;
use App\Models\Package;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class LemonSqueezyCatalogService
{
public function __construct(private readonly LemonSqueezyClient $client) {}
/**
* @return array<string, mixed>
*/
public function fetchProduct(string $productId): array
{
return $this->extractEntity($this->client->get("/products/{$productId}"));
}
/**
* @return array<string, mixed>
*/
public function fetchPrice(string $priceId): array
{
return $this->extractEntity($this->client->get("/prices/{$priceId}"));
}
/**
* @return array<string, mixed>
*/
public function createProduct(Package $package, array $overrides = []): array
{
$payload = $this->buildProductPayload($package, $overrides);
return $this->extractEntity($this->client->post('/products', $payload));
}
/**
* @return array<string, mixed>
*/
public function updateProduct(string $productId, Package $package, array $overrides = []): array
{
$payload = $this->buildProductPayload($package, $overrides);
return $this->extractEntity($this->client->patch("/products/{$productId}", $payload));
}
/**
* @return array<string, mixed>
*/
public function createPrice(Package $package, string $productId, array $overrides = []): array
{
$payload = $this->buildPricePayload($package, $productId, $overrides, includeProduct: true);
return $this->extractEntity($this->client->post('/prices', $payload));
}
/**
* @return array<string, mixed>
*/
public function updatePrice(string $priceId, Package $package, array $overrides = []): array
{
$payload = $this->buildPricePayload(
$package,
$overrides['product_id'] ?? $package->lemonsqueezy_product_id,
$overrides,
includeProduct: false
);
return $this->extractEntity($this->client->patch("/prices/{$priceId}", $payload));
}
/**
* @return array<string, mixed>
*/
public function buildProductPayload(Package $package, array $overrides = []): array
{
$payload = array_merge([
'name' => $this->resolveName($package, $overrides),
'description' => $this->resolveDescription($package, $overrides),
'tax_category' => $overrides['tax_category'] ?? 'standard',
'type' => $overrides['type'] ?? 'standard',
'custom_data' => $this->buildCustomData($package, $overrides['custom_data'] ?? []),
], Arr::except($overrides, ['tax_category', 'type', 'custom_data']));
return $this->cleanPayload($payload);
}
/**
* @return array<string, mixed>
*/
public function buildPricePayload(Package $package, string $productId, array $overrides = [], bool $includeProduct = true): array
{
$unitPrice = $overrides['unit_price'] ?? [
'amount' => (string) $this->priceToMinorUnits($package->price),
'currency_code' => Str::upper((string) ($package->currency ?? 'EUR')),
];
$base = [
'description' => $this->resolvePriceDescription($package, $overrides),
'unit_price' => $unitPrice,
'custom_data' => $this->buildCustomData($package, $overrides['custom_data'] ?? []),
];
if ($includeProduct) {
$base['product_id'] = $productId;
}
$payload = array_merge($base, Arr::except($overrides, ['unit_price', 'description', 'custom_data', 'product_id']));
return $this->cleanPayload($payload);
}
/**
* @param array<string, mixed> $response
* @return array<string, mixed>
*/
protected function extractEntity(array $response): array
{
return Arr::get($response, 'data', $response);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected function cleanPayload(array $payload): array
{
$filtered = collect($payload)
->reject(static fn ($value) => $value === null || $value === '' || $value === [])
->all();
if (array_key_exists('custom_data', $filtered)) {
$filtered['custom_data'] = collect($filtered['custom_data'])
->reject(static fn ($value) => $value === null || $value === '' || $value === [])
->all();
}
return $filtered;
}
/**
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
protected function buildCustomData(Package $package, array $extra = []): array
{
$base = [
'fotospiel_package_id' => (string) $package->id,
'slug' => $package->slug,
'type' => $package->type,
'features' => $package->features,
'limits' => array_filter([
'max_photos' => $package->max_photos,
'max_guests' => $package->max_guests,
'gallery_days' => $package->gallery_days,
'max_tasks' => $package->max_tasks,
'max_events_per_year' => $package->max_events_per_year,
], static fn ($value) => $value !== null),
'translations' => array_filter([
'name' => $package->name_translations,
'description' => $package->description_translations,
], static fn ($value) => ! empty($value)),
];
return array_merge($base, $extra);
}
protected function resolveName(Package $package, array $overrides): string
{
if (isset($overrides['name']) && is_string($overrides['name'])) {
return $overrides['name'];
}
if (! empty($package->name)) {
return $package->name;
}
$translations = $package->name_translations ?? [];
return $translations['en'] ?? $translations['de'] ?? $package->slug;
}
protected function resolveDescription(Package $package, array $overrides): string
{
if (array_key_exists('description', $overrides)) {
$value = is_string($overrides['description']) ? trim($overrides['description']) : null;
if ($value !== null && $value !== '') {
return $value;
}
}
if (! empty($package->description)) {
return strip_tags((string) $package->description);
}
$translations = $package->description_translations ?? [];
$fallback = $translations['en'] ?? $translations['de'] ?? null;
if ($fallback !== null) {
$fallback = trim(strip_tags((string) $fallback));
if ($fallback !== '') {
return $fallback;
}
}
return sprintf('Fotospiel package %s', $package->slug ?? $package->id);
}
/**
* @param array<string, mixed> $overrides
*/
protected function resolvePriceDescription(Package $package, array $overrides): string
{
if (array_key_exists('description', $overrides)) {
$value = is_string($overrides['description']) ? trim($overrides['description']) : null;
if ($value !== null && $value !== '') {
return $value;
}
}
if (! empty($package->description)) {
return strip_tags((string) $package->description);
}
$translations = $package->description_translations ?? [];
$fallback = $translations['en'] ?? $translations['de'] ?? null;
if ($fallback !== null) {
$fallback = trim(strip_tags((string) $fallback));
if ($fallback !== '') {
return $fallback;
}
}
$name = $package->name ?? $package->getNameForLocale('en');
if ($name) {
return sprintf('%s package', trim($name));
}
return sprintf('Package %s', $package->slug ?? $package->id);
}
protected function priceToMinorUnits(mixed $price): int
{
$value = is_string($price) ? (float) $price : (float) ($price ?? 0);
return (int) round($value * 100);
}
}

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

@@ -0,0 +1,84 @@
<?php
namespace App\Services\LemonSqueezy;
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;
class LemonSqueezyClient
{
public function __construct(
private readonly HttpFactory $http,
) {}
public function get(string $endpoint, array $query = []): array
{
return $this->send('GET', $endpoint, ['query' => $query]);
}
public function post(string $endpoint, array|object $payload = []): array
{
return $this->send('POST', $endpoint, ['json' => $payload]);
}
public function patch(string $endpoint, array|object $payload = []): array
{
return $this->send('PATCH', $endpoint, ['json' => $payload]);
}
public function delete(string $endpoint, array|object $payload = []): array
{
return $this->send('DELETE', $endpoint, ['json' => $payload]);
}
protected function send(string $method, string $endpoint, array $options = []): array
{
$request = $this->preparedRequest();
try {
$response = $request->send(strtoupper($method), ltrim($endpoint, '/'), $options);
} catch (RequestException $exception) {
throw new LemonSqueezyException(
$exception->getMessage(),
$exception->response?->status(),
$exception->response?->json() ?? []
);
}
if ($response->failed()) {
$body = $response->json() ?? [];
$message = Arr::get($body, 'errors.0.detail')
?? Arr::get($body, 'error')
?? Arr::get($body, 'message')
?? sprintf('Lemon Squeezy request failed with status %s', $response->status());
throw new LemonSqueezyException($message, $response->status(), $body);
}
return $response->json() ?? [];
}
protected function preparedRequest(): PendingRequest
{
$apiKey = config('lemonsqueezy.api_key');
if (! $apiKey) {
throw new LemonSqueezyException('Lemon Squeezy API key is not configured.');
}
$baseUrl = rtrim((string) config('lemonsqueezy.base_url'), '/');
return $this->http
->baseUrl($baseUrl)
->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

@@ -0,0 +1,204 @@
<?php
namespace App\Services\LemonSqueezy;
use App\Enums\CouponType;
use App\Models\Coupon;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class LemonSqueezyDiscountService
{
public function __construct(private readonly LemonSqueezyClient $client) {}
/**
* @return array<string, mixed>
*/
public function createDiscount(Coupon $coupon): array
{
$existing = $this->findExistingDiscount($coupon->code);
if ($existing !== null) {
return $existing;
}
$payload = $this->buildDiscountPayload($coupon);
$response = $this->client->post('/discounts', $payload);
return Arr::get($response, 'data', $response);
}
/**
* @return array<string, mixed>
*/
public function updateDiscount(Coupon $coupon): array
{
if (! $coupon->lemonsqueezy_discount_id) {
return $this->createDiscount($coupon);
}
$payload = $this->buildDiscountPayload($coupon);
$response = $this->client->patch('/discounts/'.$coupon->lemonsqueezy_discount_id, $payload);
return Arr::get($response, 'data', $response);
}
public function archiveDiscount(Coupon $coupon): void
{
if (! $coupon->lemonsqueezy_discount_id) {
return;
}
$this->client->delete('/discounts/'.$coupon->lemonsqueezy_discount_id);
}
/**
* @param array<int, array{price_id: string, quantity?: int}> $items
* @param array{currency?: string, address?: array{country_code: string, postal_code?: string}, customer_id?: string, address_id?: string} $context
* @return array<string, mixed>
*/
public function previewDiscount(Coupon $coupon, array $items, array $context = []): array
{
$payload = [
'items' => $items,
'discount_id' => $coupon->lemonsqueezy_discount_id,
];
if (isset($context['currency'])) {
$payload['currency_code'] = Str::upper($context['currency']);
}
if (isset($context['address'])) {
$payload['address'] = $context['address'];
}
if (isset($context['customer_id'])) {
$payload['customer_id'] = $context['customer_id'];
}
if (isset($context['address_id'])) {
$payload['address_id'] = $context['address_id'];
}
$response = $this->client->post('/transactions/preview', $payload);
return Arr::get($response, 'data', $response);
}
/**
* @return array<string, mixed>|null
*/
protected function findExistingDiscount(?string $code): ?array
{
$normalized = Str::upper(trim((string) $code));
if ($normalized === '') {
return null;
}
$response = $this->client->get('/discounts', [
'code' => $normalized,
'per_page' => 1,
]);
$items = Arr::get($response, 'data', []);
if (! is_array($items) || $items === []) {
return null;
}
$match = Collection::make($items)->first(static function ($item) use ($normalized) {
$codeValue = Str::upper((string) Arr::get($item, 'code', ''));
return $codeValue === $normalized ? $item : null;
});
return is_array($match) ? $match : null;
}
/**
* @return array<string, mixed>
*/
protected function buildDiscountPayload(Coupon $coupon): array
{
$payload = [
'code' => $coupon->code,
'type' => $this->mapType($coupon->type),
'amount' => $this->formatAmount($coupon),
'currency_code' => Str::upper((string) ($coupon->currency ?? config('app.currency', 'EUR'))),
'enabled_for_checkout' => $coupon->enabled_for_checkout,
'description' => $this->resolveDescription($coupon),
'mode' => $coupon->lemonsqueezy_mode ?? 'standard',
'usage_limit' => $coupon->usage_limit,
'maximum_recurring_intervals' => null,
'recur' => false,
'restrict_to' => $this->resolveRestrictions($coupon),
'expires_at' => optional($coupon->ends_at)?->toIso8601String(),
];
if ($payload['type'] === 'percentage') {
unset($payload['currency_code']);
}
return Collection::make($payload)
->reject(static fn ($value) => $value === null || $value === '')
->all();
}
protected function formatAmount(Coupon $coupon): string
{
if ($coupon->type === CouponType::PERCENTAGE) {
return (string) $coupon->amount;
}
return (string) ((int) round($coupon->amount * 100));
}
protected function mapType(CouponType $type): string
{
return match ($type) {
CouponType::PERCENTAGE => 'percentage',
CouponType::FLAT => 'flat',
CouponType::FLAT_PER_SEAT => 'flat_per_seat',
};
}
protected function resolveRestrictions(Coupon $coupon): ?array
{
$packages = ($coupon->relationLoaded('packages')
? $coupon->packages
: $coupon->packages()->get())
->whereNotNull('lemonsqueezy_variant_id');
if ($packages->isEmpty()) {
return null;
}
$prices = $packages->pluck('lemonsqueezy_variant_id')->filter()->values();
return $prices->isEmpty() ? null : $prices->all();
}
protected function resolveDescription(Coupon $coupon): string
{
$description = trim((string) ($coupon->description ?? ''));
if ($description !== '') {
return $description;
}
$name = trim((string) ($coupon->name ?? ''));
if ($name !== '') {
return $name;
}
$code = trim((string) ($coupon->code ?? ''));
if ($code !== '') {
return sprintf('Coupon %s', $code);
}
return sprintf('Coupon %d', $coupon->id);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Services\LemonSqueezy;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class LemonSqueezyGiftVoucherCatalogService
{
public function __construct(private readonly LemonSqueezyClient $client) {}
/**
* @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['lemonsqueezy_product_id'] ?? null;
$variant = $tier['lemonsqueezy_variant_id'] ?? null;
if (! $product) {
$product = $this->createProduct($tier)['id'];
}
if (! $variant) {
$variant = $this->createPrice($tier, $product)['id'];
}
return [
'product_id' => $product,
'variant_id' => $variant,
];
}
/**
* @param array{key:string,label:string,amount:float,currency?:string} $tier
* @return array<string, mixed>
*/
public function createProduct(array $tier): array
{
$payload = [
'name' => $tier['label'],
'description' => sprintf('Geschenkgutschein im Wert von %.2f %s für Fotospiel Pakete.', $tier['amount'], $this->currency($tier)),
'type' => 'standard',
'tax_category' => 'standard',
'custom_data' => [
'kind' => 'gift_voucher',
'tier_key' => $tier['key'],
'amount' => $tier['amount'],
'currency' => $this->currency($tier),
],
];
$response = $this->client->post('/products', $payload);
return Arr::get($response, 'data', $response);
}
/**
* @param array{key:string,label:string,amount:float,currency?:string} $tier
* @return array<string, mixed>
*/
public function createPrice(array $tier, string $productId): array
{
$payload = [
'product_id' => $productId,
'description' => sprintf('Geschenkgutschein %.2f %s', $tier['amount'], $this->currency($tier)),
'unit_price' => [
'amount' => (string) $this->toMinorUnits($tier['amount']),
'currency_code' => $this->currency($tier),
],
'custom_data' => [
'kind' => 'gift_voucher',
'tier_key' => $tier['key'],
],
];
$response = $this->client->post('/prices', $payload);
return Arr::get($response, 'data', $response);
}
protected function currency(array $tier): string
{
return Str::upper($tier['currency'] ?? 'EUR');
}
protected function toMinorUnits(float $amount): int
{
return (int) round($amount * 100);
}
}

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