# Online payments: Paymera and eCash

Built 2026-10-31 (branch `feat/ecash-gateway-2026-10-31`). The store takes two online payment gateways next to cash on delivery, on the website and in the app:
- **Paymera**, live since 2026-08.
- **eCash**, new: Syrian bank cards and accounts on eCash's own page.

Each gateway is its own module under `app/Services/Payments/`. What they share is in that folder, and staff see and control both from the admin.

eCash's sources: *Merchant Integration Guide v3.0* (May 2026), and the merchant dashboard's user manual (`merchants.ecash-pay.co/Documents/UserManualMerchantDashboard.pdf`). App contract for eCash: [`docs/api/ecash.md`](../../api/ecash.md).

## 1. Map

| Where | What |
|---|---|
| `Payments/PrepaidGateways` | **The map.** Which payment types are online (`isPrepaid`), the gateways (`GATEWAYS`, `gateway($code)`), each on/off (`enabled`), amount limits (`limits` / `refusal` / `limitNote`), SYP amounts (`amountSyp`), the app's list (`forApp`) |
| `Payments/Gateway` (+ `Paymera\PaymeraGateway`, `Ecash\EcashGateway`) | **The template every gateway implements**: start a payment, the customer's refund, staff reversal, accepting money that waited. Everything shared talks to it |
| `Payments/PrepaidCheckouts` + `CustomerOrders/WebCheckoutOrders` | The website checkout paid online: the cart frozen, orders built only once paid with the website's own order code (`WebCheckoutOrders`, which `OrderController@store` uses for cash on delivery) |
| `Payments/PrepaidOrders` | Paid / refunded / nothing to charge: what happens to a checkout's orders, any gateway. Moved unchanged from `PaymeraUtility` |
| `Payments/AppCheckoutCart` | The app's cart → frozen snapshot, shared by `paymera/create` and `ecash/create` |
| `Payments/PaymentAlerts` + `OnlinePaymentWatch` | The staff bell, and "a paid order was cancelled or returned somewhere". Both gateways |
| `Payments/OnlinePaymentReviews` | What staff do: give back through the gateway, "cancelled in its dashboard", "given back by hand", "checked / accept" |
| `Payments/Paymera/*` | `PaymeraCheckout` (the four entry points in one place), `PaymeraPayments` (status sync, its map), `PaymeraRefunds`, `PaymeraRecord` (the copy in `online_payments` + alerts). HTTP: `App\Services\PaymeraService` |
| `Payments/Ecash/*` | `EcashConfig`, `EcashSignature`, `EcashCheckout`, `EcashPayments` (callback, its map), `EcashRefunds`, `EcashClient` |
| `App\Utility\PaymeraUtility` | Old names kept for older callers, now one line each into the classes above |
| `App\Utility\PaymeraAppCheckoutUtility` | The app's "orders only after payment" snapshot. Shared: `paymera_app_checkouts.gateway` says whose row it is |
| `Models/OnlinePayment` (+ `EcashPayment`, `PaymeraOnlinePayment`, each scoped to its gateway) | Table `online_payments`: one row per payment attempt, any gateway |
| `Admin\OnlinePaymentController`, `backend/online_payments/*`, `backend/sales/partials/_online_payment` | Admin → Online payments; settings on Admin → Payment methods |
| `Console/Commands/OnlinePaymentsMaintenance` | `online-payments:maintain` every 5 min. eCash: expire abandoned attempts, follow reversals. Paymera: check older links |

Migrations:
- `2026_10_31_000001`: `online_payments` table, `paymera_app_checkouts.gateway` column.
- `2026_10_31_000002`: permissions `view_online_payments` / `manage_online_payments`, granted to "full access".

## 2. Paymera, now a module, behaviour unchanged

**Pinned before the move:**
- `PaymeraFlowsTest` (SQLite) covers every entry point: what Paymera is asked (amount, URLs, language, note), what is saved, and what the customer gets back.
- `PrepaidCheckoutRuntimeTest` (MySQL) compares the app checkout and the website checkout byte for byte with a baseline recorded on the old code. The baseline also matched `dev`'s original `CheckoutController`.
- The old `PaymeraAppCheckoutTest`, `PaymeraRefundCancelsOrderTest` and `PaymeraServiceContractTest` also pass unchanged.

**Quirks kept on purpose:**
- **Note:** its spacing differs per entry point (`PaymeraCheckout::ENTRIES` lists them).
- **App "pay again":** still answers `SYP currency not found` if the currency is missing.

