user(); if (! $user->account_token) { $user->forceFill(['account_token' => (string) Str::uuid()])->save(); } $base = $config['environment'] === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'; $response = Http::withToken($config['api_key'])->timeout(15)->post($base.'/transactions', ['items' => [['price_id' => $config['price_id'], 'quantity' => 1]], 'collection_mode' => 'automatic', 'custom_data' => ['account_token' => $user->account_token]]); abort_unless($response->successful(), 502, 'Payment provider unavailable.'); return response()->json(['transaction_id' => $response->json('data.id')]); } public function webhook(Request $r, LicenseService $licenses): JsonResponse { $secret = config('tarnax.paddle.webhook_secret'); abort_unless($secret, 503); $parts = []; foreach (explode(';', $r->header('Paddle-Signature', '')) as $part) { $pair = explode('=', trim($part), 2); if (count($pair) === 2) { $parts[$pair[0]][] = $pair[1]; } } $ts = $parts['ts'][0] ?? ''; abort_unless(ctype_digit($ts) && abs(time() - (int) $ts) <= 300, 401); $expected = hash_hmac('sha256', $ts.':'.$r->getContent(), $secret); $valid = false; foreach ($parts['h1'] ?? [] as $signature) { $valid = $valid || hash_equals($expected, $signature); }abort_unless($valid, 401); $event = $r->validate(['event_id' => 'required|string|max:100', 'event_type' => 'required|string|max:100', 'occurred_at' => 'required|date', 'data' => 'required|array']); DB::transaction(function () use ($event, $licenses) { $inserted = DB::table('webhook_events')->insertOrIgnore(['id' => $event['event_id'], 'type' => $event['event_type'], 'processed_at' => now()]); if (! $inserted) { return; } $d = $event['data']; $type = $event['event_type']; $at = Carbon::parse($event['occurred_at']); if ($type === 'transaction.completed') { abort_unless(($d['status'] ?? '') === 'completed' && ! empty($d['id']), 422); DB::table('payment_locks')->insertOrIgnore(['id' => $d['id']]); DB::table('payment_locks')->where('id', $d['id'])->lockForUpdate()->first(); abort_unless(config('tarnax.paddle.price_id'), 503); $matching = collect($d['items'] ?? [])->contains(fn ($i) => ($i['price']['id'] ?? null) === config('tarnax.paddle.price_id') && ($i['quantity'] ?? 0) >= 1); if (! $matching) { return; } if (DB::table('refund_blocks')->where('transaction_id', $d['id'])->exists()) { return; } $user = User::where('account_token', $d['custom_data']['account_token'] ?? '')->first(); abort_unless($user, 422, 'Unknown customer.'); $purchase = DB::table('purchases')->where('transaction_id', $d['id'])->lockForUpdate()->first(); if ($purchase && ($purchase->status === 'refunded' || Carbon::parse($purchase->provider_updated_at)->gte($at))) { return; } $id = $purchase?->id ?? DB::table('purchases')->insertGetId(['user_id' => $user->id, 'provider' => 'paddle', 'transaction_id' => $d['id'], 'subscription_id' => $d['subscription_id'] ?? null, 'status' => 'completed', 'total' => (int) ($d['details']['totals']['grand_total'] ?? 0), 'currency' => $d['currency_code'] ?? 'USD', 'provider_updated_at' => $at, 'created_at' => now(), 'updated_at' => now()]); $licenses->issue($user->id, $id); if (! empty($d['subscription_id'])) { $state = DB::table('subscription_states')->where('id', $d['subscription_id'])->first(); if ($state && ! in_array($state->status, ['active', 'trialing'])) { DB::table('licenses')->where('purchase_id', $id)->update(['status' => 'revoked']); } } $user->notify(new AccountUpdate('Your Tarnax license is ready', 'Your purchase has been confirmed. Your license and downloads are in your account.', url('/account'), 'purchase-confirmed')); } if ($type === 'adjustment.updated' || $type === 'adjustment.created') { if (($d['status'] ?? '') !== 'approved' || ! in_array($d['action'] ?? '', ['refund', 'chargeback', 'chargeback_warning'])) { return; } abort_unless(! empty($d['transaction_id']), 422); DB::table('payment_locks')->insertOrIgnore(['id' => $d['transaction_id']]); DB::table('payment_locks')->where('id', $d['transaction_id'])->lockForUpdate()->first(); DB::table('refund_blocks')->updateOrInsert(['transaction_id' => $d['transaction_id']], ['occurred_at' => $at]); $purchase = DB::table('purchases')->where('transaction_id', $d['transaction_id'])->lockForUpdate()->first(); if ($purchase) { DB::table('purchases')->where('id', $purchase->id)->update(['status' => 'refunded', 'provider_updated_at' => $at, 'updated_at' => now()]); DB::table('licenses')->where('purchase_id', $purchase->id)->update(['status' => 'revoked', 'updated_at' => now()]); } } if (str_starts_with($type, 'subscription.') && ! empty($d['id']) && in_array($d['status'] ?? '', ['active', 'trialing', 'canceled', 'paused', 'past_due'])) { DB::table('subscription_states')->insertOrIgnore(['id' => $d['id'], 'status' => $d['status'], 'occurred_at' => $at]); $state = DB::table('subscription_states')->where('id', $d['id'])->lockForUpdate()->first(); if (Carbon::parse($state->occurred_at)->gt($at)) { return; } DB::table('subscription_states')->where('id', $d['id'])->update(['status' => $d['status'], 'occurred_at' => $at]); $purchases = DB::table('purchases')->where('subscription_id', $d['id'])->where('status', '!=', 'refunded')->lockForUpdate()->get(); foreach ($purchases as $purchase) { DB::table('licenses')->where('purchase_id', $purchase->id)->update(['status' => in_array($d['status'], ['active', 'trialing']) ? 'active' : 'revoked', 'updated_at' => now()]); DB::table('purchases')->where('id', $purchase->id)->update(['status' => $d['status'], 'provider_updated_at' => $at, 'updated_at' => now()]); } } }, 3); return response()->json(['received' => true]); } }