# WooCommerce

The Inovio Payment Gateway plugin for WooCommerce, with tokenized direct-post card entry on both the classic and Block checkouts.

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

Tokenized direct-post card checkout for WooCommerce, on the Inovio gateway. The
card number never reaches the WordPress server.

## Overview and status

> **Functional pre-release**
> Sale, authorize, capture, void, refund, vault and 3-D Secure are implemented
> and have been exercised end to end against the gateway. A 10-spec Playwright
> suite drives the real storefront and the real wp-admin. Not yet hardened for
> production.

The plugin was ported from the PrestaShop module, which carries the full design
rationale. What makes the WooCommerce build different from its siblings is that
WooCommerce has a native payment-token API, so saved cards use the platform's
own vault with no custom UI, and that WooCommerce ships two entirely different
checkouts. Both are supported.

## Requirements

| | |
|---|---|
| WordPress | 6.0+ |
| WooCommerce | 7.0+. Block Checkout support needs 8.3+ |
| PHP | 8.0+ |
| PHP extensions | `bcmath` (required), `curl`, `json` |
| From Inovio | API username, API password, Site ID, Site Key, Gateway Product ID |

`bcmath` is required, not optional. The SDK's `Money` type does every decimal
calculation through it so amounts never touch a binary float. Installation fails
without it.

## Install

Copy the `inovio-payment-gateway/` directory into `wp-content/plugins/`, or
upload the ZIP through **Plugins, Add New, Upload Plugin**. Then activate it.


Configure it under **WooCommerce, Settings, Payments, Inovio**.

**WP-CLI**

```bash
wp plugin activate inovio-payment-gateway
```

**Admin UI**

```text
Plugins -> Installed Plugins -> Inovio Payment Gateway -> Activate
```

## Configuration

All settings live on the Inovio payment method screen under **WooCommerce,
Settings, Payments**.

### Gateway credentials

| Setting | Required | Meaning |
|---|---|---|
| Enable/Disable | Yes | Turns the payment method on. |
| Title | No | The payment method name shoppers see at checkout. Defaults to "Credit card". |
| Description | No | Shown beneath the payment method name at checkout. |
| API Username | Yes | Gateway `REQ_USERNAME`. |
| API Password | Yes | Gateway `REQ_PASSWORD`. |
| 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 | The per-site HMAC secret. Not the API password. |
| Gateway Product ID | Yes | `LI_PROD_ID`. Not a WooCommerce SKU. |
| Gateway Endpoint | No | The `pmt_service.cfm` transaction URL. The tokenization and 3-D Secure endpoints are derived from it, exactly as the SDK derives them, so the three cannot drift apart. Leave it at the default for production. |

**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 only
to sign the browser's tokenization request. Without it the browser cannot
tokenize and checkout fails with error 121.

**Gateway Product ID** (`LI_PROD_ID`) is a product registered on the gateway,
not a product or SKU from your WooCommerce catalogue. The whole order bills as
one line item under it.

Until API Username, API Password, Site ID, Site Key and Gateway Product ID are
all filled in, `is_configured()` returns false and the method 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 the funds and leaves the order on hold until you capture it from the order screen. |
| 3-D Secure | No | Enables 3-D Secure authentication. Requires a 3DS-configured merchant account. Off by default. |
| Saved Cards | No | Lets logged-in shoppers save cards for reuse. On by default. Stores the gateway's own card references only, never a card number. |
| Statement Descriptor | No | `PMT_DESCRIPTOR`, what appears on the cardholder's statement. See the warning below. |
| Descriptor Phone | No | `PMT_DESCRIPTOR_PHONE`, support number shown alongside the descriptor. |
| Debug Logging | No | Logs gateway activity to **WooCommerce, Status, Logs**. Declines and errors are always logged. Card numbers are never logged, because they never reach this server. |

> **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. If you are unsure,
> leave the field empty.

## Payment behaviour

| Capability | Support |
|---|---|
| Sale | Authorize and capture in one step at checkout. |
| Authorize only | Reserve funds, capture later from the order screen. |
| Capture | Admin order action when the payment action is authorize-only. |
| Void | Reverse an uncaptured authorization, from the order screen. |
| Refund | Full and partial, through WooCommerce's own admin refund UI (`process_refund()`). Settlement-aware. |
| Saved cards | Native `WC_Payment_Tokens`. Saved cards appear in the shopper's account and at checkout with no custom UI. |
| 3-D Secure | Device-data collection and challenge, two-token flow. |
| Checkout | Both the classic `[woocommerce_checkout]` shortcode and the Block Checkout (`woocommerce/checkout`). A default WooCommerce install works out of the box. |

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 yet; retry it
after settlement.

