797 lines
22 KiB
PHP
Executable File
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);
|
|
}
|
|
}
|
|
}
|
|
}
|