Migrate billing from Paddle to Lemon Squeezy
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
137
app/Services/LemonSqueezy/LemonSqueezyAddonCatalogService.php
Normal file
137
app/Services/LemonSqueezy/LemonSqueezyAddonCatalogService.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
255
app/Services/LemonSqueezy/LemonSqueezyCatalogService.php
Normal file
255
app/Services/LemonSqueezy/LemonSqueezyCatalogService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
128
app/Services/LemonSqueezy/LemonSqueezyCheckoutService.php
Normal file
128
app/Services/LemonSqueezy/LemonSqueezyCheckoutService.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\LemonSqueezy;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class LemonSqueezyCheckoutService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LemonSqueezyClient $client,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{success_url?: string|null, return_url?: string|null, discount_code?: string|null, metadata?: array, custom_data?: array, customer_email?: string|null, customer_name?: string|null} $options
|
||||
*/
|
||||
public function createCheckout(Tenant $tenant, Package $package, array $options = []): array
|
||||
{
|
||||
$storeId = (string) config('lemonsqueezy.store_id');
|
||||
|
||||
$customData = $this->buildCustomData(
|
||||
$tenant,
|
||||
$package,
|
||||
array_merge(
|
||||
$options['metadata'] ?? [],
|
||||
$options['custom_data'] ?? [],
|
||||
array_filter([
|
||||
'success_url' => $options['success_url'] ?? null,
|
||||
'return_url' => $options['return_url'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== '')
|
||||
)
|
||||
);
|
||||
|
||||
return $this->createVariantCheckout((string) $package->lemonsqueezy_variant_id, $customData, $options + [
|
||||
'customer_email' => $options['customer_email'] ?? null,
|
||||
'customer_name' => $options['customer_name'] ?? null,
|
||||
'store_id' => $storeId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{success_url?: string|null, return_url?: string|null, discount_code?: string|null, customer_email?: string|null, customer_name?: string|null, store_id?: string|null} $options
|
||||
*/
|
||||
public function createVariantCheckout(string $variantId, array $customData, array $options = []): array
|
||||
{
|
||||
$storeId = $options['store_id'] ?? (string) config('lemonsqueezy.store_id');
|
||||
|
||||
$attributes = array_filter([
|
||||
'checkout_data' => array_filter([
|
||||
'custom' => $customData,
|
||||
'email' => $options['customer_email'] ?? null,
|
||||
'name' => $options['customer_name'] ?? null,
|
||||
'discount_code' => $options['discount_code'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== ''),
|
||||
'checkout_options' => [
|
||||
'embed' => true,
|
||||
],
|
||||
'product_options' => array_filter([
|
||||
'redirect_url' => $options['success_url'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== ''),
|
||||
'test_mode' => (bool) config('lemonsqueezy.test_mode', false),
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$payload = [
|
||||
'data' => [
|
||||
'type' => 'checkouts',
|
||||
'attributes' => $attributes,
|
||||
'relationships' => [
|
||||
'store' => [
|
||||
'data' => [
|
||||
'type' => 'stores',
|
||||
'id' => $storeId,
|
||||
],
|
||||
],
|
||||
'variant' => [
|
||||
'data' => [
|
||||
'type' => 'variants',
|
||||
'id' => $variantId,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->client->post('/checkouts', $payload);
|
||||
|
||||
$checkoutUrl = Arr::get($response, 'data.attributes.url')
|
||||
?? Arr::get($response, 'data.attributes.checkout_url')
|
||||
?? Arr::get($response, 'data.url')
|
||||
?? Arr::get($response, 'url');
|
||||
|
||||
if (! $checkoutUrl) {
|
||||
Log::warning('Lemon Squeezy checkout response missing url', ['response' => $response]);
|
||||
}
|
||||
|
||||
return [
|
||||
'checkout_url' => $checkoutUrl,
|
||||
'expires_at' => Arr::get($response, 'data.attributes.expires_at'),
|
||||
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function buildCustomData(Tenant $tenant, Package $package, array $extra = []): array
|
||||
{
|
||||
$metadata = [
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
];
|
||||
|
||||
foreach ($extra as $key => $value) {
|
||||
if (! is_string($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) {
|
||||
$metadata[$key] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
}
|
||||
84
app/Services/LemonSqueezy/LemonSqueezyClient.php
Normal file
84
app/Services/LemonSqueezy/LemonSqueezyClient.php
Normal 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();
|
||||
}
|
||||
}
|
||||
204
app/Services/LemonSqueezy/LemonSqueezyDiscountService.php
Normal file
204
app/Services/LemonSqueezy/LemonSqueezyDiscountService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
151
app/Services/LemonSqueezy/LemonSqueezyOrderService.php
Normal file
151
app/Services/LemonSqueezy/LemonSqueezyOrderService.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\LemonSqueezy;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class LemonSqueezyOrderService
|
||||
{
|
||||
public function __construct(private readonly LemonSqueezyClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
|
||||
*/
|
||||
public function listForCustomer(string $customerId, array $query = []): array
|
||||
{
|
||||
$payload = array_filter(array_merge([
|
||||
'filter[customer_id]' => $customerId,
|
||||
'sort' => '-created_at',
|
||||
], $query), static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$response = $this->client->get('/orders', $payload);
|
||||
|
||||
$orders = Arr::get($response, 'data', []);
|
||||
$meta = Arr::get($response, 'meta', []);
|
||||
|
||||
if (! is_array($orders)) {
|
||||
$orders = [];
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => array_map([$this, 'mapOrder'], $orders),
|
||||
'meta' => $this->mapPagination($meta),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function retrieve(string $orderId): array
|
||||
{
|
||||
$response = $this->client->get("/orders/{$orderId}");
|
||||
$order = Arr::get($response, 'data');
|
||||
|
||||
return is_array($order) ? $order : (is_array($response) ? $response : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findByCheckoutId(string $checkoutId): ?array
|
||||
{
|
||||
$response = $this->client->get("/checkouts/{$checkoutId}");
|
||||
$checkout = Arr::get($response, 'data');
|
||||
|
||||
if (! is_array($checkout)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$orderId = Arr::get($checkout, 'attributes.order_id');
|
||||
if (! $orderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->retrieve((string) $orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a refund for a Lemon Squeezy order.
|
||||
*
|
||||
* @param array{reason?: string|null} $options
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function refund(string $orderId, array $options = []): array
|
||||
{
|
||||
$payload = [
|
||||
'data' => [
|
||||
'type' => 'refunds',
|
||||
'attributes' => array_filter([
|
||||
'order_id' => $orderId,
|
||||
'reason' => $options['reason'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== ''),
|
||||
],
|
||||
];
|
||||
|
||||
return $this->client->post('/refunds', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mapOrder(array $order): array
|
||||
{
|
||||
$attributes = Arr::get($order, 'attributes', []);
|
||||
|
||||
return [
|
||||
'id' => $order['id'] ?? null,
|
||||
'order_number' => $attributes['order_number'] ?? null,
|
||||
'status' => $attributes['status'] ?? null,
|
||||
'amount' => $this->convertAmount($attributes['subtotal'] ?? null),
|
||||
'currency' => $attributes['currency'] ?? 'EUR',
|
||||
'origin' => 'lemonsqueezy',
|
||||
'checkout_id' => $attributes['checkout_id'] ?? null,
|
||||
'created_at' => $attributes['created_at'] ?? null,
|
||||
'updated_at' => $attributes['updated_at'] ?? null,
|
||||
'receipt_url' => Arr::get($attributes, 'urls.receipt'),
|
||||
'tax' => $this->convertAmount($attributes['tax'] ?? null),
|
||||
'grand_total' => $this->convertAmount($attributes['total'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mapPagination(array $meta): array
|
||||
{
|
||||
$page = Arr::get($meta, 'page', []);
|
||||
$current = (int) ($page['currentPage'] ?? $page['current_page'] ?? 1);
|
||||
$totalPages = (int) ($page['totalPages'] ?? $page['total_pages'] ?? 1);
|
||||
|
||||
return [
|
||||
'next' => $current < $totalPages ? (string) ($current + 1) : null,
|
||||
'previous' => $current > 1 ? (string) ($current - 1) : null,
|
||||
'has_more' => $current < $totalPages,
|
||||
];
|
||||
}
|
||||
|
||||
protected function convertAmount(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value) && isset($value['amount'])) {
|
||||
$value = $value['amount'];
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$value = preg_replace('/[^0-9.-]/', '', $value);
|
||||
}
|
||||
|
||||
if ($value === '' || $value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$amount = (float) $value;
|
||||
|
||||
return $amount / 100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\LemonSqueezy;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class LemonSqueezySubscriptionService
|
||||
{
|
||||
public function __construct(private readonly LemonSqueezyClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function retrieve(string $subscriptionId): array
|
||||
{
|
||||
$response = $this->client->get("/subscriptions/{$subscriptionId}");
|
||||
|
||||
return Arr::get($response, 'data', is_array($response) ? $response : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $subscription
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function customData(array $subscription): array
|
||||
{
|
||||
$attributes = Arr::get($subscription, 'attributes', []);
|
||||
|
||||
$custom = Arr::get($attributes, 'custom_data', Arr::get($attributes, 'custom', []));
|
||||
|
||||
return is_array($custom) ? $custom : [];
|
||||
}
|
||||
|
||||
public function portalUrl(array $subscription): ?string
|
||||
{
|
||||
return Arr::get($subscription, 'attributes.urls.customer_portal')
|
||||
?? Arr::get($subscription, 'attributes.urls.customer_portal_url');
|
||||
}
|
||||
|
||||
public function updatePaymentMethodUrl(array $subscription): ?string
|
||||
{
|
||||
return Arr::get($subscription, 'attributes.urls.update_payment_method')
|
||||
?? Arr::get($subscription, 'attributes.urls.update_payment_method_url');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user