# Mastercard Payment Gateway Services (MPGS)

> [!CAUTION]
> **INTERNAL ENGINEERING DOCUMENT — DO NOT SHARE WITH MERCHANTS.**
> It names the acquiring gateways, internal tables, and routes.
>
> Merchant-facing documentation is in [`merchant/`](merchant/):
> [INTEGRATION.md](merchant/INTEGRATION.md) and [PAYMENT_FLOW.md](merchant/PAYMENT_FLOW.md).

How a Mastercard payment travels from the merchant's page to the gateway and back —
every table written, every check that can stop it.

| | |
|---|---|
| **Branch** | `feature/mpgs-mastercard-gateway` |
| **Commits** | `62ea893` → `e8af46c` |
| **Status** | Implemented and unit-checked; **never run against a live gateway** |

> [!WARNING]
> Every request and response shape here is derived from the reference WooCommerce
> plugin, not observed from MPGS. Migrations are unrun. Treat the whole integration
> as unproven until it has been exercised against MTF credentials.

---

## Contents

- [Zones](#zones)
- [1. The whole picture](#1-the-whole-picture)
- [2. Hosted checkout](#2-hosted-checkout)
- [3. Hosted session and 3-D Secure](#3-hosted-session-and-3-d-secure)
- [4. Notifications](#4-notifications)
- [5. Validation gates](#5-validation-gates)
- [6. Data model](#6-data-model)
- [Configuration](#configuration)
- [Module map](#module-map)
- [Routes](#routes)
- [Before live testing](#before-live-testing)

---

## Zones

Three machines are involved, and which one holds what is the point of the design.

| Zone | Holds |
|---|---|
| **Browser** | A session id, and nothing else. Never a gateway credential. |
| **CloudLayer** (`payment_gateway_app`, :4065) | Credentials, order numbering, the transaction record. Never a card number. |
| **MPGS** (regional gateway) | Card data, and sole authority on whether money moved. |

---

## 1. The whole picture

The single decision that shapes everything else: **we create the gateway session
server-side**, then hand the browser only a session id. Card details go from the payer
straight to Mastercard.

The reference WooCommerce plugin does the opposite — it creates the session from the
browser, which forces it to embed `base64(merchant.<id>:<apiPassword>)` in the page and
accept it back as an `X-MG-Access-Token` header. That puts a password capable of issuing
refunds in the page source of every checkout. Moving that one call to the server removes
the credential from the client entirely, so there is no browser-callable authenticated
endpoint left to protect.

```mermaid
flowchart LR
  MP["Merchant page<br/>+ widget token"]
  ST["/mastercard/start"]
  CARD["Payer enters card"]
  RET["/mastercard/return"]
  CB["/mastercard/callback"]
  GW["MPGS gateway"]

  CRED[("mpgs_merchant_credentials")]
  SEQ[("mpgs_order_sequences")]
  TXN[("pg_transactions")]

  MP -->|"amount, order, customer"| ST
  ST -->|"read + decrypt secrets"| CRED
  ST -->|"allocate order ref"| SEQ
  ST -->|"INSERT status PENDING"| TXN
  ST -->|"create session server-side"| GW
  GW -->|"session id"| ST
  ST -->|"page carrying session id only"| CARD
  CARD ==>|"CARD DETAILS - direct, bypasses us"| GW
  GW -->|"redirect + resultIndicator"| RET
  RET -->|"retrieve order = the authority"| GW
  RET -->|"UPDATE settled"| TXN
  GW -.->|"notification, async, retried"| CB
  CB -->|"UPDATE settled"| TXN

  classDef cloud fill:#E2EFF0,stroke:#0E7C86,stroke-width:1.5px,color:#101820;
  classDef mpgs fill:#F6EBDA,stroke:#A8690D,stroke-width:1.5px,color:#101820;
  classDef browser fill:#EEF1F3,stroke:#5C6B72,stroke-width:1.5px,color:#101820;
  classDef store fill:#FFFFFF,stroke:#0E7C86,stroke-width:1.5px,color:#101820;

  class ST,RET,CB cloud;
  class GW mpgs;
  class MP,CARD browser;
  class CRED,SEQ,TXN store;
```

The thick arrow is the point: card details travel browser → MPGS directly. CloudLayer
holds credentials and the ledger but never a card number, which is what keeps the
integration out of PCI scope.

---

## 2. Hosted checkout

MPGS hosts the payment page; we bracket it with a session creation and a settlement.
Three tables are touched, in a fixed order, before the payer ever sees a form.

```mermaid
sequenceDiagram
  autonumber
  participant B as Browser
  participant P as payment_gateway_app
  participant D as MySQL
  participant G as MPGS

  B->>P: GET /mastercard/start?token=...
  P->>D: SELECT mpgs_merchant_credentials
  Note over P: GATE 1 active row? password set?<br/>region resolves to a host?
  P->>P: decrypt AES-256-GCM
  Note over P: GATE 2 tampered ciphertext fails here
  P->>D: allocate mpgs_order_sequences
  Note over D: LAST_INSERT_ID(seq+1)<br/>atomic, per merchant
  P->>D: INSERT pg_transactions PENDING
  P->>G: POST session INITIATE_CHECKOUT
  Note over P,G: GATE 3 amounts via Decimal<br/>blank fields pruned
  G-->>P: session.id + successIndicator
  Note over P: GATE 4 session id present?
  P->>D: UPDATE session id + successIndicator
  P-->>B: page + checkout.min.js

  B->>G: payer submits card
  G-->>B: redirect /mastercard/return

  B->>P: GET /mastercard/return?txn_id&resultIndicator
  P->>P: compare indicators
  Note over P: GATE 5 mismatch logs a warning<br/>but does NOT decide
  P->>G: GET order by ref
  G-->>P: order + transactions
  Note over P: GATE 6 order result AND gatewayCode<br/>must BOTH be good
  P->>D: UPDATE pg_transactions CLOSED / FAILED
  P-->>B: redirect to merchant return_url
```

**Why the retrieval wins.** The payer controls their own browser and can edit the query
string. If `resultIndicator` decided the outcome, changing it by hand would turn a decline
into a success. Retrieving the order asks Mastercard directly, so a tampered return URL
achieves nothing.

---

## 3. Hosted session and 3-D Secure

Our checkout UI stays, but MPGS owns the card input fields via `session.js`. The payment
is submitted by us rather than by the gateway, which makes 3-D Secure ours to drive — and
the two schemes differ enough that they cannot share a code path.

```mermaid
flowchart TB
  A["/mastercard/session"] --> B["POST session - empty"]
  B --> C["Render card form<br/>MPGS owns the fields"]
  C --> D["Payer types card"]
  D ==>|"card goes straight to MPGS"| E["updateSessionFromForm"]
  E --> F["POST /mastercard/pay<br/>session id only"]
  F --> G["PUT session by id<br/>add order + payer"]
  G --> H{"3-D Secure<br/>setting?"}

  H -->|"off"| PAY
  H -->|"3DS2"| I["Declare intent on session"]
  H -->|"3DS1 legacy"| J["PUT 3DSecureId<br/>CHECK_3DS_ENROLLMENT"]

  I --> K["MPGS JS runs challenge<br/>in the browser"]
  K --> L["/mastercard/3ds-return<br/>carries transaction id"]
  L --> M{"recommendation<br/>= PROCEED?"}

  J --> N{"enrolled?"}
  N -->|"no"| PAY
  N -->|"yes"| O["POST payer to bank ACS"]
  O --> P["Bank posts PaRes back"]
  P --> Q["POST 3DSecureId<br/>PROCESS_ACS_RESULT"]
  Q --> M

  M -->|"no"| FAIL["Mark FAILED<br/>card never charged"]
  M -->|"yes"| PAY

  PAY["PUT order transaction ref-n<br/>PAY or AUTHORIZE"] --> R["Classify + UPDATE pg_transactions"]

  classDef cloud fill:#E2EFF0,stroke:#0E7C86,stroke-width:1.5px,color:#101820;
  classDef mpgs fill:#F6EBDA,stroke:#A8690D,stroke-width:1.5px,color:#101820;
  classDef decide fill:#FFFFFF,stroke:#8A6D1F,stroke-width:1.5px,color:#101820;
  classDef bad fill:#F7E4E1,stroke:#A82A1E,stroke-width:1.5px,color:#101820;

  class A,C,F,L,R cloud;
  class B,E,G,I,J,K,O,P,Q,PAY mpgs;
  class H,M,N decide;
  class FAIL bad;
```

Whichever authentication path runs, the payment proceeds only on an explicit `PROCEED`.
Anything else stops before the card is charged.

> [!NOTE]
> **3DS1 is on borrowed time.** The legacy scheme is retained only for merchants not yet
> migrated and is being withdrawn by the card schemes. New merchants default to 3DS2,
> where the challenge runs in the browser and we never handle a PaRes.

---

## 4. Notifications

Payers close tabs. Bank authentication pages hang. Without an asynchronous path, every
abandoned payment would sit at `PENDING` forever. The notification — not the browser
return — is the settlement path of record.

```mermaid
flowchart TB
  A["MPGS POST /mastercard/callback<br/>X-Notification-Secret header"] --> B["Read order.id from body"]
  B --> C{"transaction<br/>found?"}
  C -->|"no"| X["401 empty body"]
  C -->|"yes"| D["Look up merchant, decrypt webhook secret"]
  D --> E{"secure_compare<br/>matches?"}
  E -->|"no"| X
  E -->|"yes"| F["Reshape payload to order form"]
  F --> G["Classify - same rules as the return path"]
  G --> H{"already<br/>settled?"}
  H -->|"yes, same state"| I["200 - ignored"]
  H -->|"CLOSED, non-success arriving"| I
  H -->|"no"| J["UPDATE pg_transactions"]
  J --> K["200 - applied"]

  classDef cloud fill:#E2EFF0,stroke:#0E7C86,stroke-width:1.5px,color:#101820;
  classDef mpgs fill:#F6EBDA,stroke:#A8690D,stroke-width:1.5px,color:#101820;
  classDef decide fill:#FFFFFF,stroke:#8A6D1F,stroke-width:1.5px,color:#101820;
  classDef bad fill:#F7E4E1,stroke:#A82A1E,stroke-width:1.5px,color:#101820;

  class B,D,F,G,J,K,I cloud;
  class A mpgs;
  class C,E,H decide;
  class X bad;
```

Every failure answers **401 with an empty body**. Wrong secret, missing header and unknown
order are deliberately indistinguishable, so the endpoint cannot be used to probe for valid
order references.

The body is read before the secret is checked — but only to find *which* merchant's secret
to compare against. Nothing in the payload is acted on until the comparison passes.

---

## 5. Validation gates

In the order a payment meets them.

| Gate | Where | Checks | On failure |
|---|---|---|---|
| **CREDENTIALS** | `Credentials.resolve/1` | Active row exists; API password present; region maps to a host; custom region has a host | **STOP** — "not configured for Mastercard payments" |
| **DECRYPT** | `Vault.decrypt/1` | AES-256-GCM authentication tag verifies | **STOP** — tampered or wrong-key secret refuses to decrypt |
| **PAYLOAD** | `CheckoutBuilder` | Amounts via `Decimal`, never floats; blanks and nils pruned; country codes to ISO-3 | **COERCE** — unparseable amount becomes `0.00`, gateway rejects it |
| **TRANSPORT** | `Client.request/4` | HTTP status *and* `result: "ERROR"` inside a 200 body | **STOP** — an `:ok` return always means MPGS accepted |
| **SESSION** | `Checkout.initiate/2` | A session id actually came back | **STOP** — before the browser is handed a nil |
| **3-D SECURE** | `Session.proceed?/1` | Recommendation is explicitly `PROCEED` | **STOP** — marked FAILED, card never charged |
| **INDICATOR** | `Payments` | `resultIndicator` vs stored `success_indicator`, constant-time | **WARN** — logged only; the retrieved order decides |
| **OUTCOME** | `Mapper.classify/2` | Order result *and* transaction `gatewayCode` must both be good | **FAILED** — order-SUCCESS with a declined txn is not paid |
| **WEBHOOK** | `Webhook.process/2` | Shared secret, constant-time compare | **401** — empty body, indistinguishable from unknown order |
| **IDEMPOTENCY** | `Webhook` | Settled payments cannot be reopened; duplicate states ignored | **IGNORE** — 200, no write |
| **REFUND** | `Payments.refund/2` | Payment must be `CLOSED` first | **STOP** — `{:not_refundable, status}` |

---

## 6. Data model

All three tables live in `DaProductApp.Repo` — deliberately the same database as the
existing transaction table, so credential reads and transaction writes share one connection
and can sit in one transaction. Merchant *branding* still lives in the separate
`shukria_mms` database; MPGS never reads it.

```mermaid
erDiagram
  MPGS_MERCHANT_CREDENTIALS ||--o{ PG_TRANSACTIONS : "authorises"
  MPGS_ORDER_SEQUENCES ||--o{ PG_TRANSACTIONS : "numbers"
  PG_TRANSACTIONS ||--o{ PG_TRANSACTIONS : "parent_txn_id - refunds, captures"

  MPGS_MERCHANT_CREDENTIALS {
    bigint user_id UK
    varchar merchant_id
    varbinary api_password_ciphertext "AES-256-GCM"
    varbinary webhook_secret_ciphertext "AES-256-GCM"
    varchar region "eu ap na custom"
    varchar method "checkout or session"
    varchar txn_mode "capture or authorize"
    varchar threedsecure "no yes 2"
  }

  MPGS_ORDER_SEQUENCES {
    varchar scope PK "merchant:ID"
    bigint seq "LAST_INSERT_ID(seq+1)"
  }

  PG_TRANSACTIONS {
    bigint id PK
    varchar gateway "ysp or mpgs"
    varchar gateway_order_ref "sent to MPGS"
    varchar gateway_session_id
    varchar success_indicator "checked on return"
    int attempt_seq
    varchar order_number "merchant's own"
    decimal total_amount
    varchar closure_status "PENDING CLOSED FAILED"
    varchar pg_reference "MPGS transaction id"
    text raw_payload "full response"
  }
```

The `gateway` column matters: two gateways now write to `pg_transactions`, and without a
discriminator, reconciling YSP against Mastercard rows would be guesswork.

### When each table is touched

| Moment | `mpgs_merchant_credentials` | `mpgs_order_sequences` | `pg_transactions` |
|---|---|---|---|
| Payment starts | READ + decrypt | ALLOCATE | INSERT `PENDING` |
| Session created | — | — | UPDATE session id, indicator |
| Payer returns | READ + decrypt | — | UPDATE settled |
| Notification arrives | READ webhook secret only | — | UPDATE unless already settled |
| Refund or capture | READ + decrypt | — | INSERT child row via `parent_txn_id` |

### Why a sequence table at all

MPGS treats the order id as a payment's primary key and rejects reuse, so a retry cannot
resubmit under the same id. MySQL has no native sequences; the `LAST_INSERT_ID(seq + 1)`
idiom gives an atomic per-merchant counter whose result is scoped to the connection,
avoiding a read-modify-write race between concurrent payments:

```sql
INSERT INTO mpgs_order_sequences (scope, seq, updated_dateTime)
VALUES (?, LAST_INSERT_ID(1), ?)
ON DUPLICATE KEY UPDATE
  seq = LAST_INSERT_ID(seq + 1),
  updated_dateTime = VALUES(updated_dateTime);
SELECT LAST_INSERT_ID();
```

Order references come out as `SHK-1042` (prefix + sequence); retries within an order become
`SHK-1042-2`, `SHK-1042-3`.

---

## Configuration

### 1. Encryption key

Secrets are encrypted at rest with AES-256-GCM. Without this key, credentials cannot be
saved or read, and **the webhook returns 401 for every notification**.

```bash
openssl rand -base64 32     # → MPGS_CREDENTIAL_KEY
```

> [!CAUTION]
> Rotating this key invalidates every stored secret. Credentials must be re-entered
> after a rotation.

### 2. Environment

| Variable | Default | Purpose |
|---|---|---|
| `MPGS_CREDENTIAL_KEY` | *(none — required)* | Base64, 32 bytes. Encrypts secrets at rest. |
| `MPGS_WEBHOOK_URL` | `https://shukriapg.ariticapp.com/mastercard/callback` | Where MPGS posts notifications. Must be public HTTPS and match the gateway-side setting. |
| `MPGS_DEFAULT_REGION` | `eu` | Fallback when a merchant row omits one. |

Deployment-wide settings live under `config :payment_gateway_app, PaymentGatewayApp.Mpgs`
in [`config/config.exs`](config/config.exs) — `api_version` (currently `version/100`) and
`request_timeout_ms` among them. Everything merchant-specific belongs in the database, not
here.

### 3. Migrations

```bash
INCLUDE_PAYMENT_GATEWAY=true mix ecto.migrate
```

| Migration | Effect |
|---|---|
| `20260816090000` | creates `mpgs_merchant_credentials` |
| `20260816090100` | creates `mpgs_order_sequences` |
| `20260816090200` | adds `gateway`, `gateway_order_ref`, `attempt_seq` to `pg_transactions` |
| `20260816093000` | adds `gateway_session_id`, `success_indicator` |
| `20260816094000` | adds `threedsecure` to credentials |

Existing `pg_transactions` rows default to `gateway = 'ysp'`.

### 4. A merchant row

Secrets must go through `put_secrets/2` — never assign a plaintext password to a changeset
field.

```elixir
alias PaymentGatewayApp.Mpgs.MerchantCredential

%MerchantCredential{}
|> MerchantCredential.changeset(%{
  user_id: 42,
  merchant_id: "YOUR_MPGS_MERCHANT_ID",
  region: "eu",                  # eu | ap | na | custom
  method: "hosted-checkout",     # or "hosted-session"
  hc_interaction: "embedded",    # or "redirect"
  txn_mode: "capture",           # or "authorize"
  threedsecure: "2",             # "2" = 3DS2, "yes" = legacy 3DS1, "no" = off
  order_prefix: "SHK-",
  sandbox: true
})
|> MerchantCredential.put_secrets(%{
  api_password: "...",
  webhook_secret: "..."
})
|> DaProductApp.Repo.insert()
```

### 5. Verify

`paymentOptionsInquiry` is the cheapest authenticated call MPGS offers — no payload, creates
nothing — which makes it the right probe:

```elixir
PaymentGatewayApp.Mpgs.verify_credentials(42)
#=> {:ok, %{"result" => "SUCCESS", ...}}
```

---

## Module map

| Module | Responsibility |
|---|---|
| [`Mpgs`](lib/payment_gateway_app/mpgs.ex) | Facade: `verify_credentials/1`, `retrieve_order/2`, `webhook_url/0` |
| [`Mpgs.Vault`](lib/payment_gateway_app/mpgs/vault.ex) | AES-256-GCM encrypt/decrypt for secrets at rest |
| [`Mpgs.MerchantCredential`](lib/payment_gateway_app/mpgs/merchant_credential.ex) | Ecto schema; `put_secrets/2` is the only write path for secrets |
| [`Mpgs.Credentials`](lib/payment_gateway_app/mpgs/credentials.ex) | Lookup, decrypt, region → host; yields a `Config` struct |
| [`Mpgs.OrderSequence`](lib/payment_gateway_app/mpgs/order_sequence.ex) | Atomic order-reference allocation |
| [`Mpgs.Client`](lib/payment_gateway_app/mpgs/client.ex) | HTTP transport, Basic auth, error normalisation |
| [`Mpgs.CheckoutBuilder`](lib/payment_gateway_app/mpgs/checkout_builder.ex) | Payload construction; amounts, countries, pruning |
| [`Mpgs.Checkout`](lib/payment_gateway_app/mpgs/checkout.ex) | `INITIATE_CHECKOUT`, checkout.js URL |
| [`Mpgs.Session`](lib/payment_gateway_app/mpgs/session.ex) | Session create/update, 3DS enrollment and ACS result |
| [`Mpgs.Transaction`](lib/payment_gateway_app/mpgs/transaction.ex) | `PAY`, `AUTHORIZE`, `CAPTURE`, `REFUND`, `VOID` |
| [`Mpgs.Mapper`](lib/payment_gateway_app/mpgs/mapper.ex) | Gateway response → `pg_transactions` attrs; classification |
| [`Mpgs.Payments`](lib/payment_gateway_app/mpgs/payments.ex) | Orchestration across database and gateway |
| [`Mpgs.Webhook`](lib/payment_gateway_app/mpgs/webhook.ex) | Notification authentication and application |
| [`MastercardController`](lib/payment_gateway_app_web/controllers/mastercard_controller.ex) | HTTP endpoints and payer-facing pages |

---

## Routes

| Method | Path | Purpose |
|---|---|---|
| `GET` `POST` | `/mastercard/start` | Entry point; dispatches on the merchant's `method` |
| `GET` `POST` | `/mastercard/checkout` | Force hosted checkout |
| `GET` `POST` | `/mastercard/session` | Force hosted session |
| `POST` | `/mastercard/pay` | Submit a hosted-session payment |
| `GET` `POST` | `/mastercard/3ds-return` | Return from 3-D Secure (both schemes) |
| `GET` `POST` | `/mastercard/return` | Payer returns from the MPGS-hosted page |
| `GET` `POST` | `/mastercard/cancel` | Payer abandoned on the MPGS page |
| `POST` | `/mastercard/callback` | Asynchronous MPGS notification |

Payer-facing routes use the `:embedded_payment` pipeline — no CSRF, no `x-frame-options` —
because the payer arrives via a cross-origin redirect and the page is embedded in the
merchant's iframe. The webhook uses its own JSON pipeline with no session.

---

## Before live testing

Verified so far: 71 checks covering encryption (round-trip, tamper, wrong key, missing key),
amount formatting (`0.1 + 0.2 → "0.30"`), order classification including the
order-SUCCESS-with-declined-transaction case, notification reshaping, region routing, 3DS
gating, and credential validation. None of it touched MPGS.

Three things most likely to need adjusting once real credentials exist:

1. **Order-reference format.** MPGS constrains the order id character set and length. The
   allocator emits `SHK-1042`; if the merchant profile rejects the hyphen or the prefix,
   `order_prefix` covers it — but confirm against their spec first.
2. **3DS2 return parameters.** The authentication transaction id is read from
   `transaction_id` and the recommendation from `response_gatewayRecommendation`, matching
   the reference plugin. Worth confirming against MPGS `version/100` docs, since the plugin
   may target an older shape.
3. **`session.js` field binding.** Fields bind to `<span>` elements by id. MPGS's
   hosted-session JS is particular about the element types it attaches to; this is the most
   likely thing to need changing once a real form loads.

Also note: the webhook is reachable but returns 401 for every notification until
`MPGS_CREDENTIAL_KEY` is set and a merchant row carries a `webhook_secret` — the secret
cannot be decrypted without the key. An empty log is not evidence that MPGS is failing to
call.
