# Tạo thanh toán

Payment là yêu cầu thu tiền cho một đơn hàng. Mỗi payment có mã chuyển khoản, VietQR và trang checkout riêng, và Hạ Tầng chuyển nó sang `paid` khi có giao dịch mang mã đó. Trang này hướng dẫn tạo payment, các trạng thái, mã thanh toán, và các lời gọi hủy hoặc ghi nhận thanh toán thủ công.

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

- Một [kết nối ngân hàng](https://hatang.dev/vi/docs/guides/payments/bank-connections.md) đang hoạt động ở môi trường bạn dùng.
- API key có `payments:write`.
- Một endpoint HTTPS cho [IPN](https://hatang.dev/vi/docs/guides/payments/payment-results.md), nếu bạn muốn nhận lời gọi cho payment này.

## 1. Tạo payment

Tạo khi khách bấm thanh toán. Gửi `Idempotency-Key` (mã đơn là lựa chọn tốt) để request thử lại trả về cùng một payment thay vì tạo cái thứ hai.

**cURL**

```bash
curl https://api.hatang.dev/payments \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Idempotency-Key: order-1001" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 150000,
    "description": "Đơn hàng #1001",
    "expires_in": 3600,
    "return_url": "https://shop.example.vn/orders/1001",
    "ipn_url": "https://shop.example.vn/hatang/ipn",
    "customer": { "name": "Trần Thị B", "email": "b@example.vn" },
    "metadata": { "order_id": "1001" }
  }'
```

**Node.js**

```ts
const { data: payment, error } = await hatang.payments.create(
  {
    amount: 150_000,
    description: "Đơn hàng #1001",
    expires_in: 3600,
    return_url: "https://shop.example.vn/orders/1001",
    ipn_url: "https://shop.example.vn/hatang/ipn",
    customer: { name: "Trần Thị B", email: "b@example.vn" },
    metadata: { order_id: "1001" },
  },
  { idempotencyKey: "order-1001" },
);
if (error) throw new Error(`${error.name}: ${error.message}`);
```

**Python**

```python
payment = hatang.payments.create(
    {
        "amount": 150000,
        "description": "Đơn hàng #1001",
        "expires_in": 3600,
        "return_url": "https://shop.example.vn/orders/1001",
        "ipn_url": "https://shop.example.vn/hatang/ipn",
        "customer": {"name": "Trần Thị B", "email": "b@example.vn"},
        "metadata": {"order_id": "1001"},
    },
    idempotency_key="order-1001",
)  # lỗi thì ném hatang.HatangError, có .name và .message
```

**PHP**

```php
<?php
use GuzzleHttp\Exception\ClientException;

try {
    $res = $client->post('/payments', [
        'headers' => ['Idempotency-Key' => 'order-1001'],
        'json' => [
            'amount' => 150000,
            'description' => 'Đơn hàng #1001',
            'expires_in' => 3600,
            'return_url' => 'https://shop.example.vn/orders/1001',
            'ipn_url' => 'https://shop.example.vn/hatang/ipn',
            'customer' => ['name' => 'Trần Thị B', 'email' => 'b@example.vn'],
            'metadata' => ['order_id' => '1001'],
        ],
    ]);
    $payment = json_decode($res->getBody(), true);
} catch (ClientException $e) {
    $err = json_decode($e->getResponse()->getBody(), true);
    throw new RuntimeException("{$err['name']}: {$err['message']}");
}
```

| Trường | Ý nghĩa |
|---|---|
| `amount` | **Bắt buộc.** VND, đã gồm VAT: số tiền người mua chuyển. Số nguyên từ 1 đến 100.000.000.000 |
| `description` | Hiển thị cho người mua trên trang checkout, tối đa 200 ký tự. Đây không phải nội dung chuyển khoản |
| `code` | Mã chuyển khoản của bạn thay cho mã tự sinh; xem [Mã của riêng bạn](#your-own-code) |
| `connection_id` | Tài khoản ngân hàng nhận tiền. Chỉ cần khi có nhiều tài khoản đang hoạt động |
| `expires_in` | Số giây tới khi hết hạn: 1 đến 2.592.000 (30 ngày). Mặc định 86.400 (24 giờ) |
| `return_url` | Trang checkout chuyển người mua về đây sau khi trả |
| `ipn_url` | Endpoint nhận IPN của payment này (xem [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md)). Chỉ chấp nhận `https` công khai |
| `customer` | `name`, `email`, `phone`. Của bạn; trả lại nguyên văn |
| `metadata` | Dữ liệu key-value của bạn. Key tối đa 40 ký tự; giá trị là chuỗi (tối đa 500 ký tự), số, boolean hoặc `null`. Trả lại nguyên văn |
| `invoice` | Phát hành hóa đơn điện tử khi đã trả: [yêu cầu xuất hóa đơn](https://hatang.dev/vi/docs/guides/payments/einvoices.md#invoice-request) (người mua, dòng hàng, VAT). Xem [Hóa đơn điện tử](https://hatang.dev/vi/docs/guides/payments/einvoices.md) |

### Idempotency

Cùng `Idempotency-Key` với cùng body sẽ trả về payment tạo lần đầu, mã `201`. Cùng key nhưng body **khác** sẽ trả về `400 invalid_idempotent_request`. Key dài tối đa 256 ký tự và có phạm vi theo doanh nghiệp và môi trường.

### Phản hồi {#response}

```json
{
  "object": "payment",
  "id": "pay_01a0b904297873e8b3524a26fdca0ab5",
  "environment": "production",
  "status": "pending",
  "amount": 150000,
  "paid_amount": 0,
  "currency": "VND",
  "code": "HTBF7RDGVQ",
  "description": "Đơn hàng #1001",
  "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41",
  "bank": { "bin": "970422", "name": "MB Bank", "account_number": "910452658", "account_name": "NGUYEN VAN A" },
  "qr": {
    "payload": "00020101021238530010A0000007270123000697042201099104526580208QRIBFTTA530370454061500005802VN62140810HTBF7RDGVQ63044DEC",
    "image_url": "https://api.hatang.dev/pay/pay_01a0b904297873e8b3524a26fdca0ab5/qr.svg"
  },
  "checkout_url": "https://app.hatang.dev/pay/pay_01a0b904297873e8b3524a26fdca0ab5",
  "return_url": "https://shop.example.vn/orders/1001",
  "ipn_url": "https://shop.example.vn/hatang/ipn",
  "customer": { "name": "Trần Thị B", "email": "b@example.vn" },
  "metadata": { "order_id": "1001" },
  "invoice": null,
  "expires_at": "2026-09-19T10:34:12.856Z",
  "paid_at": null,
  "canceled_at": null,
  "created_at": "2026-09-19T09:34:12.856Z"
}
```

Lưu `id` cùng đơn hàng của bạn. Webhook `payment.created` được gửi (sự kiện này không có IPN).

### Lỗi

| Lỗi | Khi nào | Cách xử lý |
|---|---|---|
| `422 validation_error` | Thiếu hoặc sai trường; thông báo nêu tên trường (`amount: Number must be greater than or equal to 1`) | Sửa request |
| `422 validation_error` ở `ipn_url` | URL trỏ tới địa chỉ nội bộ, hoặc không phải `https` | Dùng URL HTTPS công khai |
| `422 validation_error` "Several bank accounts are linked; pass connection_id" | Có nhiều hơn một tài khoản đang hoạt động | Gửi `connection_id` |
| `422 no_bank_connection` | Không có tài khoản đang hoạt động, hoặc `connection_id` không hoạt động | Liên kết hoặc liên kết lại tài khoản |
| `409 code_taken` | `code` của bạn đã được một payment khác vào tài khoản này dùng | Dùng mã khác |
| `400 invalid_idempotent_request` | Key đã dùng với body khác | Dùng key mới cho đơn mới |
| `429 rate_limit_exceeded` | Hơn 20 request Thanh toán mỗi giây cho doanh nghiệp của bạn | Thử lại sau thời gian trong header `Retry-After` |

## 2. Để người mua thanh toán

Chuyển tới `checkout_url`, hoặc tự hiển thị `qr` và thông tin `bank`. Xem [Cách người mua thanh toán](https://hatang.dev/vi/docs/guides/payments/buyer-flow.md).

## 3. Nhận kết quả

Chờ lời gọi IPN hoặc webhook (xem [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md)), rồi giao hàng khi `status` là `paid` hoặc `overpaid` và `paid_amount` đủ cho đơn. Không bao giờ giao hàng dựa trên trình duyệt.

## Trạng thái

```mermaid
stateDiagram-v2
    [*] --> pending
    pending --> paid: đủ tiền
    pending --> partially_paid: thiếu tiền
    pending --> overpaid: thừa tiền
    partially_paid --> paid: chuyển nốt cùng mã
    partially_paid --> overpaid: chuyển nhiều hơn phần còn thiếu
    paid --> overpaid: thêm giao dịch cùng mã
    pending --> expired: quá expires_at
    expired --> paid: tiền về muộn
    pending --> canceled: POST /cancel
```

| `status` | Ý nghĩa | Sự kiện |
|---|---|---|
| `pending` | Đang chờ chuyển khoản | `payment.created` |
| `paid` | Đã nhận đủ; `paid_at` được đặt | `payment.succeeded` |
| `partially_paid` | Nhận ít hơn số tiền. Giao dịch tiếp theo cùng mã sẽ cộng dồn | `payment.partially_paid` |
| `overpaid` | Nhận nhiều hơn số tiền | `payment.overpaid` |
| `expired` | Không có tiền nào trước `expires_at` | `payment.expired` |
| `canceled` | Bạn đã hủy khi payment còn `pending` | `payment.canceled` |

`payment.succeeded`, `payment.partially_paid` và `payment.overpaid` kèm theo `transaction` ngân hàng tương ứng.

### Tình huống đặc biệt

- **Tiền về muộn.** Giao dịch đến sau `expires_at` vẫn được ghi nhận: payment chuyển từ `expired` sang `paid` (hoặc `partially_paid`, `overpaid`) và bạn được báo. Tự quyết định có giao đơn trễ hay không.
- **Payment trả thiếu không hết hạn.** Chỉ payment `pending` mới chuyển sang `expired`. Payment `partially_paid` giữ nguyên trạng thái cho tới khi phần còn lại về hoặc bạn ghi nhận thủ công.
- **Trả hai lần.** Giao dịch thứ hai mang mã của payment đã `paid` sẽ chuyển nó sang `overpaid`, và `paid_amount` là tổng. Hoàn tiền thực hiện ngoài Hạ Tầng.
- **Payment đã hủy** không còn được khớp. Giao dịch mang mã của nó đến dưới dạng `transaction.received` chưa khớp.
- **Thời điểm hết hạn.** Việc xử lý hết hạn chạy mỗi 30 giây, nên `payment.expired` đến ngay sau `expires_at`.

## Mã thanh toán {#payment-code}

`code` gồm tiền tố (`HT` mặc định, đổi được theo tài khoản ngân hàng qua `config.code_prefix`) và 8 ký tự không dễ nhầm: không có `0`, `O`, `1`, `I`. Người mua phải ghi mã vào nội dung chuyển khoản. Xung quanh có gì cũng được (`CK HTBF7RDGVQ thanh toan`); chữ hoa thường, khoảng trắng, dấu câu và dấu tiếng Việt đều được bỏ qua. VietQR đã chứa sẵn mã, nên người quét QR không phải gõ.

### Mã của riêng bạn {#your-own-code}

Gửi `code` và mã đó sẽ là nội dung chuyển khoản thay cho mã tự sinh: ví dụ số đơn hàng của bạn, để sao kê ngân hàng và đơn hàng khớp nhau.

```json
{ "amount": 275000, "code": "DH1002" }
```

- Chỉ gồm chữ và số, từ 6 đến 20 ký tự; được viết hoa.
- Mã phải chưa được dùng trên tài khoản ngân hàng đó, nếu không lời gọi trả về `409 code_taken`. Thử lại với cùng `Idempotency-Key` sẽ trả về payment đã có.
- **Mã dài an toàn hơn.** Mã được tìm bằng cách dò trong nội dung chuyển khoản, nên một mã ngắn, phổ biến (`ORDER1`) có thể khớp nhầm giao dịch tình cờ chứa nó. Mã có tiền tố riêng của bạn (`DH1002`) là đủ đặc trưng.
- **Khi một mã nằm trong mã khác** (`DH1002` và `DH10024`), mã dài hơn được nhận giao dịch chứa nó.

## Đọc và liệt kê payment

```bash
curl https://api.hatang.dev/payments/pay_123 -H "Authorization: Bearer $HATANG_API_KEY"
curl "https://api.hatang.dev/payments?status=paid,overpaid&limit=50" -H "Authorization: Bearer $HATANG_API_KEY"
```

| Tham số | Ý nghĩa |
|---|---|
| `limit` | 1 đến 100, mặc định 20 |
| `after` | `next_cursor` của trang trước |
| `status` | Một hoặc nhiều trạng thái, cách nhau bởi dấu phẩy |
| `connection_id` | Một tài khoản ngân hàng |

Danh sách trả về `{ "object": "list", "data": [...], "has_more": false, "next_cursor": null }`, mới nhất trước.

## Hủy payment

**cURL**

```bash
curl -X POST https://api.hatang.dev/payments/pay_123/cancel \
  -H "Authorization: Bearer $HATANG_API_KEY"
```

**Node.js**

```ts
const { data, error } = await hatang.payments.cancel("pay_123");
```

**Python**

```python
hatang.payments.cancel("pay_123")
```

**PHP**

```php
$client->post('/payments/pay_123/cancel');
```

Chỉ hủy được payment `pending`. Trạng thái khác trả về `409 invalid_state` ("A paid payment cannot be canceled").

## Ghi nhận tiền nhận bằng cách khác

Khi tiền đến ngoài ngân hàng (tiền mặt, hoặc chuyển khoản không có mã), hãy ghi nhận thủ công. Dashboard có thao tác tương tự: **Đánh dấu đã thanh toán** trên trang payment.

**cURL**

```bash
curl https://api.hatang.dev/payments/pay_123/mark-paid \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "note": "Khách trả tiền mặt tại quầy" }'
```

**Node.js**

```ts
await hatang.payments.markPaid("pay_123", { note: "Khách trả tiền mặt tại quầy" });
```

**Python**

```python
requests.post("https://api.hatang.dev/payments/pay_123/mark-paid", headers=HEADERS,
              json={"note": "Khách trả tiền mặt tại quầy"}, timeout=10).raise_for_status()
```

**PHP**

```php
$client->post('/payments/pay_123/mark-paid', ['json' => ['note' => 'Khách trả tiền mặt tại quầy']]);
```

| Trường | Ý nghĩa |
|---|---|
| `amount` | Số tiền đã nhận. Mặc định là phần còn thiếu (`amount - paid_amount`) |
| `note` | Tối đa 500 ký tự, được lưu trong sự kiện |

Áp dụng cho payment `pending`, `partially_paid` và `expired` (trạng thái khác trả về `409 invalid_state`). Payment chuyển sang `paid`, `partially_paid` hoặc `overpaid` như khi có giao dịch thật, và `data` của sự kiện webhook có thêm `manual: { by, note, amount }` để bạn phân biệt.

## Bước tiếp theo

- [Cách người mua thanh toán](https://hatang.dev/vi/docs/guides/payments/buyer-flow.md): trang checkout, QR và trang thanh toán của riêng bạn.
- [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md): IPN và webhook.
- [Hóa đơn điện tử](https://hatang.dev/vi/docs/guides/payments/einvoices.md): thêm khối `invoice`.

Source: https://hatang.dev/vi/docs/guides/payments/create-payment
