PrestaShop
The Inovio Payment Gateway module for PrestaShop 9, with tokenized direct-post card entry, module-built vaulting and 3-D Secure.
Tokenized direct-post card checkout for PrestaShop 9, on the Inovio gateway. The card number never reaches the shop server.
Overview and status
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.
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.
php bin/console prestashop:module install inoviopayment
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 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. 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.
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.
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. |
| 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 (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.