**Changed on purpose afterwards (2026-10-31):**
- **Amount:** `PrepaidGateways::amountSyp()` on every path, for both gateways: the total at the checkout's rate, rounded to the store's 5-pound step, i.e. exactly the total the customer is shown. Before, all but the website's "pay again" dropped the fraction (2499.8 → 2499). Found paying on eCash's staging: a cart shown as 1,410 SYP was charged 1,409, because totals are kept in dollars to the cent (9.72 × 145 = 1409.4).
- **Website orders only once paid:** see §4.

**Added beside the flow, which never reads it:** every payment link is recorded in `online_payments` by `PaymeraRecord`, which swallows its own errors.

**Older links are now applied on their own** (decided 2026-10-31, like eCash). `combined_orders.payment_id` keeps only the newest link, so money paid through an older one (a second tab) used to be silently lost: the order stayed unpaid. `checkOldLinks()` asks Paymera about older links every 5 minutes for 2 days (read only at Paymera). A paid one goes through the same steps as the newest link would (`applyOlderLink`: the app's frozen cart becomes orders, every order is paid). It waits for staff instead when the checkout is already paid (`duplicate_payment`), all its orders were cancelled (`order_closed`), or less money arrived (`amount_mismatch`).

**Staff alerts** (the orders are never changed by them):

| Case | Reason |
|---|---|
| Paymera accepts a checkout that was already paid | `duplicate_payment` |
| Paymera accepts a checkout whose orders were all cancelled | `order_closed`. The orders are still marked paid, as before |
| A paid Paymera order is cancelled or returned anywhere | `refund_needed` / `partial_refund` |
| A payment on an older link that can't simply be applied | `duplicate_payment` / `order_closed` / `amount_mismatch` |

**Switch:** `paymera_payment`. No setting means on, as before. When it is off, the website hides Paymera and every entry point refuses it with a message.

**Staff refund** (`PaymeraRefunds::reverseOnly`): reverses one payment at Paymera and touches no order. The customer's own refund is unchanged (`PaymeraRefunds::refund` cancels the checkout).

## 3. eCash

### How eCash works
- **No "create payment" call.** We build the checkout page URL and send the customer there:
  `https://checkout.ecash-pay.co|.com/Checkout/CardCheckout?tk&mid&vc&c=SYP&a&lang&or&ru&cu`.
  - `vc` = `UPPER(MD5(MerchantId MerchantSecret Amount OrderRef))`.
  - `or` is our reference: `KP<checkout>A<attempt>`.
  - `ru` is where the browser comes back to, `cu` is our callback.
  - On eCash's page the customer picks the bank, then card or account, then confirms with an SMS code.
- **The callback is the truth.** eCash POSTs `{IsSuccess, Message, OrderRef, TransactionNo, Amount, Token}`, with `Token = MD5(MerchantId MerchantSecret TransactionNo Amount OrderRef)`. It retries 5 times.
- **Transaction API** (look up, reverse) needs `client_id` / `client_secret`, separate from the Merchant Key / Secret. The Merchant Key / Secret gave `invalid_client` (2026-10-31); eCash's own client keys arrived 2026-11-01 and are accepted (`invalid_grant` with no user, `unauthorized_client` for client_credentials), and the password grant takes the merchant info file's "Merchant User Name" / "Merchant Portal Password" as `ECASH_API_USERNAME` / `ECASH_API_PASSWORD`. Tried on staging 2026-11-01: a token (1 hour), `GetTransactionInfoAsync` answers `status` `Confirmed` or `Reversed` with `amount`, `orderRef`, `channelType`, and `ReverseTransaction` on a payment older than 6 hours answers 403 "Transaction must be within valid period!" (recorded as a failed refund for staff). A reversal inside the window still has to be tried with a fresh payment. Looking a payment up works only by eCash's `TransactionNo`.
- **Reversal** gives back the whole amount, only within 6 hours of the payment: through the API, or by staff in the merchant dashboard (Transactions, the payment's actions).

### Tried on staging (2026-10-31)
- `/Checkout/CardCheckout` opens the page with our store name, amount and reference.
- `/Checkout/Card`, which the guide's text says, drops every parameter. `/Checkout/AccountCheckout` is a 404.
- A wrong `vc` gives "Verification Code is not valid".
- **End to end on staging, through a public tunnel to a local copy (2026-10-31)**, website and app:
  - On the page the customer picks the bank, then card or account, and confirms with an SMS code. The staging simulator accepts any code.
  - The callback reached `api/v2/ecash/webhook` within seconds; its `Amount` is the string `"2500.00"`. Our `ru` gets no query parameters back.
  - eCash keeps a 1% fee from the merchant (2,500 paid → 2,475 credited). The customer pays the amount we send.
  - "Cancel" on eCash's page sends no callback and skips our return page: the browser goes back to our checkout page, the cart untouched. The attempt stays pending until the 60-minute expiry.
  - A reversal done in eCash's dashboard sends no callback either: staff record it with "cancelled in the eCash dashboard".

### Flows
**Website** (orders only once paid, §4):
1. Limits are checked before anything exists.
2. The cart is frozen (`PrepaidCheckouts::startWeb()`) and the customer goes to eCash.
3. The callback builds the orders and marks them paid (`PaymeraAppCheckoutUtility::complete()` → `PrepaidOrders::markPaid`).
4. The return page `/ecash/return/{ref}` polls `/ecash/status/{ref}` until the callback has decided, then shows "View order", or "Back to checkout" when nothing was paid.

The order pages' "Pay" button (`online_payment.pay`) pays an unpaid order from before the change.

**App** (like Paymera's):
1. `POST v2/ecash/create` freezes the cart. No order is created yet.
2. The callback builds the orders even when the app never comes back.
3. The app polls `v2/ecash/status`.

**Callback cases** (`EcashGatewayTest`):

| Case | Result |
|---|---|
| Wrong Token | 401 |
| Unknown reference | 404 |
| Success | paid; repeats change nothing |
| Failure | attempt `failed`; a later success with the same reference counts |
| Failure after success | ignored |
| Less money | `amount_mismatch`, orders not paid. "Accept" applies it |
| More money | accepted (eCash's fee) |
| Second payment | `duplicate_payment` |
| Paid after cancellation | `order_closed` |
| Paid after expiry (60 min) | still applied |
| Our error | 500, so eCash retries |

**Refunds** (`EcashRefunds`):
- **Who can ask:** the customer, for 5 hours after paying, while no seller has confirmed.
- **With API keys:** reversed at once.
- **Without keys:** the checkout is cancelled at once, the refund is `requested`, and the staff bell says "cancel it in the eCash dashboard before HH:MM".
- **A failed reversal** leaves the order and alerts staff.

## 4. The website checkout paid online (both gateways)

Since 2026-10-31 the website works like the app: **no order exists until the money does.**

1. The customer chooses Paymera or eCash. The checkout page greys out a method whose limit the total misses as soon as the summary is drawn (`window.kpPrepaidLimits()`, called again by `cart_summary` on every redraw, since a coupon or a shipping change moves the total). `CheckoutController@checkout` checks the gateway's switch and limits again, then calls `PrepaidCheckouts::startWeb()`.
2. `startWeb()` runs `WebCheckoutOrders::check()`, the exact checks of cash on delivery (empty cart, stock, coupon, address).
3. It freezes the cart rows, the customer's choices, the address and the shipping into `paymera_app_checkouts` (snapshot `source = web`), and reserves an empty combined order for the gateway's references. Its total (`PrepaidCheckouts::total()`) rounds each line to the cent like the orders do, or the amount asked for could differ by a cent from the orders built later (9.72 vs 9.73, seen on staging).
4. The customer goes to the gateway. **The cart stays as it is.**
5. **Paid:** Paymera's sync or eCash's callback calls `PaymeraAppCheckoutUtility::complete()`. It builds the orders with `WebCheckoutOrders::create()`, the same code cash on delivery uses, and removes the ordered rows from the cart. A product sold out while paying still becomes its line, since the money is taken; it is logged.
6. **Not paid** (refused, cancelled, the gateway down, the customer went back): no order, the cart untouched. Paymera's return page and eCash's return page lead back to the checkout page (since 2026-11-01; before, the cart), where eCash's own cancel button also lands. The reserved checkout expires with the gateway's own expiry.

This also fixed an old Paymera bug: when Paymera refused at checkout, the customer used to land on an empty checkout while an unpaid order stayed behind.

Pinned by `PrepaidCheckoutRuntimeTest`:
- the orders a Paymera or an eCash checkout builds once paid are compared field by field with the **cash-on-delivery baseline** recorded on the old `store()`;
- the not-paid and gateway-down cases keep the cart and leave no order.

Orders from before the change (unpaid, created at checkout) can still be paid through the order pages' one "Pay" button (`online_payment.pay`, any gateway).

## 5. Admin

**Admin → Payment methods → "Online payment":**
- Paymera on/off (on by default).
- eCash on/off (needs its keys).
- Smallest and largest amount per gateway: 0 or empty means no limit. eCash defaults to 15 – 250,000; Paymera has none. Each gateway's `start()` checks them too, so no path (checkout or "pay again", website or app) can ask a gateway for an amount outside them (Paymera's app paths could until 2026-11-01).

**Admin → Online payments:** every payment of both gateways, with filters for gateway, "needs action", paid, waiting, failed and refunds. A side-menu badge counts what needs action, and the admin order page shows a banner.

On a payment's page, staff can:
- give it back through the gateway;
- record "cancelled in the dashboard" (eCash, within 6 hours);
- record "given back by hand" (a note is required);
- mark it checked, or accept the money (a wrong amount; it is then applied to the orders).

Alerts go to the staff bell of every account with `view_online_payments`, and to the admins.

## 6. Change together

- **A new gateway:** a class implementing `Payments\Gateway`, one line in `PrepaidGateways::GATEWAYS` (class, label, default limits), and its own module (its API, its callback or status check calling `PaymeraAppCheckoutUtility::complete()` + `PrepaidOrders::markPaid()` when paid). The checkout, the "Pay" button, refunds, limits, the admin page and alerts need nothing. Still per gateway: its option on the checkout page (`payment_info`), its app endpoints, and its staff-reversal rules.
- **The website's order building** is `WebCheckoutOrders` for both cash on delivery and online payment: run `PrepaidCheckoutRuntimeTest` after touching it.
- **A path that cancels or returns an order line** must write `delivery_status` through Eloquent, or `OnlinePaymentWatch` doesn't see it.
- **The app's cart preparation** (`AppCheckoutCart`) is both gateways'. Run `PrepaidCheckoutRuntimeTest`.
- **What Paymera is asked** is pinned: change `PaymeraCheckout::ENTRIES` only on purpose, then re-record the baseline (`KAPITANO_PREPAID_BASELINE=record`).

## 7. Deploy

1. `php artisan migrate`, then `php artisan cache:clear`.
2. `.env`:
   - `ECASH_ENV=staging|live`
   - `ECASH_TERMINAL_KEY`
   - `ECASH_MERCHANT_ID` (the "Merchant Key")
   - `ECASH_MERCHANT_SECRET`
   - for refunds through the API: `ECASH_CLIENT_ID`, `ECASH_CLIENT_SECRET` (from eCash) and `ECASH_API_USERNAME`, `ECASH_API_PASSWORD` (the merchant info file's "Merchant User Name" / "Merchant Portal Password"). Without them refunds stay by hand.

   Then `php artisan config:cache` if the config is cached.
3. Admin → Payment methods → "Online payment": switch eCash on and check the limits. Paymera needs nothing.
4. `https://<site>/api/v2/ecash/webhook` must be reachable from the internet. It needs no System-Key and no CSRF.
5. `php artisan queue:restart`.

## 8. Known gaps

- **An eCash callback lost after 5 retries cannot be recovered automatically** (no lookup by our reference).
- **Paymera payments made before the deploy have no row** until Paymera reports on them again, so they are not watched for refunds.
- **An old app version that pays an eCash order through `paymera/re-pay`** pays it with Paymera: the order is paid, but its `payment_type` stays `ecash`.
- **eCash's live (production) environment has not been tried yet.** Its staging was, end to end (§3). The first live payment should be a small one, refunded from the dashboard.
- **Paymera's "cancel" button sends the customer to the site's home page**, not to our return page (Paymera's choice). The cart is kept and the reserved checkout is recorded as cancelled when it expires.
- **App orders paid online before the fix took their stock twice** (fixed 2026-10-31, the bug was older than this work): `PaymeraAppCheckoutUtility::takeStock()` took the quantities when the paid orders were created, and since such an order is dated after `stock_on_confirm_since`, confirming it took them again; a cancel before confirming gave none back. Now `takeStock()` leaves the stock to the confirmation when `OrderStockUtility::appliesTo($order)` (pinned in `PaymeraAppCheckoutTest` and `EcashGatewayTest`). Not repaired: the stock those orders took twice (9 delivered orders on the local copy since 2026-10-03), and orders created before the deploy but confirmed after it, which still take it again on confirmation.
