# Liên kết ngân hàng

Kết nối ngân hàng (bank connection) là liên kết giữa Hạ Tầng và tài khoản ngân hàng khách hàng chuyển tiền vào. Hạ Tầng chỉ đọc giao dịch tiền vào của tài khoản, không bao giờ chuyển tiền đi. Trang này hướng dẫn liên kết từng ngân hàng, cấu hình khớp mã, hủy liên kết, và nhập sao kê khi ngân hàng bỏ sót giao dịch.

## Trước khi bắt đầu

- API key có `payments:write`, hoặc quyền vào **Cài đặt thanh toán → Tài khoản ngân hàng** trên dashboard.
- Với ngân hàng thật: số tài khoản, tên chủ tài khoản đúng như ngân hàng ghi nhận, và (với MB Bank) CCCD hoặc mã số thuế dùng khi mở tài khoản.
- Chọn đúng môi trường. Ngân hàng thật chỉ có ở `production`; ngân hàng giả lập chỉ có ở `development`. Chọn sai sẽ nhận `403 environment_mismatch`.

## Endpoint

| Method | Endpoint | Chức năng |
|---|---|---|
| `GET` | `/payments/bank-connections` | Danh sách tài khoản đã liên kết (bỏ qua tài khoản đã hủy) |
| `POST` | `/payments/bank-connections` | Bắt đầu liên kết |
| `GET` | `/payments/bank-connections/{id}` | Chi tiết |
| `PATCH` | `/payments/bank-connections/{id}` | Đổi nhãn hoặc cấu hình khớp mã |
| `POST` | `/payments/bank-connections/{id}/confirm` | Xác nhận liên kết bằng mã của ngân hàng |
| `POST` | `/payments/bank-connections/{id}/unlink` | Bắt đầu hủy liên kết |
| `POST` | `/payments/bank-connections/{id}/unlink/confirm` | Xác nhận hủy liên kết bằng mã của ngân hàng |
| `POST` | `/payments/statements` | Nhập sao kê (CSV) |
| `GET` | `/payments/statements/{id}` | Tiến độ nhập sao kê |

Endpoint đọc cần `payments:read`, endpoint thay đổi cần `payments:write`.

## Cách liên kết từng ngân hàng

| Ngân hàng | `provider` | `link_mode` | Bạn cần làm |
|---|---|---|---|
| MB Bank | `mbbank` | `otp` | Gửi thông tin tài khoản; MB nhắn mã tới số điện thoại đăng ký của tài khoản; xác nhận bằng mã đó |
| BIDV | `bidv` | `bank_registered` | Đăng ký dịch vụ thông báo biến động số dư của BIDV cho tài khoản tại BIDV, rồi gửi số tài khoản. Kết nối kích hoạt khi BIDV gửi tin đầu tiên cho tài khoản |
| ACB | `acb` | `redirect` | Gửi số tài khoản, rồi mở `challenge.authorize_url` để chủ tài khoản đồng ý trên trang của ACB. Họ được đưa về dashboard và tài khoản chuyển sang hoạt động |
| Pay2S | `pay2s` | `bank_registered` | Liên kết tài khoản trên Pay2S, tạo Hook trên đó trỏ tới `webhook_url`, rồi gửi số tài khoản, `bank_code` và token của Hook. Kết nối kích hoạt khi có giao dịch đầu tiên |
| Ngân hàng giả lập | `simulator` | `otp` | Xác nhận bằng mã `000000` |

## Liên kết tài khoản MB Bank {#link-an-account}

```mermaid
sequenceDiagram
    participant S as Bạn
    participant H as Hạ Tầng
    participant B as Ngân hàng
    S->>H: POST /payments/bank-connections
    H->>B: Đối chiếu tài khoản với giấy tờ chủ tài khoản
    B-->>S: SMS mã xác thực tới chủ tài khoản
    H-->>S: status pending_otp + challenge
    S->>H: POST /payments/bank-connections/{id}/confirm
    H->>B: Xác nhận
    H-->>S: status active + webhook bank_connection.activated
```

### 1. Bắt đầu liên kết

**cURL**

```bash
curl https://api.hatang.dev/payments/bank-connections \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "mbbank",
    "account_number": "910452658",
    "account_name": "NGUYEN VAN A",
    "account_type": "personal",
    "holder_id": "001099012345",
    "label": "Tài khoản bán hàng"
  }'
```

**Node.js**

