# Magento 2

The Inovio Payment Gateway module for Magento 2 and Mage-OS, built on Magento's payment-provider gateway with Vault and 3-D Secure.

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

Tokenized direct-post card checkout for Magento 2 and Mage-OS, on the
Inovio/Argus gateway. The card number never reaches the Magento server.

## Overview and status

> **Functional pre-release**
> Sale, capture, refund, void, vault and 3-D Secure are implemented and have
> been exercised end to end against the gateway. An 8-spec Playwright suite
> drives the real storefront and the real admin. Not yet hardened for
> production: there is no automated unit-test suite for the module beyond the
> correctness-critical gateway logic.

The module is a formal Magento payment-provider gateway, with a command pool
(`authorize`, `sale`, `capture`, `refund`, `void`) and Magento Vault for stored
cards. All settings are scoped to default, website and store view, so a
multi-site install can use different credentials per site.

## Requirements

| | |
|---|---|
| Magento | 2.4.4+, or Mage-OS |
| 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. Magento 2.4 requires `bcmath` in its own right, so a compliant
Magento install already has it.

## Install

The module is installed with Composer. The SDK it depends on
(`inovio/gateway-sdk`) is **not on Packagist**, so the module's own
`composer.json` declares the VCS repository for it. You need GitHub access to
that private repository.

From the Magento root:

```bash
composer require inovio/module-payment-gateway
bin/magento module:enable Inovio_PaymentGateway
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flush
```

In production mode, also deploy static content:

```bash
bin/magento setup:static-content:deploy
```

Then configure under **Stores, Configuration, Sales, Payment Methods, Inovio
Payment Gateway**.

> **Re-run `setup:di:compile` after any DI change**
> Changing a constructor signature invalidates Magento's compiled DI. Removing
> `generated/code/<Vendor>` without regenerating makes the payment method
> silently vanish from checkout: no exception, just an absent radio button.
> Worse, in developer mode Magento tries to regenerate lazily into
> `generated/code/`, which is not writable by `www-data`, so every request dies
> with a `main.CRITICAL` "Can't create directory" and every checkout fails
> instantly.

## Configuration

All settings live at **Stores, Configuration, Sales, Payment Methods, Inovio
Payment Gateway**.

### Gateway credentials

| Setting | Required | Meaning |
|---|---|---|
| Enabled | Yes | Turns the payment method on. |
| Title | No | The payment method name shoppers see at checkout. |
| API Username | Yes | Gateway `REQ_USERNAME`. |
| API Password | Yes | Gateway `REQ_PASSWORD`. Stored encrypted by Magento. |
| 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. Stored encrypted. |
| Gateway Product ID | Yes | `LI_PROD_ID`. Not a Magento SKU. |
| Environment | Yes | *Production*, or *Custom URL (QA / testing)*. |
| Custom Gateway URL | Only when Environment is *Custom* | The `pmt_service.cfm` URL for a non-production gateway. The token and 3-D Secure endpoints are derived from it, exactly as the SDK derives them, so the three cannot drift apart. |

**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 Magento catalogue. The whole order bills as one
line item under it.

