GitHub Markdown

Accept credit and debit cards through the Inovio/Argus Payments gateway on OpenCart 4. The card number never reaches the OpenCart server.

Overview and status

Functional pre-release

Sale, authorize, capture (full and partial), void, refund, vaulting and 3-D Secure are implemented and exercised end to end, including a real Cardinal ACS challenge. A 7-file, 10-spec Playwright suite drives the real storefront and the real admin. Not yet hardened for production.

Target is OpenCart 4.1.x, developed and verified against 4.1.0.4 on PHP 8.2. This is the same direct-post architecture proven in the Magento 2 and PrestaShop 9 modules, ported to OpenCart 4, with a handful of deliberate divergences where OpenCart's own conventions are a better fit or where the PrestaShop reference had a behaviour worth not repeating.

Requirements

OpenCart 4.1.x (developed and verified against 4.1.0.4)
PHP 8.1+
PHP extensions bcmath (required), curl
From Inovio API username, API password, Site ID, Site Key, Gateway Product ID

bcmath is required, not optional. The SDK computes every monetary amount through it so money never passes through a binary float. The settings screen refuses to enable the payment method without it.

Install

The extension ships as a standard OpenCart .ocmod.zip.

Building the package

cd inovio-gateway-opencart
rm -rf build && mkdir build
cp -R upload/. build/
cp install.json build/install.json
cd build && zip -r ../inovio.ocmod.zip . && cd ..
The zip must be FLAT

admin/, catalog/, system/ and install.json go at the archive root. Do not wrap them in an upload/ directory. OpenCart 4.1.0.4's installer copies zip entries verbatim into extension/<code>/; despite a comment in its own source claiming it "only extracts the contents of the upload folder", there is no code that strips an upload/ prefix. A wrapped zip installs to extension/inovio/upload/..., where nothing is autoloadable and the extension silently never appears. Verified against the running 4.1.0.4 admin/controller/marketplace/installer.php.

Installing

  1. Admin, Extensions, Installer: upload inovio.ocmod.zip, then press Install on the row that appears.
  2. Admin, Extensions, Extensions: choose extension type Payments, find Inovio Payment Gateway, press the green + (Install). This step creates the extension's two database tables.
  3. Press Edit on the same row, fill in the settings below, set Status to enabled, and Save.

The customer-facing "Saved cards" list appears automatically under My Account, Payment Methods once vaulting is enabled. There is no extra step.

Uninstalling

Uninstalling removes the settings but deliberately leaves the two tables in place. Dropping oc_inovio_order_ref would destroy the gateway PO_ID of every historical order, permanently removing the merchant's ability to refund anything ever taken through the extension. Uninstall and reinstall is also how OpenCart upgrades an extension, so silent data destruction there is indefensible. Drop the tables by hand if you genuinely want the data gone.

Configuration

All settings live at Extensions, Payments, Inovio Payment Gateway, Edit.

Gateway credentials

Setting Required Meaning
API Username Yes Gateway REQ_USERNAME.
API Password Yes Gateway REQ_PASSWORD. Write-only in the UI: rendered blank, and saving it blank keeps the stored value.
Site ID Yes Gateway SITE_ID.
Merchant Account ID No MERCH_ACCT_ID. Leave empty to let the gateway distribute by currency and country.
Site Key Yes A separate per-site HMAC secret issued by Inovio support, not the API password. Used only to sign browser tokenization requests. Without it the browser cannot tokenize, checkout fails with error 121, and the payment method stays hidden. Also write-only.
Gateway Product ID Yes The Inovio product (LI_PROD_ID) orders are billed under. This is a gateway product id, not a catalogue SKU from your store. The whole order bills as one line item under it.
Gateway Endpoint No The pmt_service.cfm transaction URL. The tokenization endpoint (token_service.cfm) and the 3-D Secure endpoint (3dsrequest.cfm) are derived from it by suffix rewrite, exactly as the SDK derives them, so one setting cannot drift from the others. The settings screen shows you the derived token URL.

