# OpenCart

The Inovio Payment Gateway extension for OpenCart 4, with tokenized direct-post card entry, configurable order statuses and an IDOR-guarded vault.

Source: https://developer.inoviopay.com/carts/opencart.html  
Markdown: https://developer.inoviopay.com/carts/opencart.md
Repository: https://github.com/Inoviopay/inovio-gateway-opencart

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

```bash
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](https://developer.inoviopay.com/carts/index.md#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](https://developer.inoviopay.com/carts/index.md#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](#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](#install). |

## 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). |
| 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](#source). |
| 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](https://developer.inoviopay.com/carts/index.md#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](https://github.com/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.
