update to provisioner code

This commit is contained in:
Mark Gregory
2026-09-30 12:49:17 +01:00
parent 20dbb09433
commit 2d9d07f722
+374 -82
View File
@@ -3,8 +3,8 @@
namespace App\Services\Payments;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use App\Services\Accounts\PurchasedAccountProvisioner;
use App\Services\Licensing\LicenseIssuer;
use Illuminate\Support\Carbon;
@@ -54,12 +54,16 @@ final class PurchaseProvisioner
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']);
->update([
'revoked_at' => now(),
'revoke_reason' => 'Payment refunded',
]);
}
}
}
@@ -67,38 +71,119 @@ final class PurchaseProvisioner
private function syncPaddleSubscription(string $type, array $data): void
{
$subscriptionID = (string) ($data['id'] ?? '');
if ($subscriptionID === '') {
throw new RuntimeException('Paddle subscription webhook has no subscription ID.');
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'] ?? '');
/*
* 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'] : [];
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)) ?? [];
$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),
$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),
$transactionID,
$customerID,
true,
);
$this->accounts->ensure($license->customer_email, $license->customer_name);
$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 key.
$product = $this->mappedProduct('paddle', $priceID, $productID);
} 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;
@@ -106,32 +191,78 @@ final class PurchaseProvisioner
}
$periodEnd = $data['current_billing_period']['ends_at'] ?? null;
$scheduled = $data['scheduled_change'] ?? null;
$cancelAt = is_array($scheduled) && ($scheduled['action'] ?? null) === 'cancel'
$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);
$this->accounts->ensure($license->customer_email, $license->customer_name);
$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;
$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;
// Trialing/active subscriptions remain valid through the current period.
$license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : 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 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));
/*
* 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->revoke_reason = $status === 'paused'
? 'Subscription paused'
: 'Subscription canceled';
}
$license->save();
@@ -139,57 +270,152 @@ final class PurchaseProvisioner
private function syncPaddleTransaction(array $data): void
{
$subscriptionID = $this->nullableString($data['subscription_id'] ?? null);
$transactionID = (string) ($data['id'] ?? '');
$subscriptionID = $this->nullableString(
$data['subscription_id'] ?? null
);
// Recurring purchases are provisioned from subscription events because those
// events contain the authoritative trial/status/current-period state.
$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) {
License::where('payment_provider', 'paddle')
->where('external_subscription_id', $subscriptionID)
->whereNull('external_order_id')
->update(['external_order_id' => $transactionID ?: null]);
if ($transactionID) {
License::where('payment_provider', 'paddle')
->where(
'external_subscription_id',
$subscriptionID
)
->whereNull('external_order_id')
->update([
'external_order_id' => $transactionID,
]);
}
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)) ?? [];
/*
* 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),
$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),
$customerID,
true,
);
$this->accounts->ensure($license->customer_email, $license->customer_name);
$this->accounts->ensure(
$license->customer_email,
$license->customer_name
);
}
private function provisionLemonSqueezy(array $event): void
{
$type = (string) ($event['meta']['event_name'] ?? '');
$attributes = $event['data']['attributes'] ?? [];
$order = (string) ($event['data']['id'] ?? $attributes['order_id'] ?? '');
$type = (string) (
$event['meta']['event_name']
?? ''
);
if (in_array($type, ['order_refunded', 'subscription_expired'], true)) {
License::where('payment_provider', 'lemon-squeezy')
$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']);
->update([
'revoked_at' => now(),
'revoke_reason' =>
'Payment refunded or subscription expired',
]);
return;
}
if (! in_array($type, ['order_created', 'subscription_created'], true)) {
if (! in_array(
$type,
['order_created', 'subscription_created'],
true
)) {
return;
}
@@ -201,53 +427,110 @@ final class PurchaseProvisioner
[$license] = $this->issuer->issue(
$product,
$this->nullableString($attributes['user_name'] ?? null),
$this->nullableString($attributes['user_email'] ?? null),
$this->nullableString(
$attributes['user_name'] ?? null
),
$this->nullableString(
$attributes['user_email'] ?? null
),
'lemon-squeezy',
$order,
$this->nullableString($attributes['customer_id'] ?? null),
$this->nullableString(
$attributes['customer_id'] ?? null
),
true,
);
$this->accounts->ensure($license->customer_email, $license->customer_name);
$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'] : [];
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? $license->external_customer_id)) ?? [];
private function backfillPaddleCustomer(
License $license,
array $data
): void {
$custom = is_array($data['custom_data'] ?? null)
? $data['custom_data']
: [];
$name = $this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null);
$email = $this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null);
$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) {
$license->customer_email = strtolower($email);
LicenseDeliveryOutbox::where('license_id', $license->id)
$email = strtolower($email);
$license->customer_email = $email;
LicenseDeliveryOutbox::where(
'license_id',
$license->id
)
->whereNull('delivered_at')
->update(['recipient_email' => strtolower($email)]);
->update([
'recipient_email' => $email,
]);
}
}
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.
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)
$product = LicenseProduct::where(
'payment_provider',
$provider
)
->where('external_price_id', $priceID)
->where('active', true)
->first();
} else {
$product = LicenseProduct::where('payment_provider', $provider)
$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}.");
throw new RuntimeException(
"No active licence product maps to {$provider} "
."price {$priceID} / product {$productID}."
);
}
return $product;
@@ -256,12 +539,18 @@ final class PurchaseProvisioner
private function firstRecurringItem(array $items): array
{
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
if (($item['recurring'] ?? true) === true) {
return $item;
}
}
return $items[0] ?? [];
$first = $items[0] ?? [];
return is_array($first) ? $first : [];
}
private function nullableString(mixed $value): ?string
@@ -269,8 +558,11 @@ final class PurchaseProvisioner
if (! is_scalar($value)) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
return $value === ''
? null
: $value;
}
}