Until all five required fields are filled in, the extension hides itself at checkout rather than presenting a card form it cannot process.

Payment behaviour settings

Setting Required Meaning
Payment Action Yes Sale charges at checkout. Authorize only reserves funds and leaves the order awaiting a capture you trigger from the order screen.
Enable 3-D Secure No Runs 3DS authentication. Requires a 3DS-configured merchant account. When on, checkout mints two tokens per card.
Enable Saved Cards No Lets logged-in customers save a card for reuse. Stores the gateway's own card references only, never a card number.
Statement Descriptor No PMT_DESCRIPTOR, what appears on the cardholder's statement. Optional. See the warning below.
Descriptor Phone No PMT_DESCRIPTOR_PHONE, support number shown alongside the descriptor. Optional.

Order statuses

Each transition is merchant-configurable. Defaults reference stock OpenCart statuses read from oc_order_status, not guessed.

Setting Default Notes
Approved Order Status Processing (2) A completed sale, or a fully-captured authorization.
Awaiting Capture Status Pending (1) Must be an unpaid status. Funds are only reserved.
Awaiting 3-D Secure Status Pending (1) Must be an unpaid status. An order parked here has not been charged and must not count as revenue.
Failed Order Status Failed (10) Declines and gateway errors.
Refunded Order Status Refunded (11) Reached by either refund verb.
Voided Order Status Voided (16) A reversed authorization.

OpenCart ships Pending, Processing, Failed, Refunded and Voided already, so the extension exposes them as settings instead of adding rows to oc_order_status. This is a deliberate divergence from PrestaShop, which has no unpaid "awaiting capture" or "awaiting 3DS" states and so creates them at install.

Display and availability

Geo Zone, Status, Sort Order and Debug Logging behave as in any OpenCart payment extension. Debug logging writes gateway activity to system/storage/logs/error.log. Card numbers are never logged, because they never reach this server, so there is nothing to redact.

The statement descriptor must not contain a space, underscore or slash

The gateway rejects the entire transaction with Invalid Data if PMT_DESCRIPTOR contains a space, an underscore or a forward slash. Every sale fails, not just the descriptor.

Descriptor Result
ACME STORE Rejected
ACME_STORE Rejected
ACME/STORE Rejected
ACME-STORE Accepted
ACMESTORE Accepted
ACME.STORE Accepted

The full allowed set is A-Z, a-z, 0-9, and . - * + & @. This was mapped empirically by sending each candidate character in an otherwise-identical approved request; the SDK now rejects an invalid descriptor rather than letting the gateway kill the sale. A multi-word descriptor is the natural thing for a merchant to type, and it kills every sale, so if you are unsure, leave the field empty.

Payment behaviour

Capability Status
Sale (authorize and capture in one step) Supported.
Authorize only, capture later from the admin Supported.
Capture, full and partial Supported.
Void (reverse an uncaptured authorization) Supported.
Refund, settlement-aware Supported.
3-D Secure (enrollment, DDC, challenge) Fully verified end to end, including a real Cardinal ACS challenge.
Saved cards, with an IDOR guard Supported.
Gateway timeout reconciliation Implemented.

Refunds follow the shared settlement-aware contract described in Refunds are settlement-aware. A full refund issues one reverseCapture call with creditOnFail, and the gateway decides between reversing and crediting. A partial refund issues refund and fails loudly with service code 536 if the order has not settled; retry it after settlement.

No silent fallback

There is no catch-and-ignore anywhere in the payment path.

  • A 3DS prepare failure is logged loudly, returned to the browser as an error, and the checkout JavaScript refuses to place the order. This is a deliberate divergence from the PrestaShop module, which returned an empty object and let checkout proceed with no 3DS. On an SCA-mandated transaction that produces an unexplained soft decline with nothing in the shop recording why. A merchant who does not want 3DS turns it off in the settings; the extension never decides that at runtime.
  • A gateway timeout means the transaction state is genuinely unknown. The extension reconciles via CCSTATUS on the external order id before failing, and never lets the shopper simply retry into a possible double charge.
  • Declines surface the gateway's own cardholder-safe advice verbatim. Every other refusal shows a neutral message while the specific reason goes to the log for the operator.
  • An order that already carries a status is refused rather than charged again.
  • If the settlement state cannot be determined at all, the extension refuses and says so rather than guessing between two money-moving verbs. This is a deliberate divergence from PrestaShop's original isSettled(), which returned false on error and so quietly turned an unknown state into a reversal.