A gateway timeout means the transaction state is genuinely unknown. The plugin
reconciles via `status()` before failing an order, including on the 3-D Secure
completion leg, rather than letting the shopper retry into a possible double
charge.

## How checkout works on this platform

### The direct-post flow

Card fields live in the checkout page's DOM. The shipped JavaScript reads them,
asks the plugin for an HMAC signature, exchanges the PAN for a single-use
`TOKEN_GUID` by POSTing directly to Inovio, and submits only that token to
WordPress. Nothing in the payment path on your server ever sees a card number.

This holds identically on both checkout types, because both scripts tokenize the
same way and both hand the same field names to the same `process_payment()`.

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

Saved cards store the gateway's own references (`CUST_ID`, `PMT_ID`) plus the
card brand and last four digits, which are display-only fields. No PAN is ever
stored. Orders store only gateway references
(`_inovio_po_id`, `_inovio_trans_id`, `_inovio_req_id`).

This was verified, not assumed: a full `mysqldump` of the WordPress database
taken after a live test run contains zero occurrences of either test card
number, in any representation (plain, spaced, dashed).

### Two checkouts, one server implementation

A default WooCommerce install of 8.3 or later puts the Block Checkout on the
checkout page, not the classic shortcode. `WC_Payment_Gateway` classes are
invisible to it on their own, so the plugin also registers
`Inovio_Blocks_Support` (extending
`Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType`)
on the `woocommerce_blocks_payment_method_type_registration` hook, and ships a
second checkout script registered via
`wc.wcBlocksRegistry.registerPaymentMethod`.

That script performs the same direct-post tokenization as the classic script.
The tokenize, validate and 3DS helper functions are deliberately mirrored
between the two files rather than shared, because the Block Checkout and the
classic checkout are different JS runtimes with different script-dependency
graphs and there is no bundler in this plugin. If you change one, keep the
behaviour identical in the other.

Server-side there is only **one** implementation.
`Inovio_Payment_Gateway::process_payment()` is unchanged.
`Automattic\WooCommerce\StoreApi\Legacy::process_legacy_payment()` sets `$_POST`
from the Blocks `paymentMethodData` before calling it, so
`collect_payment_data()`'s existing `$_POST` reads pick up the Blocks-minted
tokens exactly as they pick up the classic form's hidden fields. Same field
names, same code path, same PAN-safety guarantees.

### 3-D Secure on the Block Checkout

The Store API's `payment_result.payment_details` response schema types every
value as a plain `string` (`CheckoutSchema::get_item_schema()`). A nested-array
3DS payload gets silently coerced by WordPress's REST schema sanitizer into the
literal string `"Array"` on the Blocks path, a fully swallowed failure with no
error anywhere. This was verified empirically. The contract for both checkout
types is therefore that `inovio_3ds` travels as a **JSON string**, parsed back
into an object by whichever checkout script reads it.

The Blocks 3DS challenge overlay is driven from `onCheckoutSuccess`
(`window.wc.blocksCheckoutEvents.checkoutEvents`), which the Store API awaits via
`emitWithAbort` before marking checkout complete. Returning a promise from the
handler holds the shopper on the checkout page, challenge overlaid on top, until
the ACS challenge resolves, instead of navigating to a thank-you page for a
payment that has not actually completed. A failed challenge resolves with an
`ERROR` response instead of `SUCCESS`, which keeps the shopper on checkout with
a notice; the order itself was already left `pending` server-side by
`begin_challenge()`, so there is nothing to undo.

