247 lines
9.7 KiB
PHP
247 lines
9.7 KiB
PHP
<?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;
|
|
}
|
|
}
|