How checkout works on this platform

The direct-post flow

Card fields live in the checkout page's DOM. The browser exchanges them for a single-use TOKEN_GUID by POSTing directly to Inovio's token_service.cfm, and only that token is submitted to OpenCart.

Unlike the PrestaShop module, the extension does not intercept a form submit. It owns its own confirm button and drives the flow with fetch(), replying with a JSON {redirect} or {error}. That is the same contract OpenCart's own cod extension uses. OpenCart hands the whole payment area to the extension, so the form-interception complexity in the PrestaShop build was PrestaShop-specific, not payment-specific.

The order row also already exists before payment runs. OpenCart's checkout/confirm writes the order at status 0 before the payment extension is called, whereas PrestaShop creates it only after approval. This is a better fit: the amount, currency and addresses are frozen on one authoritative record instead of being recomputed from a live cart.

No Inovio-hosted infrastructure is required: there is no hosted payment page and no iframe you have to redirect to.

The PAN-safety property is structural, not a rule operators must remember:

  • The card fields in catalog/view/template/payment/inovio.twig have no name attribute and are not inside a form that posts to OpenCart. They cannot be submitted to the shop server even by accident.
  • inovio-checkout.js reads them, exchanges them for a token against Inovio directly, and blanks both fields before anything else happens.
  • No server-side PHP file in this extension contains the strings card_pan, card_cvv, cardNumber or PMT_NUMB, excluding the SDK's own server-tokenization helper, which this extension does not call.
  • The vault table stores gateway references (CUST_ID, PMT_ID) plus brand, last four and expiry, which are display-only fields. There is no column that could hold a PAN or CVV.

This was verified, not assumed: a full database dump taken after a live test run contains zero occurrences of the test card number.

The signature endpoint is a minting oracle

extension/inovio/payment/inovio.signature mints HMACs on demand. Left open, it would let anyone tokenize cards against the merchant's site, which is card testing. It enforces three checks:

  1. Session-bound. session.data['payment_method']['code'] must be inovio.inovio. OpenCart has no per-form CSRF token on the storefront (the customer_token in account URLs does not exist during checkout), so the session's own checkout state is the binding. A cross-site attacker cannot set it.
  2. Cart-bound. The session must hold a non-empty cart.
  3. Rate-limited. Twelve signatures per 60 seconds per session.

Every refusal is logged with its reason. A silent 403 here would mean the browser cannot tokenize and the shopper sees an unexplained failure.

3-D Secure

For the two-token mechanics, see Two-token 3D Secure. Token B is persisted to oc_inovio_order_ref at the moment the order is parked, because the ACS return arrives on a separate request that has no access to the original POST.

Securing the ACS return. The ACS POSTs the challenge outcome cross-site, and it may arrive with no session cookie, so the return leg cannot rely on session state. It is bound to a legitimate order by the order id on the return URL and by a constant-time comparison of the ACS TransactionId against the procTransId stored at enrollment. The second is what makes the first safe: an order id is a guessable integer, but the procTransId is not, and only the real ACS knows it. A replay guard additionally refuses any order that has already left the awaiting-3DS status.

Admin operations

Capture, void and refund are driven from the extension's own panel on the order screen, rendered by admin/view/template/payment/inovio_order.twig.

Operation Where What happens
Capture The order screen panel, when Payment Action is Authorize only Captures the reserved funds, full or partial. The order moves to the configured Approved status.
Void The order screen panel Reverses an uncaptured authorization. The order moves to the configured Voided status.
Refund The order screen panel One Refund control. The gateway routes reverse versus credit for a full refund; a partial refund before settlement fails loudly with 536. The order moves to the configured Refunded status.

