# Tham chiếu API

[English](../en/05-api-reference.md) · [Mục lục](./README.md)

Mọi method đều trả `Promise` và nhận tham số cuối `options?: { signal?, timeoutMs?, headers? }`.

---

## `new GpmPay(options)`

| Option | Kiểu | Mặc định | Ghi chú |
|---|---|---|---|
| `apiToken` | `string` | — | **Bắt buộc.** Thiếu/sai định dạng → ném `GpmPayConfigError` ngay |
| `baseUrl` | `string` | `https://api.gpmpay.com` | Thường không cần đặt. Nhận host trần, `host/api`, hoặc `host/api/v1` |
| `sandbox` | `boolean` | `false` | `true` → môi trường thử nghiệm. Bỏ qua nếu có `baseUrl` |
| `timeoutMs` | `number` | `30000` | Timeout mỗi request |
| `maxRetries` | `number` | `2` | `0` để tắt |
| `retryBaseDelayMs` | `number` | `500` | Full-jitter exponential, trần 8s |
| `fetch` | `typeof fetch` | global | Inject cho test / proxy |
| `userAgent` | `string` | — | Nối thêm vào UA của SDK |
| `defaultHeaders` | `Record<string,string>` | `{}` | Không ghi đè được `Authorization` |
| `strictTokenFormat` | `boolean` | `true` | Tắt kiểm định dạng; **vẫn** bắt buộc có token |
| `onRequest` | `(e) => void` | — | `{ method, url, requestId, attempt }` |
| `onResponse` | `(e) => void` | — | `{ requestId, status, durationMs, attempt }` |

### Static & thuộc tính

```ts
GpmPay.fromEnv(overrides?)   // đọc GPMPAY_API_TOKEN
client.baseUrl               // 'https://api.gpmpay.com/api/v1'
client.tokenPrefix           // 'gpm_a1b2c3d4' — an toàn để log
client.toString()            // 'GpmPay(https://…, gpm_a1b2c3d4••••••••)'
client.ping(options?)        // PingResult
client.request(method, path, options?)  // escape hatch cho endpoint chưa được model
```

`ping()` bắn song song mỗi scope một lệnh đọc rẻ nhất rồi suy ra token thực sự có scope nào. Nó **không** đọc được bản ghi của chính token: `GET /api-tokens` không khai báo scope nên guard fail-closed chặn mọi API token.

Một scope chỉ vào `denied` khi thực sự nhận 403 — lỗi 401 hoặc 5xx được ném ra ngoài chứ không bị báo nhầm thành thiếu scope.

```ts
interface PingResult {
  ok: true;
  baseUrl: string;
  tokenPrefix: string;
  latencyMs: number;
  scopes: { granted: ApiTokenScope[]; denied: ApiTokenScope[] };
}
```

---

## `client.transactions`

| Method | HTTP | Scope | Trả về |
|---|---|---|---|
| `list(params?)` | `GET /transactions` | `transactions:read` | `Page<Transaction>` |
| `listAll(params?)` | — | `transactions:read` | `AsyncGenerator<Transaction>` — tự phân trang |
| `retrieve(id)` | `GET /transactions/:id` | `transactions:read` | `TransactionDetail` |

```ts
interface ListTransactionsParams extends ListParams {
  bankAccountId?: string;
  type?: 'IN' | 'OUT';
  source?: 'REAL' | 'SIMULATED';
}

interface ListParams {
  page?: number;
  limit?: number;              // chặn tối đa 50 phía server
  sortBy?: string;
  sortOrder?: 'asc' | 'desc';
  search?: string;             // khớp referenceCode và transferContent
  startDate?: string | Date;   // với transactions: lọc theo transactionTime
  endDate?: string | Date;
  filters?: Record<string, unknown>;   // serialize thành chuỗi JSON
}
```

```ts
interface Page<T> {
  data: T[];
  meta: { page: number; limit: number; totalItems: number; totalPages: number };
}
```

---

## `client.bankAccounts`

| Method | HTTP | Scope | Trả về |
|---|---|---|---|
| `bankAccounts.list(params?)` | `GET /bank-accounts` | `bank-accounts:read` | `Page<BankAccount>` |
| `bankAccounts.retrieve(id)` | `GET /bank-accounts/:id` | `bank-accounts:read` | `BankAccount` |

Cả hai đều kèm quan hệ `bank`, nơi chứa mã `bin` NAPAS mà các helper VietQR cần.

> Các thao tác ghi trên bank account (`create`, `update`, `remove`, `rotate-ingest-secret`, `subscribe`) **cố ý không** được expose: không tồn tại scope `bank-accounts:write`, `rotate-ingest-secret` trả secret plaintext, và `subscribe` **tiêu tiền**. Dùng dashboard cho những việc đó.

---

## `client.banks`

| Method | HTTP | Scope | Trả về |
|---|---|---|---|
| `banks.list()` | `GET /banks` | `bank-accounts:read` | `Bank[]` — mảng thô, không phân trang |

