OpenCart
The Inovio Payment Gateway extension for OpenCart 4, with tokenized direct-post card entry, configurable order statuses and an IDOR-guarded vault.
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
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 ..
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
- Admin, Extensions, Installer: upload
inovio.ocmod.zip, then press Install on the row that appears. - Admin, Extensions, Extensions: choose extension type Payments, find Inovio Payment Gateway, press the green + (Install). This step creates the extension's two database tables.
- 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 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
CCSTATUSon 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.twighave nonameattribute and are not inside a form that posts to OpenCart. They cannot be submitted to the shop server even by accident. inovio-checkout.jsreads 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,cardNumberorPMT_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:
- Session-bound.
session.data['payment_method']['code']must beinovio.inovio. OpenCart has no per-form CSRF token on the storefront (thecustomer_tokenin account URLs does not exist during checkout), so the session's own checkout state is the binding. A cross-site attacker cannot set it. - Cart-bound. The session must hold a non-empty cart.
- 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-gbtranslations 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_idonly, with nostore_idcolumn. PrestaShop's equivalent carriesid_shop. - Apache access and error logs could not be scanned for the PAN.
docker exechung 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_IDis 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.