Order history is written directly by the panel rather than through addHistory(). The admin sale/order model has no addHistory() at all; only the catalog model does, and OpenCart's own admin reaches it by spinning up a second store instance and proxying to the api/order route, which requires a configured API user and fails without one. Worse, the catalog method re-runs anti-fraud extensions, subtracts stock and redeems coupons on a transition into a processing status, all of which are wrong to re-run when capturing or refunding an order that already completed checkout. The panel writes the history row and status itself: exactly the two intended effects, nothing else.

Saved cards

OpenCart 4.1.0.4 has no native payment-token API. It does ship catalog/controller/account/payment_method.php, but that only asks each enabled payment extension for a rendered HTML fragment (extension/<ext>/account/<code>). There is no storage, schema or CRUD behind it, and a stock install has no oc_customer_payment* table of any kind. So the extension owns oc_inovio_stored_card and renders into that native hook, which is strictly better wiring than PrestaShop's displayCustomerAccount.

The ownership (IDOR) guard is mandatory and explicit. Stored-card ids are small integers that appear in form posts, so guessing another shopper's id is trivial. Every lookup or charge goes through Vault::findForCustomer(), which requires customer_id to match the logged-in session as a checked condition, and re-asserts it on the returned row so a future SQL edit cannot quietly drop it. "Row does not exist" and "row is not yours" are answered identically, so the endpoint cannot be used as an oracle for which ids are live.

Deleting a saved card removes the local row only. Whether it should also revoke the gateway-side PMT_ID is an open question against Inovio's customer API; behaviour is kept identical to the PrestaShop module rather than invented here.

Known gaps

  • Only en-gb translations ship. Other languages fall back to OpenCart's default behaviour for a missing language file.
  • Multi-store installs were not tested. The vault is keyed on customer_id only, with no store_id column. PrestaShop's equivalent carries id_shop.
  • Apache access and error logs could not be scanned for the PAN. docker exec hung reliably on those files in the test environment. docker logs, the same streams, was scanned and returned zero. A PAN could never appear in a request URL in any case, since the only request that carries it is a POST body sent to Inovio, not to OpenCart.
  • Whether deleting a saved card should revoke the gateway-side PMT_ID is open.

Verified end to end

A Playwright suite lives in e2e/, covering seven spec files and ten tests.

Spec What it proves
01-sale.spec.js Card checkout approves and confirms.
02-vault.spec.js Save a card, then pay a second order with it.
03-3ds.spec.js A Cardinal Purchase Authentication PAN triggers a real ACS challenge which is completed; and a standard PAN completes checkout frictionlessly with no challenge overlay.
04-decline.spec.js A declined card does not create a paid order.
05-refund.spec.js Refunding a Sale order from the admin.
06-invalid-card-input.spec.js Luhn-failing PAN, short PAN and short CVV are all rejected client-side, with no paid order created.
07-vault-idor.spec.js Shopper B, in a separate browser context, cannot pay with shopper A's saved card. This is the guard for the vault ownership check.

Divergences from the PrestaShop reference

Divergence Rationale
No form-submit interception; the extension owns its confirm button and uses fetch(). PrestaShop has no order-submission JS event, forcing that module to intercept the payment form's native submit. OpenCart hands the payment area to the extension and expects a JSON reply, the same contract its own cod extension uses.
The order row already exists before payment. OpenCart's checkout/confirm writes the order at status 0 before the payment extension runs. The amount, currency and addresses are frozen on one authoritative record.
A 3DS prepare failure is fatal, not skipped. Silently skipping 3DS was a real bug in the PrestaShop build's history. It is not repeated here.
A settlement-check failure refuses instead of assuming "unsettled". Choosing the wrong verb is a money-moving mistake, so the merchant is told instead.
Order history is written directly rather than via addHistory(). See Admin operations.
Custom order statuses are configured, not created. OpenCart already ships the statuses this extension needs.
The vault UI uses a native hook. OpenCart's account/payment_method page already asks each payment extension for a fragment.
Uninstall keeps the tables. See Uninstalling.

