Merge branch 'stage' into develop
# Conflicts: # app/Console/Commands/SonarDeskAccountsBackfill.php
This commit is contained in:
@@ -3,50 +3,56 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\License;
|
||||
use App\Services\Payments\CustomerAccountProvisioner;
|
||||
use App\Models\User;
|
||||
use App\Services\Accounts\PurchasedAccountProvisioner;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class SonarDeskAccountsBackfill extends Command
|
||||
{
|
||||
protected $signature = 'sonardesk:accounts:backfill {--dry-run : Show missing customer accounts without creating them}';
|
||||
protected $signature = 'sonardesk:accounts:backfill {--dry-run : Report missing customer accounts without creating them} {--no-email : Create accounts without sending password setup links}';
|
||||
|
||||
protected $description = 'Create portal accounts for existing licence customers that do not yet have a user account';
|
||||
protected $description = 'Create portal accounts for existing licence holders that do not already have one.';
|
||||
|
||||
public function handle(CustomerAccountProvisioner $accounts): int
|
||||
public function handle(PurchasedAccountProvisioner $accounts): int
|
||||
{
|
||||
$customers = License::query()
|
||||
->whereNotNull('customer_email')
|
||||
->where('customer_email', '!=', '')
|
||||
->orderBy('id')
|
||||
->get(['customer_email', 'customer_name'])
|
||||
->where('customer_email', '<>', '')
|
||||
->select(['customer_email', 'customer_name'])
|
||||
->orderBy('customer_email')
|
||||
->get()
|
||||
->unique(fn (License $license) => strtolower(trim((string) $license->customer_email)));
|
||||
|
||||
$missing = 0;
|
||||
$created = 0;
|
||||
|
||||
foreach ($customers as $license) {
|
||||
$email = strtolower(trim((string) $license->customer_email));
|
||||
$exists = \App\Models\User::whereRaw('lower(email) = ?', [$email])->exists();
|
||||
if ($exists) {
|
||||
if ($email === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (User::query()->whereRaw('lower(email) = ?', [$email])->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missing++;
|
||||
if ($this->option('dry-run')) {
|
||||
$this->line("Missing account: {$email}");
|
||||
$created++;
|
||||
$this->line("MISSING {$email}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$user = $accounts->ensure($email, $license->customer_name);
|
||||
if ($user) {
|
||||
$this->info("Created account: {$email}");
|
||||
if ($accounts->ensure($email, $license->customer_name, ! $this->option('no-email'))) {
|
||||
$created++;
|
||||
$this->info("CREATED {$email}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info($this->option('dry-run')
|
||||
? "{$created} customer account(s) need creating."
|
||||
: "{$created} customer account(s) created.");
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info("{$missing} account(s) would be created.");
|
||||
} else {
|
||||
$this->info("{$created} account(s) created.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -11,68 +11,181 @@ use App\Services\Payments\PurchaseProvisioner;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class PaymentWebhookController extends Controller
|
||||
{
|
||||
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse
|
||||
{
|
||||
public function __invoke(
|
||||
string $provider,
|
||||
Request $request,
|
||||
PurchaseProvisioner $provisioner,
|
||||
LicenseDeliveryService $delivery
|
||||
): JsonResponse {
|
||||
$raw = (string) $request->getContent();
|
||||
|
||||
$verifier = match ($provider) {
|
||||
'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null
|
||||
'paddle' => app(PaddleWebhookVerifier::class),
|
||||
'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (! $verifier) {
|
||||
return response()->json(['message' => 'Unknown webhook provider.'], 404);
|
||||
return response()->json([
|
||||
'message' => 'Unknown webhook provider.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$verifier->verify($request, $raw);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json(['message' => 'Invalid webhook signature.'], 400);
|
||||
return response()->json([
|
||||
'message' => 'Invalid webhook signature.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Invalid JSON.'], 400);
|
||||
$payload = json_decode(
|
||||
$raw,
|
||||
true,
|
||||
flags: JSON_THROW_ON_ERROR
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return response()->json([
|
||||
'message' => 'Invalid JSON.',
|
||||
], 400);
|
||||
}
|
||||
$externalID = (string) ($provider === 'paddle' ? ($payload['event_id'] ?? '') : ($payload['meta']['event_name'] ?? '').':'.($payload['data']['id'] ?? ''));
|
||||
$eventType = (string) ($provider === 'paddle' ? ($payload['event_type'] ?? 'unknown') : ($payload['meta']['event_name'] ?? 'unknown'));
|
||||
|
||||
$externalID = (string) (
|
||||
$provider === 'paddle'
|
||||
? ($payload['event_id'] ?? '')
|
||||
: ($payload['meta']['event_name'] ?? '')
|
||||
.':'
|
||||
.($payload['data']['id'] ?? '')
|
||||
);
|
||||
|
||||
$eventType = (string) (
|
||||
$provider === 'paddle'
|
||||
? ($payload['event_type'] ?? 'unknown')
|
||||
: ($payload['meta']['event_name'] ?? 'unknown')
|
||||
);
|
||||
|
||||
if ($externalID === '') {
|
||||
return response()->json(['message' => 'Webhook event has no stable identifier.'], 400);
|
||||
return response()->json([
|
||||
'message' => 'Webhook event has no stable identifier.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$payloadHash = hash('sha256', $raw);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) {
|
||||
$event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]);
|
||||
if ($event->processed_at) {
|
||||
return;
|
||||
}
|
||||
if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) {
|
||||
throw new \RuntimeException('Webhook ID was reused with a different payload.');
|
||||
}
|
||||
try {
|
||||
$provisioner->provision($provider, $payload);
|
||||
$event->update(['processed_at' => now(), 'processing_error' => null]);
|
||||
} catch (\Throwable $e) {
|
||||
$event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]);
|
||||
throw $e;
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
/*
|
||||
* Paddle can deliver several related events concurrently.
|
||||
*
|
||||
* Laravel retries this complete transaction when MySQL reports
|
||||
* a deadlock. Five attempts gives short-lived lock contention
|
||||
* enough opportunity to resolve without acknowledging an event
|
||||
* that was not actually processed.
|
||||
*/
|
||||
DB::transaction(
|
||||
function () use (
|
||||
$provider,
|
||||
$externalID,
|
||||
$eventType,
|
||||
$payload,
|
||||
$payloadHash,
|
||||
$provisioner
|
||||
): void {
|
||||
/*
|
||||
* Insert the event if this is its first delivery.
|
||||
*
|
||||
* Once present, lock the row so duplicate deliveries of
|
||||
* the same event cannot provision concurrently.
|
||||
*/
|
||||
LicenseWebhookEvent::firstOrCreate(
|
||||
[
|
||||
'provider' => $provider,
|
||||
'external_event_id' => $externalID,
|
||||
],
|
||||
[
|
||||
'event_type' => $eventType,
|
||||
'payload_sha256' => $payloadHash,
|
||||
'payload' => $payload,
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json(['message' => 'Webhook could not be processed.'], 500);
|
||||
$event = LicenseWebhookEvent::query()
|
||||
->where('provider', $provider)
|
||||
->where('external_event_id', $externalID)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if ($event->processed_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! hash_equals(
|
||||
(string) $event->payload_sha256,
|
||||
$payloadHash
|
||||
)) {
|
||||
throw new RuntimeException(
|
||||
'Webhook ID was reused with a different payload.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$provisioner->provision(
|
||||
$provider,
|
||||
$payload
|
||||
);
|
||||
|
||||
$event->processed_at = now();
|
||||
$event->processing_error = null;
|
||||
$event->save();
|
||||
} catch (Throwable $e) {
|
||||
/*
|
||||
* This assignment is useful when the surrounding
|
||||
* transaction eventually succeeds. If processing
|
||||
* throws, the transaction itself is rolled back and
|
||||
* Paddle will retry the event.
|
||||
*/
|
||||
$event->processing_error = mb_substr(
|
||||
$e->getMessage(),
|
||||
0,
|
||||
4000
|
||||
);
|
||||
|
||||
$event->save();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
},
|
||||
5
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
return response()->json([
|
||||
'message' => 'Webhook could not be processed.',
|
||||
], 500);
|
||||
}
|
||||
|
||||
// Delivery is intentionally outside the webhook transaction. A temporary mail
|
||||
// outage must not roll back an otherwise valid Paddle event. Pending outbox
|
||||
// rows remain available for portal resend or a later webhook delivery attempt.
|
||||
/*
|
||||
* Delivery deliberately happens outside the webhook transaction.
|
||||
*
|
||||
* A temporary SMTP failure must not undo a successfully processed
|
||||
* payment event. The outbox retains anything that still needs
|
||||
* delivery.
|
||||
*/
|
||||
try {
|
||||
$delivery->deliverPending(10);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Accounts;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
final class PurchasedAccountProvisioner
|
||||
{
|
||||
public function ensure(
|
||||
?string $email,
|
||||
?string $name = null,
|
||||
bool $sendSetupLink = true
|
||||
): ?User {
|
||||
$email = strtolower(trim((string) $email));
|
||||
|
||||
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Fast path for the normal case.
|
||||
*
|
||||
* Paddle can deliver several related webhook events concurrently, so
|
||||
* this lookup alone cannot guarantee that another request will not
|
||||
* create the user between this SELECT and our INSERT.
|
||||
* enure this is pushed
|
||||
*/
|
||||
$user = $this->findByEmail($email);
|
||||
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$created = false;
|
||||
|
||||
try {
|
||||
$user = User::create([
|
||||
'name' => $this->displayName($name, $email),
|
||||
'email' => $email,
|
||||
'email_verified_at' => now(),
|
||||
'password' => Str::random(64),
|
||||
'is_admin' => false,
|
||||
]);
|
||||
|
||||
$created = true;
|
||||
} catch (UniqueConstraintViolationException $exception) {
|
||||
/*
|
||||
* Another webhook may have created this account after our SELECT
|
||||
* but before our INSERT. In that case the unique email constraint
|
||||
* correctly rejects our INSERT; simply reuse the account that won
|
||||
* the race.
|
||||
*/
|
||||
$user = $this->findByEmail($email);
|
||||
|
||||
if (! $user) {
|
||||
/*
|
||||
* The unique violation was not caused by the email account
|
||||
* appearing concurrently, so preserve the original failure.
|
||||
*/
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Only the request that actually created the account should send the
|
||||
* account setup/reset link. Concurrent Paddle events must not result
|
||||
* in several setup emails being sent to the customer.
|
||||
*/
|
||||
if ($created && $sendSetupLink) {
|
||||
DB::afterCommit(static function () use ($email): void {
|
||||
Password::sendResetLink(['email' => $email]);
|
||||
});
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function findByEmail(string $email): ?User
|
||||
{
|
||||
return User::query()
|
||||
->whereRaw('LOWER(email) = ?', [$email])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function displayName(?string $name, string $email): string
|
||||
{
|
||||
$name = trim((string) $name);
|
||||
|
||||
if ($name !== '') {
|
||||
return mb_substr($name, 0, 120);
|
||||
}
|
||||
|
||||
$localPart = strstr($email, '@', true) ?: 'SonarDesk Customer';
|
||||
$localPart = trim(str_replace(['.', '_', '-'], ' ', $localPart));
|
||||
|
||||
return mb_substr(
|
||||
$localPart !== ''
|
||||
? ucwords($localPart)
|
||||
: 'SonarDesk Customer',
|
||||
0,
|
||||
120
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
@@ -19,60 +20,147 @@ final class PaddleClient
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json('data');
|
||||
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
public function updateSubscriptionPrice(string $subscriptionID, string $priceID): array
|
||||
public function subscription(string $subscriptionID): array
|
||||
{
|
||||
$this->requireWritableConfiguration();
|
||||
|
||||
$response = $this->request()->patch('/subscriptions/'.$subscriptionID, [
|
||||
'proration_billing_mode' => 'prorated_immediately',
|
||||
'items' => [[
|
||||
'price_id' => $priceID,
|
||||
'quantity' => 1,
|
||||
]],
|
||||
]);
|
||||
$response = $this->request()
|
||||
->get('/subscriptions/'.$subscriptionID);
|
||||
|
||||
return $this->subscriptionData($response, 'Paddle could not update the subscription.');
|
||||
return $this->subscriptionData(
|
||||
$response,
|
||||
'Paddle could not retrieve the subscription.'
|
||||
);
|
||||
}
|
||||
|
||||
public function cancelSubscriptionImmediately(string $subscriptionID): array
|
||||
{
|
||||
public function updateSubscriptionPrice(
|
||||
string $subscriptionID,
|
||||
string $priceID
|
||||
): array {
|
||||
$this->requireWritableConfiguration();
|
||||
|
||||
$response = $this->request()->post('/subscriptions/'.$subscriptionID.'/cancel', [
|
||||
'effective_from' => 'immediately',
|
||||
]);
|
||||
/*
|
||||
* Paddle restricts subscription item changes while a subscription
|
||||
* is trialing. Existing item quantities may only be updated using
|
||||
* do_not_bill during the trial.
|
||||
*
|
||||
* Once the subscription is outside its trial we can use normal
|
||||
* immediate proration.
|
||||
*/
|
||||
$subscription = $this->subscription($subscriptionID);
|
||||
|
||||
return $this->subscriptionData($response, 'Paddle could not cancel the subscription.');
|
||||
$status = (string) ($subscription['status'] ?? '');
|
||||
|
||||
$prorationBillingMode = $status === 'trialing'
|
||||
? 'do_not_bill'
|
||||
: 'prorated_immediately';
|
||||
|
||||
/*
|
||||
* Preserve the existing subscription item and replace its price.
|
||||
*
|
||||
* Paddle treats the submitted items array as the desired
|
||||
* subscription items, so quantity is retained where possible.
|
||||
*/
|
||||
$existingItems = is_array($subscription['items'] ?? null)
|
||||
? $subscription['items']
|
||||
: [];
|
||||
|
||||
$existingItem = $existingItems[0] ?? [];
|
||||
|
||||
$quantity = max(
|
||||
1,
|
||||
(int) ($existingItem['quantity'] ?? 1)
|
||||
);
|
||||
|
||||
$response = $this->request()->patch(
|
||||
'/subscriptions/'.$subscriptionID,
|
||||
[
|
||||
'proration_billing_mode' => $prorationBillingMode,
|
||||
'items' => [
|
||||
[
|
||||
'price_id' => $priceID,
|
||||
'quantity' => $quantity,
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
return $this->subscriptionData(
|
||||
$response,
|
||||
'Paddle could not update the subscription.'
|
||||
);
|
||||
}
|
||||
|
||||
private function request()
|
||||
public function cancelSubscriptionImmediately(
|
||||
string $subscriptionID
|
||||
): array {
|
||||
$this->requireWritableConfiguration();
|
||||
|
||||
$response = $this->request()->post(
|
||||
'/subscriptions/'.$subscriptionID.'/cancel',
|
||||
[
|
||||
'effective_from' => 'immediately',
|
||||
]
|
||||
);
|
||||
|
||||
return $this->subscriptionData(
|
||||
$response,
|
||||
'Paddle could not cancel the subscription.'
|
||||
);
|
||||
}
|
||||
|
||||
private function request(): PendingRequest
|
||||
{
|
||||
return Http::baseUrl((string) config('sonardesk.paddle.api_url'))
|
||||
->withToken((string) config('sonardesk.paddle.api_key'))
|
||||
return Http::baseUrl(
|
||||
(string) config('sonardesk.paddle.api_url')
|
||||
)
|
||||
->withToken(
|
||||
(string) config('sonardesk.paddle.api_key')
|
||||
)
|
||||
->acceptJson()
|
||||
->timeout(15);
|
||||
}
|
||||
|
||||
private function requireWritableConfiguration(): void
|
||||
{
|
||||
if (trim((string) config('sonardesk.paddle.api_key')) === '') {
|
||||
throw new RuntimeException('Paddle subscription management is not configured.');
|
||||
if (
|
||||
trim(
|
||||
(string) config('sonardesk.paddle.api_key')
|
||||
) === ''
|
||||
) {
|
||||
throw new RuntimeException(
|
||||
'Paddle subscription management is not configured.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function subscriptionData(Response $response, string $fallback): array
|
||||
{
|
||||
private function subscriptionData(
|
||||
Response $response,
|
||||
string $fallback
|
||||
): array {
|
||||
if (! $response->successful()) {
|
||||
$detail = trim((string) ($response->json('error.detail') ?? ''));
|
||||
throw new RuntimeException($detail !== '' ? $detail : $fallback);
|
||||
$detail = trim(
|
||||
(string) (
|
||||
$response->json('error.detail')
|
||||
?? ''
|
||||
)
|
||||
);
|
||||
|
||||
throw new RuntimeException(
|
||||
$detail !== ''
|
||||
? $detail
|
||||
: $fallback
|
||||
);
|
||||
}
|
||||
|
||||
$data = $response->json('data');
|
||||
|
||||
if (! is_array($data)) {
|
||||
throw new RuntimeException($fallback);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
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;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class PurchaseProvisioner
|
||||
{
|
||||
public function __construct(private LicenseIssuer $issuer, private PaddleClient $paddle) {}
|
||||
public function __construct(
|
||||
private LicenseIssuer $issuer,
|
||||
private PaddleClient $paddle,
|
||||
private PurchasedAccountProvisioner $accounts,
|
||||
) {}
|
||||
|
||||
public function provision(string $provider, array $event): void
|
||||
{
|
||||
@@ -27,16 +34,24 @@ final class PurchaseProvisioner
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Unsupported payment provider.');
|
||||
throw new RuntimeException(
|
||||
'Unsupported payment provider.'
|
||||
);
|
||||
}
|
||||
|
||||
private function provisionPaddle(array $event): void
|
||||
{
|
||||
$type = (string) ($event['event_type'] ?? '');
|
||||
$data = $event['data'] ?? [];
|
||||
|
||||
$data = is_array($event['data'] ?? null)
|
||||
? $event['data']
|
||||
: [];
|
||||
|
||||
if (str_starts_with($type, 'subscription.')) {
|
||||
$this->syncPaddleSubscription($type, $data);
|
||||
$this->syncPaddleSubscription(
|
||||
$type,
|
||||
$data
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -48,220 +63,724 @@ final class PurchaseProvisioner
|
||||
}
|
||||
|
||||
if ($type === 'transaction.refunded') {
|
||||
$transactionID = (string) ($data['id'] ?? '');
|
||||
if ($transactionID !== '') {
|
||||
License::where('payment_provider', 'paddle')
|
||||
->where('external_order_id', $transactionID)
|
||||
$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']);
|
||||
->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.');
|
||||
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.'
|
||||
);
|
||||
}
|
||||
|
||||
$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'] ?? '');
|
||||
/*
|
||||
* 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;
|
||||
|
||||
$license = License::where('payment_provider', 'paddle')
|
||||
->where('external_subscription_id', $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'] : [];
|
||||
$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
|
||||
) ?? [];
|
||||
|
||||
[$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,
|
||||
);
|
||||
$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);
|
||||
|
||||
/*
|
||||
* 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;
|
||||
$license->activation_limit =
|
||||
$product->activation_limit;
|
||||
$license->max_major_version =
|
||||
$product->max_major_version;
|
||||
}
|
||||
|
||||
$periodEnd = $data['current_billing_period']['ends_at'] ?? null;
|
||||
$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);
|
||||
$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;
|
||||
|
||||
if (in_array($status, ['active', 'trialing'], true)) {
|
||||
$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;
|
||||
// Trialing/active subscriptions remain valid through the current period.
|
||||
$license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : null;
|
||||
|
||||
$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));
|
||||
} elseif (in_array($status, ['canceled', 'paused'], true)) {
|
||||
/*
|
||||
* 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->revoke_reason =
|
||||
$status === 'paused'
|
||||
? 'Subscription paused'
|
||||
: 'Subscription canceled';
|
||||
}
|
||||
|
||||
$license->save();
|
||||
}
|
||||
|
||||
private function syncPaddleTransaction(array $data): void
|
||||
{
|
||||
$subscriptionID = $this->nullableString($data['subscription_id'] ?? null);
|
||||
$transactionID = (string) ($data['id'] ?? '');
|
||||
private function syncPaddleTransaction(
|
||||
array $data
|
||||
): void {
|
||||
$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.
|
||||
if ($subscriptionID) {
|
||||
License::where('payment_provider', 'paddle')
|
||||
->where('external_subscription_id', $subscriptionID)
|
||||
->whereNull('external_order_id')
|
||||
->update(['external_order_id' => $transactionID ?: 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;
|
||||
}
|
||||
|
||||
// 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']
|
||||
: [];
|
||||
|
||||
$this->issuer->issue(
|
||||
$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
|
||||
);
|
||||
}
|
||||
|
||||
private function provisionLemonSqueezy(array $event): void
|
||||
{
|
||||
$type = (string) ($event['meta']['event_name'] ?? '');
|
||||
$attributes = $event['data']['attributes'] ?? [];
|
||||
$order = (string) ($event['data']['id'] ?? $attributes['order_id'] ?? '');
|
||||
private function provisionLemonSqueezy(
|
||||
array $event
|
||||
): void {
|
||||
$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::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']);
|
||||
->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;
|
||||
}
|
||||
|
||||
$product = $this->mappedProduct(
|
||||
'lemon-squeezy',
|
||||
(string) ($attributes['variant_id'] ?? ''),
|
||||
(string) ($attributes['product_id'] ?? ''),
|
||||
(string) (
|
||||
$attributes['variant_id']
|
||||
?? ''
|
||||
),
|
||||
(string) (
|
||||
$attributes['product_id']
|
||||
?? ''
|
||||
),
|
||||
);
|
||||
|
||||
$this->issuer->issue(
|
||||
[$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
|
||||
);
|
||||
}
|
||||
|
||||
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::query()
|
||||
->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 because multiple billing intervals
|
||||
* can belong to the same Paddle product.
|
||||
*/
|
||||
if ($priceID !== '') {
|
||||
$product = LicenseProduct::where('payment_provider', $provider)
|
||||
->where('external_price_id', $priceID)
|
||||
$product = LicenseProduct::query()
|
||||
->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)
|
||||
$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}.");
|
||||
throw new RuntimeException(
|
||||
"No active licence product maps to {$provider} "
|
||||
."price {$priceID} / product {$productID}."
|
||||
);
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
private function firstRecurringItem(array $items): array
|
||||
{
|
||||
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
|
||||
{
|
||||
private function nullableString(
|
||||
mixed $value
|
||||
): ?string {
|
||||
if (! is_scalar($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value === '' ? null : $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.
|
||||
*/
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseProduct;
|
||||
use App\Models\User;
|
||||
use App\Services\Payments\PurchaseProvisioner;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
@@ -102,4 +103,30 @@ class PaddleSubscriptionProvisioningTest extends TestCase
|
||||
$this->assertNotNull($license->revoked_at);
|
||||
$this->assertFalse($license->is_usable);
|
||||
}
|
||||
|
||||
public function test_new_purchase_creates_a_portal_user_for_the_customer(): void
|
||||
{
|
||||
$this->product('pri_personal_monthly', 'Personal', 1);
|
||||
|
||||
app(PurchaseProvisioner::class)->provision('paddle', $this->subscription(
|
||||
'subscription.created', 'active', 'pri_personal_monthly', 'pro_personal'
|
||||
));
|
||||
|
||||
$user = User::where('email', 'mark@example.com')->firstOrFail();
|
||||
$this->assertSame('Mark', $user->name);
|
||||
$this->assertNotNull($user->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_existing_customer_account_is_reused(): void
|
||||
{
|
||||
User::factory()->create(['name' => 'Existing Mark', 'email' => 'mark@example.com']);
|
||||
$this->product('pri_personal_monthly', 'Personal', 1);
|
||||
|
||||
app(PurchaseProvisioner::class)->provision('paddle', $this->subscription(
|
||||
'subscription.created', 'active', 'pri_personal_monthly', 'pro_personal'
|
||||
));
|
||||
|
||||
$this->assertSame(1, User::where('email', 'mark@example.com')->count());
|
||||
$this->assertSame('Existing Mark', User::where('email', 'mark@example.com')->firstOrFail()->name);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user