Danh mục ngân hàng đang bật. Chỉ cần khi bạn muốn một ngân hàng **mình không có tài khoản ở đó** — để render bank picker, hoặc dựng VietQR cho tài khoản ngoài. Với tài khoản của chính bạn thì mã `bin` đã đi kèm quan hệ `bank` trong `bankAccounts.*`.

---

## `client.webhookSettings`

Scope: `webhooks:manage`.

| Method | HTTP |
|---|---|
| `list(params?)` | `GET /webhook-settings` |
| `retrieve(id)` | `GET /webhook-settings/:id` |
| `create(params)` | `POST /webhook-settings` |
| `update(id, params)` | `PATCH /webhook-settings/:id` |
| `remove(id)` | `DELETE /webhook-settings/:id` |
| `createHmacEndpoint(params)` | tiện ích — tạo endpoint HMAC + trả secret một lần |

`CreateWebhookSettingParams` là union phân biệt theo `driver`, nên các ràng buộc điều kiện thành lỗi biên dịch thay vì lỗi 400:

```ts
{ driver?: 'HTTP';          url: string }
{ driver: 'WORDPRESS';      url: string; wpSecret: string }          // wpSecret bắt buộc
{ driver: 'GOOGLE_SHEETS';  url: string; googleSheetName?: string }
{ driver: 'TELEGRAM';       telegramChatId: string; ... }            // chatId bắt buộc
```
cộng với `{ scope?: 'ALL' }` hoặc `{ scope: 'SPECIFIC'; bankAccountIds: [string, ...string[]] }`.

```ts
const { setting, secret } = await client.webhookSettings.createHmacEndpoint({
  url: 'https://shop.example.com/webhooks/gpmpay',
  name: 'Production',
  scope: 'SPECIFIC',
  bankAccountIds: [bankAccountId],
  secret: undefined,     // tự sinh 256-bit hex nếu bỏ trống
});
```

---

## `client.webhookHistories`

Scope: `webhooks:manage`.

| Method | HTTP |
|---|---|
| `list(params?)` | `GET /webhook-histories` — lọc `settingId`, `transactionId`, `status` |
| `retrieve(id)` | `GET /webhook-histories/:id` |
| `retry(id)` | `POST /webhook-histories/:id/retry` |

---

## `client.apiTokens`

| Method | HTTP |
|---|---|
| `remove(id)` | `DELETE /api-tokens/:id` |

Chỉ một method, và đó là cố ý. `DELETE /api-tokens/:id` là route api-token duy nhất chấp nhận API token (nó mang `@AllowAnyApiToken()` để tích hợp tự thu hồi token của mình khi ngắt kết nối; quyền sở hữu vẫn được backend kiểm tra). Các route list/create/regenerate/status đều là dashboard-only — model chúng ở đây chỉ tạo ra method luôn 403.

Quản lý token tại <https://app.gpmpay.com/api-tokens>.

---

## `client.simulator`

Chỉ dùng dev/test. Từ chối chạy trên production trừ khi `{ allowOnProduction: true }`.

| Method | HTTP | Scope |
|---|---|---|
| `createTransaction(params, opts?)` | `POST /simulator/transactions` | `transactions:read` |

> Route *tạo* giao dịch mà lại đòi `transactions:read` nghe hơi ngược. Đây là đánh đổi có chủ ý ở backend để không phải thêm scope mới: bản ghi giả lập luôn mang `source: 'SIMULATED'` và chỉ fan-out tới endpoint bật `fireOnSimulated`.

```ts
interface SimulateTransactionParams {
  bankAccountId: string;
  amount: number;              // VND, số nguyên
  transferContent: string;     // <= 100 ký tự — đặt MÃ ĐỐI SOÁT CỦA BẠN ở đây
  type?: 'IN' | 'OUT';         // mặc định IN
  referenceCode?: string;      // <= 64 ký tự — mã giao dịch của "ngân hàng"
  counterAccount?: string;
  counterName?: string;
  transactionTime?: string | Date;
}

// Route trả một envelope, KHÔNG phải một Transaction trần.
interface SimulatedTransactionResult {
  transaction: Transaction;
  historyIds: string[];        // mỗi webhook delivery được xếp hàng một id
}
```

```ts
const { transaction, historyIds } = await client.simulator.createTransaction({
  bankAccountId,
  amount: 50_000,
  transferContent: 'DH1042',
});

// historyIds rỗng = không endpoint nào bật fireOnSimulated, nên handler của bạn
// sẽ không bao giờ được gọi. Đây là câu trả lời cho "sao webhook không về?".
if (historyIds.length === 0) {
  console.warn('Không có endpoint nào nhận — bật fireOnSimulated ở dashboard.');
}
```

---

## `@gpmpay/sdk/webhooks`

