diff --git a/Controllers/Webhooks/BTCPayWebhookController.php b/Controllers/Webhooks/BTCPayWebhookController.php index b2eefc4..567ad31 100644 --- a/Controllers/Webhooks/BTCPayWebhookController.php +++ b/Controllers/Webhooks/BTCPayWebhookController.php @@ -1,5 +1,7 @@ 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 diff --git a/Controllers/Webhooks/StripeWebhookController.php b/Controllers/Webhooks/StripeWebhookController.php index 3b5b305..a9f45db 100644 --- a/Controllers/Webhooks/StripeWebhookController.php +++ b/Controllers/Webhooks/StripeWebhookController.php @@ -1,5 +1,7 @@ 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']); diff --git a/Migrations/2026_01_29_000001_create_webhook_events_table.php b/Migrations/2026_01_29_000001_create_webhook_events_table.php new file mode 100644 index 0000000..f70c725 --- /dev/null +++ b/Migrations/2026_01_29_000001_create_webhook_events_table.php @@ -0,0 +1,64 @@ +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'); + } +}; diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..f1530d6 --- /dev/null +++ b/TODO.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a459a8a --- /dev/null +++ b/docs/architecture.md @@ -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 diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..e69b7e0 --- /dev/null +++ b/docs/security.md @@ -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. diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..cf66a92 --- /dev/null +++ b/docs/webhooks.md @@ -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; +``` diff --git a/tests/Feature/WebhookTest.php b/tests/Feature/WebhookTest.php index c3245dc..0ceb506 100644 --- a/tests/Feature/WebhookTest.php +++ b/tests/Feature/WebhookTest.php @@ -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(); });