Files

247 lines
9.7 KiB
PHP
Executable File

<?php
namespace App\Services\Payments;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use Illuminate\Support\Carbon;
use RuntimeException;
final class PurchaseProvisioner
{
public function __construct(private LicenseIssuer $issuer, private PaddleClient $paddle) {}
public function provision(string $provider, array $event): void
{
if ($provider === 'paddle') {
$this->provisionPaddle($event);
return;
}
if ($provider === 'lemon-squeezy') {
$this->provisionLemonSqueezy($event);
return;
}
throw new RuntimeException('Unsupported payment provider.');
}
private function provisionPaddle(array $event): void
{
$type = (string) ($event['event_type'] ?? '');
$data = $event['data'] ?? [];
if (str_starts_with($type, 'subscription.')) {
$this->syncPaddleSubscription($type, $data);
return;
}
if ($type === 'transaction.completed') {
$this->syncPaddleTransaction($data);
return;
}
if ($type === 'transaction.refunded') {
$transactionID = (string) ($data['id'] ?? '');
if ($transactionID !== '') {
License::where('payment_provider', 'paddle')
->where('external_order_id', $transactionID)
->whereNull('external_subscription_id')
->whereNull('revoked_at')
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded']);
}
}
}
private function syncPaddleSubscription(string $type, array $data): void
{
$subscriptionID = (string) ($data['id'] ?? '');
if ($subscriptionID === '') {
throw new RuntimeException('Paddle subscription webhook has no subscription ID.');
}
$status = (string) ($data['status'] ?? '');
$item = $this->firstRecurringItem($data['items'] ?? []);
$price = $item['price'] ?? [];
$priceID = (string) ($price['id'] ?? $item['price_id'] ?? '');
$productID = (string) ($price['product_id'] ?? $item['product_id'] ?? '');
$license = License::where('payment_provider', 'paddle')
->where('external_subscription_id', $subscriptionID)
->first();
if (! $license) {
$product = $this->mappedProduct('paddle', $priceID, $productID);
$custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : [];
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null));
[$license] = $this->issuer->issue(
$product,
$this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null),
$this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null),
'paddle',
$this->nullableString($data['transaction_id'] ?? null),
$this->nullableString($data['customer_id'] ?? null),
true,
);
$license->external_subscription_id = $subscriptionID;
} elseif ($priceID !== '' && $priceID !== $license->external_price_id) {
// Upgrades/downgrades change the entitlement attached to the existing key.
$product = $this->mappedProduct('paddle', $priceID, $productID);
$license->license_product_id = $product->id;
$license->edition = $product->edition;
$license->activation_limit = $product->activation_limit;
$license->max_major_version = $product->max_major_version;
}
$periodEnd = $data['current_billing_period']['ends_at'] ?? null;
$scheduled = $data['scheduled_change'] ?? null;
$cancelAt = is_array($scheduled) && ($scheduled['action'] ?? null) === 'cancel'
? ($scheduled['effective_at'] ?? null)
: null;
$license->external_customer_id = $this->nullableString($data['customer_id'] ?? null) ?? $license->external_customer_id;
$license->external_price_id = $priceID !== '' ? $priceID : $license->external_price_id;
$license->subscription_status = $status !== '' ? $status : $license->subscription_status;
$license->current_period_ends_at = $periodEnd ? Carbon::parse($periodEnd) : $license->current_period_ends_at;
$license->cancel_scheduled_at = $cancelAt ? Carbon::parse($cancelAt) : null;
if (in_array($status, ['active', 'trialing'], true)) {
$license->revoked_at = null;
$license->revoke_reason = null;
// Trialing/active subscriptions remain valid through the current period.
$license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : null;
} elseif ($status === 'past_due') {
// Do not instantly kill a licence on the first failed renewal. Preserve the
// period Paddle says is current; a later canceled event ends entitlement.
$license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : ($license->expires_at ?? now()->addDays(7));
} elseif (in_array($status, ['canceled', 'paused'], true)) {
$license->expires_at = now();
$license->revoked_at = now();
$license->revoke_reason = $status === 'paused' ? 'Subscription paused' : 'Subscription canceled';
}
$license->save();
}
private function syncPaddleTransaction(array $data): void
{
$subscriptionID = $this->nullableString($data['subscription_id'] ?? null);
$transactionID = (string) ($data['id'] ?? '');
// Recurring purchases are provisioned from subscription events because those
// events contain the authoritative trial/status/current-period state.
if ($subscriptionID) {
License::where('payment_provider', 'paddle')
->where('external_subscription_id', $subscriptionID)
->whereNull('external_order_id')
->update(['external_order_id' => $transactionID ?: null]);
return;
}
// Keep support for any future one-time Paddle licence product.
$item = $data['items'][0] ?? [];
$price = $item['price'] ?? [];
$priceID = (string) ($price['id'] ?? $item['price_id'] ?? '');
$productID = (string) ($price['product_id'] ?? $item['product_id'] ?? '');
$product = $this->mappedProduct('paddle', $priceID, $productID);
$custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : [];
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null));
$this->issuer->issue(
$product,
$this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null),
$this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null),
'paddle',
$transactionID,
$this->nullableString($data['customer_id'] ?? null),
true,
);
}
private function provisionLemonSqueezy(array $event): void
{
$type = (string) ($event['meta']['event_name'] ?? '');
$attributes = $event['data']['attributes'] ?? [];
$order = (string) ($event['data']['id'] ?? $attributes['order_id'] ?? '');
if (in_array($type, ['order_refunded', 'subscription_expired'], true)) {
License::where('payment_provider', 'lemon-squeezy')
->where('external_order_id', $order)
->whereNull('revoked_at')
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']);
return;
}
if (! in_array($type, ['order_created', 'subscription_created'], true)) {
return;
}
$product = $this->mappedProduct(
'lemon-squeezy',
(string) ($attributes['variant_id'] ?? ''),
(string) ($attributes['product_id'] ?? ''),
);
$this->issuer->issue(
$product,
$this->nullableString($attributes['user_name'] ?? null),
$this->nullableString($attributes['user_email'] ?? null),
'lemon-squeezy',
$order,
$this->nullableString($attributes['customer_id'] ?? null),
true,
);
}
private function mappedProduct(string $provider, string $priceID, string $productID): LicenseProduct
{
// Price IDs are the authoritative mapping because monthly and annual prices
// intentionally share the same Paddle product ID.
if ($priceID !== '') {
$product = LicenseProduct::where('payment_provider', $provider)
->where('external_price_id', $priceID)
->where('active', true)
->first();
} else {
$product = LicenseProduct::where('payment_provider', $provider)
->where('external_product_id', $productID)
->where('active', true)
->first();
}
if (! $product) {
throw new RuntimeException("No active licence product maps to {$provider} price {$priceID} / product {$productID}.");
}
return $product;
}
private function firstRecurringItem(array $items): array
{
foreach ($items as $item) {
if (($item['recurring'] ?? true) === true) {
return $item;
}
}
return $items[0] ?? [];
}
private function nullableString(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
}