2026-01-27 00:24:22 +00:00
|
|
|
<?php
|
|
|
|
|
|
2026-01-29 12:32:25 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-27 16:23:12 +00:00
|
|
|
namespace Core\Mod\Commerce\Controllers\Webhooks;
|
2026-01-27 00:24:22 +00:00
|
|
|
|
|
|
|
|
use Core\Front\Controller;
|
|
|
|
|
use Illuminate\Http\Request;
|
|
|
|
|
use Illuminate\Http\Response;
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
use Illuminate\Support\Facades\Log;
|
2026-01-27 16:23:12 +00:00
|
|
|
use Core\Mod\Commerce\Models\Order;
|
|
|
|
|
use Core\Mod\Commerce\Models\Payment;
|
2026-01-29 12:32:25 +00:00
|
|
|
use Core\Mod\Commerce\Models\WebhookEvent;
|
2026-01-27 16:23:12 +00:00
|
|
|
use Core\Mod\Commerce\Notifications\OrderConfirmation;
|
|
|
|
|
use Core\Mod\Commerce\Services\CommerceService;
|
|
|
|
|
use Core\Mod\Commerce\Services\PaymentGateway\BTCPayGateway;
|
|
|
|
|
use Core\Mod\Commerce\Services\WebhookLogger;
|
2026-01-27 00:24:22 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle BTCPay Server webhooks.
|
|
|
|
|
*
|
|
|
|
|
* BTCPay sends webhooks for invoice state changes:
|
|
|
|
|
* - InvoiceCreated: Invoice was created
|
|
|
|
|
* - InvoiceReceivedPayment: Payment detected (0 confirmations)
|
|
|
|
|
* - InvoiceProcessing: Payment processing (waiting for confirmations)
|
|
|
|
|
* - InvoiceExpired: Invoice expired without full payment
|
|
|
|
|
* - InvoiceSettled: Payment fully confirmed
|
|
|
|
|
* - InvoiceInvalid: Payment was invalid/rejected
|
|
|
|
|
*/
|
|
|
|
|
class BTCPayWebhookController extends Controller
|
|
|
|
|
{
|
|
|
|
|
public function __construct(
|
|
|
|
|
protected BTCPayGateway $gateway,
|
|
|
|
|
protected CommerceService $commerce,
|
|
|
|
|
protected WebhookLogger $webhookLogger,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
public function handle(Request $request): Response
|
|
|
|
|
{
|
|
|
|
|
$payload = $request->getContent();
|
|
|
|
|
$signature = $request->header('BTCPay-Sig');
|
|
|
|
|
|
|
|
|
|
// Verify webhook signature
|
|
|
|
|
if (! $this->gateway->verifyWebhookSignature($payload, $signature)) {
|
|
|
|
|
Log::warning('BTCPay webhook signature verification failed');
|
|
|
|
|
|
|
|
|
|
return response('Invalid signature', 401);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$event = $this->gateway->parseWebhookEvent($payload);
|
|
|
|
|
|
2026-01-29 12:32:25 +00:00
|
|
|
// Log the webhook event for audit trail (also handles deduplication via unique constraint)
|
|
|
|
|
$webhookEvent = $this->webhookLogger->startFromParsedEvent('btcpay', $event, $payload, $request);
|
|
|
|
|
|
|
|
|
|
// Idempotency check: if this event was already processed, return success without reprocessing
|
|
|
|
|
if ($this->isAlreadyProcessed($webhookEvent, $event)) {
|
|
|
|
|
Log::info('BTCPay webhook already processed (idempotency check)', [
|
|
|
|
|
'type' => $event['type'],
|
|
|
|
|
'id' => $event['id'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return response('Already processed (duplicate)', 200);
|
|
|
|
|
}
|
2026-01-27 00:24:22 +00:00
|
|
|
|
|
|
|
|
Log::info('BTCPay webhook received', [
|
|
|
|
|
'type' => $event['type'],
|
|
|
|
|
'id' => $event['id'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Wrap all webhook processing in a transaction to ensure data integrity
|
|
|
|
|
$response = DB::transaction(function () use ($event) {
|
|
|
|
|
return match ($event['type']) {
|
|
|
|
|
'invoice.created' => $this->handleInvoiceCreated($event),
|
|
|
|
|
'invoice.payment_received' => $this->handlePaymentReceived($event),
|
|
|
|
|
'invoice.processing' => $this->handleProcessing($event),
|
|
|
|
|
'invoice.paid', 'payment.settled' => $this->handleSettled($event),
|
|
|
|
|
'invoice.expired' => $this->handleExpired($event),
|
|
|
|
|
'invoice.failed' => $this->handleFailed($event),
|
|
|
|
|
default => $this->handleUnknownEvent($event),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->webhookLogger->success($response);
|
|
|
|
|
|
|
|
|
|
return $response;
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
Log::error('BTCPay webhook processing error', [
|
|
|
|
|
'type' => $event['type'],
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->webhookLogger->fail($e->getMessage(), 500);
|
|
|
|
|
|
|
|
|
|
return response('Processing error', 500);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:32:25 +00:00
|
|
|
/**
|
|
|
|
|
* Check if the webhook event has already been processed.
|
|
|
|
|
*
|
|
|
|
|
* This provides idempotency protection against replay attacks and
|
|
|
|
|
* duplicate webhook deliveries from the payment gateway.
|
|
|
|
|
*/
|
|
|
|
|
protected function isAlreadyProcessed(WebhookEvent $webhookEvent, array $event): bool
|
|
|
|
|
{
|
|
|
|
|
// If no event ID, we can't deduplicate
|
|
|
|
|
if (empty($event['id'])) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If the webhook event we just created has a different ID than the one
|
|
|
|
|
// that already existed in the database, it means this is a duplicate
|
|
|
|
|
$existingEvent = WebhookEvent::where('gateway', 'btcpay')
|
|
|
|
|
->where('event_id', $event['id'])
|
|
|
|
|
->where('id', '!=', $webhookEvent->id)
|
|
|
|
|
->whereIn('status', [WebhookEvent::STATUS_PROCESSED, WebhookEvent::STATUS_SKIPPED])
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
if ($existingEvent) {
|
|
|
|
|
$this->webhookLogger->skip('Duplicate event (already processed)');
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Also check if the current event was already processed (fetched from DB due to duplicate insert)
|
|
|
|
|
if ($webhookEvent->isProcessed() || $webhookEvent->isSkipped()) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 00:24:22 +00:00
|
|
|
protected function handleUnknownEvent(array $event): Response
|
|
|
|
|
{
|
|
|
|
|
$this->webhookLogger->skip('Unhandled event type: '.$event['type']);
|
|
|
|
|
|
|
|
|
|
return response('Unhandled event type', 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function handleInvoiceCreated(array $event): Response
|
|
|
|
|
{
|
|
|
|
|
// Invoice created - no action needed
|
|
|
|
|
return response('OK', 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function handlePaymentReceived(array $event): Response
|
|
|
|
|
{
|
|
|
|
|
// Payment detected but not confirmed
|
|
|
|
|
$order = $this->findOrderByInvoiceId($event['id']);
|
|
|
|
|
|
|
|
|
|
if ($order) {
|
|
|
|
|
// Update order status to show payment is incoming
|
|
|
|
|
$order->update(['status' => 'processing']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response('OK', 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function handleProcessing(array $event): Response
|
|
|
|
|
{
|
|
|
|
|
// Payment is being processed (waiting for confirmations)
|
|
|
|
|
$order = $this->findOrderByInvoiceId($event['id']);
|
|
|
|
|
|
|
|
|
|
if ($order) {
|
|
|
|
|
$order->update(['status' => 'processing']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response('OK', 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function handleSettled(array $event): Response
|
|
|
|
|
{
|
|
|
|
|
// Payment fully confirmed - fulfil the order
|
|
|
|
|
$order = $this->findOrderByInvoiceId($event['id']);
|
|
|
|
|
|
|
|
|
|
if (! $order) {
|
|
|
|
|
Log::warning('BTCPay webhook: Order not found', ['invoice_id' => $event['id']]);
|
|
|
|
|
|
|
|
|
|
return response('Order not found', 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Link webhook event to order for audit trail
|
|
|
|
|
$this->webhookLogger->linkOrder($order);
|
|
|
|
|
|
|
|
|
|
// Skip if already paid
|
|
|
|
|
if ($order->isPaid()) {
|
|
|
|
|
return response('Already processed', 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get invoice details from BTCPay
|
|
|
|
|
$invoiceData = $this->gateway->getCheckoutSession($event['id']);
|
|
|
|
|
|
2026-01-29 12:32:25 +00:00
|
|
|
// SECURITY: Verify the paid amount matches the order total
|
|
|
|
|
$amountVerification = $this->verifyPaymentAmount($order, $invoiceData);
|
|
|
|
|
if (! $amountVerification['valid']) {
|
|
|
|
|
Log::warning('BTCPay webhook: Payment amount verification failed', [
|
|
|
|
|
'order_id' => $order->id,
|
|
|
|
|
'order_total' => $order->total,
|
|
|
|
|
'paid_amount' => $amountVerification['paid_amount'],
|
|
|
|
|
'currency' => $order->currency,
|
|
|
|
|
'discrepancy' => $amountVerification['discrepancy'],
|
|
|
|
|
'reason' => $amountVerification['reason'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Handle underpayment - mark order as failed with details
|
|
|
|
|
if ($amountVerification['reason'] === 'underpaid') {
|
|
|
|
|
$order->markAsFailed(sprintf(
|
|
|
|
|
'Underpaid: received %s %s, expected %s %s',
|
|
|
|
|
$amountVerification['paid_amount'],
|
|
|
|
|
$order->currency,
|
|
|
|
|
$order->total,
|
|
|
|
|
$order->currency
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
// Create a partial payment record for audit trail
|
|
|
|
|
Payment::create([
|
|
|
|
|
'workspace_id' => $order->workspace_id,
|
|
|
|
|
'order_id' => $order->id,
|
|
|
|
|
'invoice_id' => null,
|
|
|
|
|
'gateway' => 'btcpay',
|
|
|
|
|
'gateway_payment_id' => $event['id'],
|
|
|
|
|
'amount' => $amountVerification['paid_amount'],
|
|
|
|
|
'currency' => $order->currency,
|
|
|
|
|
'status' => 'underpaid',
|
|
|
|
|
'paid_at' => now(),
|
|
|
|
|
'gateway_response' => $invoiceData['raw'] ?? [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return response('Underpaid - order not fulfilled', 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Currency mismatch - this is a serious issue
|
|
|
|
|
if ($amountVerification['reason'] === 'currency_mismatch') {
|
|
|
|
|
$order->markAsFailed(sprintf(
|
|
|
|
|
'Currency mismatch: received %s, expected %s',
|
|
|
|
|
$amountVerification['received_currency'],
|
|
|
|
|
$order->currency
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
return response('Currency mismatch - order not fulfilled', 200);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create payment record with the verified amount
|
2026-01-27 00:24:22 +00:00
|
|
|
$payment = Payment::create([
|
|
|
|
|
'workspace_id' => $order->workspace_id,
|
|
|
|
|
'order_id' => $order->id,
|
|
|
|
|
'invoice_id' => null, // Will be set by fulfillOrder
|
|
|
|
|
'gateway' => 'btcpay',
|
|
|
|
|
'gateway_payment_id' => $event['id'],
|
2026-01-29 12:32:25 +00:00
|
|
|
'amount' => $amountVerification['paid_amount'],
|
2026-01-27 00:24:22 +00:00
|
|
|
'currency' => $order->currency,
|
|
|
|
|
'status' => 'succeeded',
|
|
|
|
|
'paid_at' => now(),
|
|
|
|
|
'gateway_response' => $invoiceData['raw'] ?? [],
|
|
|
|
|
]);
|
|
|
|
|
|
2026-01-29 12:32:25 +00:00
|
|
|
// Log overpayment for manual review but still fulfil
|
|
|
|
|
if ($amountVerification['reason'] === 'overpaid') {
|
|
|
|
|
Log::info('BTCPay webhook: Overpayment received', [
|
|
|
|
|
'order_id' => $order->id,
|
|
|
|
|
'order_total' => $order->total,
|
|
|
|
|
'paid_amount' => $amountVerification['paid_amount'],
|
|
|
|
|
'overpayment' => $amountVerification['discrepancy'],
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 00:24:22 +00:00
|
|
|
// Fulfil the order (provisions entitlements, creates invoice)
|
|
|
|
|
$this->commerce->fulfillOrder($order, $payment);
|
|
|
|
|
|
|
|
|
|
// Send confirmation email
|
|
|
|
|
$this->sendOrderConfirmation($order);
|
|
|
|
|
|
|
|
|
|
Log::info('BTCPay order fulfilled', [
|
|
|
|
|
'order_id' => $order->id,
|
|
|
|
|
'order_number' => $order->order_number,
|
|
|
|
|
'payment_id' => $payment->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return response('OK', 200);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:32:25 +00:00
|
|
|
/**
|
|
|
|
|
* Verify that the payment amount from BTCPay matches the order total.
|
|
|
|
|
*
|
|
|
|
|
* This is a critical security check to prevent attacks where an attacker
|
|
|
|
|
* pays less than the required amount but the order is still fulfilled.
|
|
|
|
|
*
|
|
|
|
|
* @return array{valid: bool, paid_amount: float, discrepancy: float, reason: string|null, received_currency: string|null}
|
|
|
|
|
*/
|
|
|
|
|
protected function verifyPaymentAmount(Order $order, array $invoiceData): array
|
|
|
|
|
{
|
|
|
|
|
$orderTotal = (float) $order->total;
|
|
|
|
|
$orderCurrency = strtoupper($order->currency);
|
|
|
|
|
|
|
|
|
|
// Extract paid amount from BTCPay response
|
|
|
|
|
// BTCPay uses 'amount' field for the invoice amount
|
|
|
|
|
$paidAmount = isset($invoiceData['amount']) ? (float) $invoiceData['amount'] : 0.0;
|
|
|
|
|
$paidCurrency = isset($invoiceData['currency']) ? strtoupper($invoiceData['currency']) : null;
|
|
|
|
|
|
|
|
|
|
// Also check raw response for additional payment data
|
|
|
|
|
$rawData = $invoiceData['raw'] ?? [];
|
|
|
|
|
if (isset($rawData['amount'])) {
|
|
|
|
|
$paidAmount = (float) $rawData['amount'];
|
|
|
|
|
}
|
|
|
|
|
if (isset($rawData['currency'])) {
|
|
|
|
|
$paidCurrency = strtoupper($rawData['currency']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check currency matches
|
|
|
|
|
if ($paidCurrency && $paidCurrency !== $orderCurrency) {
|
|
|
|
|
return [
|
|
|
|
|
'valid' => false,
|
|
|
|
|
'paid_amount' => $paidAmount,
|
|
|
|
|
'discrepancy' => 0,
|
|
|
|
|
'reason' => 'currency_mismatch',
|
|
|
|
|
'received_currency' => $paidCurrency,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate discrepancy
|
|
|
|
|
$discrepancy = $paidAmount - $orderTotal;
|
|
|
|
|
|
|
|
|
|
// Allow a small tolerance for floating point precision (0.01 currency units)
|
|
|
|
|
$tolerance = 0.01;
|
|
|
|
|
|
|
|
|
|
if ($paidAmount < ($orderTotal - $tolerance)) {
|
|
|
|
|
return [
|
|
|
|
|
'valid' => false,
|
|
|
|
|
'paid_amount' => $paidAmount,
|
|
|
|
|
'discrepancy' => $discrepancy,
|
|
|
|
|
'reason' => 'underpaid',
|
|
|
|
|
'received_currency' => $paidCurrency,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($paidAmount > ($orderTotal + $tolerance)) {
|
|
|
|
|
// Overpayment is valid but logged for manual review
|
|
|
|
|
return [
|
|
|
|
|
'valid' => true,
|
|
|
|
|
'paid_amount' => $paidAmount,
|
|
|
|
|
'discrepancy' => $discrepancy,
|
|
|
|
|
'reason' => 'overpaid',
|
|
|
|
|
'received_currency' => $paidCurrency,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'valid' => true,
|
|
|
|
|
'paid_amount' => $paidAmount,
|
|
|
|
|
'discrepancy' => 0,
|
|
|
|
|
'reason' => null,
|
|
|
|
|
'received_currency' => $paidCurrency,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 00:24:22 +00:00
|
|
|
protected function handleExpired(array $event): Response
|
|
|
|
|
{
|
|
|
|
|
// Invoice expired - mark order as failed
|
|
|
|
|
$order = $this->findOrderByInvoiceId($event['id']);
|
|
|
|
|
|
|
|
|
|
if ($order && ! $order->isPaid()) {
|
|
|
|
|
$order->markAsFailed('Payment expired');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response('OK', 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function handleFailed(array $event): Response
|
|
|
|
|
{
|
|
|
|
|
// Payment invalid/rejected
|
|
|
|
|
$order = $this->findOrderByInvoiceId($event['id']);
|
|
|
|
|
|
|
|
|
|
if ($order && ! $order->isPaid()) {
|
|
|
|
|
$order->markAsFailed('Payment rejected');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response('OK', 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function findOrderByInvoiceId(string $invoiceId): ?Order
|
|
|
|
|
{
|
|
|
|
|
return Order::where('gateway', 'btcpay')
|
|
|
|
|
->where('gateway_session_id', $invoiceId)
|
|
|
|
|
->first();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function sendOrderConfirmation(Order $order): void
|
|
|
|
|
{
|
|
|
|
|
if (! config('commerce.notifications.order_confirmation', true)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use resolved workspace to handle both Workspace and User orderables
|
|
|
|
|
$workspace = $order->getResolvedWorkspace();
|
|
|
|
|
$owner = $workspace?->owner();
|
|
|
|
|
if ($owner) {
|
|
|
|
|
$owner->notify(new OrderConfirmation($order));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|