# PayStation

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

**Status:** stable · **Driver:** `paystation` · **Refunds:** no API (refund from the merchant panel) · **Sync:** yes

PayStation (Service Hub Ltd) is a hosted checkout covering cards (Visa, Mastercard) and the bKash, Nagad, Rocket and Upay wallets.

The request and response shapes below were checked against PayStation's documentation and its public sandbox, but not against a live merchant account. See [Not yet verified](#not-yet-verified).

## Setup

```dotenv
PAYSTATION_MERCHANT_ID=104-1234567890
PAYSTATION_PASSWORD=your-store-password
PAYSTATION_SANDBOX=true
```

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

```ts
gateways: {
  paystation: {
    merchantId: process.env.PAYSTATION_MERCHANT_ID,
    password: process.env.PAYSTATION_PASSWORD,
    sandbox: true,
    // payWithCharge: true, // sent as pay_with_charge=1, see below
  },
}
```

`payWithCharge` has no environment variable; set it in code. `sandbox` defaults to `true`. Set it to `false` for live payments.

PayStation issues separate merchant IDs for sandbox and live. Register at <https://merchant.paystation.com.bd/registration>; the docs are at <https://paystation.com.bd/documentation>.

| Environment | API host |
|---|---|
| Sandbox | `https://sandbox.paystation.com.bd` |
| Live | `https://api.paystation.com.bd` |

## How a payment is verified

1. `pay.charge()` calls `POST /initiate-payment` (form fields) with `merchantId`, `password`, this attempt's `gatewayReference` as `invoice_number`, the amount as `payment_amount` (for example `"150.00"`), and your own reference in `reference`. The package's payment id goes in `opt_a`.
2. PayStation answers with `status_code: "200"` and a `payment_url`. Any other `status_code` is an error, even though the HTTP status is 200 (for example `1008` "Duplicate invoice number.", `1001` "Invalid Credential."). The package throws `GatewayError` with PayStation's message and code.
3. The customer comes back to `/bdpaygate/paystation/callback?status=Successful|Failed|Canceled&invoice_number=…&trx_id=…`. These query parameters are unsigned, so the package ignores everything except `invoice_number` (and uses `status` only to tell a cancel from a failure).
4. The package asks `POST /transaction-status` (header `merchantId`, body `invoice_number`) and acts on `data.trx_status`:

| `trx_status` (any casing) | Result |
|---|---|
| `success` or `successful` | Completed, once `invoice_number` and the amount match. `trx_id` is stored as `payment.gatewayTransactionId`. |
| `failed` | Failed, or cancelled if the customer's callback said `Canceled`. |
| `processing` | Stays pending (the customer has not finished paying). |
| `refund` | Stays pending, and the response is logged in `payment.gatewayResponse` for you to review. |
| any `status_code` other than `"200"`, or a non-JSON body | Stays pending. `pay.syncPending()` asks again later. |

A failed or cancelled payment still becomes completed if a later status check says `success`.

### Amount check

The package compares `request_amount` (or `payment_amount` when `request_amount` is missing) with the order amount. If `payWithCharge` is off, `payment_amount` must also match exactly.

Turn `payWithCharge` on only if your PayStation account adds the processing charge to what the customer pays. The amount actually paid is then allowed to be higher than the order amount.

## Refunds

PayStation documents no refund API, so `pay.for(payment).capabilities.refunds` is `false` and `pay.refund()` throws `UnsupportedOperationError`. Refund from the PayStation merchant panel.

## Limits and quirks

- Business errors come back as **HTTP 200** with a string `status_code`. The driver never treats HTTP 200 alone as success.
- `trx_status` casing is inconsistent: the field description says `success`, but the samples show `Success` and `Failed`. The driver lowercases and trims it before comparing.
- The status endpoint is authenticated by the `merchantId` header alone, with no password and no token. That is how PayStation documents it. Treat what it returns as non-secret.
- `payment_amount` is documented as an integer, but the sandbox accepts and echoes two decimal places, and the driver always sends two decimals.
- `cust_email` is mandatory. If the customer has no email, the driver sends `noemail@example.com`.
- PayStation's own PHP sample turns off TLS certificate checks. This package never does.
- The public docs site sits behind Cloudflare and may block automated fetches. Read it in a browser.

## Not yet verified

- **IPN / webhook.** PayStation's current documentation describes only the browser redirect to `callback_url`. Any server-to-server notification is configured per merchant and its payload is undocumented. If PayStation sends one to `/bdpaygate/paystation/ipn` with an `invoice_number`, it is verified through `/transaction-status` exactly like the redirect. Run `pay.syncPending()` on a schedule either way:

  ```ts
  // every 10 minutes: node-cron, a queue, a Vercel/Cloudflare cron, or a protected route
  const { settled, errors } = await pay.syncPending();
  ```

- **`payWithCharge` semantics.** The documentation says only "Will the merchant bear the payment charge or not". Confirm with PayStation which direction `1` means for your account.
- **Live behaviour.** The shapes come from the documentation and the sandbox, not from a live account.
- **"Invalid Token."** One documented error mentions a token, which suggests an older token-based API exists. This driver uses only the documented `merchantId` and password endpoints.
- **Status v2.** `POST /v2/transaction-status` (by PayStation `trxId`) exists but is not used. Lookups are always by our own `invoice_number`.

## Sandbox runs

The Laravel package was verified against the real PayStation sandbox through a Laravel 13 app: a 120 BDT payment paid with bKash inside PayStation's checkout, confirmed by `POST /transaction-status`. The live sandbox reports `trx_status: "successful"`, not the documented `"success"`; the driver accepts both. No refund API and no separate IPN exist (checked against the current docs): the callback to `callback_url` is the only notification, and it is never trusted on its own.

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

- A payment was paid via bKash.
- Syncing an unpaid payment left it pending.
