Files
2026-10-02 10:21:54 +01:00

797 lines
22 KiB
PHP
Executable File

<?php
namespace Modules\Paddle\Services;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseProduct;
use App\Services\Accounts\PurchasedAccountProvisioner;
use Modules\Licensing\Services\LicenseIssuer;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Throwable;
final class PurchaseProvisioner
{
public function __construct(
private LicenseIssuer $issuer,
private PaddleClient $paddle,
private PurchasedAccountProvisioner $accounts,
) {}
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 = is_array($event['data'] ?? null)
? $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 = $this->nullableString(
$data['id'] ?? null
);
if ($transactionID !== null) {
License::query()
->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 = $this->nullableString(
$data['id'] ?? null
);
if ($subscriptionID === null) {
throw new RuntimeException(
'Paddle subscription webhook has no subscription ID.'
);
}
/*
* Multiple Paddle events for the same subscription can arrive
* simultaneously.
*
* The unique index protects database integrity, but by itself it
* cannot stop two workers from both observing "no licence" before
* either worker inserts the subscription ID.
*
* A MySQL advisory lock serialises provisioning for one Paddle
* subscription while still allowing unrelated subscriptions to be
* processed concurrently.
*/
$lockName = 'sonardesk:paddle:subscription:'
.$subscriptionID;
$this->withMysqlLock(
$lockName,
function () use (
$type,
$data,
$subscriptionID
): void {
$this->syncLockedPaddleSubscription(
$type,
$data,
$subscriptionID
);
}
);
}
private function syncLockedPaddleSubscription(
string $type,
array $data,
string $subscriptionID
): void {
$transactionID = $this->nullableString(
$data['transaction_id'] ?? null
);
$status = (string) ($data['status'] ?? '');
$items = is_array($data['items'] ?? null)
? $data['items']
: [];
$item = $this->firstRecurringItem($items);
$price = is_array($item['price'] ?? null)
? $item['price']
: [];
$priceID = (string) (
$price['id']
?? $item['price_id']
?? ''
);
$productID = (string) (
$price['product_id']
?? $item['product_id']
?? ''
);
/*
* The Paddle subscription ID is the authoritative identity for a
* recurring licence.
*
* lockForUpdate protects an existing licence while we mutate it.
* Creation of a missing licence is serialised by the advisory lock
* acquired by syncPaddleSubscription().
*/
$license = License::query()
->where('payment_provider', 'paddle')
->where(
'external_subscription_id',
$subscriptionID
)
->lockForUpdate()
->first();
if (! $license) {
$product = $this->mappedProduct(
'paddle',
$priceID,
$productID
);
$custom = is_array($data['custom_data'] ?? null)
? $data['custom_data']
: [];
$customerID = $this->nullableString(
$data['customer_id'] ?? null
);
$customer = $this->paddle->customer(
$customerID
) ?? [];
[$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',
$transactionID,
$customerID,
true,
);
/*
* LicenseIssuer may return an existing licence if the
* transaction ID has already been seen.
*
* Re-read it with a row lock before attaching the Paddle
* subscription.
*/
$license = License::query()
->whereKey($license->getKey())
->lockForUpdate()
->firstOrFail();
/*
* Do not attach this subscription to a licence already owned
* by a different subscription.
*/
if (
$license->external_subscription_id !== null
&& $license->external_subscription_id
!== $subscriptionID
) {
throw new RuntimeException(
'Paddle transaction resolved to a licence '
.'belonging to another subscription.'
);
}
$license->external_subscription_id =
$subscriptionID;
} elseif (
$priceID !== ''
&& $priceID !== $license->external_price_id
) {
/*
* Upgrades and downgrades modify the entitlement attached to
* the existing licence key rather than issuing another 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;
$this->backfillPaddleCustomer(
$license,
$data
);
/*
* Account provisioning is expected to be idempotent.
*/
$this->accounts->ensure(
$license->customer_email,
$license->customer_name
);
if ($priceID !== '') {
$license->external_price_id = $priceID;
}
if (
$transactionID !== null
&& ! $license->external_order_id
) {
$license->external_order_id =
$transactionID;
}
if ($status !== '') {
$license->subscription_status = $status;
}
if ($periodEnd) {
$license->current_period_ends_at =
Carbon::parse($periodEnd);
}
$license->cancel_scheduled_at = $cancelAt
? Carbon::parse($cancelAt)
: null;
/*
* Subscription entitlement.
*/
if (in_array(
$status,
['active', 'trialing'],
true
)) {
$license->revoked_at = null;
$license->revoke_reason = null;
$license->expires_at = $periodEnd
? Carbon::parse($periodEnd)
: null;
} elseif ($status === 'past_due') {
/*
* A failed renewal does not immediately destroy the
* entitlement. Preserve Paddle's current billing period when
* available and otherwise provide the existing grace period.
*/
$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 = $this->nullableString(
$data['id'] ?? null
);
/*
* A recurring transaction belongs to its subscription.
*
* Serialise this operation using the same lock as subscription
* provisioning. This prevents transaction.completed from updating
* the licence while a subscription event is still creating or
* modifying it.
*/
if ($subscriptionID !== null) {
$lockName = 'sonardesk:paddle:subscription:'
.$subscriptionID;
$this->withMysqlLock(
$lockName,
function () use (
$subscriptionID,
$transactionID
): void {
if ($transactionID === null) {
return;
}
$license = License::query()
->where(
'payment_provider',
'paddle'
)
->where(
'external_subscription_id',
$subscriptionID
)
->lockForUpdate()
->first();
/*
* transaction.completed may legitimately arrive before
* subscription.created. In that case there is nothing
* to update yet. The subscription event will create the
* licence and Paddle's transaction_id can populate the
* order ID.
*/
if (! $license) {
return;
}
if (! $license->external_order_id) {
$license->external_order_id =
$transactionID;
$license->save();
}
}
);
return;
}
/*
* No subscription ID means this may be a one-time purchase.
*/
$items = is_array($data['items'] ?? null)
? $data['items']
: [];
$item = $items[0] ?? [];
$price = is_array($item['price'] ?? null)
? $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']
: [];
$customerID = $this->nullableString(
$data['customer_id'] ?? null
);
$customer = $this->paddle->customer(
$customerID
) ?? [];
[$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',
$transactionID,
$customerID,
true,
);
$this->accounts->ensure(
$license->customer_email,
$license->customer_name
);
}
private function provisionLemonSqueezy(
array $event
): void {
$type = (string) (
$event['meta']['event_name']
?? ''
);
$attributes = is_array(
$event['data']['attributes'] ?? null
)
? $event['data']['attributes']
: [];
$order = (string) (
$event['data']['id']
?? $attributes['order_id']
?? ''
);
if (in_array(
$type,
[
'order_refunded',
'subscription_expired',
],
true
)) {
License::query()
->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']
?? ''
),
);
[$license] = $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,
);
$this->accounts->ensure(
$license->customer_email,
$license->customer_name
);
}
private function backfillPaddleCustomer(
License $license,
array $data
): void {
$custom = is_array(
$data['custom_data'] ?? null
)
? $data['custom_data']
: [];
$customerID = $this->nullableString(
$data['customer_id']
?? $license->external_customer_id
);
$customer = $this->paddle->customer(
$customerID
) ?? [];
$name = $this->nullableString(
$custom['customer_name']
?? $custom['name']
?? $customer['name']
?? null
);
$email = $this->nullableString(
$custom['customer_email']
?? $custom['email']
?? $customer['email']
?? null
);
if (! $license->customer_name && $name) {
$license->customer_name = $name;
}
if (! $license->customer_email && $email) {
$email = strtolower($email);
$license->customer_email = $email;
LicenseDeliveryOutbox::query()
->where(
'license_id',
$license->id
)
->whereNull('delivered_at')
->update([
'recipient_email' => $email,
]);
}
}
private function mappedProduct(
string $provider,
string $priceID,
string $productID
): LicenseProduct {
/*
* Price IDs are authoritative because multiple billing intervals
* can belong to the same Paddle product.
*/
if ($priceID !== '') {
$product = LicenseProduct::query()
->where(
'payment_provider',
$provider
)
->where(
'external_price_id',
$priceID
)
->where('active', true)
->first();
} else {
$product = LicenseProduct::query()
->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 (! is_array($item)) {
continue;
}
if (($item['recurring'] ?? true) === true) {
return $item;
}
}
$first = $items[0] ?? [];
return is_array($first)
? $first
: [];
}
private function nullableString(
mixed $value
): ?string {
if (! is_scalar($value)) {
return null;
}
$value = trim((string) $value);
return $value === ''
? null
: $value;
}
/**
* Execute a callback while holding a MySQL advisory lock.
*
* The lock is connection-scoped, so it must always be explicitly
* released in finally. A short timeout is sufficient because Paddle
* will retry failed webhook deliveries and the controller also retries
* database deadlocks.
*/
private function withMysqlLock(
string $name,
callable $callback
): mixed {
/*
* MySQL advisory lock names have a finite length. Hashing also
* prevents provider supplied identifiers from creating unusual
* lock names.
*/
/*
* MySQL advisory locks are only available when using MySQL/MariaDB.
*
* Tests use SQLite in-memory, where GET_LOCK() and RELEASE_LOCK()
* do not exist.
*/
if (DB::connection()->getDriverName() !== 'mysql') {
return $callback();
}
$lockName = 'sd:'.substr(hash('sha256', $name), 0, 61);
$row = DB::selectOne(
'SELECT GET_LOCK(?, ?) AS acquired',
[
$lockName,
10,
]
);
$acquired = (int) (
$row->acquired
?? 0
);
if ($acquired !== 1) {
throw new RuntimeException(
'Could not acquire Paddle subscription lock.'
);
}
try {
return $callback();
} finally {
try {
DB::selectOne(
'SELECT RELEASE_LOCK(?) AS released',
[$lockName]
);
} catch (Throwable $e) {
/*
* Do not hide the original processing exception merely
* because releasing an advisory lock failed. MySQL also
* releases connection-owned locks when the connection
* closes.
*/
report($e);
}
}
}
}