update to provisioner code
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user