Files
SonarDesk-Licencing-Server/app/Services/Payments/PurchaseProvisioner.php
T

569 lines
16 KiB
PHP
Executable File

<?php
namespace App\Services\Payments;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use App\Services\Accounts\PurchasedAccountProvisioner;
use App\Services\Licensing\LicenseIssuer;
use Illuminate\Support\Carbon;
use RuntimeException;
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 = $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.'
);
}
/*
* Paddle webhook delivery order is not guaranteed.
*
* A transaction.completed event can arrive before the corresponding
* subscription event has created our local licence. Capture the
* transaction ID from the subscription payload as well whenever
* Paddle provides one.
*/
$transactionID = $this->nullableString(
$data['transaction_id'] ?? null
);
$status = (string) ($data['status'] ?? '');
$item = $this->firstRecurringItem(
is_array($data['items'] ?? null)
? $data['items']
: []
);
$price = is_array($item['price'] ?? null)
? $item['price']
: [];
$priceID = (string) (
$price['id']
?? $item['price_id']
?? ''
);
$productID = (string) (
$price['product_id']
?? $item['product_id']
?? ''
);
/*
* Subscription ID is the authoritative identity for recurring
* Paddle licences.
*/
$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']
: [];
$customerID = $this->nullableString(
$data['customer_id'] ?? null
);
$customer = $this->paddle->customer($customerID) ?? [];
/*
* Use the transaction ID as the initial external order ID where
* available. If it is absent, transaction.completed can backfill
* it later.
*/
[$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
);
$license->external_subscription_id = $subscriptionID;
} elseif (
$priceID !== ''
&& $priceID !== $license->external_price_id
) {
/*
* Upgrades/downgrades change the entitlement attached to the
* existing licence 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);
/*
* ensure() must be idempotent. Existing users must simply be
* returned rather than created again.
*/
$this->accounts->ensure(
$license->customer_email,
$license->customer_name
);
if ($priceID !== '') {
$license->external_price_id = $priceID;
}
if ($transactionID && ! $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;
/*
* Entitlement handling.
*/
if (in_array($status, ['active', 'trialing'], true)) {
$license->revoked_at = null;
$license->revoke_reason = null;
/*
* Active/trialing subscriptions remain valid through the
* current Paddle billing period.
*/
$license->expires_at = $periodEnd
? Carbon::parse($periodEnd)
: null;
} elseif ($status === 'past_due') {
/*
* Do not revoke immediately after the first failed renewal.
* Preserve Paddle's current billing period where possible.
*/
$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
);
/*
* Recurring transactions belong to an existing subscription.
*
* The subscription event normally creates the licence. If the
* transaction event arrives afterwards we simply backfill the
* transaction/order ID.
*
* If transaction.completed arrives first, it does nothing here.
* The subsequent subscription event will obtain transaction_id
* directly from its payload where Paddle supplies it.
*/
if ($subscriptionID) {
if ($transactionID) {
License::where('payment_provider', 'paddle')
->where(
'external_subscription_id',
$subscriptionID
)
->whereNull('external_order_id')
->update([
'external_order_id' => $transactionID,
]);
}
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::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::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.
*
* Monthly and annual Paddle prices can intentionally belong to the
* same Paddle product, therefore product ID alone cannot reliably
* identify the licence plan.
*/
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 (! 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;
}
}