269 lines
9.6 KiB
PHP
269 lines
9.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Checkout;
|
|
|
|
use App\Mail\PurchaseConfirmation;
|
|
use App\Models\AbandonedCheckout;
|
|
use App\Models\CheckoutSession;
|
|
use App\Models\Package;
|
|
use App\Models\PackagePurchase;
|
|
use App\Models\Tenant;
|
|
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;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Support\Str;
|
|
|
|
class CheckoutAssignmentService
|
|
{
|
|
/**
|
|
* Persist the purchase artefacts for a completed checkout session.
|
|
*
|
|
* @param array{provider_reference?: string, payload?: array} $options
|
|
*/
|
|
public function finalise(CheckoutSession $session, array $options = []): void
|
|
{
|
|
DB::transaction(function () use ($session, $options) {
|
|
$tenant = $session->tenant;
|
|
$user = $session->user;
|
|
|
|
if (! $tenant && $user) {
|
|
$tenant = $this->ensureTenant($user, $session);
|
|
}
|
|
|
|
if (! $tenant) {
|
|
Log::warning('Checkout assignment skipped: missing tenant', ['session' => $session->id]);
|
|
|
|
return;
|
|
}
|
|
|
|
$package = $session->package;
|
|
if (! $package) {
|
|
Log::warning('Checkout assignment skipped: missing package', ['session' => $session->id]);
|
|
|
|
return;
|
|
}
|
|
|
|
$metadata = $session->provider_metadata ?? [];
|
|
$consents = [
|
|
'accepted_terms_at' => optional($session->accepted_terms_at)->toIso8601String(),
|
|
'accepted_privacy_at' => optional($session->accepted_privacy_at)->toIso8601String(),
|
|
'accepted_withdrawal_notice_at' => optional($session->accepted_withdrawal_notice_at)->toIso8601String(),
|
|
'digital_content_waiver_at' => optional($session->digital_content_waiver_at)->toIso8601String(),
|
|
'legal_version' => $session->legal_version,
|
|
];
|
|
$consents = array_filter($consents);
|
|
|
|
$providerReference = $options['provider_reference']
|
|
?? $metadata['paddle_transaction_id'] ?? null
|
|
?? $metadata['paddle_checkout_id'] ?? null
|
|
?? CheckoutSession::PROVIDER_FREE;
|
|
|
|
$providerName = $options['provider']
|
|
?? $session->provider
|
|
?? ($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,
|
|
'package_id' => $package->id,
|
|
'provider_id' => $providerReference,
|
|
],
|
|
[
|
|
'provider' => $providerName,
|
|
'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 = TenantPackage::updateOrCreate(
|
|
[
|
|
'tenant_id' => $tenant->id,
|
|
'package_id' => $package->id,
|
|
],
|
|
[
|
|
'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);
|
|
}
|
|
|
|
if ($user) {
|
|
$mailLocale = $user->preferred_locale ?? app()->getLocale();
|
|
|
|
if ($purchase->wasRecentlyCreated) {
|
|
Mail::to($user)
|
|
->locale($mailLocale)
|
|
->queue(new PurchaseConfirmation($purchase));
|
|
|
|
$opsEmail = config('mail.ops_address');
|
|
if ($opsEmail) {
|
|
Notification::route('mail', $opsEmail)->notify(new PurchaseCreated($purchase));
|
|
}
|
|
}
|
|
|
|
AbandonedCheckout::query()
|
|
->where('user_id', $user->id)
|
|
->where('package_id', $package->id)
|
|
->where('converted', false)
|
|
->update([
|
|
'converted' => true,
|
|
'reminder_stage' => 'converted',
|
|
]);
|
|
}
|
|
|
|
Log::info('Checkout session assigned', [
|
|
'session' => $session->id,
|
|
'tenant' => $tenant->id,
|
|
'package' => $package->id,
|
|
'purchase' => $purchase->id,
|
|
]);
|
|
});
|
|
}
|
|
|
|
protected function ensureTenant(User $user, CheckoutSession $session): ?Tenant
|
|
{
|
|
if ($user->tenant) {
|
|
if (! $user->tenant_id) {
|
|
$user->forceFill(['tenant_id' => $user->tenant->getKey()])->save();
|
|
}
|
|
|
|
return $user->tenant;
|
|
}
|
|
|
|
$tenant = Tenant::create([
|
|
'user_id' => $user->id,
|
|
'name' => $session->package_snapshot['name'] ?? $user->name,
|
|
'slug' => Str::slug(($user->name ?: $user->email).' '.now()->timestamp),
|
|
'email' => $user->email,
|
|
'contact_email' => $user->email,
|
|
'is_active' => true,
|
|
'is_suspended' => false,
|
|
'subscription_tier' => 'free',
|
|
'subscription_status' => 'active',
|
|
'settings' => [
|
|
'contact_email' => $user->email,
|
|
],
|
|
]);
|
|
|
|
if ($user->tenant_id !== $tenant->id) {
|
|
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
|
}
|
|
|
|
event(new Registered($user));
|
|
|
|
return $tenant;
|
|
}
|
|
|
|
protected function resolveExpiry(Package $package, Tenant $tenant)
|
|
{
|
|
if ($package->type === 'reseller') {
|
|
$hasActive = TenantPackage::where('tenant_id', $tenant->id)
|
|
->where('active', true)
|
|
->exists();
|
|
|
|
return $hasActive ? now()->addYear() : now()->addDays(14);
|
|
}
|
|
|
|
return now()->addYear();
|
|
}
|
|
|
|
protected function activateUser(User $user): void
|
|
{
|
|
$user->forceFill([
|
|
'email_verified_at' => $user->email_verified_at ?? now(),
|
|
'role' => $user->role === 'user' ? 'tenant_admin' : $user->role,
|
|
'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);
|
|
}
|
|
}
|