For the two-token mechanics that apply on both checkouts, see
[Two-token 3D Secure](https://developer.inoviopay.com/carts/index.md#two-token-3d-secure).

## Admin operations

| Operation | Where | What happens |
|---|---|---|
| Capture | The order screen, when Payment Action is *Authorize only* | Captures the reserved funds. |
| Void | The order screen | Reverses an uncaptured authorization. |
| Refund | WooCommerce's own refund UI on the order screen | Calls `process_refund()`. Full and partial both supported; the verb is chosen as described above. |

Refunds go through WooCommerce's native admin refund control, not a
plugin-specific button, so the merchant workflow is the standard one. Debug
logging, when enabled, writes gateway activity to **WooCommerce, Status, Logs**.

## Saved cards

Saved cards use `WC_Payment_Tokens`, WooCommerce's own vault. Unlike PrestaShop
and OpenCart, Woo has a token API, so saved cards appear in the shopper's
account and at checkout with no custom UI from the plugin.

Saved-card **reuse** works on the Block Checkout without any extra code.
WooCommerce's own saved-payment-token UI renders there regardless of what a
payment method declares, because it comes from `WC_Payment_Tokens` rather than
from `get_supported_features()`, and it submits `wc-inovio-payment-token`
generically as part of the Store API's payment data, which
`collect_payment_data()` already reads unchanged on either checkout type. This
was verified live: an existing saved Visa was charged through the Block Checkout
with zero new code and zero new tokens created.

When a saved card is selected, the new-card fields are hidden. This matters:
WooCommerce auto-selects a stored card, and a saved-card payment needs no
tokenization, so anything typed into the new-card fields would be silently
dropped and the stored card charged instead. Hiding them prevents a shopper who
types a different card from being charged the old one.

## Known gaps

- **Saving a new card is classic-checkout-only.** The classic checkout renders
  its own "Save this card for future purchases" checkbox
  (`$this->save_payment_method_checkbox()`). The Blocks card-entry component
  does not render an equivalent, and `Inovio_Blocks_Support::get_supported_features()`
  deliberately omits `'tokenization'`, so WooCommerce Blocks' own built-in
  save-option checkbox does not appear either. Verified live: entering a new
  card on the Block Checkout shows no save-card control anywhere on the page. A
  shopper can charge a new card there, with and without 3DS, but cannot opt to
  save it for later. Reusing an already-saved card works on both checkouts.

## Verified end to end

A 10-spec Playwright suite in `e2e/` drives the classic checkout through a real
browser, with screenshots and video:

```bash
cd e2e && npx playwright test
```

| Spec | What it proves |
|---|---|
| `01-sale.spec.js` | Add to cart, pay with a new card, order confirms. |
| `02-vault.spec.js` | Save a card, then reuse it on a later order. |
| `03-3ds.spec.js` | A real Cardinal ACS challenge is presented and completed by the shopper. |
| `04-frictionless-3ds.spec.js` | With 3DS active and a frictionless PAN, no challenge appears and the order confirms. |
| `05-decline.spec.js` | A declined card does not create a paid order. |
| `06-refund.spec.js` | Refunding a Sale order from the WooCommerce admin order screen. |
| `07-invalid-card-input.spec.js` | Luhn-failing PAN, short PAN, short CVV and a past expiry are all rejected client-side, with no order created. |
| `08-blocks-checkout.spec.js` | A real order completes through the actual Block Checkout DOM, placed by the same server-side `process_payment()` the classic specs use. |
| `09-saved-card-ux.spec.js` | With a saved card selected, the new-card fields are hidden rather than editable-but-ignored. |

The suite requires the checkout page to be on the classic `[woocommerce_checkout]`
shortcode; `playwright.config.js`'s global setup switches the page to it and the
teardown restores the Block Checkout. Spec 08 needs the opposite and switches the
page itself, so it is self-contained regardless of run order.

Block Checkout was also verified live outside the suite, with the checkout page
switched to `<!-- wp:woocommerce/checkout -->`: a new card without 3DS
(`PO_ID 18193587`, `TRANS_ID 2001994863`, zero browser console errors, genuine
`.wp-block-woocommerce-checkout` DOM confirmed rather than a fallback render),
and a new card with a real Cardinal step-up OTP entered through the nested
cross-origin ACS iframe (`PO_ID 18193577`, `TRANS_ID 2001994852`, `ECI 05`, with
an order note confirming 3-D Secure authentication completed). A full
`mysqldump` after these runs contained zero occurrences of either test PAN.

There is also a CLI script, `tests/e2e-order.php`, that places a real order
through the plugin's own gateway class, performing exactly the two steps the
checkout JS does in the browser and handing only the resulting token to
`process_payment()`.

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| No payment method appears at checkout | One of the five required credential fields is empty, so the method hides itself. | Fill in API Username, API Password, Site ID, Site Key and Gateway Product ID. |
| Still no payment method, and credentials are complete | The checkout page is neither the classic `[woocommerce_checkout]` shortcode nor the `woocommerce/checkout` block, for example a page builder's own checkout. | Put the checkout page on one of the two supported checkout types. |
| 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. |
| No "save this card" control on the Block Checkout | Known gap. Vaulting a new card is classic-checkout-only. | Reusing an already-saved card works on both. |
| Refund fails with `SERVICE 536` | The order has not settled, so a partial credit is the wrong verb. | Retry the partial refund after settlement. A full refund is routed by the gateway and does not hit this. |
| 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). |

## Source

Repository:
[Inoviopay/inovio-gateway-woocommerce](https://github.com/Inoviopay/inovio-gateway-woocommerce)
(private, available from Inovio).

Plugin directory: `inovio-payment-gateway/`, installed to
`wp-content/plugins/inovio-payment-gateway/`.
