# Moneybag

Also for Laravel: https://github.com/byteminus/laravel-bdpaygate/blob/main/docs/gateways/moneybag.md

**Status:** stable · **Driver:** `moneybag` · **Refunds:** no public API · **Sync:** no public API (see below)

Moneybag is a Bangladeshi hosted checkout (cards, mobile wallets, EMI). Its public merchant API is small: create a checkout, and verify a transaction.

## Setup

```dotenv
MONEYBAG_API_KEY=your-merchant-api-key
MONEYBAG_SANDBOX=true
# MONEYBAG_BASE_URL=https://api.moneybag.com.bd   # optional: override the API origin
```

These are read by `gatewaysFromEnv(process.env)`. Or configure the gateway in code:

```ts
gateways: {
  moneybag: {
    apiKey: process.env.MONEYBAG_API_KEY,
    sandbox: true,
    // baseUrl: process.env.MONEYBAG_BASE_URL, // override the API origin
  },
}
```

`sandbox` defaults to `true`. Set it to `false` for live payments.

| | API origin |
|---|---|
| Sandbox | `https://sandbox.api.moneybag.com.bd` |
| Live | `https://api.moneybag.com.bd` |

Sandbox accounts are self-serve: create one at <https://developers.moneybag.com.bd/sandbox> (or <https://sandbox.moneybag.com.bd>). The merchant API key is shown **once**, so store it straight away. Production keys need a separate review and approval by Moneybag. The public contract lists only the sandbox origin; the live origin above comes from Moneybag's own SDK and plugin releases. Confirm it with Moneybag at go-live, and set `baseUrl` if they give you another. Docs: <https://developers.moneybag.com.bd/docs>.

## How a payment is verified

1. `pay.charge()` creates a checkout (`POST /api/v2/payments/checkout`, header `X-Merchant-API-Key`). This attempt's `gatewayReference` is sent as `order_id`, and your own reference goes in `metadata.reference`. The returned `session_id` is stored in `payment.gatewayPaymentId`, and the customer is sent to `payment.checkoutUrl`.
2. Moneybag returns the customer to `/bdpaygate/moneybag/{success|fail|cancel}?ref=…`, adding `transaction_id` and `status` (`SUCCESS`, `FAILED` or `CANCELLED`).
3. Neither parameter is trusted. The driver calls `GET /api/v2/payments/verify/{transaction_id}` and checks:
   - that the transaction's `order_id` is this attempt's `gatewayReference`;
   - for a payment, that `status` is `SUCCESS` **and** `verified` is `true`, and that `amount` and `currency` match.
4. A verified `SUCCESS` completes the payment, even on the fail or cancel URL. A verified `FAILED` or `CANCELLED` fails or cancels it. Anything else stays pending: an unknown transaction, a non-success answer, or `verified: false`.
5. The Moneybag `transaction_id` is stored in `payment.gatewayTransactionId`.

## No sync, no refunds

The public API can only verify by `transaction_id`. There is no lookup by order or session, and no refund API: Moneybag lists refunds, payouts and transaction history as merchant-portal features outside the public API. So:

- `pay.for(payment).capabilities` is `{ sync: false, refunds: false }`. `pay.sync()` and `pay.refund()` throw `UnsupportedOperationError`, and `pay.syncPending()` skips Moneybag payments. Refund from the Moneybag merchant portal.
- If a customer pays but never comes back, find the transaction id in the merchant portal and settle the payment yourself:

  ```ts
  import type { MoneybagGateway } from 'bdpaygate';

  const updated = await (pay.for(payment) as MoneybagGateway).verifyTransaction(payment, 'txn…');
  ```

  This runs the same checks as a return, and fires the usual events.
- If the customer cancels before a transaction exists, the return has no `transaction_id`. The payment stays pending, and the customer lands on your `pending` redirect.

## Limits and quirks

- Amount must be 10.00 to 1000000.00 BDT. Checked before the API call.
- `order_id` must be 10 to 36 characters; attempt ids are 20.
- `customer.email` is required by Moneybag; the driver falls back to `noemail@example.com`. Pass `customer.postcode` (or `meta.postcode`) to send a postcode.
- Validation errors are HTTP 422 with `{"detail":[{"loc","msg","type"}]}`; they are included in the `GatewayError` message.
- **Webhooks are not handled yet.** Moneybag signs webhooks (`X-Webhook-Signature: sha256=HMAC(secret, "{timestamp}.{raw body}")`), but the payment event body is not in the public contract, and webhook endpoints are configured in the merchant portal. The driver sends no `ipn_url`.
- Still **unverified**: the non-terminal `status` values returned by verify. Only `SUCCESS`, `FAILED` and `CANCELLED` are documented; anything else leaves the payment pending.

## Hosts (checked 2026-10-15)

Both `https://sandbox.api.moneybag.com.bd` (this driver's sandbox default) and `https://staging.api.moneybag.com.bd` (the official SDK's default) are live and require authentication. `https://api.moneybag.com.bd` is production. Override any of them with `baseUrl`. A merchant API key is issued from the Moneybag panel; there is no public demo key.

## Sandbox runs

The Laravel package was verified against the real Moneybag sandbox through a Laravel 13 app with a self-serve sandbox account (developer portal → sandbox onboarding issues a merchant ID and a one-time-display API key). A 120 BDT payment paid with bKash completed after `GET /api/v2/payments/verify/{transaction_id}`; a simulated failure was confirmed as `FAILED` and marked failed. The verify response reports the order amount in `amount` and Moneybag's service charge separately in `charge_amount` (the customer saw 121.20), so the amount check correctly compares `amount`.

This Node package was also run end to end in the Moneybag sandbox on 2026-10-15:

- A payment was paid via Upay. Moneybag showed its service charge separately, and the order amount was the one checked.
- There is no status API, so no sync was run.