```ts
verifyWebhookSignature(input): boolean
assertWebhookSignature(input): { timestamp }        // ném GpmPayWebhookSignatureError
constructWebhookEvent(input): GpmPayWebhookEvent    // verify + JSON.parse
signWebhookPayload({ rawBody, secret, timestamp? }): string
verifyApiKeyHeader(received, expected): boolean     // cho authorizationType: 'API_KEY'

gpmpayWebhook(options)                              // middleware Express
createNextWebhookHandler(options)                   // Next App Router POST handler
verifyNextRequest(request, options)                 // Next App Router thủ công
readRawBody(stream, maxBytes?)                      // Next Pages Router

SIGNATURE_HEADER            // 'X-GPMPay-Signature'
EVENT_HEADER                // 'X-GPMPay-Event'
DEFAULT_TOLERANCE_SECONDS   // 300
WEBHOOK_RETRY_SCHEDULE_SECONDS  // [10, 30, 120, 600, 3600, 21600]
WEBHOOK_MAX_ATTEMPTS            // 6
WEBHOOK_DELIVERY_TIMEOUT_MS     // 5000
```

```ts
interface VerifyWebhookInput {
  rawBody: string | Buffer | Uint8Array;   // ĐÚNG byte gốc
  signature: string;                        // 't=<unix>,v1=<hex>'
  secret: string;
  toleranceSeconds?: number;                // mặc định 300; 0 để tắt
  now?: () => number;                       // seam cho test
}
```

---

## `@gpmpay/sdk/vietqr`

```ts
buildVietQrPayload(input): string         // chuỗi EMVCo
buildVietQrImageUrl(input): string        // URL img.vietqr.io, chọn template
buildPaymentInstructions(request)         // đủ mọi thứ một trang thanh toán cần
crc16ccitt(input): string
```

```ts
interface PaymentRequest {
  bankAccount: BankAccount & { bank?: Bank };   // lấy từ client.bankAccounts.*
  amount: number | string;
  transferContent: string;                      // MÃ CỦA BẠN
  serviceCode?: 'QRIBFTTA' | 'QRIBFTTC';
  template?: 'compact' | 'compact2' | 'qr_only' | 'print';
}

interface PaymentInstructions {
  qrPayload: string;
  qrImageUrl: string;
  amount: number;
  transferContent: string;
  bankName: string | undefined;
  bankBin: string | undefined;
  accountNumber: string;
  accountName: string;
}
```

Ném `GpmPayConfigError` khi thiếu `bankAccount.bank.bin` — không có BIN thì không dựng được payload VietQR hợp lệ.

> Mã đối soát là **của bạn**. GPM Pay không sinh mã nào, nên SDK cũng không ship hàm sinh hay hàm parse: dùng đúng định dạng hệ thống bạn đang có, và tự dò bằng regex của mình.

---

## Tiện ích chung

```ts
import { toVnd, formatVnd, normalizeBaseUrl, maskToken, tokenPrefix } from '@gpmpay/sdk';

toVnd('50000')            // 50000
formatVnd('50000')        // '50.000 ₫'
normalizeBaseUrl('api.gpmpay.com')  // 'https://api.gpmpay.com/api/v1'
maskToken(token)          // 'gpm_a1b2c3d4••••••••'
```

---

## CLI

```
gpmpay ping                        Kiểm tra token + probe từng scope
gpmpay accounts list               [--status <s>] [--limit <n>] — nguồn của --account
gpmpay accounts get <id>
gpmpay transactions list           [--limit <n>] [--account <uuid>] [--type IN|OUT]
gpmpay simulate tx                 --account <uuid> --amount <vnd> --content <text>
                                   [--type IN|OUT] [--allow-production]
gpmpay webhook send --url <url>    [--secret <s>] [--amount <vnd>] [--content <text>]
                                   [--file <body.json>]
                                   [--skew <s>] [--bad-signature]
gpmpay webhook listen              [--port 4444] [--secret <s>]
gpmpay webhook verify              --signature "t=..,v1=.." [--file body.json]
gpmpay webhook settings            [--driver <d>] [--limit <n>]
gpmpay webhook history             [--status <s>] [--setting <id>] [--limit <n>]
gpmpay webhook retry <id>

--token --base-url --sandbox --json --no-color -h -v
```

Exit code: `0` OK · `1` lỗi chung · `2` sai cú pháp / thiếu token · `3` xác thực thất bại · `4` mạng/timeout.

Mọi lệnh đều nhận `--json`. Đầu ra `--json` **tự che** các trường secret (`ingestSecret`, `authorizationSecret`, `telegramBotToken`, `wpSecret`, `verificationCode`) — API trả về chúng ở dạng mã hoá, nên chúng không nên rơi vào log CI.

`webhook send` là lệnh duy nhất **không cần API token**: nó ký một payload mẫu rồi POST thẳng vào handler của bạn, không chạm API GPM Pay.

`simulate` cần base URL không phải production (`--sandbox`), nếu không sẽ dừng ở exit 2 trước khi gửi request nào.
