# PrestaShop

The Inovio Payment Gateway module for PrestaShop 9, with tokenized direct-post card entry, module-built vaulting and 3-D Secure.

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

Tokenized direct-post card checkout for PrestaShop 9, on the Inovio gateway. The
card number never reaches the shop server.

## Overview and status

> **Functional pre-release**
> Sale, authorize, capture (full and partial), void, refund and vaulting are
> implemented and exercised end to end. An 18-spec Playwright suite drives the
> real storefront and the real back office. Two things are not yet proven in a
> real browser: see [Known gaps](#known-gaps).

This is the reference implementation of the four. The other three plugins were
ported from it, and the design document behind it is the shared design rationale
for all of them.

PrestaShop gives the least framework of the four platforms. It has no native
payment-token or vault API, no platform concept of authorize versus capture, no
pending-authentication order state, and no order-submission JavaScript event.
Each of those is a verified absence against official PrestaShop 9 documentation,
not an assumption, and each is a thing this module builds itself. The module
creates its own saved-card table and its own two order states (awaiting capture,
awaiting 3-D Secure) at install time.

## Requirements

| | |
|---|---|
| PrestaShop | 9.0+ (9.x only) |
| PHP | 8.1+ |
| 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.

PrestaShop 8.x is not supported. 8.0 through 8.2 supports PHP 7.2.5 through 8.1
and no higher, while 9.x requires 8.1+ and supports through 8.5. The two lines
overlap at exactly PHP 8.1 and PrestaShop maintains separate documentation
branches for them, so supporting both would mean runtime version shims or two
builds. PrestaShop 1.7 is end of life as of the 9.0 release.

## Install

Copy the `inoviopayment/` directory into your shop's `modules/` directory, or
upload the ZIP through **Modules, Module Manager, Upload a module**. Then
install it.


Configure it under **Payment, Payment Methods, Inovio Payment Gateway,
Configure**.

Installation creates the module's own saved-card table and the two order states
it needs.

**CLI**

```bash
php bin/console prestashop:module install inoviopayment
```

**Admin UI**

```text
Modules -> Module Manager -> search "Inovio" -> Install
```

## Configuration

All settings are on the module's Configure screen.

### Gateway credentials

| Setting | Required | Meaning |
|---|---|---|
| API Username | Yes | Gateway `REQ_USERNAME`. |
| API Password | Yes | Gateway `REQ_PASSWORD`. Leave blank when saving to keep the stored 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 PrestaShop SKU. |
| Gateway Endpoint | No | The `pmt_service.cfm` transaction URL. The tokenization endpoint (`token_service.cfm`) and the 3-D Secure endpoint are derived from it by suffix rewrite, 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 catalogue. The whole order bills as one line item
under it.

Until all five required fields are filled in, the module 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 awaiting a capture you trigger from the order page. |
| Enable 3D Secure | No | Runs 3-D Secure authentication. Requires a 3DS-configured merchant account. |
| Enable Saved Cards | No | Lets logged-in customers save a card for reuse. |
| 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. 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. 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 | Support |
|---|---|
| Sale | Authorize and capture in one step at checkout. |
| Authorize only | Reserve funds, capture later from the order page. |
| Capture | Full and partial, from the order page. |
| Void | Reverse an uncaptured authorization. |
| Refund | Full and partial, settlement-aware. |
| Saved cards | The module's own storage plus a customer-account UI. PrestaShop has no vault API. |
| 3-D Secure | Device-data collection and challenge, with a module-created pending order state. |
| Timeout recovery | Reconciles via `status()` before failing an order. |

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. The merchant gets one Refund control and is never asked to guess at
the settlement state, because they have no way to know it.

Refunds are driven from PrestaShop's `actionOrderSlipAdd` hook. All three of
PrestaShop 9's refund flows go through `OrderSlipCreator`, which fires that hook
once per operation with the created slip; the amount is
`total_products_tax_incl + total_shipping_tax_incl`. The `TransactionResult` is
consumed, so a declined refund throws rather than producing a credit slip that
tells the customer they were refunded.

A gateway timeout means the transaction state is genuinely unknown. The module
reconciles via `status()` before failing an order, including on the 3-D Secure
completion leg. A non-challenge `PENDING` response is parked in a pending state
with the references recorded, not treated as a decline, because the gateway may
still settle it.

## How checkout works on this platform

### The direct-post flow

Card fields live in the checkout page's DOM, inside the embedded PaymentOption
form. The shipped JavaScript reads them, requests an HMAC signature from the
module's `signature` front controller, exchanges the PAN for a single-use
`TOKEN_GUID` by POSTing directly to Inovio, and only that token is submitted to
PrestaShop. Nothing in the payment path on your server ever sees a card number.

The card fields carry **no `name` attribute**, so a JavaScript failure cannot
POST the PAN to the shop server by accident.

Because PrestaShop has no order-submission JavaScript event, the module
intercepts the payment form's own `submit` event and re-submits it once
tokenization completes. This is undocumented but standard practice across
PrestaShop payment modules.

What reaches the PrestaShop server is the whole payload and nothing else:
`token_guid`, `token_guid_completion` (3DS only), `pmt_expiry`, `cc_brand`,
`cc_last4`, and the 3DS `ddc_reference_id` and browser data. **The CVV is never
in that list.** It goes browser-direct to the token service alongside the PAN
and is stored nowhere, by anyone.

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, last four digits 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 `mysqldump` of the shop database taken after a live
test run contains zero occurrences of the test card number in any representation.
`ps_order_payment.card_number` holds the last four digits only.

> **PrestaShop core ships columns that will happily hold a PAN**
> Core provides `ps_order_payment.card_number`, `card_brand`, `card_expiration`
> and `card_holder`, which any module may populate, with no guardrail against a
> full PAN being written there. This module writes last four only. Treat these
> columns as display fields; they are the one place on this platform where a
> careless implementation would put cardholder data into the shop database.

### The signature endpoint

`controllers/front/signature.php` mints HMACs on demand. Left open it would let
anyone tokenize cards against the merchant's site, so it is CSRF-protected with a
per-visitor random nonce, cart-bound, and rate-limited with a server-side
database-backed counter. The 3DS prepare endpoint is rate-limited too, because
every hit there is a paid gateway call.

### 3-D Secure

The module creates its own "awaiting 3-D Secure" order state at install, because
PrestaShop has no pending-authentication state and no equivalent state machine.
The order is parked there during the challenge and exited by an explicit
`OrderHistory` transition. The ACS return arrives at the `threedsreturn` front
controller, which is CSRF-exempt and authenticated by `TransactionId` instead.

For the two-token mechanics, 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 page, when Payment Action is *Authorize only* | Captures the reserved funds, full or partial. The order leaves the module's "awaiting capture" state. |
| Void | The order page | Reverses an uncaptured authorization. |
| Refund | PrestaShop's own partial-refund and standard-refund flows on the order page | Fires `actionOrderSlipAdd`; the module issues the transaction and throws on a non-approved result. |

PrestaShop has no documented admin hook for a merchant-triggered capture, so the
module owns its own back-office controls for capture and void, alongside the two
order states it created at install.

## Saved cards

PrestaShop has **no native payment-token or vault API**. Core ships
`ps_order_payment.card_number`, `card_brand`, `card_expiration` and `card_holder`
columns, but there is no storage, encryption, UI or API behind them, and no
official example module demonstrates vaulting. This is what real modules do:
PayPlug, SIBS and Axerve all ship one-click this way.

So the module owns an `inovio_stored_card` table (an `ObjectModel`) plus a
`storedcards` front controller for the customer-account UI, surfaced through the
`displayCustomerAccount` hook. Rows hold the gateway's `CUST_ID` and `PMT_ID`
references plus brand, last four and expiry.

The save-card opt-in is persisted on the order reference and restored after the
ACS return, so it is not dropped across a 3-D Secure challenge.

Deleting a saved card removes the local row.

## Known gaps

- **The checkout JavaScript has not been exercised in a real browser** as part of
  the module's own developer tests. Because PrestaShop has no order-submission
  event, the script intercepts the form's native submit, and that path needs a
  browser pass plus a theme-compatibility matrix. The Playwright suite does drive
  a real browser through checkout; the gap is systematic theme coverage.
- **3DS challenge completion needs a browser and an HTTPS-reachable return URL.**
  Test 3-D Secure on an HTTPS-reachable host.
- **Whether deleting a saved card should also revoke the gateway-side payment
  method is open** (design document §11). Behaviour is currently local-row-only,
  and the OpenCart extension deliberately matches it rather than inventing
  different behaviour.

## Verified end to end

An 18-spec Playwright suite lives in `e2e/`, with a local stack at
`stack/prestashop/docker-compose.yml`.

| Spec | What it proves |
|---|---|
| `01-sale.spec.js` | Card checkout approves and confirms. |
| `02-vault.spec.js` | Save a card and reuse it on a later order; the saved card is listed in the customer account. |
| `03-3ds.spec.js` | A real ACS challenge is presented and completed by the shopper. |
| `04-backoffice.spec.js` | The order detail screen shows the Inovio gateway references. |
| `05-authorize-capture.spec.js` | Authorize at checkout, then capture from the back office. |
| `06-void.spec.js` | Authorize at checkout, then void from the back office. |
| `07-refund.spec.js` | Refunding a Sale order from the back office. |
| `08-decline.spec.js` | A declined card does not create a confirmed order. |
| `09-vault-delete.spec.js` | The shopper deletes a saved card. |
| `10-frictionless-3ds.spec.js` | With 3DS active and a frictionless PAN, no challenge appears and the order confirms. |
| `11-vault-idor.spec.js` | Shopper B cannot pay with shopper A's saved card. |
| `12-invalid-card-input.spec.js` | Luhn-failing PAN, short PAN, past expiry and short CVV are all rejected client-side, with no order created. |
| `13-partial-capture.spec.js` | Authorize, then partially capture from the back office. |
| `14-partial-refund.spec.js` | Two-phase: pre-settlement the partial refund fails with "order not settled, partial refunds available after settlement" and no money moves; with settlement simulated gateway-side, the same refund is approved as a credit slip strictly smaller than the order total, with the partial flag and refunded quantity set. |

Spec 14 is the regression guard for the money-losing bug this design eliminated:
the old code would have silently reversed the entire order total in phase A.
Settlement is simulated gateway-side because settlement is an acquirer batch
process with no UI anywhere, so it fakes the acquirer, not the shop.

There are also CLI developer scripts under `tests/`, which need a booted Symfony
kernel and so run through a small runner: `e2e_module.php` (sale, order, vault,
refund through module code), `e2e_module_verbs.php` (authorize, capture, void,
partial capture, saved card) and `sdk_verbs.php` (raw SDK verbs, deliberately
exercising the 536 path). They are CLI-only, guarded so they are not
web-reachable.

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| No payment method appears at checkout | One of the five required credential fields is empty. The module hides itself rather than showing a card form it cannot process. | Fill in API Username, API Password, Site ID, Site Key and Gateway Product ID. |
| 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. |
| 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). |
| 3-D Secure challenge never completes | The ACS return URL must be reachable over HTTPS. | Test 3DS on an HTTPS-reachable host. |

## Source

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

Module technical name `inoviopayment`, installed to `modules/inoviopayment/`.

```
inoviopayment.php              main class (PaymentModule)
classes/
  InovioGateway.php            SDK requests + transaction verbs
  InovioStoredCard.php         vault ObjectModel
  InovioVault.php              vault write path
controllers/front/
  signature.php                HMAC for browser tokenization
  validation.php               order placement
  threeds.php                  3DS prepare (AJAX)
  threedsreturn.php            ACS return
  storedcards.php              saved-cards management
views/js/inovio-checkout.js    direct-post + 3DS checkout JS
vendor/inovio/gateway-sdk/     vendored SDK
```

The SDK is vendored rather than Composer-required because it is not on
Packagist and PrestaShop Addons requires a self-contained single-module ZIP. The
SDK has zero Composer dependencies, which makes this clean.
