Updated checkout to wait for backend confirmation before advancing, added a “Processing payment…” state with retry/ refresh fallback, and now use Paddle totals/currency for purchase records + confirmation emails (with new email translations).
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
namespace App\Services\Checkout;
|
||||
|
||||
use App\Mail\PurchaseConfirmation;
|
||||
use App\Mail\Welcome;
|
||||
use App\Models\AbandonedCheckout;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
@@ -13,6 +12,7 @@ use App\Models\TenantPackage;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Ops\PurchaseCreated;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
@@ -69,6 +69,10 @@ class CheckoutAssignmentService
|
||||
?? ($metadata['paddle_transaction_id'] ?? $metadata['paddle_checkout_id'] ? CheckoutSession::PROVIDER_PADDLE : null)
|
||||
?? CheckoutSession::PROVIDER_FREE;
|
||||
|
||||
$totals = $this->resolvePaddleTotals($session, $options['payload'] ?? []);
|
||||
$currency = $totals['currency'] ?? $session->currency ?? $package->currency ?? 'EUR';
|
||||
$price = array_key_exists('total', $totals) ? $totals['total'] : (float) $session->amount_total;
|
||||
|
||||
$purchase = PackagePurchase::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
@@ -77,30 +81,39 @@ class CheckoutAssignmentService
|
||||
],
|
||||
[
|
||||
'provider' => $providerName,
|
||||
'price' => $session->amount_total,
|
||||
'price' => round($price, 2),
|
||||
'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event',
|
||||
'purchased_at' => now(),
|
||||
'metadata' => array_filter([
|
||||
'payload' => $options['payload'] ?? null,
|
||||
'checkout_session_id' => $session->id,
|
||||
'consents' => $consents ?: null,
|
||||
]),
|
||||
'paddle_totals' => $totals !== [] ? $totals : null,
|
||||
'currency' => $currency,
|
||||
], static fn ($value) => $value !== null && $value !== ''),
|
||||
]
|
||||
);
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
$tenantPackage = TenantPackage::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
],
|
||||
[
|
||||
'price' => $session->amount_total,
|
||||
'price' => round($price, 2),
|
||||
'active' => true,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => $this->resolveExpiry($package, $tenant),
|
||||
]
|
||||
);
|
||||
|
||||
if ($package->type !== 'reseller') {
|
||||
$tenant->forceFill([
|
||||
'subscription_status' => 'active',
|
||||
'subscription_expires_at' => $tenantPackage->expires_at,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($user && $user->pending_purchase) {
|
||||
$this->activateUser($user);
|
||||
}
|
||||
@@ -108,10 +121,6 @@ class CheckoutAssignmentService
|
||||
if ($user) {
|
||||
$mailLocale = $user->preferred_locale ?? app()->getLocale();
|
||||
|
||||
Mail::to($user)
|
||||
->locale($mailLocale)
|
||||
->queue(new Welcome($user));
|
||||
|
||||
if ($purchase->wasRecentlyCreated) {
|
||||
Mail::to($user)
|
||||
->locale($mailLocale)
|
||||
@@ -196,4 +205,63 @@ class CheckoutAssignmentService
|
||||
'pending_purchase' => false,
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float}
|
||||
*/
|
||||
protected function resolvePaddleTotals(CheckoutSession $session, array $payload): array
|
||||
{
|
||||
$metadataTotals = $session->provider_metadata['paddle_totals'] ?? null;
|
||||
|
||||
if (is_array($metadataTotals) && $metadataTotals !== []) {
|
||||
return $metadataTotals;
|
||||
}
|
||||
|
||||
$totals = Arr::get($payload, 'details.totals', Arr::get($payload, 'totals', []));
|
||||
if (! is_array($totals) || $totals === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$currency = Arr::get($totals, 'currency_code')
|
||||
?? Arr::get($payload, 'currency_code')
|
||||
?? Arr::get($totals, 'currency')
|
||||
?? Arr::get($payload, 'currency');
|
||||
|
||||
$subtotal = $this->convertMinorAmount(Arr::get($totals, 'subtotal.amount', $totals['subtotal'] ?? null));
|
||||
$discount = $this->convertMinorAmount(Arr::get($totals, 'discount.amount', $totals['discount'] ?? null));
|
||||
$tax = $this->convertMinorAmount(Arr::get($totals, 'tax.amount', $totals['tax'] ?? null));
|
||||
$total = $this->convertMinorAmount(
|
||||
Arr::get(
|
||||
$totals,
|
||||
'total.amount',
|
||||
$totals['total'] ?? Arr::get($totals, 'grand_total.amount', $totals['grand_total'] ?? null)
|
||||
)
|
||||
);
|
||||
|
||||
return array_filter([
|
||||
'currency' => $currency ? strtoupper((string) $currency) : null,
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $discount,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
protected function convertMinorAmount(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value) && isset($value['amount'])) {
|
||||
$value = $value['amount'];
|
||||
}
|
||||
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(((float) $value) / 100, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ class CheckoutWebhookService
|
||||
return true;
|
||||
|
||||
case 'transaction.completed':
|
||||
$this->syncSessionTotals($session, $data);
|
||||
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
||||
$this->sessions->markProcessing($session, [
|
||||
'paddle_status' => $status ?: 'completed',
|
||||
@@ -146,6 +147,87 @@ class CheckoutWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
protected function syncSessionTotals(CheckoutSession $session, array $data): void
|
||||
{
|
||||
$totals = $this->normalizePaddleTotals($data);
|
||||
|
||||
if ($totals === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
|
||||
if (array_key_exists('subtotal', $totals)) {
|
||||
$updates['amount_subtotal'] = $totals['subtotal'];
|
||||
}
|
||||
|
||||
if (array_key_exists('discount', $totals)) {
|
||||
$updates['amount_discount'] = $totals['discount'];
|
||||
}
|
||||
|
||||
if (array_key_exists('total', $totals)) {
|
||||
$updates['amount_total'] = $totals['total'];
|
||||
}
|
||||
|
||||
if (! empty($totals['currency'])) {
|
||||
$updates['currency'] = $totals['currency'];
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$session->forceFill($updates)->save();
|
||||
}
|
||||
|
||||
$this->mergeProviderMetadata($session, [
|
||||
'paddle_totals' => $totals,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float}
|
||||
*/
|
||||
protected function normalizePaddleTotals(array $data): array
|
||||
{
|
||||
$totals = Arr::get($data, 'details.totals', Arr::get($data, 'totals', []));
|
||||
$currency = Arr::get($totals, 'currency_code')
|
||||
?? $data['currency_code'] ?? Arr::get($totals, 'currency') ?? Arr::get($data, 'currency');
|
||||
|
||||
$subtotal = $this->convertMinorAmount(Arr::get($totals, 'subtotal.amount', $totals['subtotal'] ?? null));
|
||||
$discount = $this->convertMinorAmount(Arr::get($totals, 'discount.amount', $totals['discount'] ?? null));
|
||||
$tax = $this->convertMinorAmount(Arr::get($totals, 'tax.amount', $totals['tax'] ?? null));
|
||||
$total = $this->convertMinorAmount(
|
||||
Arr::get(
|
||||
$totals,
|
||||
'total.amount',
|
||||
$totals['total'] ?? Arr::get($totals, 'grand_total.amount', $totals['grand_total'] ?? null)
|
||||
)
|
||||
);
|
||||
|
||||
return array_filter([
|
||||
'currency' => $currency ? strtoupper((string) $currency) : null,
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $discount,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
protected function convertMinorAmount(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value) && isset($value['amount'])) {
|
||||
$value = $value['amount'];
|
||||
}
|
||||
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(((float) $value) / 100, 2);
|
||||
}
|
||||
|
||||
protected function handlePaddleSubscriptionEvent(string $eventType, array $data): bool
|
||||
{
|
||||
$subscriptionId = $data['id'] ?? null;
|
||||
@@ -154,8 +236,8 @@ class CheckoutWebhookService
|
||||
return false;
|
||||
}
|
||||
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
$tenant = $this->resolveTenantFromSubscription($data, $metadata, $subscriptionId);
|
||||
$customData = $this->extractCustomData($data);
|
||||
$tenant = $this->resolveTenantFromSubscription($data, $customData, $subscriptionId);
|
||||
|
||||
if (! $tenant) {
|
||||
Log::info('[CheckoutWebhook] Paddle subscription tenant not resolved', [
|
||||
@@ -165,7 +247,7 @@ class CheckoutWebhookService
|
||||
return false;
|
||||
}
|
||||
|
||||
$package = $this->resolvePackageFromSubscription($data, $metadata, $subscriptionId);
|
||||
$package = $this->resolvePackageFromSubscription($data, $customData, $subscriptionId);
|
||||
|
||||
if (! $package) {
|
||||
Log::info('[CheckoutWebhook] Paddle subscription package not resolved', [
|
||||
@@ -317,7 +399,7 @@ class CheckoutWebhookService
|
||||
|
||||
protected function isGiftVoucherEvent(array $data): bool
|
||||
{
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
$metadata = $this->extractCustomData($data);
|
||||
|
||||
$type = is_array($metadata) ? ($metadata['type'] ?? $metadata['kind'] ?? $metadata['category'] ?? null) : null;
|
||||
|
||||
@@ -336,7 +418,7 @@ class CheckoutWebhookService
|
||||
|
||||
protected function locatePaddleSession(array $data): ?CheckoutSession
|
||||
{
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
$metadata = $this->extractCustomData($data);
|
||||
|
||||
if (is_array($metadata)) {
|
||||
$sessionId = $metadata['checkout_session_id'] ?? null;
|
||||
@@ -372,4 +454,27 @@ class CheckoutWebhookService
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function extractCustomData(array $data): array
|
||||
{
|
||||
$customData = [];
|
||||
|
||||
if (isset($data['custom_data']) && is_array($data['custom_data'])) {
|
||||
$customData = $data['custom_data'];
|
||||
}
|
||||
|
||||
if (isset($data['customData']) && is_array($data['customData'])) {
|
||||
$customData = array_merge($customData, $data['customData']);
|
||||
}
|
||||
|
||||
if (isset($data['metadata']) && is_array($data['metadata'])) {
|
||||
$customData = array_merge($customData, $data['metadata']);
|
||||
}
|
||||
|
||||
return $customData;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user