```ts
const { data: conn, error } = await hatang.payments.bankConnections.create({
  provider: "mbbank",
  account_number: "910452658",
  account_name: "NGUYEN VAN A",
  account_type: "personal",
  holder_id: "001099012345",
  label: "Tài khoản bán hàng",
});
if (error) throw new Error(`${error.name}: ${error.message}`);
```

**Python**

```python
conn = requests.post("https://api.hatang.dev/payments/bank-connections", headers=HEADERS, json={
    "provider": "mbbank",
    "account_number": "910452658",
    "account_name": "NGUYEN VAN A",
    "account_type": "personal",
    "holder_id": "001099012345",
    "label": "Tài khoản bán hàng",
}, timeout=30).json()
```

**PHP**

```php
$conn = json_decode($client->post('/payments/bank-connections', ['json' => [
    'provider' => 'mbbank',
    'account_number' => '910452658',
    'account_name' => 'NGUYEN VAN A',
    'account_type' => 'personal',
    'holder_id' => '001099012345',
    'label' => 'Tài khoản bán hàng',
]])->getBody(), true);
```

| Trường | Mô tả |
|---|---|
| `provider` | **Bắt buộc.** `mbbank`, `bidv`, `acb` hoặc `pay2s` ở production; `simulator` ở development |
| `account_number` | Bắt buộc với ngân hàng thật. Chữ số (Pay2S cho phép cả chữ in hoa), 4 đến 30 ký tự. Ngân hàng giả lập tự sinh nếu bạn bỏ trống |
| `account_name` | Tên chủ tài khoản đúng như ngân hàng ghi nhận. Bắt buộc với MB Bank |
| `account_type` | `personal` (mặc định) hoặc `business` |
| `holder_id` | CCCD (cá nhân) hoặc mã số thuế (doanh nghiệp) dùng khi mở tài khoản. Bắt buộc với MB Bank; được lưu mã hóa |
| `bank_code` | Với nhà cung cấp phục vụ nhiều ngân hàng (Pay2S, ngân hàng giả lập): tài khoản ở ngân hàng nào, vd `BIDV`, `MB`, `VCB`. Quyết định ngân hàng ghi trên VietQR. Các nhà cung cấp khác sẽ từ chối trường này |
| `webhook_token` | Chỉ dùng và bắt buộc với Pay2S: token của Hook bạn tạo trên Pay2S |
| `label` | Tên bạn đặt cho tài khoản, tối đa 100 ký tự |
| `config` | Cấu hình khớp mã; xem [Cấu hình khớp mã](#config) |

Phản hồi là `201` với kết nối ở `pending_otp` kèm `challenge`:

```json
{
  "object": "bank_connection",
  "id": "bcn_01a0b90428fd7c499145a12c133abe41",
  "environment": "production",
  "provider": "mbbank",
  "bank": { "bin": "970422", "name": "MB Bank" },
  "account_number": "910452658",
  "account_name": "NGUYEN VAN A",
  "account_type": "personal",
  "status": "pending_otp",
  "label": "Tài khoản bán hàng",
  "config": {
    "code_prefix": "HT",
    "payment_code_pattern": null,
    "transaction_events": { "direction": "in", "require_code": false, "min_amount": 0 }
  },
  "capabilities": { "pushNotifications": true, "history": false, "accountInfo": false, "virtualAccounts": false, "refunds": false },
  "link_mode": "otp",
  "webhook_url": null,
  "consent": null,
  "last_transaction_at": null,
  "activated_at": null,
  "revoked_at": null,
  "created_at": "2026-09-19T09:29:51.402Z",
  "challenge": { "id": "lch_01a0b90428e17d2c8b5f0a4e3c2d1b0a", "expires_at": "2026-09-19T09:34:51.402Z", "delivery": "sms_otp" }
}
```

`challenge.delivery` cho biết bước tiếp theo: `sms_otp` (mã đang được gửi), `redirect` (mở `challenge.authorize_url`) hoặc `bank_registration` (chờ tin đầu tiên của ngân hàng).

### 2. Xác nhận bằng mã

**cURL**

```bash
curl https://api.hatang.dev/payments/bank-connections/bcn_123/confirm \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "otp": "123456" }'
```

**Node.js**

```ts
const { data, error } = await hatang.payments.bankConnections.confirm(conn.id, "123456");
```

**Python**

```python
requests.post(f"https://api.hatang.dev/payments/bank-connections/{conn['id']}/confirm",
              headers=HEADERS, json={"otp": "123456"}, timeout=30).raise_for_status()
```

**PHP**

```php
$client->post("/payments/bank-connections/{$conn['id']}/confirm", ['json' => ['otp' => '123456']]);
```

`otp` gồm 4 đến 8 chữ số. Khi thành công, kết nối chuyển sang `active` và webhook `bank_connection.activated` được gửi. Mỗi lần nhập sai được tính một lượt; sau 5 lượt sai, mã hết hiệu lực và bạn phải bắt đầu lại.

## Liên kết tài khoản BIDV, ACB hoặc Pay2S

- **BIDV.** Đăng ký dịch vụ thông báo biến động số dư cho tài khoản tại BIDV, rồi `POST` với `provider: "bidv"` và `account_number`. Kết nối ở trạng thái `pending_otp` (dashboard hiển thị "Chờ ngân hàng") cho tới khi BIDV gửi tin đầu tiên cho tài khoản, lúc đó kết nối được kích hoạt. Gọi `confirm` sẽ nhận `409 invalid_state`.
- **ACB.** `POST` với `provider: "acb"` và `account_number`, rồi đưa chủ tài khoản tới `challenge.authorize_url` trong vài phút. Sau khi họ đồng ý trên trang của ACB, ACB đưa họ về **Cài đặt thanh toán → Tài khoản ngân hàng** và tài khoản chuyển sang `active`. Sự đồng ý này cho phép đọc lịch sử tài khoản, có hiệu lực tối đa 180 ngày và hiển thị ở trường `consent` của kết nối. ACB không đẩy giao dịch; Hạ Tầng đọc lịch sử mỗi 10 phút.
- **Pay2S.** Liên kết tài khoản trên Pay2S trước. Lấy `webhook_url` từ một kết nối Pay2S bất kỳ (hoặc từ form trên dashboard), tạo Hook trên Pay2S trỏ tới URL đó, và sao chép token của Hook. Sau đó `POST` với `provider: "pay2s"`, `bank_code`, `account_number` (đúng như Pay2S ghi) và `webhook_token`. Kết nối kích hoạt khi Pay2S đẩy giao dịch đầu tiên của tài khoản. Pay2S đọc được tài khoản tại `VCB`, `ICB`, `TCB`, `BIDV`, `ACB`, `MB` và `TPB`.

## Trạng thái

| `status` | Ý nghĩa |
|---|---|
| `pending_otp` | Đang chờ: chờ mã, chờ chủ tài khoản đồng ý, hoặc chờ tin đầu tiên của ngân hàng |
| `active` | Đang nhận giao dịch |
| `needs_reauth` | Ngân hàng yêu cầu liên kết lại |
| `revoked` | Đã hủy liên kết |
| `error` | Ngân hàng báo lỗi với liên kết |

## Cấu hình khớp mã (`config`) {#config}

Đổi bằng `PATCH`, hoặc trên dashboard tại **Cài đặt thanh toán → Tài khoản ngân hàng → Khớp lệnh và thông báo**. Chỉ những trường bạn gửi mới thay đổi.

**cURL**

```bash
curl -X PATCH https://api.hatang.dev/payments/bank-connections/bcn_123 \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Cửa hàng chính",
    "config": {
      "code_prefix": "SHOP",
      "payment_code_pattern": { "prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits" },
      "transaction_events": { "direction": "in", "require_code": true, "min_amount": 10000 }
    }
  }'
```

**Node.js**

```ts
await hatang.payments.bankConnections.update("bcn_123", {
  label: "Cửa hàng chính",
  config: {
    code_prefix: "SHOP",
    payment_code_pattern: { prefix: "DH", min_length: 4, max_length: 8, charset: "digits" },
    transaction_events: { direction: "in", require_code: true, min_amount: 10_000 },
  },
});
```

**Python**

```python
requests.patch("https://api.hatang.dev/payments/bank-connections/bcn_123", headers=HEADERS, json={
    "label": "Cửa hàng chính",
    "config": {
        "code_prefix": "SHOP",
        "payment_code_pattern": {"prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits"},
        "transaction_events": {"direction": "in", "require_code": True, "min_amount": 10000},
    },
}, timeout=10).raise_for_status()
```

**PHP**

```php
$client->patch('/payments/bank-connections/bcn_123', ['json' => [
    'label' => 'Cửa hàng chính',
    'config' => [
        'code_prefix' => 'SHOP',
        'payment_code_pattern' => ['prefix' => 'DH', 'min_length' => 4, 'max_length' => 8, 'charset' => 'digits'],
        'transaction_events' => ['direction' => 'in', 'require_code' => true, 'min_amount' => 10000],
    ],
]]);
```

| Trường | Mặc định | Mô tả |
|---|---|---|
| `code_prefix` | `HT` | Tiền tố của mã thanh toán tự sinh, 2 đến 5 chữ in hoa (luồng đơn hàng) |
| `payment_code_pattern` | `null` | Định dạng mã đơn của bạn, được tìm trong mọi giao dịch (luồng chỉ nhận giao dịch). `null` để tắt |
| `payment_code_pattern.prefix` | – | 1 đến 10 chữ in hoa hoặc chữ số |
| `payment_code_pattern.min_length` / `max_length` | `3` / `10` | Độ dài phần sau tiền tố, từ 1 đến 30 |
| `payment_code_pattern.charset` | `digits` | `digits` hoặc `alnum` (chữ và số) |
| `transaction_events.direction` | `in` | `in`, hoặc `all` để báo cả tiền ra |
| `transaction_events.require_code` | `false` | Chỉ báo giao dịch tìm thấy mã hoặc khớp với một payment |
| `transaction_events.min_amount` | `0` | Không báo giao dịch dưới số tiền này (VND) |

`transaction_events` chỉ lọc `transaction.received`. Payment luôn được khớp và báo.

## Hủy liên kết

**cURL**

```bash
curl -X POST https://api.hatang.dev/payments/bank-connections/bcn_123/unlink \
  -H "Authorization: Bearer $HATANG_API_KEY"

# Chỉ khi phản hồi có challenge:
curl https://api.hatang.dev/payments/bank-connections/bcn_123/unlink/confirm \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "otp": "123456" }'
```

**Node.js**

```ts
const { data } = await hatang.payments.bankConnections.unlink("bcn_123");
if (data?.status !== "revoked") await hatang.payments.bankConnections.confirmUnlink("bcn_123", "123456");
```

**Python**

```python
conn = requests.post("https://api.hatang.dev/payments/bank-connections/bcn_123/unlink",
                     headers=HEADERS, timeout=30).json()
if conn.get("challenge"):
    requests.post("https://api.hatang.dev/payments/bank-connections/bcn_123/unlink/confirm",
                  headers=HEADERS, json={"otp": "123456"}, timeout=30).raise_for_status()
```

**PHP**

```php
$conn = json_decode($client->post('/payments/bank-connections/bcn_123/unlink')->getBody(), true);
if (!empty($conn['challenge'])) {
    $client->post('/payments/bank-connections/bcn_123/unlink/confirm', ['json' => ['otp' => '123456']]);
}
```

MB Bank xác nhận hủy liên kết bằng mã, giống khi liên kết. Các ngân hàng khác, và tài khoản đang chờ liên kết, được hủy ngay: phản hồi đã ở `revoked` và `challenge` là `null`. Hủy liên kết tài khoản ACB cũng thu hồi sự đồng ý tại ACB. Để BIDV hoặc Pay2S ngừng đẩy giao dịch, hãy tắt dịch vụ ở phía họ. Hủy liên kết sẽ gửi `bank_connection.revoked`.

## Nhập sao kê {#statement-import}

Nhập sao kê giúp khôi phục giao dịch mà ngân hàng không đẩy về, với mọi ngân hàng. Tải sao kê từ ngân hàng, chuyển sang CSV theo mẫu dưới đây, rồi tải lên trên dashboard (**Cài đặt thanh toán → Tài khoản ngân hàng → Nhập sao kê ngân hàng**) hoặc qua API.

```csv
reference,booked_at,direction,amount,description,counterparty_account,counterparty_name
FT1789810452930810,2026-09-19T09:34:12Z,in,99000,DH20250919 chuyen tien,0987654321,NGUYEN VAN KHACH
```

**cURL**

```bash
curl https://api.hatang.dev/payments/statements \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg csv "$(cat sao-ke.csv)" \
        '{ connection_id: "bcn_123", filename: "sao-ke-thang-9.csv", content: $csv }')"
```

**Node.js**

```ts
import { readFile } from "node:fs/promises";

const { data: job } = await hatang.payments.statements.import({
  connection_id: "bcn_123",
  filename: "sao-ke-thang-9.csv",
  content: await readFile("sao-ke.csv", "utf8"),
});
const { data: progress } = await hatang.payments.statements.get(job.id);
```

**Python**

```python
with open("sao-ke.csv", encoding="utf-8") as f:
    job = requests.post("https://api.hatang.dev/payments/statements", headers=HEADERS, json={
        "connection_id": "bcn_123",
        "filename": "sao-ke-thang-9.csv",
        "content": f.read(),
    }, timeout=30).json()
```

**PHP**

```php
$job = json_decode($client->post('/payments/statements', ['json' => [
    'connection_id' => 'bcn_123',
    'filename' => 'sao-ke-thang-9.csv',
    'content' => file_get_contents('sao-ke.csv'),
]])->getBody(), true);
```

Lời gọi trả về `202` và các dòng được xử lý ở nền. Theo dõi qua `GET /payments/statements/{id}`:

```json
{
  "object": "statement_import",
  "id": "stm_01a0b9042a1c7f0e9d8c7b6a5f4e3d2c",
  "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41",
  "filename": "sao-ke-thang-9.csv",
  "status": "done",
  "rows": 1,
  "processed_rows": 1,
  "error": null,
  "created_at": "2026-09-20T02:10:00.000Z",
  "completed_at": "2026-09-20T02:10:01.250Z"
}
```

| Quy tắc | Chi tiết |
|---|---|
| Cột | `reference`, `booked_at`, `direction`, `amount`, `description` là bắt buộc; `counterparty_account` và `counterparty_name` không bắt buộc. Dòng tiêu đề ghi tên cột, thứ tự tùy ý |
| `direction` | `in`, `out`, `c`, `d`, `credit` hoặc `debit` |
| `amount` | Số nguyên dương, đơn vị VND; chấp nhận dấu phân cách hàng nghìn (`99.000`, `99,000`) |
| `booked_at` | Ngày giờ theo ISO 8601. Giá trị không đọc được sẽ lấy thời điểm nhập |
| Dung lượng | `content` tối đa 5.000.000 ký tự |
| Trùng lặp | Giao dịch Hạ Tầng đã có (cùng mã tham chiếu và số tiền trên tài khoản) được bỏ qua |
| `status` | `queued`, `processing`, `done` hoặc `failed` (kèm `error`) |

Giao dịch nhập từ sao kê đi qua cùng quy trình khớp mã như giao dịch được đẩy về: giao dịch có mã thanh toán sẽ cập nhật payment đó và gửi các sự kiện như bình thường.

Thiếu cột hoặc dòng sai sẽ bị từ chối ngay với `422 validation_error`, ví dụ `Statement is missing the "amount" column` hoặc `Statement row 3 is invalid`. Khi đó không có gì được lưu.

## Lỗi

| Lỗi | Khi nào |
|---|---|
| `403 environment_mismatch` | Ngân hàng thật ở development, hoặc ngân hàng giả lập ở production |
| `422 missing_required_field` | MB Bank thiếu `account_number`, `account_name` hoặc `holder_id`; BIDV hoặc Pay2S thiếu `account_number`; Pay2S thiếu `webhook_token` |
| `422 validation_error` | `provider` không tồn tại, số tài khoản sai định dạng, `bank_code` với ngân hàng không nhận trường này, hoặc `bank_code` mà Pay2S không đọc được |
| `422 bank_link_failed` | Ngân hàng từ chối: không có tài khoản, tên hoặc giấy tờ không khớp, tài khoản đã liên kết với doanh nghiệp khác, hoặc chủ tài khoản không đồng ý |
| `422 invalid_otp` | Sai mã |
| `410 otp_expired` | Mã hết hạn; bắt đầu liên kết lại |
| `409 invalid_state` | Tài khoản đã liên kết, đang được liên kết, đã hủy, hoặc không xác nhận bằng mã |
| `503 provider_unavailable` | Ngân hàng không phản hồi; thử lại sau |

## Bước tiếp theo

- [Nhận giao dịch không cần tạo đơn](https://hatang.dev/vi/docs/guides/payments/transactions.md) với `payment_code_pattern`.
- [Tạo thanh toán](https://hatang.dev/vi/docs/guides/payments/create-payment.md) vào tài khoản đã liên kết.
- [Webhook](https://hatang.dev/vi/docs/guides/payments/webhooks.md): các sự kiện `bank_connection.*`.

Source: https://hatang.dev/vi/docs/guides/payments/bank-connections
