# Shopping cart plugins

Tokenized direct-post card checkout for WooCommerce, Magento 2, PrestaShop 9 and OpenCart 4, on the Inovio gateway.

Source: https://developer.inoviopay.com/carts/index.html  
Markdown: https://developer.inoviopay.com/carts/index.md

Inovio ships four first-party cart plugins. They are separate codebases, one per
platform, but they are the same integration: the same PHP SDK, the same
tokenized direct-post card entry, the same refund contract, and the same five
credentials from Inovio.

> **Note**
> The plugins are functional pre-release. Every verb documented here has been
> exercised end to end against the gateway, and each plugin ships a Playwright
> suite that drives a real browser through the real storefront and admin. They
> are not yet hardened for production.


| Platform | Page | Package |
|---|---|---|
| WooCommerce | [WooCommerce](https://developer.inoviopay.com/carts/woocommerce.md) | `inovio-payment-gateway` |
| Magento 2 / Mage-OS | [Magento 2](https://developer.inoviopay.com/carts/magento2.md) | `inovio/module-payment-gateway` |
| PrestaShop 9 | [PrestaShop](https://developer.inoviopay.com/carts/prestashop.md) | `inoviopayment` |
| OpenCart 4 | [OpenCart](https://developer.inoviopay.com/carts/opencart.md) | `inovio.ocmod.zip` |

## What they have in common

All four use **tokenized direct post**. The card number never reaches the store
server.

1. Card fields render in the store's own checkout page, in the merchant's own
   DOM. There is no hosted payment page and no Inovio-hosted iframe to redirect
   to.
2. The plugin's checkout JavaScript asks the store server for an HMAC
   signature. The store mints it from the **Site Key** and returns it. The
   store never sees the card.
3. The browser POSTs the PAN, expiry and CVV **directly to Inovio's
   `token_service.cfm`**, with that signature in the `X-timestamp` and
   `X-signature` headers, and gets back a single-use `TOKEN_GUID`.
4. Only the `TOKEN_GUID` (plus the brand and last four digits, which are
   display-only fields) is submitted to the store.
5. The store server sends the transaction to `pmt_service.cfm` through the
   [PHP SDK](https://developer.inoviopay.com/sdks/php.md), carrying the token instead of a card number.

The signature covers `timestamp + uniqueId + siteId`. **The PAN is not part of
the signed message.** The v4.14 PDF says it is, and its worked example agrees;
both are wrong. Verified against the gateway: the token service validates the
signature without the PAN, and including it yields error 121, "signature match
fail". The SDK's `Tokenize::signRequest()` is the single source of truth. Never
hand-roll this hash.

No Inovio-hosted infrastructure is required: there is no hosted payment page and
no iframe you have to redirect to. In each plugin this was verified rather than
assumed: a full database dump taken after a live test run contains zero
occurrences of the test card number in any representation.

All four consume the same **[PHP SDK](https://developer.inoviopay.com/sdks/php.md)** (`inovio/gateway-sdk`).
None of them touch raw `REQUEST_ACTION` wire fields. Magento requires it through
Composer; PrestaShop and OpenCart vendor a verbatim copy into the shipped
package, because the SDK is not on Packagist and both platforms install as a
self-contained archive through the admin UI with no Composer step.

## Capability matrix

Values come from each plugin's own README. A blank behaviour is not implied
anywhere: where a platform does something differently, the cell says so.

| | WooCommerce | Magento 2 | PrestaShop 9 | OpenCart 4 |
|---|---|---|---|---|
| Sale | Yes | Yes | Yes | Yes |
| Authorize only | Yes | Yes | Yes | Yes |
| Capture, full | Yes | Yes | Yes | Yes |
| Capture, partial | No, full capture only (WooCommerce has no capture-amount field) | Yes, via Magento invoicing | Yes | Yes |
| Void | Yes | Yes | Yes | Yes |
| Refund, full | Yes | Yes | Yes | Yes |
| Refund, partial | Yes | Yes | Yes | Yes |
| Saved cards | Native `WC_Payment_Tokens` | Magento Vault | Module's own table plus a customer-account UI | Module's own table, rendered into OpenCart's native account hook |
| 3D Secure | Yes, two-token flow | Yes, frictionless and challenge | Yes, with a module-created pending order state | Yes, enrollment / DDC / challenge |
| Block or modern checkout | Both the classic shortcode and the Block Checkout | Standard Magento checkout | Standard PrestaShop 9 checkout | Standard OpenCart 4 checkout |
| Timeout reconciliation | Yes | Yes, via `status()` | Yes, via `status()` | Yes, via `CCSTATUS` |

Three details the matrix flattens:

- **WooCommerce saved cards are checkout-type dependent.** A shopper can *use*
  an existing saved card on either the classic checkout or the Block Checkout,
  but can only *save a new* card on the classic checkout. See
  [WooCommerce known gaps](https://developer.inoviopay.com/carts/woocommerce.md#known-gaps).
- **Magento does not hide itself on incomplete credentials.** WooCommerce,
  PrestaShop and OpenCart all withhold the payment method at checkout until all
  five required fields are filled in. Magento will show the method and then
  fail.
- **Wallets are out of scope in all four.** Apple Pay and Google Pay are
  gateway features (see [Apple Pay](https://developer.inoviopay.com/api/apple-pay.md) and
  [Google Pay](https://developer.inoviopay.com/api/google-pay.md)) but are not wired into any cart plugin
  yet, and neither are LatAm rails or raw-card admin MOTO orders.

## Requirements

| | WooCommerce | Magento 2 | PrestaShop 9 | OpenCart 4 |
|---|---|---|---|---|
| Platform | WordPress 6.0+, WooCommerce 7.0+ (Block Checkout needs 8.3+) | Magento 2.4.4+, or Mage-OS | PrestaShop 9.0+ | OpenCart 4.1.x, verified against 4.1.0.4 |
| PHP | 8.0+ | 8.1+ | 8.1+ | 8.1+ |
| Extensions | `bcmath`, `curl`, `json` | `bcmath`, `curl`, `json` | `bcmath`, `curl`, `json` | `bcmath`, `curl` |

`bcmath` is required, not optional, in every one of them. The SDK's `Money`
type does every decimal calculation through it so amounts never touch a binary
float. Installation fails without it. Magento 2.4 requires `bcmath` in its own
right, so a compliant Magento install already has it; OpenCart's settings
screen refuses to enable the payment method without it.

PrestaShop is 9.x only. PrestaShop 8.0 through 8.2 supports PHP 7.2.5 through
8.1 and no higher, while 9.x requires 8.1+; the two lines overlap at exactly PHP
8.1 and maintain separate documentation branches. PrestaShop 1.7 is end of
life.

### What you need from Inovio

The same five values on every platform.

| Credential | Gateway field | Required |
|---|---|---|
| API username | `REQ_USERNAME` | Yes |
| API password | `REQ_PASSWORD` | Yes |
| Site ID | `SITE_ID` | Yes |
| Site Key | (HMAC signing secret, not sent as a field) | Yes |
| Gateway Product ID | `LI_PROD_ID` | Yes |
| Merchant Account ID | `MERCH_ACCT_ID` | No |

## The five credentials explained

**API username and API password** are your gateway API credentials, sent as
`REQ_USERNAME` and `REQ_PASSWORD` on every transaction. See
[Authentication](https://developer.inoviopay.com/api/authentication.md).

**Site ID** is `SITE_ID`, the gateway site the transactions belong to.

**Site Key** is a **separate per-site HMAC secret issued by Inovio support**. It
is not the API password, and it is not something you can generate. It is used
for exactly one thing: signing the browser's tokenization request. Without it
the browser cannot tokenize and checkout fails with **error 121**, "Get CCtoken
GUID signature match fail". This is the single most common misconfiguration, and
the symptom is a card form that never produces a token rather than an obvious
credential error.

**Gateway Product ID** is `LI_PROD_ID`, a product registered on the **gateway**.
It is not a SKU from your store catalogue. The whole order bills as one line
item under it, regardless of how many products the cart contains. See
[Line items](https://developer.inoviopay.com/api/line-items.md).

**Merchant Account ID** is `MERCH_ACCT_ID` and is optional in all four plugins.
Leave it empty to let the gateway distribute by currency and country.

Every plugin also exposes a gateway endpoint setting. In all four, the
tokenization endpoint (`token_service.cfm`) and the 3-D Secure endpoint
(`3dsrequest.cfm`) are **derived from the `pmt_service.cfm` URL** by suffix
rewrite, exactly as the SDK derives them, so the three cannot drift apart. There
is one setting, not three. Leave it at the default for production.

## Refunds are settlement-aware

This is a shared design across all four plugins, and it is the one place where
the obvious implementation loses money.

The gateway rejects a credit (`CCCREDIT`) against an order that has **not
settled yet** with `SERVICE 536`, "Order not settled: Please reverse". Before
settlement the correct undo verb is a reversal (`CCREVERSECAP`), not a credit. A
naive refund-only button therefore fails on every same-day refund.

The plugins do **not** pre-check settlement and pick a verb themselves. The
gateway owns that decision:

**Full refund** issues `reverseCapture($ref, creditOnFail: true)`, which sends
`CCREVERSECAP` with `CREDIT_ON_FAIL=1`. When the transaction is already settled
and cannot be reversed, **the gateway itself re-routes to `CCCREDIT`**. One
call, one round trip, and the plugin never has to know the settlement state.
This is documented in v4.14 §5.4.2, confirmed in the gateway's own order
handling, and used by the gateway internally on system auto-voids.

Verified live on both paths:

| Case | Request | Response |
|---|---|---|
| Unsettled capture | `CCREVERSECAP` with `CREDIT_ON_FAIL=1` | `REQUEST_ACTION=CCREVERSECAP`, APPROVED |
| Settled capture | `CCREVERSECAP` with `CREDIT_ON_FAIL=1` | `REQUEST_ACTION=CCCREDIT`, APPROVED |
| Control: settled, no flag | `CCREVERSECAP` | `SERVICE 515`, "Order fully credited" |

**Partial refund** issues `refund($ref, $money)`, which is always `CCCREDIT`,
because a reversal takes no amount and voids the entire capture. Against an
unsettled order the gateway returns 536, and the plugin **fails loudly**:
"order not settled, partial refunds available after settlement". The merchant
sees the error, no money moves, and the refund is retried after settlement.

> **There is no module-side fallback, and you must not add one**
> Every plugin previously carried a settlement pre-check and a 536 fallback
> branch. Both are deleted. The pre-check dropped the requested amount on the
> unsettled path, so a partial refund of $10 against an unsettled $100 order
> reversed the full $100 while the store recorded $10. The fallback branch was
> dead code: it tested for status `FAILED`, but a real 536 arrives as
> `DECLINED`. If you are extending a plugin, let the gateway route the verb.

See [Reversal](https://developer.inoviopay.com/api/reversal.md) and [Credit](https://developer.inoviopay.com/api/credit.md) for the
underlying request types, and [service codes](https://developer.inoviopay.com/reference/service-codes.md)
for 536.

## The statement descriptor warning

> **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. A multi-word descriptor is the natural
> thing for a merchant to type, and it kills every sale. The SDK now rejects an
> invalid descriptor rather than letting the gateway kill the sale. If you are
> unsure, leave the field empty.

## Two-token 3D Secure

When 3-D Secure is enabled, checkout mints **two** `TOKEN_GUID`s from the same
card entry. Gateway tokens are single-use and the enrollment leg consumes one,
so token A drives enrollment and token B completes the transaction after the
cardholder finishes the challenge. Both resolve to the same card at the gateway,
so the authentication carries across the two legs.

Reusing token A on the completion leg fails with `API 401 Invalid TOKEN_GUID`,
verified against the live gateway. This is the most tempting thing in any of the
four codebases to collapse into one token, and doing so produces the worst
possible failure mode: it appears to work for every frictionless card, which
never reaches the completion leg at all, and fails only for cards that are
actually challenged.

See [3-D Secure through the gateway](https://developer.inoviopay.com/api/3ds-gateway.md).

## Testing

The same test cards and decline triggers apply on all four platforms, against
the **Inovio test gateway** only.

| Card | Behaviour |
|---|---|
| `4111111111111111` | Approves, no 3-D Secure challenge |
| `4000000000002503` | Triggers a 3-D Secure challenge. Sandbox OTP `1234` |

Any future expiry date and any CVV are accepted.

The sandbox decides declines from the **whole order total**, matched exactly.
The PAN, expiry and CVV are not consulted at all.

| Order total | Result |
|---|---|
| `6.35` | Declined, Insufficient Funds |
| `5.06` | Declined, Fraud |

The total must land on the trigger amount exactly, including tax and shipping,
not the product price. This is also why a test order can decline unexpectedly:
check the total before assuming the card or the credentials are at fault.

See [Testing](https://developer.inoviopay.com/api/testing.md).

## Source

All four repositories are private. They are available from Inovio.

- [Inoviopay/inovio-gateway-woocommerce](https://github.com/Inoviopay/inovio-gateway-woocommerce)
- [Inoviopay/inovio-gateway-magento2](https://github.com/Inoviopay/inovio-gateway-magento2)
- [Inoviopay/inovio-gateway-prestashop](https://github.com/Inoviopay/inovio-gateway-prestashop)
- [Inoviopay/inovio-gateway-opencart](https://github.com/Inoviopay/inovio-gateway-opencart)