Troubleshooting

Symptom Cause Fix
No payment method appears at checkout One of the five required credential fields is empty, so getMethods() returns an empty array and the method withholds itself. Fill in API Username, API Password, Site ID, Site Key and Gateway Product ID, and set Status to enabled.
Still no payment method, credentials complete A Geo Zone is set and the shopper's address falls outside it. Clear the Geo Zone setting, or check the address.
The extension never appears under Extensions, Payments after install The .ocmod.zip was wrapped in an upload/ directory. Rebuild it flat. See Building the package.
Install dies with Class "Opencart\System\Library\Extension\Inovio\Gateway" not found Files are at system/library/inovio/gateway.php instead of system/library/gateway.php. See File placement is load-bearing.
Error 121 at checkout, or the card form never tokenizes Site Key missing or wrong. Enter the per-site HMAC Site Key from Inovio support. It is not the API password.
Invalid Data returned on every transaction The statement descriptor contains a space, underscore or forward slash. Remove them, or clear the field.
Refund fails with SERVICE 536 The order has not settled, so a partial credit is the wrong verb. Retry the partial refund after settlement.
Refund refuses with a "settlement state unknown" error The CCSTATUS call itself failed, so the extension will not guess between two money-moving verbs. Retry once the gateway is reachable. This is deliberate.
A test transaction declines with Insufficient Funds The Inovio test gateway decides from the order total, not the card. Change the order total. See Testing.
Checkout fails and the log shows a refused signature request The signature endpoint is session-bound, cart-bound and rate-limited to 12 per 60 seconds. Every refusal is logged with its reason. Read system/storage/logs/error.log.

Source

Repository: Inoviopay/inovio-gateway-opencart (private, available from Inovio).

Extension code inovio, packaged as inovio.ocmod.zip, installed to extension/inovio/.

upload/
  admin/
    controller/payment/inovio.php     settings, install/uninstall, capture/void/refund
    language/en-gb/payment/inovio.php
    view/template/payment/inovio.twig        settings screen
    view/template/payment/inovio_order.twig  order-screen panel
  catalog/
    controller/payment/inovio.php     index/signature/threeds/confirm/threedsReturn
    controller/account/inovio.php     "Saved cards" UI + delete
    model/payment/inovio.php          offers the method to checkout
    view/template/payment/inovio.twig card form
    view/javascript/inovio-checkout.js direct-post + 3DS client
  system/
    library/gateway.php               gateway service (verbs, refs, request building)
    library/vault.php                 saved-card storage + IDOR guard
    vendor/inovio/                    vendored PHP SDK + classmap autoloader
install.json

File placement is load-bearing

Both startup controllers register Opencart\System\Library\Extension\<Extension> to extension/<code>/system/library/, and OpenCart's autoloader then appends only the class tail after that namespace. So Opencart\System\Library\Extension\Inovio\Gateway resolves to extension/inovio/system/library/gateway.php. There is no second inovio/ directory beneath system/library/.

This build originally shipped the files at system/library/inovio/gateway.php, which looks natural and is wrong. The failure mode is nasty: everything appears fine until the moment you press Install, which dies with Class "Opencart\System\Library\Extension\Inovio\Gateway" not found, after the oc_extension row has already been written but before the schema is created.

The vendored SDK

system/vendor/inovio/gateway-sdk/ is a verbatim copy of the Inovio PHP SDK, loaded by a small classmap autoloader at system/vendor/inovio/autoload.php. It is vendored rather than Composer-required because the package is not on Packagist, and an OpenCart extension installs as a self-contained zip through the admin UI with no Composer step in that path. The SDK has zero Composer dependencies, which makes this clean.

The autoloader indexes by file content, not by filename, for two reasons. OpenCart's own autoloader lower-cases and snake-cases class tails, so ResultMapper would be looked for at result_mapper.php; and the SDK's files are not one-to-one with class names anyway, since Result.php declares about a dozen classes. Neither OpenCart's convention nor PSR-4 can resolve it.