switched to paddle inline checkout, removed paypal and most of stripe. added product sync between app and paddle.

This commit is contained in:
Codex Agent
2025-10-27 17:26:39 +01:00
parent ecf5a23b28
commit 5432456ffd
117 changed files with 4114 additions and 3639 deletions

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Services\Paddle\Exceptions;
use RuntimeException;
class PaddleException 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,245 @@
<?php
namespace App\Services\Paddle;
use App\Models\Package;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PaddleCatalogService
{
public function __construct(private readonly PaddleClient $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);
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->paddle_product_id, $overrides);
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 = []): array
{
$unitPrice = $overrides['unit_price'] ?? [
'amount' => (string) $this->priceToMinorUnits($package->price),
'currency_code' => Str::upper((string) ($package->currency ?? 'EUR')),
];
$payload = array_merge([
'product_id' => $productId,
'description' => $this->resolvePriceDescription($package, $overrides),
'unit_price' => $unitPrice,
'custom_data' => $this->buildCustomData($package, $overrides['custom_data'] ?? []),
], Arr::except($overrides, ['unit_price', 'description', 'custom_data']));
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,84 @@
<?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} $options
*/
public function createCheckout(Tenant $tenant, Package $package, array $options = []): array
{
$customerId = $this->customers->ensureCustomerId($tenant);
$successUrl = $options['success_url'] ?? route('marketing.success', ['packageId' => $package->id]);
$returnUrl = $options['return_url'] ?? route('packages', ['highlight' => $package->slug]);
$metadata = $this->buildMetadata($tenant, $package, $options['metadata'] ?? []);
$payload = [
'customer_id' => $customerId,
'items' => [
[
'price_id' => $package->paddle_price_id,
'quantity' => 1,
],
],
'metadata' => $metadata,
'success_url' => $successUrl,
'cancel_url' => $returnUrl,
];
if ($tenant->contact_email) {
$payload['customer_email'] = $tenant->contact_email;
}
$response = $this->client->post('/checkout/links', $payload);
$checkoutUrl = 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.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

@@ -0,0 +1,82 @@
<?php
namespace App\Services\Paddle;
use App\Services\Paddle\Exceptions\PaddleException;
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
{
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 $payload = []): array
{
return $this->send('POST', $endpoint, ['json' => $payload]);
}
public function patch(string $endpoint, array $payload = []): array
{
return $this->send('PATCH', $endpoint, ['json' => $payload]);
}
public function delete(string $endpoint, array $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 PaddleException($exception->getMessage(), $exception->response?->status(), $exception->response?->json() ?? []);
}
if ($response->failed()) {
$body = $response->json() ?? [];
$message = Arr::get($body, 'error.message')
?? Arr::get($body, 'message')
?? sprintf('Paddle request failed with status %s', $response->status());
throw new PaddleException($message, $response->status(), $body);
}
return $response->json() ?? [];
}
protected function preparedRequest(): PendingRequest
{
$apiKey = config('paddle.api_key');
if (! $apiKey) {
throw new PaddleException('Paddle 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',
];
return $this->http
->baseUrl($baseUrl)
->withHeaders($headers)
->withToken($apiKey)
->acceptJson()
->asJson();
}
}

View File

@@ -0,0 +1,41 @@
<?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;
}
$payload = [
'email' => $tenant->contact_email ?: ($tenant->user?->email ?? null),
'name' => $tenant->name,
];
if (! $payload['email']) {
throw new PaddleException('Tenant email address required to create Paddle customer.');
}
$response = $this->client->post('/customers', $payload);
$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;
}
}

View File

@@ -0,0 +1,33 @@
<?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
{
return Arr::get($subscription, 'data.metadata', []);
}
}

View File

@@ -0,0 +1,92 @@
<?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',
], $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),
];
}
/**
* @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),
];
}
}