security: add webhook idempotency and payment amount verification

Idempotency (replay attack protection):
- Add WebhookEvent model for tracking processed events
- Add webhook_events migration with unique constraint
- Add isAlreadyProcessed() to BTCPay and Stripe controllers
- Reject duplicate events with 200 response

Payment amount verification (BTCPay):
- Add verifyPaymentAmount() method
- Reject underpayments (mark order failed, create audit record)
- Reject currency mismatches
- Log overpayments for manual review
- Add 0.01 tolerance for floating point precision

Add comprehensive tests for both features.
Update TODO.md to mark P1 issues as fixed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 12:32:25 +00:00
parent 9113cede8a
commit c19e467735
8 changed files with 2039 additions and 6 deletions

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Controllers\Webhooks;
use Core\Front\Controller;
@ -9,6 +11,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\Payment;
use Core\Mod\Commerce\Models\WebhookEvent;
use Core\Mod\Commerce\Notifications\OrderConfirmation;
use Core\Mod\Commerce\Services\CommerceService;
use Core\Mod\Commerce\Services\PaymentGateway\BTCPayGateway;
@ -47,8 +50,18 @@ class BTCPayWebhookController extends Controller
$event = $this->gateway->parseWebhookEvent($payload);
// Log the webhook event for audit trail
$this->webhookLogger->startFromParsedEvent('btcpay', $event, $payload, $request);
// 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);
}
Log::info('BTCPay webhook received', [
'type' => $event['type'],
@ -84,6 +97,41 @@ class BTCPayWebhookController extends Controller
}
}
/**
* 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;
}
protected function handleUnknownEvent(array $event): Response
{
$this->webhookLogger->skip('Unhandled event type: '.$event['type']);
@ -144,20 +192,81 @@ class BTCPayWebhookController extends Controller
// Get invoice details from BTCPay
$invoiceData = $this->gateway->getCheckoutSession($event['id']);
// Create payment record
// 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
$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'],
'amount' => $order->total,
'amount' => $amountVerification['paid_amount'],
'currency' => $order->currency,
'status' => 'succeeded',
'paid_at' => now(),
'gateway_response' => $invoiceData['raw'] ?? [],
]);
// 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'],
]);
}
// Fulfil the order (provisions entitlements, creates invoice)
$this->commerce->fulfillOrder($order, $payment);
@ -173,6 +282,80 @@ class BTCPayWebhookController extends Controller
return response('OK', 200);
}
/**
* 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,
];
}
protected function handleExpired(array $event): Response
{
// Invoice expired - mark order as failed

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Controllers\Webhooks;
use Carbon\Carbon;
@ -14,6 +16,7 @@ use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\Payment;
use Core\Mod\Commerce\Models\PaymentMethod;
use Core\Mod\Commerce\Models\Subscription;
use Core\Mod\Commerce\Models\WebhookEvent;
use Core\Mod\Commerce\Notifications\OrderConfirmation;
use Core\Mod\Commerce\Notifications\PaymentFailed;
use Core\Mod\Commerce\Notifications\SubscriptionCancelled;
@ -57,8 +60,18 @@ class StripeWebhookController extends Controller
$event = $this->gateway->parseWebhookEvent($payload);
// Log the webhook event for audit trail
$this->webhookLogger->startFromParsedEvent('stripe', $event, $payload, $request);
// Log the webhook event for audit trail (also handles deduplication via unique constraint)
$webhookEvent = $this->webhookLogger->startFromParsedEvent('stripe', $event, $payload, $request);
// Idempotency check: if this event was already processed, return success without reprocessing
if ($this->isAlreadyProcessed($webhookEvent, $event)) {
Log::info('Stripe webhook already processed (idempotency check)', [
'type' => $event['type'],
'id' => $event['id'],
]);
return response('Already processed (duplicate)', 200);
}
Log::info('Stripe webhook received', [
'type' => $event['type'],
@ -98,6 +111,41 @@ class StripeWebhookController extends Controller
}
}
/**
* Check if the webhook event has already been processed.
*
* This provides idempotency protection against replay attacks and
* duplicate webhook deliveries from Stripe.
*/
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', 'stripe')
->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;
}
protected function handleUnknownEvent(array $event): Response
{
$this->webhookLogger->skip('Unhandled event type: '.$event['type']);

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Webhook Events table for tracking incoming payment gateway webhooks.
*
* This provides:
* - Audit trail of all webhook events
* - Idempotency via unique constraint on (gateway, event_id)
* - Replay attack protection
*/
public function up(): void
{
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
// Gateway identification
$table->string('gateway', 32); // stripe, btcpay, etc.
$table->string('event_id', 255)->nullable(); // Gateway's unique event ID
$table->string('event_type', 64); // checkout.session.completed, invoice.paid, etc.
// Payload storage
$table->mediumText('payload'); // Raw webhook payload
$table->json('headers')->nullable(); // Relevant headers (sanitised)
// Processing status
$table->string('status', 32)->default('pending'); // pending, processed, failed, skipped
$table->text('error_message')->nullable();
$table->unsignedSmallInteger('http_status_code')->nullable();
// Related entities (for linking/audit)
$table->foreignId('order_id')->nullable()->constrained('orders')->nullOnDelete();
$table->foreignId('subscription_id')->nullable()->constrained('subscriptions')->nullOnDelete();
// Timestamps
$table->timestamp('received_at');
$table->timestamp('processed_at')->nullable();
$table->timestamps();
// Idempotency: prevent duplicate processing of same event
// The unique constraint ensures only one record per gateway+event_id
$table->unique(['gateway', 'event_id'], 'webhook_events_idempotency');
// Query indexes
$table->index(['gateway', 'status', 'received_at']);
$table->index(['gateway', 'event_type']);
$table->index(['order_id']);
$table->index(['subscription_id']);
$table->index(['status', 'received_at']);
});
}
public function down(): void
{
Schema::dropIfExists('webhook_events');
}
};

211
TODO.md Normal file
View file