> **Set credentials through the admin UI, not directly in `core_config_data`**
> `req_password` and `site_key` are obscure/encrypted fields. Writing a raw value
> into the database bypasses Magento's encryption, and the module's `decrypt()`
> then returns binary garbage, producing a signing key that is silently wrong.
> The symptom is checkout stalling at the payment step with "Card could not be
> processed", and the token service returning `error_code 121, Get CCtoken GUID
> signature match fail`. An encrypted value in the database starts with `0:3:`;
> a 64-character hex string is a plaintext value that will not work.

### Payment behaviour settings

| Setting | Required | Meaning |
|---|---|---|
| Payment Action | Yes | *Authorize and Capture* charges at checkout. *Authorize Only* reserves the funds and leaves the order awaiting an invoice you raise from the order screen. |
| Enable 3D Secure | No | Runs 3DS 2 device-data collection and, when the issuer demands it, a challenge at checkout. Requires a 3DS-configured merchant account. |
| Enable Stored Cards (Vault) | No | Magento Vault: lets logged-in shoppers save cards for reuse. Stores the gateway's own card references only, never a card number. Off by default. |
| Allowed Card Types | No | Which card brands the checkout form accepts. |
| 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. |

### Availability

| Setting | Meaning |
|---|---|
| Payment from Applicable Countries | All countries, or a specific list. |
| Payment from Specific Countries | The list, when the setting above is restricted. |
| Sort Order | Position of the method in the checkout payment list. |

> **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, invoice later from the order screen. |
| Capture | Full and partial, through Magento's own invoicing. |
| Void | Reverse an uncaptured authorization. |
| Refund | Full and partial, through Magento's own credit memos. |
| Saved cards | Magento Vault, the platform's native vault, using the gateway's `CUST_ID` and `PMT_ID` references. |
| 3-D Secure | Device-data collection and challenge; frictionless and challenge flows. |
| Timeout recovery | Reconciles via `status()` before failing an order. |
| Other | Multi-currency read-back, dynamic descriptors, AVS/CVV policy, idempotency on order increment ID. |

Out of scope in v1: wallets (Apple Pay and Google Pay), LatAm rails, and
raw-card admin MOTO orders.

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 credit
memo that covers everything still refundable on the order is a **full** refund
and calls `reverseCapture($ref, creditOnFail: true)` in one gateway call; the
gateway reverses a still-unsettled capture, or auto-credits an already-settled
one. A credit memo for less is a **partial** refund and calls the amount-scoped
`refund()`, which is always `CCCREDIT`, and which the gateway refuses with
service code 536 on an order that has not settled. The module surfaces that as
a loud, actionable error.

Amounts are handled in the **base** currency throughout. Magento's
`SubjectReader::readAmount()` supplies base amounts, and pairing those with the
order's display currency undercharges a multi-currency store. The enrollment
leg's exact base amount is persisted and reused by the 3DS completion leg.

Timeout reconciliation is filtered by the expected action per command, so a
timed-out refund cannot be confirmed by the original sale leg.

## How checkout works on this platform

### The direct-post flow

Card fields render in the checkout page. A module-shipped script fetches an HMAC
signature from a module controller, which holds the per-site key via
`Tokenize::signRequest`, POSTs the PAN from the browser **directly to Inovio's
token service**, and submits the order with only the single-use `TOKEN_GUID`.
Nothing in the payment path on your server ever sees a card number. The token
service's CORS support for this flow is verified against production.

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. This was verified, not assumed: a full database
dump taken after a live test run contains zero occurrences of the test card
number.

### 3-D Secure

The SDK provides the server legs (`ThreeDSecureClient`); the module supplies a
hidden device-data-collection iframe and a visible ACS challenge iframe. Both
frictionless and challenge flows are supported. The challenge return comes back
through a module controller that calls `completeSale()` or `completeAuthorize()`.

For the two-token mechanics, see
[Two-token 3D Secure](https://developer.inoviopay.com/carts/index.md#two-token-3d-secure). Collapsing the two tokens
into one appears to work for every frictionless card, which never reaches the
completion leg at all, and fails only for cards that are actually challenged.

**Leaving Payment Review after a challenge.** The 3DS enrollment leg parks the
order in `payment_review`. Magento's `RegisterCaptureNotificationCommand` only
promotes an order to `STATE_PROCESSING` from `new`, `pending_payment` or empty,
never from `payment_review`. So `Gateway/ThreeDSCompletionProcessor.php`
explicitly calls `$payment->accept()` after registering the notification,
mirroring the decline path's `$payment->deny(false)`. Without it the payment is
captured but the order sits in `payment_review` forever.

## Admin operations

| Operation | Where | What happens |
|---|---|---|
| Invoice (capture) | The order's **Invoice** action | Captures against the authorization. Full and partial both supported through Magento's own invoicing. |
| Void | The order screen | Reverses an uncaptured authorization. |
| Refund | Open the **Invoice**, then its **Credit Memo** button, then **Refund** | Calls `RefundCommand` and reaches the gateway. |

> **Only the invoice-scoped "Refund" button reaches the gateway**
> Magento exposes two differently-behaved credit-memo entry points, confirmed by
> hand. The order-view toolbar's own **Credit Memo** button pops a confirm dialog
> reading "This will create an offline refund", and that path never calls the
> gateway. Opening the order's **Invoice** and clicking **its** Credit Memo
> button reaches the real New Memo form, which offers **Refund Offline** and
> **Refund**. Only **Refund** (online) reaches `RefundCommand`.

## Saved cards

Saved cards use **Magento Vault**, the platform's native vault. Tokens carry the
gateway's `CUST_ID` and `PMT_ID` references plus brand and last four digits.
Vaulting is off by default; turn on **Enable Stored Cards (Vault)**.

The gateway returns the **same `PMT_ID` for the same PAN**, and
`vault_payment_token`'s unique key is
`(payment_method_code, customer_id, gateway_token)`, which ignores `is_active`
and `is_visible`. `VaultTokenBuilder` therefore looks up the existing row via
`PaymentTokenManagementInterface::getByGatewayToken()` and reuses or reactivates
it, rather than always inserting. Without that, a customer who saves a card,
deletes it (a soft delete), then saves the same card again hits a duplicate-key
exception and Magento aborts the **whole order** with a generic error.

The reactivation needs an **explicit repository save**, which is worth knowing
before anyone simplifies it. Magento core's `AfterPaymentSaveObserver` only
*links* a token that already has an `entity_id`; it short-circuits before calling
`PaymentTokenRepositoryInterface::save()`, because its own path only ever sets
`entity_id` on a token it inserted in the same request. A row fetched from a
prior request therefore never persists its reactivation. New tokens still go
through the normal observer flow, and guest checkout (`customer_id = 0`) is
unaffected.

## Known gaps

- **No automated unit-test suite for the module** beyond the correctness-critical
  gateway logic. The SDK's conformance suite covers gateway behaviour; module
  tests cover the Magento side (request builders, order lifecycle, vault, 3DS
  controllers).
- **The method does not hide itself on incomplete credentials.** Unlike the other
  three plugins, Magento shows the payment method and then fails if one of the
  five required fields is empty.
- **Wallets, LatAm rails and raw-card admin MOTO orders** are out of scope in v1.

## Verified end to end

An 8-spec Playwright suite lives in `e2e/`. Every step in every spec goes through
the real storefront or admin UI. Nothing writes to the database, calls the
module's PHP directly, or POSTs to the module's own controllers to advance a
test; a handful of specs use a read-only query strictly to verify an outcome
after the fact.

```bash
npm test                                    # all specs
npx playwright test tests/03-3ds.spec.js    # one spec
```

| 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 Cardinal ACS challenge is presented and completed, and the order reaches `processing`. |
| `04-frictionless-3ds.spec.js` | With 3DS active and a frictionless PAN, no challenge appears and the order confirms. |
| `05-backoffice.spec.js` | The order detail screen shows the Inovio gateway references. |
| `06-invalid-card-input.spec.js` | Luhn-failing PAN, short PAN, past expiry and short CVV are all rejected client-side, with no order created. |
| `07-refund.spec.js` | A full online credit memo against the invoice produces a real `CCREVERSECAP` leg against the original AuthCap, confirmed by a read-only query against the gateway's own `pmt.transaction` table. |
| `08-partial-refund.spec.js` | Two-phase: pre-settlement, the partial online refund fails visibly in the admin with no money movement; then, with settlement simulated gateway-side, the same partial refund succeeds as a `CCCREDIT` for the smaller amount. |

Specs 03, 07 and 08 are **regression guards** for real, live bugs this suite
found and that have since been fixed:

- **03** guards the `payment_review` bug: a completed, gateway-captured 3DS
  challenge used to leave the order permanently in `payment_review` instead of
  advancing to `processing`, so the shopper was charged and the merchant's order
  never became actionable.
- **08** guards a money-losing full-versus-partial classification bug in
  `RefundCommand`. Core's `Creditmemo/RefundOperation::execute()` adds the memo
  to `base_total_refunded` on the same in-memory order **before** calling
  `refund()`, so subtracting it raw double-counted and every first partial refund
  classified as full. A $15 refund on a $20 order reversed all $20 while Magento
  recorded $15: a silent $5 loss with no error anywhere in the admin. The unit
  test hid it, because its fixture fed a pre-refund figure that production never
  produces.

Settlement in spec 08 is simulated gateway-side. Settlement is an acquirer batch
process with no UI anywhere, so this fakes the acquirer, not the shop; every
Magento interaction in the spec stays real UI.

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| No payment method appears at checkout | The method is disabled, or restricted by **Payment from Specific Countries**. | Set **Enabled** to Yes and check the country restriction. This module does not hide itself on incomplete credentials, so also fill in all five required fields. |
| Still no payment method after saving config | Magento's config cache. | `bin/magento cache:flush`. |
| Error 121 at checkout, or the card form never tokenizes | Site Key missing or wrong, or written directly into `core_config_data`. | Enter the per-site HMAC Site Key through the admin UI. 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). |
| Class-not-found or a stale layout after install | DI compilation or static content not regenerated. | Re-run `setup:di:compile` and, in production mode, `setup:static-content:deploy`. |

## Source

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

Composer package `inovio/module-payment-gateway`, module `Inovio_PaymentGateway`,
installed to `vendor/inovio/module-payment-gateway/`.
