# 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.


| 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 **Shared 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. 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. No plugin stores the card number.

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 (built 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 |
| Shared 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.

**Shared Key** is a **separate client-level HMAC secret that you generate in the
Inovio portal under Dev Tools → Key Manager**. One key covers every site on your Inovio client. It
is not the API password. 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 3D 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.

Behavior 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**
> The plugins do not attempt a module-side settlement fallback. The gateway
> decides how to route the refund. If you extend a plugin, do not add one. A
> pre-check that reverses on the unsettled path over-refunds: a $10 partial
> refund against an unsettled $100 order reverses the full $100 while the store
> records $10. And a 536 comes back as `DECLINED`, not `FAILED`, so hand-rolled
> fallback logic misfires.

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 3D 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`.
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 [3D 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 3D Secure challenge |
| `4000000000002503` | Triggers a 3D 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)
