Skip to content

Act on the IPN event instead of the re-fetched invoice status - #106

Merged
aharoitx merged 2 commits into
5.1.xfrom
fix/im94-ipn-event-status
Sep 24, 2026
Merged

aharoitx merged 2 commits into
5.1.xfrom
fix/im94-ipn-event-status

Conversation

@aharoitx

@aharoitx aharoitx commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Act on the IPN event instead of the re-fetched invoice status

Fixes #96, where the WHMCS invoice never moves from Payment Pending to Paid.

The problem

bitpaycheckout_ipn.php ignored the status that arrives in the notification. It
re-fetched the invoice from BitPay's public endpoint and switched on that:

$invoiceStatus = json_decode(checkInvoiceStatus($url_check));
$serverStatus  = $invoiceStatus->data->status;
switch ($serverStatus) { case 'complete': case 'paid': case 'expired': case 'pending': }

Two leftovers suggest this was not the intent:

$order_status = $data['status'];   // assigned, never used
if ($event['name'] == ...)         // used, never assigned

There was no case for 'confirmed', which is a normal step in the lifecycle:

new -> paid -> confirmed -> complete

So every invoice_confirmed notification was discarded, and any other
notification arriving while the public endpoint reported a different status
was discarded too. In both cases the handler still returned 200, so BitPay
treated the notification as delivered and never retried. The invoice stays in
Payment Pending permanently.

Reproduced

WHMCS 9.0.7, plugin 5.1.x, sandbox endpoint.

Before the change, a BitPay invoice in 'new' with an invoice_completed
notification posted to the handler:

public endpoint : new
event posted    : invoice_completed
response        : HTTP 200
WHMCS invoice   : unchanged, Unpaid
tblaccounts     : empty
any log entry   : none

The change

The switch now runs on the event name, matching what the WooCommerce plugin
already does in class-bitpayipnprocess.php. The re-fetched invoice is kept,
but as a consistency check: BitPay may be ahead of the event being processed,
never behind it.

invoice_paidInFull      paid, confirmed, complete   -> Payment Pending
invoice_confirmed       confirmed, complete         -> apply payment
invoice_completed       complete                    -> apply payment if not applied
invoice_expired         expired                     -> drop the transaction row
invoice_refundComplete                              -> Refunded

Payment is applied exactly once. invoice_confirmed applies it and marks the
row confirmed; invoice_completed checks that before applying again. This is
the same caution the WooCommerce settings give about mapping both confirmed
and complete to a paid state.

Mismatched or unhandled events are recorded with logTransaction, so they show
up under Billing > Gateway Log. The plugin had never written to that log
before this change.

Verified

There is no test suite in this repository, so this was verified by hand
against a local WHMCS 9.0.7 with the sandbox endpoint, by posting
notifications to the callback directly.

invoice_completed while BitPay reports 'expired'
  -> invoice unchanged, mismatch recorded in the gateway log

invoice_confirmed while BitPay reports 'complete'
  -> invoice moves to Paid, one row in tblaccounts

invoice_completed straight after
  -> transaction row moves to complete, still one row in tblaccounts

phpcs passes.

Not in this PR

Found while investigating, each to be filed separately:

  • two BitPay invoices for the same WHMCS invoice both apply payment, which
    overpays the invoice and turns the excess into client credit
  • the transactions lookup indexes [0] without checking the result is not empty
  • the notification URL hardcodes https:// and drops the port

The IPN handler did not use the status that arrives in the notification. It
re-fetched the invoice from BitPay's public endpoint and switched on that,
which meant any notification arriving while the public endpoint reported a
different status was discarded. The handler still answered 200, so BitPay
treated it as delivered and never retried.

There was also no case for 'confirmed', which is a normal step in the invoice
lifecycle, so every invoice_confirmed notification was dropped unconditionally.

The switch now runs on the event name, following the same model as the
WooCommerce plugin. The re-fetched invoice is still used, but as a check that
BitPay is not behind the event being processed. Mismatches are recorded with
logTransaction so they appear under Billing > Gateway Log.

Payment is applied once: invoice_confirmed applies it, and invoice_completed
skips it when confirmed already did.

Fixes #96
Comment on lines +237 to +239
case 'invoice_refundComplete':
$table = 'tblinvoices';
$update = array('status' => 'Refunded', 'datepaid' => date('Y-m-d H:i:s'));
Comment thread modules/gateways/bitpaycheckout/callback/bitpaycheckout_ipn.php
A late or retried invoice_paidInFull could move a confirmed transaction back
to paid, and a Paid invoice back to Payment Pending. invoice_completed would
then apply the payment a second time. Two callbacks arriving together could
also both see the old status and both apply it.

Each event now moves the row forward only from the states it expects, in a
single conditional UPDATE, and the payment is applied only by the callback
whose UPDATE changed the row.
@aharoitx
aharoitx merged commit c6abaf7 into 5.1.x Sep 24, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Whmcs module callback issue

3 participants