diff --git a/docs/API.md b/docs/API.md index 9b7e64e4..7b9336cb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1054,6 +1054,7 @@ The `X-CoinPay-Signature` header contains: "type": "payment.confirmed", "data": { "payment_id": "pay_xyz789", + "amount": "100.00", "amount_crypto": "0.0456", "amount_usd": "100.00", "currency": "ETH", @@ -1088,6 +1089,7 @@ All webhooks include these base fields: | `type` | string | Alias for `event` (for compatibility) | | `payment_id` | string | Unique payment identifier | | `business_id` | string | Your business identifier | +| `amount` | string | Settled fiat amount — alias of `amount_usd`. Use this to credit an order/invoice. | | `amount_crypto` | string | Amount in cryptocurrency | | `amount_usd` | string | Amount in USD | | `currency` | string | Blockchain/currency code (e.g., `ETH`, `BTC`) | diff --git a/plugins/whmcs/modules/gateways/callback/coinpay.php b/plugins/whmcs/modules/gateways/callback/coinpay.php index 7fa61358..f212915a 100644 --- a/plugins/whmcs/modules/gateways/callback/coinpay.php +++ b/plugins/whmcs/modules/gateways/callback/coinpay.php @@ -96,11 +96,30 @@ $rawStatus = isset($payment['status']) ? (string) $payment['status'] : null; $class = StatusMap::classifyEvent($eventType, $rawStatus); -$amount = isset($payment['amount']) ? (float) $payment['amount'] : (float) ($data['amount'] ?? 0); +// Settled fiat amount. CoinPay payment webhooks have always carried +// `amount_usd`; the generic `amount` alias was added later. Reading only +// `amount` made this fall through to 0.0, so a charged card produced a +// 0.00 WHMCS payment and the invoice silently stayed unpaid. +$amountRaw = $payment['amount'] + ?? $data['amount'] + ?? $payment['amount_usd'] + ?? $data['amount_usd'] + ?? null; +$amount = $amountRaw === null ? null : (float) $amountRaw; $currency = (string) ($payment['currency'] ?? $data['currency'] ?? ''); switch ($class) { case StatusMap::CLASS_PAID: + // Never book a zero-value payment — WHMCS would leave the invoice + // unpaid with no obvious cause. Fail loudly instead so the delivery + // is retried and recorded in CoinPay's webhook log. + if ($amount === null || $amount <= 0.0) { + logModuleCall($moduleName, 'webhook.no_amount', $event, 'paid event carried no usable amount'); + http_response_code(400); + echo json_encode(['error' => 'missing payment amount']); + exit; + } + // The CoinPay response carries no fee by default; fee = 0 unless the // merchant dashboard later exposes one. WHMCS addInvoicePayment is // idempotent by transid so this is safe on replays. diff --git a/plugins/whmcs/modules/gateways/coinpay.php b/plugins/whmcs/modules/gateways/coinpay.php index 375f5c43..169ab453 100644 --- a/plugins/whmcs/modules/gateways/coinpay.php +++ b/plugins/whmcs/modules/gateways/coinpay.php @@ -156,7 +156,7 @@ function coinpay_link($params) $metadata = [ 'platform' => 'whmcs', - 'plugin_version' => '0.6.12', + 'plugin_version' => '0.6.13', 'system_url' => $systemUrl, 'invoice_id' => (string) $invoiceId, 'client_id' => (string) ($params['clientdetails']['id'] ?? $params['userid'] ?? ''), diff --git a/plugins/whmcs/modules/gateways/coinpay/lib/CoinPay/Client.php b/plugins/whmcs/modules/gateways/coinpay/lib/CoinPay/Client.php index caf47e74..b4111d2b 100644 --- a/plugins/whmcs/modules/gateways/coinpay/lib/CoinPay/Client.php +++ b/plugins/whmcs/modules/gateways/coinpay/lib/CoinPay/Client.php @@ -16,7 +16,7 @@ class Client { public const DEFAULT_BASE_URL = 'https://coinpayportal.com/api'; public const DEFAULT_TIMEOUT = 30; - public const USER_AGENT = 'coinpay-php/0.6.12'; + public const USER_AGENT = 'coinpay-php/0.6.13'; /** @var string */ private $apiKey; diff --git a/src/app/api/businesses/[id]/webhook-test/route.ts b/src/app/api/businesses/[id]/webhook-test/route.ts index 67f9c770..f583a134 100644 --- a/src/app/api/businesses/[id]/webhook-test/route.ts +++ b/src/app/api/businesses/[id]/webhook-test/route.ts @@ -103,6 +103,11 @@ export async function POST( type: 'test.webhook', data: { payment_id: `test_${Date.now()}`, + // `amount` mirrors `amount_usd` — keep it here so the test webhook is + // shaped exactly like a real one and merchants can wire up their + // amount handling against it. See sendPaymentWebhook in + // src/lib/webhooks/service.ts. + amount: '50.00', amount_crypto: '0.001', amount_usd: '50.00', currency: 'BTC', diff --git a/src/lib/webhooks/service.test.ts b/src/lib/webhooks/service.test.ts index 0e7f7071..97dde062 100644 --- a/src/lib/webhooks/service.test.ts +++ b/src/lib/webhooks/service.test.ts @@ -891,6 +891,78 @@ describe('Webhook Service', () => { }); }); + describe('settled amount in webhook payload', () => { + // Regression: the payload only ever carried `amount_usd`. Billing + // integrations (WHMCS's addInvoicePayment, FossBilling, WooCommerce) + // read a generic `amount`, so they booked a 0.00 payment — Stripe + // captured the card, the merchant returned 200, and the invoice stayed + // unpaid with no error anywhere. + const arrange = () => { + const mockChain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ + data: { + webhook_url: 'https://example.com/webhook', + webhook_secret: 'test-webhook-secret', + }, + error: null, + }), + insert: vi.fn().mockResolvedValue({ error: null }), + }; + const mockSupabase = { + from: vi.fn().mockReturnValue(mockChain), + } as unknown as SupabaseClient; + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: 'OK' }); + return mockSupabase; + }; + + const sentBody = () => JSON.parse((global.fetch as any).mock.calls[0][1].body); + + it('mirrors amount_usd into data.amount', async () => { + const mockSupabase = arrange(); + + await sendPaymentWebhook(mockSupabase, 'business-123', 'payment-123', 'payment.confirmed', { + status: 'confirmed', + amount_usd: 140, + currency: 'usd', + }); + + const body = sentBody(); + expect(body.data.amount).toBe(140); + expect(body.data.amount_usd).toBe(140); + }); + + it('resolves to a non-zero amount for a WHMCS-style `data.amount ?? 0` reader', async () => { + const mockSupabase = arrange(); + + await sendPaymentWebhook(mockSupabase, 'business-123', 'payment-123', 'payment.confirmed', { + status: 'confirmed', + amount_usd: 140, + currency: 'usd', + metadata: { invoice_id: '42445', platform: 'whmcs' }, + }); + + const data = sentBody().data; + const whmcsAmount = Number(data.amount ?? 0); + expect(whmcsAmount).toBe(140); + expect(whmcsAmount).toBeGreaterThan(0); + }); + + it('prefers an explicit amount over amount_usd when the caller supplies one', async () => { + const mockSupabase = arrange(); + + await sendPaymentWebhook(mockSupabase, 'business-123', 'payment-123', 'payment.confirmed', { + status: 'confirmed', + amount: 139.5, + amount_usd: 140, + currency: 'usd', + }); + + expect(sentBody().data.amount).toBe(139.5); + }); + }); + describe('metadata in webhook payload', () => { it('should include metadata in webhook payload when provided', async () => { const mockChain = { diff --git a/src/lib/webhooks/service.ts b/src/lib/webhooks/service.ts index d919c932..58edb69f 100644 --- a/src/lib/webhooks/service.ts +++ b/src/lib/webhooks/service.ts @@ -504,12 +504,21 @@ export async function sendPaymentWebhook( // Build SDK-compliant nested payload format (matches test webhook) // This format is expected by verifyWebhookSignature() and parseWebhookPayload() in SDK + // + // `amount` is a deliberate alias of `amount_usd` (the settled fiat amount). + // Billing integrations read a generic `amount` — the WHMCS gateway does + // `$data['amount'] ?? 0` — and because we only ever sent `amount_usd`, they + // booked a 0.00 payment against the invoice: Stripe captured the card, the + // merchant returned HTTP 200, and the invoice silently stayed unpaid. + // Do not remove this field; it is part of the published wire contract. + const settledAmount = paymentData.amount ?? paymentData.amount_usd; const payload = { id: `evt_${paymentId}_${timestamp}`, type: event, data: { payment_id: paymentId, status: paymentData.status, + amount: settledAmount, amount_crypto: paymentData.amount_crypto, amount_usd: paymentData.amount_usd, currency: paymentData.currency,