@ -0,0 +1,211 @@
# TODO.md - core-commerce
Production-quality task list for the commerce module.
---
## P1 - Critical / Security
### Webhook Security
- [x] **Add idempotency handling for BTCPay webhooks** - ~~Currently `BTCPayWebhookController::handleSettled()` checks `$order->isPaid()` but doesn't record processed webhook IDs. A replay attack could trigger duplicate processing if timing is right.~~ **FIXED:** Added `isAlreadyProcessed()` method in both `BTCPayWebhookController` and `StripeWebhookController`. Webhook events are now stored in `webhook_events` table with unique constraint on `(gateway, event_id)`. Duplicate events are rejected early with "Already processed (duplicate)" response. Migration: `2026_01_29_000001_create_webhook_events_table.php`.
- [ ] **Add rate limiting per IP for webhook endpoints** - Current throttle (120/min) is global. A malicious actor could exhaust the limit for legitimate webhooks. Add per-IP limiting with higher limits for known gateway IPs.
- [ ] **Validate BTCPay webhook payload structure** - `parseWebhookEvent()` assumes JSON structure without schema validation. Malformed payloads could cause unexpected behaviour. Add JSON schema validation or strict key checking.
- [x] **Add webhook replay protection window** - ~~Neither gateway stores processed webhook event IDs with timestamp-based expiry.~~ **FIXED:** Webhook events are now stored permanently in `webhook_events` table with `processed_at` timestamp. Both controllers check for existing processed events before reprocessing. The unique constraint prevents race conditions at the database level.
### Payment Security
- [x] **Add amount verification for BTCPay settlements** - ~~`BTCPayWebhookController::handleSettled()` trusts the order's `total` without verifying against BTCPay's settled amount.~~ **FIXED:** Added `verifyPaymentAmount()` method that checks: 1) Currency matches order currency 2) Paid amount >= order total. Underpayments are rejected and order marked as failed with detailed reason. Overpayments are logged but fulfilled. Payment records include actual paid amount for audit trail.
- [x] **Add currency mismatch detection** - ~~If gateway returns different currency than order, this could result in incorrect fulfillment.~~ **FIXED:** The `verifyPaymentAmount()` method now validates currency matches. Orders with currency mismatch are marked as failed with "Currency mismatch: received X, expected Y" message.
- [ ] **Rate limit checkout session creation** - `CheckoutRateLimiter` exists but isn't applied in `CommerceService::createCheckout()`. Card testing attacks could abuse this endpoint.
- [ ] **Add fraud scoring integration** - No fraud detection for suspicious patterns (multiple failed payments, velocity checks, geo-anomalies). Consider Stripe Radar integration for Stripe gateway.
### Input Validation
- [ ] **Sanitise user-provided coupon codes** - `CouponService::validateByCode()` uses raw input. Add length limits, character validation, and normalisation (uppercase) before DB query.
- [ ] **Validate billing address components** - `Order::create()` accepts `billing_address` array without validating structure. Malformed addresses could cause PDF generation issues or tax calculation failures.
- [ ] **Add CSRF protection to API billing endpoints** - Routes in `api.php` use `auth` middleware but not `verified` or CSRF tokens for state-changing operations.
---
## P2 - High Priority
### Data Integrity
- [ ] **Add database transactions to ReferralService::requestPayout()** - Currently uses transaction but doesn't lock commission rows, allowing potential race conditions if user submits multiple payout requests simultaneously.
- [ ] **Add optimistic locking to Subscription model** - Concurrent subscription updates (pause/cancel/renew) could result in inconsistent state. Add `version` column and check.
- [ ] **Handle partial payments in BTCPay** - BTCPay can receive partial payments but current flow only handles full settlement. Add `InvoicePartiallyPaid` webhook handling with admin notification.
### Missing Core Features
- [ ] **Implement provisioning API endpoints** - Routes commented out in `api.php`. Required for external integrations (WHMCS, custom portals). Create `ProductApiController` and `EntitlementApiController`.
- [ ] **Add subscription upgrade/downgrade via API** - `CommerceController::executeUpgrade()` referenced in routes but implementation needs review for proration handling.
- [ ] **Add payment method management UI tests** - `PaymentMethods` Livewire component exists but no feature tests for add/remove/set-default flows.
- [ ] **Implement credit note application to future invoices** - `CreditNote` model has `applied_to_order_id` but no service method to auto-apply credits to new orders.
### Error Handling
- [ ] **Add retry mechanism for failed invoice PDF generation** - `InvoiceService` doesn't handle DomPDF failures gracefully. Add queue job with retries.
- [ ] **Improve error messages for checkout failures** - Gateway errors are caught but user-facing messages are generic. Map common errors to helpful messages.
- [ ] **Add alerting for repeated payment failures** - DunningService logs failures but doesn't alert ops team. Add Slack/email notification after N failures.
### Testing Gaps
- [ ] **Add integration tests for Stripe webhook handlers** - `WebhookTest.php` exists but focuses on BTCPay. Add coverage for `StripeWebhookController` event handlers.
- [ ] **Add tests for concurrent subscription operations** - No tests for race conditions in pause/unpause/cancel/renew flows.
- [ ] **Add tests for multi-currency order flow** - `CurrencyServiceTest` tests conversion but not full checkout with display currency different from base.
- [ ] **Add tests for referral commission maturation edge cases** - What happens if order is refunded during maturation period?
---
## P3 - Medium Priority
### Performance
- [ ] **Add index on `orders.idempotency_key`** - Used in `CommerceService::createOrder()` lookup but not indexed. Add unique index.
- [ ] **Add index on `invoices.workspace_id, status`** - `DunningService` queries by workspace and status frequently.
- [ ] **Optimise subscription expiry query** - `SubscriptionService::processExpired()` loads all matching subscriptions. Use chunking for large datasets.
- [ ] **Cache exchange rates in-memory** - `ExchangeRate::convert()` hits DB on every call. Add short-lived cache.
- [ ] **Add eager loading to order/invoice queries** - Several Livewire components load orders without eager loading items/payments, causing N+1.
### Code Quality
- [ ] **Extract TaxResult to Data/ directory** - Currently embedded in `TaxService.php`. Move to `Data/TaxResult.php` for consistency with other DTOs.
- [ ] **Add return types to gateway contract methods** - `PaymentGatewayContract::refund()` returns array but should have a `RefundResult` DTO.
- [ ] **Consolidate order status transitions** - Status changes scattered across models and services. Create `OrderStateMachine` class.
- [ ] **Remove duplicate customer creation logic** - Both `CommerceService::ensureCustomer()` and gateway methods create customers. Consolidate.
- [ ] **Standardise money handling** - Mix of `float`, `decimal:2` casts, and `int` cents. Consider using `brick/money` package.
### DX Improvements
- [ ] **Add commerce:health Artisan command** - Check gateway connectivity, webhook configuration, pending dunning items.
- [ ] **Add commerce:simulate-webhook command** - For local testing of webhook handlers without real payments.
- [ ] **Document SKU format and lineage system** - Complex M1/M2/M3 hierarchy lacks examples. Add to CLAUDE.md or docs/.
- [ ] **Add typed properties to Livewire components** - Several use `public $variable` without types, causing IDE warnings.
### Observability
- [ ] **Add metrics for payment success/failure rates** - No Prometheus/StatsD integration for monitoring conversion rates.
- [ ] **Add structured logging to webhook handlers** - Current logs use ad-hoc format. Standardise with correlation IDs.
- [ ] **Add tracing spans for checkout flow** - No distributed tracing for debugging slow checkouts.
---
## P4 - Low Priority
### UI/UX
- [ ] **Add loading states to checkout Livewire components** - Button clicks don't show loading indicator during gateway calls.
- [ ] **Add subscription change confirmation modal** - `ChangePlan` component immediately processes; should confirm proration amount first.
- [ ] **Improve invoice PDF design** - Current template is basic. Add company branding, better line item formatting.
- [ ] **Add currency selector persistence** - `CurrencySelector` component sets session but doesn't persist to user preferences.
### Features (Nice to Have)
- [ ] **Add subscription pause scheduling** - Currently pause is immediate. Allow "pause starting next billing period".
- [ ] **Add invoice PDF caching** - Regenerates PDF on every download. Cache generated PDFs on disk.
- [ ] **Add webhook event viewer in admin** - `WebhookEvent` model exists but no admin UI to browse/retry events.
- [ ] **Add referral analytics dashboard** - Basic stats exist but no charts/trends visualization.
- [ ] **Support tax-inclusive pricing** - Config supports it but implementation assumes tax-exclusive.
### Technical Debt
- [ ] **Rename View/Modal/ to View/Livewire/** - Current naming is confusing (not all are modals).
- [ ] **Move factories to database/factories/** - Reference to `Database\Factories\OrderFactory` but factories may be missing.
- [ ] **Add strict types to all files** - Some files missing `declare(strict_types=1)`.
- [ ] **Update Carbon usage for v3 compatibility** - Some `diffInDays` calls may behave differently in Carbon 3.
---
## P5 - Nice to Have / Future
### Integrations
- [ ] **Add PayPal gateway** - For regions where BTCPay/Stripe aren't preferred.
- [ ] **Add accounting software export** - Xero/QuickBooks invoice sync.
- [ ] **Add email receipt provider integration** - Currently uses Laravel mail; consider dedicated receipt service.
### Advanced Features
- [ ] **Add subscription gifting** - Allow users to gift subscriptions to others.
- [ ] **Add group/team billing** - Multiple workspaces under one billing account.
- [ ] **Add usage alerts with thresholds** - Config has `usage_threshold_alerts` but no notification implementation.
- [ ] **Add dynamic pricing rules** - Volume discounts, time-based pricing changes.
---
## P6+ - Backlog / Ideas
- [ ] **Consider Paddle as alternative to Stripe** - For simplified EU VAT handling.
- [ ] **Research revenue recognition requirements** - ASC 606 compliance for enterprise customers.
- [ ] **Evaluate tax automation providers** - TaxJar, Avalara for complex tax scenarios.
- [ ] **Multi-entity billing consolidation** - Single invoice for M1 covering all M2/M3 transactions.
---
## Completed
### 2026-01-29 - Webhook Security Fixes
- **Add idempotency handling for BTCPay/Stripe webhooks** - Added `isAlreadyProcessed()` check to both webhook controllers. Created `webhook_events` table with unique constraint on `(gateway, event_id)` for deduplication.
- **Add webhook replay protection window** - Webhook events stored permanently with status tracking. Processed/skipped events are rejected on subsequent attempts.
- **Add amount verification for BTCPay settlements** - New `verifyPaymentAmount()` method validates paid amount against order total. Underpayments rejected, overpayments logged.
- **Add currency mismatch detection** - Currency validation added to payment verification. Mismatched currencies result in order failure.
---
## Notes
- Priority levels: P1 (critical/security) through P6+ (backlog)
- Each item should be an isolated unit of work
- Security items should be addressed before public launch
- Tests should accompany all feature changes

402
docs/architecture.md Normal file
View file

@ -0,0 +1,402 @@
---
title: Architecture
description: Technical architecture of the core-commerce package
updated: 2026-01-29
---
# Commerce Architecture
This document describes the technical architecture of the `core-commerce` package, which provides billing, subscriptions, and payment processing for the Host UK platform.
## Overview
The commerce module implements a multi-gateway payment system supporting cryptocurrency (BTCPay) and traditional card payments (Stripe). It handles the complete commerce lifecycle from checkout to recurring billing, dunning, and refunds.
```
┌─────────────────────────────────────────────────────────────────┐
│ Commerce Module │
├─────────────────────────────────────────────────────────────────┤
│ Services Layer │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Commerce │ │ Subscription │ │ Dunning │ │
│ │ Service │ │ Service │ │ Service │ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Invoice │ │ Coupon │ │ Tax │ │
│ │ Service │ │ Service │ │ Service │ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Gateway Layer │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ BTCPayGateway │ │ StripeGateway │ │
│ │ (Primary) │ │ (Secondary) │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │ │ │
│ └────────────┬─────────────┘ │
│ │ │
│ ┌────────────▼─────────────┐ │
│ │ PaymentGatewayContract │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Core Concepts
### Orderable Interface
The commerce system uses polymorphic relationships via the `Orderable` contract. Both `Workspace` and `User` models can place orders, enabling:
- **Workspace orders**: Subscription packages, team features
- **User orders**: Individual boosts, one-time purchases
```php
interface Orderable
{
public function getBillingName(): string;
public function getBillingEmail(): string;
public function getBillingAddress(): array;
public function getTaxCountry(): ?string;
}
```
### Order Lifecycle
```
┌──────────┐ ┌────────────┐ ┌──────────┐ ┌────────┐
│ pending │───▶│ processing │───▶│ paid │───▶│refunded│
└──────────┘ └────────────┘ └──────────┘ └────────┘
│ │
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│cancelled │ │ failed │
└──────────┘ └──────────┘
```
1. **pending**: Order created, awaiting checkout
2. **processing**: Customer redirected to payment gateway
3. **paid**: Payment confirmed, entitlements provisioned
4. **failed**: Payment declined or expired
5. **cancelled**: Customer abandoned checkout
6. **refunded**: Full refund processed
### Subscription States
```
┌────────┐ ┌──────────┐ ┌────────┐ ┌───────────┐
│ active │───▶│ past_due │───▶│ paused │───▶│ cancelled │
└────────┘ └──────────┘ └────────┘ └───────────┘
│ │ │
▼ │ │
┌──────────┐ │ │
│ trialing │────────┘ │
└──────────┘ │
│ │
└─────────────────────────────┘
```
- **active**: Subscription in good standing
- **trialing**: Within trial period (no payment required)
- **past_due**: Payment failed, within retry window
- **paused**: Billing paused (dunning or user-initiated)
- **cancelled**: Subscription ended
## Service Layer
### CommerceService
Main orchestration service. Coordinates order creation, checkout, and fulfillment.
```php
// Create an order
$order = $commerce->createOrder($workspace, $package, 'monthly', $coupon);
// Create checkout session (redirects to gateway)
$checkout = $commerce->createCheckout($order, 'btcpay', $successUrl, $cancelUrl);
// Fulfill order after payment (called by webhook)
$commerce->fulfillOrder($order, $payment);
```
Key responsibilities:
- Gateway selection and initialization
- Customer management across gateways
- Order-to-entitlement provisioning
- Currency formatting and conversion
### SubscriptionService
Manages subscription lifecycle without gateway interaction.
```php
// Create local subscription record
$subscription = $subscriptions->create($workspacePackage, 'monthly');
// Handle plan changes with proration
$result = $subscriptions->changePlan($subscription, $newPackage, prorate: true);
// Pause/unpause with limits
$subscriptions->pause($subscription);
$subscriptions->unpause($subscription);
```
Proration calculation:
```
creditAmount = currentPrice * (daysRemaining / totalPeriodDays)
proratedNewCost = newPrice * (daysRemaining / totalPeriodDays)
netAmount = proratedNewCost - creditAmount
```
### DunningService
Handles failed payment recovery with exponential backoff.
```
Day 0: Payment fails → subscription marked past_due
Day 1: First retry
Day 3: Second retry
Day 7: Third retry → subscription paused
Day 14: Workspace suspended (features restricted)
Day 30: Subscription cancelled
```
Configuration in `config.php`:
```php
'dunning' => [
'retry_days' => [1, 3, 7],
'suspend_after_days' => 14,
'cancel_after_days' => 30,
'initial_grace_hours' => 24,
],
```
### TaxService
Jurisdiction-based tax calculation supporting:
- UK VAT (20%)
- EU VAT via VIES validation
- US state sales tax (nexus-based)
- Australian GST (10%)
B2B reverse charge is applied automatically when a valid VAT number is provided for EU customers.
```php
$taxResult = $taxService->calculate($workspace, $amount);
// Returns: TaxResult with taxAmount, taxRate, jurisdiction, isExempt
```
## Payment Gateways
### PaymentGatewayContract
All gateways implement this interface ensuring consistent behavior:
```php
interface PaymentGatewayContract
{
// Identity
public function getIdentifier(): string;
public function isEnabled(): bool;
// Customer management
public function createCustomer(Workspace $workspace): string;
// Checkout
public function createCheckoutSession(Order $order, ...): array;
public function getCheckoutSession(string $sessionId): array;
// Payments
public function charge(Workspace $workspace, int $amountCents, ...): Payment;
public function chargePaymentMethod(PaymentMethod $pm, ...): Payment;
// Subscriptions
public function createSubscription(Workspace $workspace, ...): Subscription;
public function cancelSubscription(Subscription $sub, bool $immediately): void;
// Webhooks
public function verifyWebhookSignature(string $payload, string $sig): bool;
public function parseWebhookEvent(string $payload): array;
}
```
### BTCPayGateway (Primary)
Cryptocurrency payment gateway supporting BTC, LTC, XMR.
**Characteristics:**
- No saved payment methods (each payment is unique)
- No automatic recurring billing (requires customer action)
- Invoice-based workflow with expiry
- HMAC signature verification for webhooks
**Webhook Events:**
- `InvoiceCreated` → No action
- `InvoiceReceivedPayment` → Order status: processing
- `InvoiceProcessing` → Waiting for confirmations
- `InvoiceSettled` → Fulfill order
- `InvoiceExpired` → Mark order failed
### StripeGateway (Secondary)
Traditional card payment gateway.
**Characteristics:**
- Saved payment methods for recurring
- Automatic subscription billing
- Setup intents for card-on-file
- Stripe Customer Portal integration
**Webhook Events:**
- `checkout.session.completed` → Fulfill order
- `invoice.paid` → Renew subscription
- `invoice.payment_failed` → Trigger dunning
- `customer.subscription.deleted` → Revoke entitlements
## Data Models
### Entity Relationship
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Workspace │────▶│ Order │────▶│ OrderItem │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
│ ▼
│ ┌─────────────┐ ┌─────────────┐
│ │ Invoice │────▶│InvoiceItem │
│ └─────────────┘ └─────────────┘
│ │
│ ▼
│ ┌─────────────┐ ┌─────────────┐
└───────────▶│ Payment │────▶│ Refund │
└─────────────┘ └─────────────┘
┌─────────────┐ ┌─────────────┐
│ Coupon │────▶│ CouponUsage │
└─────────────┘ └─────────────┘
```
### Multi-Entity Commerce (M1/M2/M3)
The commerce module supports a hierarchical entity structure:
- **M1 (Master Company)**: Source of truth, owns product catalog
- **M2 (Facade/Storefront)**: Selects from M1 catalog, can override content
- **M3 (Dropshipper)**: Full inheritance, no management responsibility
```
┌─────────┐
│ M1 │ ← Product catalog owner
└────┬────┘
┌────────┴────────┐
│ │
┌─────▼─────┐ ┌─────▼─────┐
│ M2 │ │ M2 │ ← Storefronts
└─────┬─────┘ └───────────┘
┌─────▼─────┐
│ M3 │ ← Dropshipper
└───────────┘
```
Permission matrix controls which operations each entity type can perform, with a "training mode" for undefined permissions.
## Event System
### Domain Events
```php
// Dispatched automatically on model changes
SubscriptionCreated::class → RewardAgentReferralOnSubscription
SubscriptionRenewed::class → ResetUsageOnRenewal
OrderPaid::class → CreateReferralCommission
```
### Listeners
- `ProvisionSocialHostSubscription`: Product-specific provisioning logic
- `RewardAgentReferralOnSubscription`: Attribute referral for new subscriptions
- `ResetUsageOnRenewal`: Clear usage counters on billing period reset
- `CreateReferralCommission`: Calculate affiliate commission on paid orders
## Directory Structure
```
core-commerce/
├── Boot.php # ServiceProvider, event registration
├── config.php # All configuration (currencies, gateways, tax)
├── Concerns/ # Traits for models
├── Console/ # Artisan commands (dunning, reminders)
├── Contracts/ # Interfaces (Orderable)
├── Controllers/ # HTTP controllers
│ ├── Api/ # REST API endpoints
│ └── Webhooks/ # Gateway webhook handlers
├── Data/ # DTOs and value objects
├── Events/ # Domain events
├── Exceptions/ # Custom exceptions
├── Jobs/ # Queue jobs
├── Lang/ # Translations
├── Listeners/ # Event listeners
├── Mail/ # Mailable classes
├── Mcp/ # MCP tool handlers
├── Middleware/ # HTTP middleware
├── Migrations/ # Database migrations
├── Models/ # Eloquent models
├── Notifications/ # Laravel notifications
├── routes/ # Route definitions
├── Services/ # Business logic layer
│ └── PaymentGateway/ # Gateway implementations
├── tests/ # Pest tests
└── View/ # Blade templates and Livewire components
├── Blade/ # Blade templates
└── Modal/ # Livewire components (Admin/Web)
```
## Configuration
All commerce configuration lives in `config.php`:
```php
return [
'currency' => 'GBP', // Default currency
'currencies' => [...], // Supported currencies, exchange rates
'gateways' => [
'btcpay' => [...], // Primary gateway
'stripe' => [...], // Secondary gateway
],
'billing' => [...], // Invoice prefixes, due days
'dunning' => [...], // Retry schedule, suspension timing
'tax' => [...], // Tax rates, VAT validation
'subscriptions' => [...], // Proration, pause limits
'checkout' => [...], // Session TTL, country restrictions
'features' => [...], // Toggle coupons, refunds, trials
'usage_billing' => [...], // Metered billing settings
'matrix' => [...], // M1/M2/M3 permission matrix
];
```
## Testing
Tests use Pest with `RefreshDatabase` trait:
```bash
# Run all tests
composer test
# Run specific test file
vendor/bin/pest tests/Feature/CheckoutFlowTest.php
# Run tests matching pattern
vendor/bin/pest --filter="proration"
```
Test categories:
- `CheckoutFlowTest`: End-to-end order flow
- `SubscriptionServiceTest`: Subscription lifecycle, proration
- `DunningServiceTest`: Payment recovery flows
- `WebhookTest`: Gateway webhook handling
- `TaxServiceTest`: Tax calculation, VAT validation
- `CouponServiceTest`: Discount application
- `RefundServiceTest`: Refund processing

327
docs/security.md Normal file
View file

@ -0,0 +1,327 @@
---
title: Security
description: Security considerations and audit notes for core-commerce
updated: 2026-01-29
---
# Security Considerations
This document outlines security controls, known risks, and recommendations for the `core-commerce` package.
## Authentication & Authorisation
### API Authentication
| Endpoint Type | Authentication Method | Notes |
|--------------|----------------------|-------|
| Webhooks (`/api/webhooks/*`) | HMAC signature | Gateway-specific verification |
| Billing API (`/api/commerce/*`) | Laravel `auth` middleware | Session/Sanctum token |
| Provisioning API | Bearer token (planned) | Currently commented out |
### Webhook Security
Both payment gateways use HMAC signature verification:
**BTCPay:**
```php
// Signature in BTCPay-Sig header
$expectedSignature = hash_hmac('sha256', $payload, $webhookSecret);
hash_equals($expectedSignature, $providedSignature);
```
**Stripe:**
```php
// Uses Stripe SDK signature verification
\Stripe\Webhook::constructEvent($payload, $signature, $webhookSecret);
```
### Current Gaps
1. **No idempotency enforcement** - Webhook handlers check order state (`isPaid()`) but don't store processed event IDs. Replay attacks within the state-check window are possible.
2. **No IP allowlisting** - Webhook endpoints accept connections from any IP. Consider adding gateway IP ranges to allowlist.
3. **Rate limiting is global** - Current throttle (`120,1`) applies globally, not per-IP. A malicious actor could exhaust the limit.
## Data Protection
### Sensitive Data Handling
| Data Type | Storage | Protection |
|-----------|---------|------------|
| Card details | Never stored | Handled by gateways via redirect |
| Gateway API keys | Environment variables | Not in codebase |
| Webhook secrets | Environment variables | Used for HMAC |
| Tax IDs (VAT numbers) | Encrypted column recommended | Currently plain text |
| Billing addresses | Database JSON column | Consider encryption |
### PCI DSS Compliance
The commerce module is designed to be **PCI DSS SAQ A** compliant:
- No card data ever touches Host UK servers
- Checkout redirects to hosted payment pages (BTCPay/Stripe)
- Only tokenized references (customer IDs, payment method IDs) are stored
- No direct card number input in application
### GDPR Considerations
Personal data in commerce models:
- `orders.billing_name`, `billing_email`, `billing_address`
- `invoices.billing_*` fields
- `referrals.ip_address`, `user_agent`
**Recommendations:**
- Implement data export for billing history (right of access)
- Add retention policy for old orders/invoices
- Hash or truncate IP addresses after 90 days
- Document lawful basis for processing (contract performance)
## Input Validation
### Current Controls
```php
// Coupon codes normalized
$data['code'] = strtoupper($data['code']);
// Order totals calculated server-side
$taxResult = $this->taxService->calculateForOrderable($orderable, $taxableAmount);
$total = $subtotal - $discountAmount + $setupFee + $taxResult->taxAmount;
// Gateway responses logged without sensitive data
protected function sanitiseErrorMessage($response): string
```
### Validation Gaps
1. **Billing address structure** - Accepted as array without schema validation
2. **Coupon code length** - No maximum length enforcement
3. **Metadata fields** - JSON columns accept arbitrary structure
### Recommendations
```php
// Add validation rules
$rules = [
'billing_address.line1' => ['required', 'string', 'max:255'],
'billing_address.city' => ['required', 'string', 'max:100'],
'billing_address.country' => ['required', 'string', 'size:2'],
'billing_address.postal_code' => ['required', 'string', 'max:20'],
'coupon_code' => ['nullable', 'string', 'max:32', 'alpha_dash'],
];
```
## Transaction Security
### Idempotency
Order creation supports idempotency keys:
```php
if ($idempotencyKey) {
$existingOrder = Order::where('idempotency_key', $idempotencyKey)->first();
if ($existingOrder) {
return $existingOrder;
}
}
```
**Gap:** Webhooks don't use idempotency. Add `WebhookEvent` lookup:
```php
if (WebhookEvent::where('idempotency_key', $event['id'])->exists()) {
return response('Already processed', 200);
}
```
### Race Conditions
**Identified risks:**
1. **Concurrent subscription operations** - Pause/unpause/cancel without locks
2. **Coupon redemption** - `incrementUsage()` without atomic check
3. **Payout requests** - Commission assignment without row locks
**Mitigation:** Add `FOR UPDATE` locks or use atomic operations:
```php
// Use DB::transaction with locking
$commission = ReferralCommission::lockForUpdate()
->where('id', $commissionId)
->where('status', 'matured')
->first();
```
### Amount Verification
**Current state:** BTCPay webhook trusts order total without verifying against gateway response.
**Risk:** Under/overpayment handling undefined.
**Recommendation:**
```php
$settledAmount = $invoiceData['raw']['amount'] ?? null;
if ($settledAmount !== null && abs($settledAmount - $order->total) > 0.01) {
Log::warning('Payment amount mismatch', [
'order_total' => $order->total,
'settled_amount' => $settledAmount,
]);
// Handle partial payment or overpayment
}
```
## Fraud Prevention
### Current Controls
- Checkout session TTL (30 minutes default)
- Rate limiting on API endpoints
- Idempotency keys for order creation
### Missing Controls
1. **Velocity checks** - No detection of rapid-fire order attempts
2. **Geo-blocking** - No IP geolocation validation against billing country
3. **Card testing detection** - No small-amount charge pattern detection
4. **Device fingerprinting** - No device/browser tracking
### Recommendations
```php
// Add CheckoutRateLimiter to createCheckout
$rateLimiter = app(CheckoutRateLimiter::class);
if (!$rateLimiter->attempt($workspace->id)) {
throw new TooManyCheckoutAttemptsException();
}
// Consider Stripe Radar for card payments
'stripe' => [
'radar_enabled' => true,
'block_threshold' => 75, // Block if risk score > 75
],
```
## Audit Logging
### What's Logged
- Order status changes via `LogsActivity` trait
- Subscription status changes via `LogsActivity` trait
- Webhook events via `WebhookLogger` service
- Payment failures and retries
### What's Not Logged
- Failed authentication attempts on billing API
- Coupon validation failures
- Tax ID validation API calls
- Admin actions on refunds/credit notes
### Recommendations
Add audit events for:
```php
// Sensitive operations
activity('commerce')
->causedBy($admin)
->performedOn($refund)
->withProperties(['reason' => $reason])
->log('Refund processed');
```
## Secrets Management
### Environment Variables
```bash
# Gateway credentials
BTCPAY_URL=https://pay.host.uk.com
BTCPAY_STORE_ID=xxx
BTCPAY_API_KEY=xxx
BTCPAY_WEBHOOK_SECRET=xxx
STRIPE_KEY=pk_xxx
STRIPE_SECRET=sk_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# Tax API credentials
COMMERCE_EXCHANGE_RATE_API_KEY=xxx
```
### Key Rotation
No automated key rotation currently implemented.
**Recommendations:**
- Store credentials in secrets manager (AWS Secrets Manager, HashiCorp Vault)
- Implement webhook secret rotation with grace period
- Alert on API key exposure in logs
## Security Checklist
### Before Production
- [ ] Webhook secrets are unique per environment
- [ ] Rate limiting tuned for expected traffic
- [ ] Error messages don't leak internal details
- [ ] API keys not in version control
- [ ] SSL/TLS required for all endpoints
### Ongoing
- [ ] Monitor webhook failure rates
- [ ] Review failed payment patterns weekly
- [ ] Audit refund activity monthly
- [ ] Update gateway SDKs quarterly
- [ ] Penetration test annually
## Incident Response
### Compromised API Key
1. Revoke key immediately in gateway dashboard
2. Generate new key
3. Update environment variable
4. Restart application
5. Audit recent transactions for anomalies
### Webhook Secret Leaked
1. Generate new secret in gateway
2. Update both old and new in config (grace period)
3. Monitor for invalid signature attempts
4. Remove old secret after 24 hours
### Suspected Fraud
1. Pause affected subscription
2. Flag orders for manual review
3. Contact gateway for chargeback advice
4. Document in incident log
## Third-Party Dependencies
### Gateway SDKs
| Package | Version | Security Notes |
|---------|---------|----------------|
| `stripe/stripe-php` | ^12.0 | Keep updated for security patches |
### Other Dependencies
- `spatie/laravel-activitylog` - Audit logging
- `barryvdh/laravel-dompdf` - PDF generation (ensure no user input in HTML)
### Dependency Audit
Run regularly:
```bash
composer audit
```
## Contact
Report security issues to: security@host.uk.com
Do not open public issues for security vulnerabilities.

387
docs/webhooks.md Normal file
View file

@ -0,0 +1,387 @@
---
title: Webhooks
description: Payment gateway webhook handling documentation
updated: 2026-01-29
---
# Webhook Handling
This document describes how payment gateway webhooks are processed in the commerce module.
## Overview
Payment gateways notify the application of payment events via webhooks. These are HTTP POST requests sent to predefined endpoints when payment state changes.
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ BTCPay │ │ Host UK │ │ Stripe │
│ Server │ │ Commerce │ │ API │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ POST /api/webhooks/ │ │
│ btcpay │ │
│ ───────────────────────▶│ │
│ │ │
│ │ POST /api/webhooks/ │
│ │ stripe │
│ │◀─────────────────────────
│ │ │
```
## Endpoints
| Gateway | Endpoint | Signature Header |
|---------|----------|------------------|
| BTCPay | `POST /api/webhooks/btcpay` | `BTCPay-Sig` |
| Stripe | `POST /api/webhooks/stripe` | `Stripe-Signature` |
Both endpoints:
- Rate limited: 120 requests per minute
- No authentication middleware (signature verification only)
- Return 200 for successful processing (even if event is skipped)
- Return 401 for invalid signatures
- Return 500 for processing errors (triggers gateway retry)
## BTCPay Webhooks
### Configuration
In BTCPay Server dashboard:
1. Navigate to Store Settings > Webhooks
2. Create webhook with URL: `https://yourdomain.com/api/webhooks/btcpay`
3. Select events to send
4. Copy webhook secret to `BTCPAY_WEBHOOK_SECRET`
### Event Types
| BTCPay Event | Mapped Type | Action |
|--------------|-------------|--------|
| `InvoiceCreated` | `invoice.created` | No action |
| `InvoiceReceivedPayment` | `invoice.payment_received` | Order → processing |
| `InvoiceProcessing` | `invoice.processing` | Order → processing |
| `InvoiceSettled` | `invoice.paid` | Fulfil order |
| `InvoiceExpired` | `invoice.expired` | Order → failed |
| `InvoiceInvalid` | `invoice.failed` | Order → failed |
### Processing Flow
```php
// BTCPayWebhookController::handle()
1. Verify signature
└── 401 if invalid
2. Parse event
└── Extract type, invoice ID, metadata
3. Log webhook event
└── WebhookLogger creates audit record
4. Route to handler (in transaction)
├── invoice.paid → handleSettled()
├── invoice.expired → handleExpired()
└── default → handleUnknownEvent()
5. Return response
└── 200 OK (even for skipped events)
```
### Invoice Settlement Handler
```php
protected function handleSettled(array $event): Response
{
// 1. Find order by gateway session ID
$order = Order::where('gateway', 'btcpay')
->where('gateway_session_id', $event['id'])
->first();
// 2. Skip if already paid (idempotency)
if ($order->isPaid()) {
return response('Already processed', 200);
}
// 3. Create payment record
$payment = Payment::create([
'gateway' => 'btcpay',
'gateway_payment_id' => $event['id'],
'amount' => $order->total,
'status' => 'succeeded',
// ...
]);
// 4. Fulfil order (provisions entitlements, creates invoice)
$this->commerce->fulfillOrder($order, $payment);
// 5. Send confirmation email
$this->sendOrderConfirmation($order);
return response('OK', 200);
}
```
## Stripe Webhooks
### Configuration
In Stripe Dashboard:
1. Navigate to Developers > Webhooks
2. Add endpoint: `https://yourdomain.com/api/webhooks/stripe`
3. Select events to listen for
4. Copy signing secret to `STRIPE_WEBHOOK_SECRET`
### Event Types
| Stripe Event | Action |
|--------------|--------|
| `checkout.session.completed` | Fulfil order, create subscription |
| `invoice.paid` | Renew subscription period |
| `invoice.payment_failed` | Mark past_due, trigger dunning |
| `customer.subscription.created` | Fallback (usually handled by checkout) |
| `customer.subscription.updated` | Sync status, period dates |
| `customer.subscription.deleted` | Cancel, revoke entitlements |
| `payment_method.attached` | Store payment method |
| `payment_method.detached` | Deactivate payment method |
| `payment_method.updated` | Update card details |
| `setup_intent.succeeded` | Attach payment method from setup flow |
### Checkout Completion Handler
```php
protected function handleCheckoutCompleted(array $event): Response
{
$session = $event['raw']['data']['object'];
$orderId = $session['metadata']['order_id'];
// Find and validate order
$order = Order::find($orderId);
if (!$order || $order->isPaid()) {
return response('Already processed', 200);
}
// Create payment record
$payment = Payment::create([
'gateway' => 'stripe',
'gateway_payment_id' => $session['payment_intent'],
'amount' => $session['amount_total'] / 100,
'status' => 'succeeded',
]);
// Handle subscription if present
if (!empty($session['subscription'])) {
$this->createOrUpdateSubscriptionFromSession($order, $session);
}
// Fulfil order
$this->commerce->fulfillOrder($order, $payment);
return response('OK', 200);
}
```
### Subscription Invoice Handler
```php
protected function handleInvoicePaid(array $event): Response
{
$invoice = $event['raw']['data']['object'];
$subscriptionId = $invoice['subscription'];
// Find subscription
$subscription = Subscription::where('gateway', 'stripe')
->where('gateway_subscription_id', $subscriptionId)
->first();
// Update period dates
$subscription->renew(
Carbon::createFromTimestamp($invoice['period_start']),
Carbon::createFromTimestamp($invoice['period_end'])
);
// Create payment record
$payment = Payment::create([...]);
// Create local invoice
$this->invoiceService->createForRenewal($subscription->workspace, ...);
return response('OK', 200);
}
```
## Signature Verification
### BTCPay
```php
// BTCPayGateway::verifyWebhookSignature()
$providedSignature = $signature;
if (str_starts_with($signature, 'sha256=')) {
$providedSignature = substr($signature, 7);
}
$expectedSignature = hash_hmac('sha256', $payload, $this->webhookSecret);
return hash_equals($expectedSignature, $providedSignature);
```
### Stripe
```php
// StripeGateway::verifyWebhookSignature()
try {
\Stripe\Webhook::constructEvent($payload, $signature, $this->webhookSecret);
return true;
} catch (\Exception $e) {
return false;
}
```
## Webhook Logging
All webhook events are logged via `WebhookLogger`:
```php
// Start logging
$this->webhookLogger->startFromParsedEvent('btcpay', $event, $payload, $request);
// Link to entities for audit trail
$this->webhookLogger->linkOrder($order);
$this->webhookLogger->linkSubscription($subscription);
// Mark outcome
$this->webhookLogger->success($response);
$this->webhookLogger->fail($errorMessage, $statusCode);
$this->webhookLogger->skip($reason);
```
Logged data includes:
- Event type and ID
- Raw payload (encrypted)
- IP address and user agent
- Processing outcome
- Related order/subscription IDs
## Error Handling
### Gateway Retries
Both gateways retry failed webhooks:
| Gateway | Retry Schedule | Max Attempts |
|---------|---------------|--------------|
| BTCPay | Exponential backoff | Configurable |
| Stripe | Exponential over 3 days | ~20 attempts |
**Important:** Return `200 OK` even for events that are skipped or already processed. Only return `500` for actual processing errors that should be retried.
### Transaction Safety
All webhook handlers wrap processing in database transactions:
```php
try {
$response = DB::transaction(function () use ($event) {
return match ($event['type']) {
'invoice.paid' => $this->handleSettled($event),
// ...
};
});
return $response;
} catch (\Exception $e) {
Log::error('Webhook processing error', [...]);
return response('Processing error', 500);
}
```
## Testing Webhooks
### Local Development
Use gateway CLI tools to send test webhooks:
**BTCPay:**
```bash
# Trigger test webhook from BTCPay admin
# Or use btcpay-cli if available
```
**Stripe:**
```bash
# Forward webhooks to local
stripe listen --forward-to localhost:8000/api/webhooks/stripe
# Trigger specific event
stripe trigger checkout.session.completed
```
### Automated Tests
See `tests/Feature/WebhookTest.php` for webhook handler tests:
```php
test('btcpay settled webhook fulfils order', function () {
$order = Order::factory()->create(['status' => 'processing']);
$payload = json_encode([
'type' => 'InvoiceSettled',
'invoiceId' => $order->gateway_session_id,
// ...
]);
$signature = hash_hmac('sha256', $payload, config('commerce.gateways.btcpay.webhook_secret'));
$response = $this->postJson('/api/webhooks/btcpay', [], [
'BTCPay-Sig' => $signature,
'Content-Type' => 'application/json',
]);
$response->assertStatus(200);
expect($order->fresh()->status)->toBe('paid');
});
```
## Troubleshooting
### Common Issues
**401 Invalid Signature**
- Check webhook secret matches environment variable
- Ensure raw payload is used (not parsed JSON)
- Verify signature header name is correct
**Order Not Found**
- Check `gateway_session_id` matches invoice ID
- Verify order was created before webhook arrived
- Check for typos in metadata passed to gateway
**Duplicate Processing**
- Normal behavior if webhook is retried
- Order state check (`isPaid()`) prevents double fulfillment
- Consider adding idempotency key storage
### Debug Logging
Enable verbose logging temporarily:
```php
// In webhook controller
Log::debug('Webhook payload', [
'type' => $event['type'],
'id' => $event['id'],
'raw' => $event['raw'],
]);
```
### Webhook Event Viewer
Query logged events:
```sql
SELECT * FROM commerce_webhook_events
WHERE event_type = 'InvoiceSettled'
AND status = 'failed'
ORDER BY created_at DESC
LIMIT 10;
```

View file

@ -996,6 +996,417 @@ describe('BTCPayGateway webhook event parsing', function () {
});
});
// ============================================================================
// Security Tests - Idempotency and Amount Verification
// ============================================================================
describe('Webhook Idempotency (Replay Attack Protection)', function () {
describe('BTCPay idempotency', function () {
beforeEach(function () {
$this->order = Order::create([
'workspace_id' => $this->workspace->id,
'order_number' => 'ORD-IDEM-BTC-001',
'gateway' => 'btcpay',
'gateway_session_id' => 'btc_invoice_idem_123',
'subtotal' => 49.00,
'tax_amount' => 9.80,
'total' => 58.80,
'currency' => 'GBP',
'status' => 'pending',
]);
OrderItem::create([
'order_id' => $this->order->id,
'name' => 'Test Product',
'quantity' => 1,
'unit_price' => 49.00,
'total' => 49.00,
'type' => 'product',
]);
});
it('rejects duplicate webhook events (replay attack protection)', function () {
// First webhook - should process successfully
$eventId = 'btc_event_unique_123';
// Pre-create a processed webhook event to simulate already processed
WebhookEvent::create([
'gateway' => 'btcpay',
'event_id' => $eventId,
'event_type' => 'invoice.paid',
'payload' => '{}',
'status' => WebhookEvent::STATUS_PROCESSED,
'received_at' => now()->subMinutes(5),
'processed_at' => now()->subMinutes(5),
]);
$mockGateway = Mockery::mock(BTCPayGateway::class);
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
'type' => 'invoice.paid',
'id' => $eventId,
'status' => 'succeeded',
'metadata' => [],
'raw' => [],
]);
$mockCommerce = Mockery::mock(CommerceService::class);
// Should NOT receive fulfillOrder because this is a duplicate
$mockCommerce->shouldNotReceive('fulfillOrder');
$webhookLogger = new WebhookLogger;
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
$request = new \Illuminate\Http\Request;
$response = $controller->handle($request);
expect($response->getStatusCode())->toBe(200)
->and($response->getContent())->toBe('Already processed (duplicate)');
});
it('processes first webhook and rejects subsequent duplicates', function () {
$eventId = 'btc_event_first_' . uniqid();
$mockGateway = Mockery::mock(BTCPayGateway::class);
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
'type' => 'invoice.paid',
'id' => $eventId,
'status' => 'succeeded',
'metadata' => [],
'raw' => ['invoiceId' => 'btc_invoice_idem_123'],
]);
$mockGateway->shouldReceive('getCheckoutSession')->once()->andReturn([
'id' => 'btc_invoice_idem_123',
'status' => 'succeeded',
'amount' => 58.80,
'currency' => 'GBP',
'raw' => ['amount' => 58.80, 'currency' => 'GBP'],
]);
$mockCommerce = Mockery::mock(CommerceService::class);
// First call should process
$mockCommerce->shouldReceive('fulfillOrder')->once();
$webhookLogger = new WebhookLogger;
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
// First request - should process
$request1 = new \Illuminate\Http\Request;
$response1 = $controller->handle($request1);
expect($response1->getStatusCode())->toBe(200);
// Verify webhook event was logged
$webhookEvent = WebhookEvent::where('gateway', 'btcpay')
->where('event_id', $eventId)
->first();
expect($webhookEvent)->not->toBeNull()
->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_PROCESSED);
// Second request with same event ID - should be rejected as duplicate
$webhookLogger2 = new WebhookLogger;
$controller2 = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger2);
$request2 = new \Illuminate\Http\Request;
$response2 = $controller2->handle($request2);
expect($response2->getStatusCode())->toBe(200)
->and($response2->getContent())->toBe('Already processed (duplicate)');
// Verify order was only fulfilled once (payment count check)
$paymentCount = Payment::where('order_id', $this->order->id)->count();
expect($paymentCount)->toBe(1);
});
});
describe('Stripe idempotency', function () {
beforeEach(function () {
$this->order = Order::create([
'workspace_id' => $this->workspace->id,
'order_number' => 'ORD-IDEM-STRIPE-001',
'gateway' => 'stripe',
'gateway_session_id' => 'cs_idem_123',
'subtotal' => 49.00,
'tax_amount' => 9.80,
'total' => 58.80,
'currency' => 'GBP',
'status' => 'pending',
]);
OrderItem::create([
'order_id' => $this->order->id,
'name' => 'Test Product',
'quantity' => 1,
'unit_price' => 49.00,
'total' => 49.00,
'type' => 'product',
]);
});
it('rejects duplicate Stripe webhook events', function () {
$eventId = 'evt_stripe_unique_123';
// Pre-create a processed webhook event
WebhookEvent::create([
'gateway' => 'stripe',
'event_id' => $eventId,
'event_type' => 'checkout.session.completed',
'payload' => '{}',
'status' => WebhookEvent::STATUS_PROCESSED,
'received_at' => now()->subMinutes(5),
'processed_at' => now()->subMinutes(5),
]);
$mockGateway = Mockery::mock(StripeGateway::class);
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
'type' => 'checkout.session.completed',
'id' => $eventId,
'raw' => [
'data' => [
'object' => [
'id' => 'cs_idem_123',
'metadata' => ['order_id' => $this->order->id],
],
],
],
]);
$mockCommerce = Mockery::mock(CommerceService::class);
$mockCommerce->shouldNotReceive('fulfillOrder');
$mockInvoice = Mockery::mock(InvoiceService::class);
$mockEntitlements = Mockery::mock(EntitlementService::class);
$webhookLogger = new WebhookLogger;
$controller = new StripeWebhookController(
$mockGateway,
$mockCommerce,
$mockInvoice,
$mockEntitlements,
$webhookLogger
);
$request = new \Illuminate\Http\Request;
$response = $controller->handle($request);
expect($response->getStatusCode())->toBe(200)
->and($response->getContent())->toBe('Already processed (duplicate)');
});
});
});
describe('BTCPay Payment Amount Verification', function () {
beforeEach(function () {
$this->order = Order::create([
'workspace_id' => $this->workspace->id,
'order_number' => 'ORD-AMT-001',
'gateway' => 'btcpay',
'gateway_session_id' => 'btc_invoice_amt_123',
'subtotal' => 49.00,
'tax_amount' => 9.80,
'total' => 58.80,
'currency' => 'GBP',
'status' => 'pending',
]);
OrderItem::create([
'order_id' => $this->order->id,
'name' => 'Test Product',
'quantity' => 1,
'unit_price' => 49.00,
'total' => 49.00,
'type' => 'product',
]);
});
it('fulfils order when paid amount matches order total', function () {
$mockGateway = Mockery::mock(BTCPayGateway::class);
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
'type' => 'invoice.paid',
'id' => 'btc_invoice_amt_123',
'status' => 'succeeded',
'metadata' => [],
'raw' => ['invoiceId' => 'btc_invoice_amt_123'],
]);
$mockGateway->shouldReceive('getCheckoutSession')->andReturn([
'id' => 'btc_invoice_amt_123',
'status' => 'succeeded',
'amount' => 58.80,
'currency' => 'GBP',
'raw' => ['amount' => 58.80, 'currency' => 'GBP'],
]);
$mockCommerce = Mockery::mock(CommerceService::class);
$mockCommerce->shouldReceive('fulfillOrder')->once();
$webhookLogger = new WebhookLogger;
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
$request = new \Illuminate\Http\Request;
$response = $controller->handle($request);
expect($response->getStatusCode())->toBe(200);
$payment = Payment::where('order_id', $this->order->id)->first();
expect($payment)->not->toBeNull()
->and($payment->status)->toBe('succeeded')
->and((float) $payment->amount)->toBe(58.80);
});
it('rejects underpayment and marks order as failed', function () {
$mockGateway = Mockery::mock(BTCPayGateway::class);
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
'type' => 'invoice.paid',
'id' => 'btc_invoice_underpaid',
'status' => 'succeeded',
'metadata' => [],
'raw' => ['invoiceId' => 'btc_invoice_amt_123'],
]);
$mockGateway->shouldReceive('getCheckoutSession')->andReturn([
'id' => 'btc_invoice_amt_123',
'status' => 'succeeded',
'amount' => 30.00, // Underpaid: only 30 GBP instead of 58.80
'currency' => 'GBP',
'raw' => ['amount' => 30.00, 'currency' => 'GBP'],
]);
$mockCommerce = Mockery::mock(CommerceService::class);
// Should NOT fulfil order due to underpayment
$mockCommerce->shouldNotReceive('fulfillOrder');
$webhookLogger = new WebhookLogger;
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
$request = new \Illuminate\Http\Request;
$response = $controller->handle($request);
expect($response->getStatusCode())->toBe(200)
->and($response->getContent())->toBe('Underpaid - order not fulfilled');
// Verify order was marked as failed
$this->order->refresh();
expect($this->order->status)->toBe('failed')
->and($this->order->metadata['failure_reason'])->toContain('Underpaid');
// Verify partial payment was recorded for audit trail
$payment = Payment::where('order_id', $this->order->id)->first();
expect($payment)->not->toBeNull()
->and($payment->status)->toBe('underpaid')
->and((float) $payment->amount)->toBe(30.00);
});
it('fulfils order but logs overpayment', function () {
$mockGateway = Mockery::mock(BTCPayGateway::class);
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
'type' => 'invoice.paid',
'id' => 'btc_invoice_overpaid',
'status' => 'succeeded',
'metadata' => [],
'raw' => ['invoiceId' => 'btc_invoice_amt_123'],
]);
$mockGateway->shouldReceive('getCheckoutSession')->andReturn([
'id' => 'btc_invoice_amt_123',
'status' => 'succeeded',
'amount' => 100.00, // Overpaid: 100 GBP instead of 58.80
'currency' => 'GBP',
'raw' => ['amount' => 100.00, 'currency' => 'GBP'],
]);
$mockCommerce = Mockery::mock(CommerceService::class);
// Should still fulfil order for overpayment
$mockCommerce->shouldReceive('fulfillOrder')->once();
$webhookLogger = new WebhookLogger;
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
$request = new \Illuminate\Http\Request;
$response = $controller->handle($request);
expect($response->getStatusCode())->toBe(200);
// Order should be fulfilled (overpayment is accepted)
$payment = Payment::where('order_id', $this->order->id)->first();
expect($payment)->not->toBeNull()
->and($payment->status)->toBe('succeeded')
->and((float) $payment->amount)->toBe(100.00);
});
it('rejects payment with currency mismatch', function () {
$mockGateway = Mockery::mock(BTCPayGateway::class);
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
'type' => 'invoice.paid',
'id' => 'btc_invoice_currency_mismatch',
'status' => 'succeeded',
'metadata' => [],
'raw' => ['invoiceId' => 'btc_invoice_amt_123'],
]);
$mockGateway->shouldReceive('getCheckoutSession')->andReturn([
'id' => 'btc_invoice_amt_123',
'status' => 'succeeded',
'amount' => 58.80,
'currency' => 'USD', // Wrong currency - order is in GBP
'raw' => ['amount' => 58.80, 'currency' => 'USD'],
]);
$mockCommerce = Mockery::mock(CommerceService::class);
// Should NOT fulfil order due to currency mismatch
$mockCommerce->shouldNotReceive('fulfillOrder');
$webhookLogger = new WebhookLogger;
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
$request = new \Illuminate\Http\Request;
$response = $controller->handle($request);
expect($response->getStatusCode())->toBe(200)
->and($response->getContent())->toBe('Currency mismatch - order not fulfilled');
// Verify order was marked as failed
$this->order->refresh();
expect($this->order->status)->toBe('failed')
->and($this->order->metadata['failure_reason'])->toContain('Currency mismatch');
});
it('allows small floating point tolerance in amount comparison', function () {
$mockGateway = Mockery::mock(BTCPayGateway::class);
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
'type' => 'invoice.paid',
'id' => 'btc_invoice_tolerance',
'status' => 'succeeded',
'metadata' => [],
'raw' => ['invoiceId' => 'btc_invoice_amt_123'],
]);
$mockGateway->shouldReceive('getCheckoutSession')->andReturn([
'id' => 'btc_invoice_amt_123',
'status' => 'succeeded',
'amount' => 58.79, // Slightly less due to floating point, within tolerance
'currency' => 'GBP',
'raw' => ['amount' => 58.79, 'currency' => 'GBP'],
]);
$mockCommerce = Mockery::mock(CommerceService::class);
// Should fulfil order as the difference is within tolerance (0.01)
$mockCommerce->shouldReceive('fulfillOrder')->once();
$webhookLogger = new WebhookLogger;
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
$request = new \Illuminate\Http\Request;
$response = $controller->handle($request);
expect($response->getStatusCode())->toBe(200);
$payment = Payment::where('order_id', $this->order->id)->first();
expect($payment)->not->toBeNull()
->and($payment->status)->toBe('succeeded');
});
});
afterEach(function () {
Mockery::close();
});