Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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`) |
Expand Down
21 changes: 20 additions & 1 deletion plugins/whmcs/modules/gateways/callback/coinpay.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/whmcs/modules/gateways/coinpay.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? ''),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/app/api/businesses/[id]/webhook-test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
72 changes: 72 additions & 0 deletions src/lib/webhooks/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
9 changes: 9 additions & 0 deletions src/lib/webhooks/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading