# 📚 Tài liệu kỹ thuật Webhook

## 🔄 Cơ chế hoạt động

Khi một giao dịch xảy ra, Pay2S sẽ gửi dữ liệu đến endpoint của bạn qua HTTP POST.

---

## 🛠️ Request từ Pay2S

### Method
```
POST
```

### Header

```json
{
  "Content-Type": "application/json",
  "Authorization": "Bearer <YOUR_TOKEN_WEBHOOK>"
}
```

### Body (POST Parameters)

```json
{
  "transactions": [
    {
      "id": 10001,
      "gateway": "ACB",
      "transactionDate": "2026-10-11 09:00:00",
      "transactionNumber": "DEMO10001",
      "accountNumber": "P2S99999999",
      "content": "THANHTOAN DH10001",
      "transferType": "IN",
      "transferAmount": 2000,
      "checksum": "demo-user-webhook-10001",
      "paymentCode": "DH10001",
      "extraData": {
        "remitterName": "NGUYEN VAN A",
        "remitterAccountNumber": "0123456789",
        "issuerBankName": "Ngan hang TMCP Quan Doi",
        "reciprocalBankCode": "970422"
      }
    }
  ]
}
```

## Trường bổ sung: paymentCode, người chuyển và tài khoản ảo

Các trường bên dưới nằm trong **từng phần tử `transactions[]`**, không nằm ở ngoài mảng. Payload dưới đây là webhook giao dịch, không phải IPN thanh toán hoặc webhook trạng thái Hosted Link.

### Có mã thanh toán và thông tin người chuyển

```json
{
  "transactions": [
    {
      "id": 10001,
      "gateway": "ACB",
      "transactionDate": "2026-10-11 09:00:00",
      "transactionNumber": "DEMO10001",
      "accountNumber": "P2S99999999",
      "content": "THANHTOAN DH10001",
      "transferType": "IN",
      "transferAmount": 2000,
      "checksum": "demo-user-webhook-10001",
      "paymentCode": "DH10001",
      "extraData": {
        "remitterName": "NGUYEN VAN A",
        "remitterAccountNumber": "0123456789",
        "issuerBankName": "Ngan hang TMCP Quan Doi",
        "reciprocalBankCode": "970422"
      }
    }
  ]
}
```

| Trường | Vị trí / kiểu dữ liệu | Khi nào xuất hiện? |
| --- | --- | --- |
| `paymentCode` | `transactions[i].paymentCode` · chuỗi | Webhook có yêu cầu mã thanh toán, đã chọn mẫu và nội dung giao dịch khớp mẫu. Worker trích mã từ nội dung, ví dụ `DH10001`; đây không phải mã giao dịch ngân hàng. |
| `extraData` | `transactions[i].extraData` · object | Bật dữ liệu bổ sung (`extraData`) trong cấu hình webhook. Tắt thì cả object bị bỏ khỏi payload. Không phải chuỗi Base64 `extraData` của API tạo thanh toán. |
| `remitterName` | `extraData.remitterName` · chuỗi | Tên người chuyển do ngân hàng cung cấp. |
| `remitterAccountNumber` | `extraData.remitterAccountNumber` · chuỗi | Số tài khoản người chuyển. Giữ dạng chuỗi để không mất số 0 đầu. |
| `issuerBankName` | `extraData.issuerBankName` · chuỗi | Tên ngân hàng trong dữ liệu người chuyển. |
| `reciprocalBankCode` | `extraData.reciprocalBankCode` · chuỗi | Mã ngân hàng đối ứng do nguồn giao dịch cung cấp; không tự suy ra từ `gateway`. |
| `billNumber` | `transactions[i].billNumber` · thường là chuỗi | Giao dịch có `billNumber` có giá trị. Worker chuyển nguyên giá trị, không sinh cho tất cả giao dịch. |
| `partnerRefId` | `transactions[i].partnerRefId` · thường là chuỗi | Giao dịch có mã tham chiếu đối tác. |
| `TID` | `transactions[i].TID` · giá trị VA, có thể null | Được thêm **cùng `partnerRefId`**, lấy từ `vaNumber` của giao dịch. Đúng tên trường là `TID` viết hoa. |
| `teller` | `transactions[i].teller` · thường là chuỗi | Nguồn giao dịch có giá trị `teller`. |
| `sequence` | `transactions[i].sequence` · giá trị từ nguồn, có thể null | Được thêm **cùng `teller`**. Nếu giá trị là `undefined` thì JSON có thể bỏ trường này. |

**Thiếu thông tin người chuyển:** khi đã bật `extraData`, worker thay giá trị `null` hoặc không có bằng chuỗi **`"N/A"`**. Chuỗi rỗng từ ngân hàng vẫn được giữ nguyên. Không coi `N/A` là tên hoặc số tài khoản thật, không bắt buộc có thông tin người chuyển mới ghi nhận một giao dịch hợp lệ.

### Bật extraData nhưng ngân hàng không cung cấp thông tin

```json
{
  "transactions": [
    {
      "id": 10001,
      "gateway": "ACB",
      "transactionDate": "2026-10-11 09:00:00",
      "transactionNumber": "DEMO10001",
      "accountNumber": "P2S99999999",
      "content": "THANHTOAN DH10001",
      "transferType": "IN",
      "transferAmount": 2000,
      "checksum": "demo-user-webhook-10001",
      "extraData": {
        "remitterName": "N/A",
        "remitterAccountNumber": "N/A",
        "issuerBankName": "N/A",
        "reciprocalBankCode": "N/A"
      }
    }
  ]
}
```

### Tài khoản ảo và mã tham chiếu đối tác

```json
{
  "transactions": [
    {
      "id": 10001,
      "gateway": "BIDV",
      "transactionDate": "2026-10-11 09:00:00",
      "transactionNumber": "DEMO10001",
      "accountNumber": "963869123456",
      "content": "THANHTOAN DH10001",
      "transferType": "IN",
      "transferAmount": 2000,
      "checksum": "demo-user-webhook-10001",
      "partnerRefId": "PARTNER_REF_DEMO",
      "TID": "123456"
    }
  ]
}
```

Theo worker hiện tại, nếu giao dịch có `vaNumber` thì **`accountNumber = "963869" + vaNumber`**; nếu không, `accountNumber` là tài khoản gốc. `TID` giữ giá trị `vaNumber` gốc và chỉ đi kèm nhánh `partnerRefId`. Không tự thêm tiền tố lần nữa hoặc chuyển số tài khoản/TID sang kiểu số. Webhook gửi trực tiếp không có trường `vaNumber` riêng trong nhánh này; payload dựng lại từ API lịch sử webhook có thể có trường đó và `accountNumber` là tài khoản gốc.

### Ví dụ liệt kê toàn bộ trường có điều kiện

Mẫu này ghép các nhánh của worker để minh họa tên và vị trí trường; **không có nghĩa một ngân hàng hoặc mọi giao dịch luôn trả đủ các trường này**. Các giá trị là dữ liệu thử, không phải bản chụp giao dịch ngân hàng thật.

```json
{
  "transactions": [
    {
      "id": 10001,
      "gateway": "ACB",
      "transactionDate": "2026-10-11 09:00:00",
      "transactionNumber": "DEMO10001",
      "accountNumber": "963869123456",
      "content": "THANHTOAN DH10001",
      "transferType": "IN",
      "transferAmount": 2000,
      "checksum": "demo-user-webhook-10001",
      "paymentCode": "DH10001",
      "billNumber": "BILL_DEMO_001",
      "partnerRefId": "PARTNER_REF_DEMO",
      "TID": "123456",
      "teller": "TELLER_DEMO",
      "sequence": "000123",
      "extraData": {
        "remitterName": "NGUYEN VAN A",
        "remitterAccountNumber": "0123456789",
        "issuerBankName": "Ngan hang TMCP Quan Doi",
        "reciprocalBankCode": "970422"
      }
    }
  ]
}
```

### Đọc dữ liệu mà không phụ thuộc trường tùy chọn

```js
for (const transaction of payload.transactions) {
  const paymentCode = transaction.paymentCode ?? null;
  const extra = transaction.extraData ?? {};
  const remitterName = extra.remitterName && extra.remitterName !== 'N/A'
    ? extra.remitterName : null;
  const remitterAccount = extra.remitterAccountNumber && extra.remitterAccountNumber !== 'N/A'
    ? extra.remitterAccountNumber : null;
  // Lưu giao dịch có chống trùng theo id/checksum.
  // paymentCode, remitterName, remitterAccount có thể không có.
}
```

Kiểm tra Bearer token trước khi xử lý; lưu giao dịch hoặc đưa vào hàng đợi bền vững trước khi ACK. Receiver nên chấp nhận các trường bổ sung mới, không từ chối toàn bộ giao dịch chỉ vì thiếu trường tùy chọn.


### Response từ Endpoint của bạn

```json
{
  "success": true
}
```

---

## 🔁 Cơ chế Retry

Worker gửi trực tiếp và worker retry có lịch riêng; timeout, khoảng cách retry và số lần thử phụ thuộc cấu hình worker. Không suy ra lịch nhận chính xác chỉ từ số lần thử.

Receiver nên trả **HTTP 200 và JSON `{ "success": true }`** sau khi đã lưu giao dịch hoặc đưa vào hàng đợi bền vững. Worker giao dịch hiện chấp nhận HTTP 2xx **đồng thời** body có `success` đúng; chỉ trả HTTP 200 hoặc chỉ có JSON thành công với HTTP lỗi đều không đủ. Đây không phải điều kiện ACK của IPN thanh toán.

---

## 💻 Code mẫu

::: code-group

```php [PHP]
<?php
// Token hợp lệ của bạn
$expectedToken = 'your_expected_token_here';

// Lấy Authorization header
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? '';

// Kiểm tra token
if (!preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
    http_response_code(401);
    echo json_encode(['success' => false]);
    exit;
}

$receivedToken = $matches[1];
if ($receivedToken !== $expectedToken) {
    http_response_code(403);
    echo json_encode(['success' => false]);
    exit;
}

// Lấy dữ liệu JSON
$data = json_decode(file_get_contents('php://input'), true);

if (!$data || !isset($data['transactions'])) {
    http_response_code(400);
    echo json_encode(['success' => false, 'message' => 'Invalid payload']);
    exit;
}

// Xử lý từng giao dịch
foreach ($data['transactions'] as $transaction) {
    $id = $transaction['id'];
    $amount = $transaction['transferAmount'];
    $content = $transaction['content'];
    
    // Lưu vào database
    // INSERT INTO transactions (id, amount, content) VALUES ($id, $amount, $content);
}

// Trả về 200
http_response_code(200);
echo json_encode(['success' => true]);
?>
```

```javascript [Node.js]
const express = require('express');
const app = express();

app.use(express.json());

const SECRET_KEY = 'your_expected_token_here';

app.post('/webhook', (req, res) => {
  // Kiểm tra Authorization
  const authHeader = req.headers['authorization'] || '';
  const token = authHeader.replace('Bearer ', '');
  
  if (token !== SECRET_KEY) {
    return res.status(403).json({ success: false });
  }

  const { transactions } = req.body;
  
  if (!transactions || !Array.isArray(transactions)) {
    return res.status(400).json({ success: false });
  }

  // Xử lý từng giao dịch
  transactions.forEach(tx => {
    const { id, transferAmount, content } = tx;
    // await Transaction.create({ id, transferAmount, content });
  });

  return res.status(200).json({ success: true });
});

app.listen(3000, () => console.log('Webhook running on :3000'));
```

```python [Python]
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET_KEY = 'your_expected_token_here'

@app.route('/webhook', methods=['POST'])
def webhook():
    # Kiểm tra Authorization
    auth_header = request.headers.get('Authorization', '')
    token = auth_header.replace('Bearer ', '')
    
    if token != SECRET_KEY:
        return jsonify({'success': False}), 403

    data = request.get_json()
    
    if not data or 'transactions' not in data:
        return jsonify({'success': False}), 400
    
    # Xử lý từng giao dịch
    for tx in data['transactions']:
        tx_id = tx.get('id')
        amount = tx.get('transferAmount')
        content = tx.get('content')
        # db.transaction.insert_one({ 'id': tx_id, 'amount': amount, 'content': content })
    
    return jsonify({'success': True}), 200

if __name__ == '__main__':
    app.run(port=5000)
```

```java [Java]
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import com.google.gson.Gson;
import java.util.Map;
import java.util.List;

@RestController
@RequestMapping("/webhook")
public class WebhookController {
    
    private static final String SECRET_KEY = "your_expected_token_here";
    
    @PostMapping
    public ResponseEntity<?> handleWebhook(
            @RequestHeader(value = "Authorization", required = false) String authHeader,
            @RequestBody Map<String, Object> data) {
        
        // Kiểm tra Authorization
        if (authHeader == null) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN)
                    .body(Map.of("success", false));
        }
        
        String token = authHeader.replace("Bearer ", "");
        if (!token.equals(SECRET_KEY)) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN)
                    .body(Map.of("success", false));
        }

        // Lấy transactions
        List<Map<String, Object>> transactions = (List<Map<String, Object>>) data.get("transactions");
        
        if (transactions == null) {
            return ResponseEntity.badRequest().body(Map.of("success", false));
        }

        // Xử lý từng giao dịch
        for (Map<String, Object> tx : transactions) {
            String id = (String) tx.get("id");
            Long amount = ((Number) tx.get("transferAmount")).longValue();
            String content = (String) tx.get("content");
            // transactionService.save(new Transaction(id, amount, content));
        }

        return ResponseEntity.ok(Map.of("success", true));
    }
}
```

```go [Go]
package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strings"
)

const SECRET_KEY = "your_expected_token_here"

type Transaction struct {
    ID             string `json:"id"`
    TransferAmount int64  `json:"transferAmount"`
    Content        string `json:"content"`
}

type WebhookRequest struct {
    Transactions []Transaction `json:"transactions"`
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    // Kiểm tra Authorization
    authHeader := r.Header.Get("Authorization")
    token := strings.TrimPrefix(authHeader, "Bearer ")
    
    if token != SECRET_KEY {
        w.WriteHeader(http.StatusForbidden)
        json.NewEncoder(w).Encode(map[string]bool{"success": false})
        return
    }

    // Parse JSON body
    var webhook WebhookRequest
    if err := json.NewDecoder(r.Body).Decode(&webhook); err != nil {
        w.WriteHeader(http.StatusBadRequest)
        json.NewEncoder(w).Encode(map[string]bool{"success": false})
        return
    }

    // Xử lý từng giao dịch
    for _, tx := range webhook.Transactions {
        log.Printf("Processing transaction: %s, Amount: %d", tx.ID, tx.TransferAmount)
        // db.SaveTransaction(tx)
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]bool{"success": true})
}

func main() {
    http.HandleFunc("/webhook", webhookHandler)
    log.Println("Webhook server running on :8080")
    http.ListenAndServe(":8080", nil)
}
```

```ruby [Ruby]
require 'sinatra'
require 'json'

SECRET_KEY = 'your_expected_token_here'

post '/webhook' do
  # Kiểm tra Authorization
  auth_header = request.headers['Authorization'] || ''
  token = auth_header.sub('Bearer ', '')
  
  if token != SECRET_KEY
    status 403
    return { success: false }.to_json
  end

  # Lấy dữ liệu
  data = JSON.parse(request.body.read)
  transactions = data['transactions']
  
  unless transactions.is_a?(Array)
    status 400
    return { success: false }.to_json
  end

  # Xử lý từng giao dịch
  transactions.each do |tx|
    id = tx['id']
    amount = tx['transferAmount']
    content = tx['content']
    # Transaction.create(id: id, amount: amount, content: content)
  end

  status 200
  { success: true }.to_json
end
```

:::

---

## 🔧 Troubleshooting

### ⚠️ Authorization header không được nhận

Nếu server của bạn không nhận được header `Authorization`, thêm rule này vào `.htaccess`:

```.htaccess
RewriteEngine On
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
```

---

## ⚡ Best Practices

1. **Xác thực ngay**: Kiểm tra token trước khi xử lý
2. **Validate dữ liệu**: Kiểm tra `transactions` array trước khi loop
3. **Response nhanh**: Trả về 200 ngay, offload xử lý vào queue/background job
4. **Idempotent**: Xử lý webhook nhiều lần mà không bị lỗi (kiểm tra ID trước insert)
5. **Logging**: Log tất cả webhook request/response để debug
6. **Error handling**: Không trả về 200 nếu xử lý thất bại
7. **Timeout**: Đặt timeout vì Pay2S sẽ retry sau 60 giây
8. **Database transaction**: Dùng DB transaction để tránh data inconsistency
9. **Monitoring**: Alert nếu webhook failed nhiều lần
10. **Versioning**: Hỗ trợ multiple webhook versions để dễ update

---

## 📌 Checklist trước deploy

- [ ] Authorization header được kiểm tra
- [ ] JSON validation đầy đủ
- [ ] Response 200 được trả về luôn
- [ ] Logging được cấu hình
- [ ] Database transaction được dùng
- [ ] Error handling cho timeout/network error
- [ ] Endpoint có HTTPS (nếu production)
- [ ] Rate limiting được setup
- [ ] Webhook được test với Postman/curl
- [ ] Alert/monitoring được cấu hình

---

## Yêu cầu lập hóa đơn từ phản hồi Webhook

Endpoint có thể yêu cầu Pay2S lập hóa đơn điện tử cho giao dịch tiền vào bằng cách trả thêm metadata hóa đơn. Cấu trúc request Pay2S gửi đến endpoint không thay đổi. Nếu không sử dụng tính năng hóa đơn, endpoint tiếp tục phản hồi như hiện tại:

```json
{
  "success": true
}
```

Nếu muốn yêu cầu Pay2S lập hóa đơn cho giao dịch vừa nhận, thêm `invoiceMetadata` vào cùng response:

```json
{
  "success": true,
  "invoiceMetadata": {
    "invoiceType": "regular",
    "invoiceOptions": {
      "requested": true,
      "buyerNotTakingInvoice": false,
      "paymentMethod": "TM/CK"
    },
    "customerInfo": {
      "buyerContactName": "Nguyễn Văn A",
      "buyerCompanyName": "",
      "taxCode": "",
      "citizenId": "079123456789",
      "address": "TP. Hồ Chí Minh",
      "email": "khach@example.com",
      "phone": "0900000000"
    },
    "items": [
      {
        "itemCode": "SP001",
        "itemGroupCode": "NHOM_SAN_PHAM",
        "itemName": "Sản phẩm A",
        "unit": "Cái",
        "quantity": 1,
        "unitPrice": 50000,
        "taxRate": -1
      }
    ]
  }
}
```

Lưu ý:

- Pay2S chỉ tiếp nhận metadata khi response có HTTP 2xx và `success: true`.
- Tài khoản phải sử dụng gói Basic trở lên, đã kích hoạt tính năng hóa đơn điện tử và bật `Nhận thông tin xuất hóa đơn` khi thêm hoặc sửa webhook; webhook chưa bật hoặc tài khoản không còn gói hợp lệ sẽ bỏ qua `invoiceMetadata`.
- Mỗi tài khoản ngân hàng chỉ được chọn một webhook để nhận thông tin xuất hóa đơn.
- Chỉ giao dịch tiền vào (`transferType: "IN"`) với số tiền lớn hơn `0` được đưa vào hàng chờ hóa đơn.
- `invoiceOptions.requested` phải là `true`.
- `items` phải có từ 1 đến 50 dòng.
- Nên gửi `itemCode` ở mỗi dòng hàng hóa. Pay2S dùng mã này để khớp với **Quy tắc sản phẩm**; nếu không khớp thì giữ nguyên tên, đơn vị và thuế suất trong response.
- `itemGroupCode` là mã nhóm tùy chọn. Với response webhook, quy tắc có nguồn `webhook` hoặc `any` được áp dụng; quy tắc riêng cửa hàng ưu tiên hơn quy tắc toàn tài khoản, mã sản phẩm ưu tiên hơn mã nhóm.
- `taxRate` nhận một trong các giá trị: `-2`, `-1`, `0`, `3.5`, `5`, `8`, `10`.
- `buyerNotTakingInvoice: true` dùng cho người mua không lấy hóa đơn. Nếu có MST hoặc CCCD, Pay2S vẫn ưu tiên thông tin định danh.
- Pay2S chống tạo trùng theo ID giao dịch ngân hàng; endpoint có thể nhận lại cùng một webhook nhưng không sinh thêm yêu cầu hóa đơn.
- Lỗi riêng của `invoiceMetadata` không làm thay đổi trạng thái tiếp nhận webhook giao dịch. Doanh nghiệp cần kiểm tra màn hình hóa đơn/đối soát để xử lý metadata bị từ chối.

### Điều kiện để Pay2S tiếp nhận yêu cầu hóa đơn

Pay2S chỉ tạo yêu cầu hóa đơn khi đồng thời thỏa các điều kiện sau:

1. Endpoint trả HTTP `2xx`, JSON hợp lệ và `success` bằng `true`.
2. Phản hồi có `invoiceMetadata.invoiceOptions.requested: true`.
3. Webhook nhận giao dịch tiền vào và đã bật **Nhận thông tin xuất hóa đơn từ phản hồi webhook**.
4. Tài khoản Pay2S đang dùng gói Basic trở lên và đã được bật tính năng hóa đơn điện tử.
5. Giao dịch có `transferType: "IN"` và `transferAmount` lớn hơn `0`.
6. `items` có từ 1 đến 50 dòng và từng dòng vượt qua kiểm tra dữ liệu bên dưới.

Nếu chỉ muốn xác nhận đã nhận giao dịch mà không yêu cầu lập hóa đơn, giữ nguyên phản hồi `{ "success": true }` hoặc đặt `requested: false`.

### Chi tiết các tham số phản hồi

#### Đối tượng gốc

| Tham số | Kiểu | Bắt buộc | Mô tả |
| --- | --- | --- | --- |
| `success` | boolean | Có | Phải là `true` để Pay2S ghi nhận endpoint đã xử lý thành công. |
| `invoiceMetadata` | object | Không | Dữ liệu yêu cầu lập hóa đơn. Bỏ trường này nếu giao dịch không cần hóa đơn. |

#### `invoiceMetadata`

Tổng dung lượng JSON của riêng `invoiceMetadata` không được vượt quá 32 KB.

| Tham số | Kiểu | Bắt buộc | Giá trị / giới hạn | Mô tả |
| --- | --- | --- | --- | --- |
| `invoiceType` | string | Không | `regular`, `vat`, `none` | Nhãn phân loại tương thích, mặc định là `regular`. Loại hóa đơn thực tế vẫn do mẫu mặc định của kết nối quyết định. Nếu không yêu cầu hóa đơn, hãy bỏ `invoiceMetadata` hoặc đặt `requested: false`; không dựa vào `none` để hủy yêu cầu. |
| `invoiceOptions` | object | Có |  | Tùy chọn lập hóa đơn. |
| `customerInfo` | object | Không |  | Thông tin người mua. Có thể để `{}` khi bán cho người tiêu dùng không lấy hóa đơn. |
| `items` | array | Có | 1–50 dòng | Danh sách hàng hóa, dịch vụ. |

#### `invoiceOptions`

| Tham số | Kiểu | Bắt buộc | Giới hạn | Mô tả |
| --- | --- | --- | --- | --- |
| `requested` | boolean | Có | Phải là `true` | Xác nhận endpoint yêu cầu Pay2S lập hóa đơn cho giao dịch. |
| `buyerNotTakingInvoice` | boolean | Không | Mặc định `false` | Đặt `true` khi bán cho người tiêu dùng không lấy hóa đơn. Nếu có MST hoặc CCCD, Pay2S vẫn ưu tiên dữ liệu định danh đã gửi. |
| `paymentMethod` | string | Không | Tối đa 100 ký tự | Hình thức thanh toán, ví dụ `TM/CK`, `Chuyển khoản`. |
| `note` | string | Không | Tối đa 500 ký tự | Ghi chú nội bộ đi cùng yêu cầu hóa đơn. |

#### `customerInfo`

| Tham số | Kiểu | Bắt buộc | Giới hạn | Mô tả |
| --- | --- | --- | --- | --- |
| `buyerContactName` | string | Không | 150 ký tự | Họ tên người mua hoặc người liên hệ. |
| `buyerCompanyName` | string | Không | 255 ký tự | Tên doanh nghiệp/đơn vị mua hàng. Để trống khi là khách lẻ. |
| `taxCode` | string | Không | 30 ký tự | Mã số thuế người mua. Không dùng chung trường này để truyền CCCD. |
| `citizenId` | string | Không | 30 ký tự | CCCD hoặc giấy tờ định danh của người mua. |
| `address` | string | Không | 500 ký tự | Địa chỉ người mua. |
| `email` | string | Không | 190 ký tự | Email nhận hóa đơn. |
| `phone` | string | Không | 30 ký tự | Số điện thoại người mua. |

#### Mỗi phần tử trong `items`

| Tham số | Kiểu | Bắt buộc | Giá trị / giới hạn | Mô tả |
| --- | --- | --- | --- | --- |
| `itemCode` | string | Không, nên có | 100 ký tự | Mã sản phẩm ổn định trong hệ thống nguồn. Dùng để khớp Quy tắc sản phẩm. |
| `itemGroupCode` | string | Không | 100 ký tự | Mã nhóm ổn định; dùng làm quy tắc dự phòng khi chưa có quy tắc theo sản phẩm. |
| `externalItemId` | string | Không | 150 ký tự | ID tham chiếu của mặt hàng trong hệ thống nguồn; phục vụ truy vết, không thay thế `itemCode`. |
| `sourceProductName` | string | Không | 500 ký tự | Tên gốc tại hệ thống nguồn. Dùng để ghép tên và đối chiếu quy tắc theo tên sản phẩm. |
| `itemName` | string | Có | 1–500 ký tự | Tên hàng hóa/dịch vụ. Là giá trị đối chiếu dự phòng cho quy tắc theo tên khi thiếu `sourceProductName`; nếu không có quy tắc khớp, Pay2S dùng trực tiếp tên này. |
| `unit` | string | Không | 50 ký tự | Đơn vị tính, ví dụ `Cái`, `Tháng`, `Lần`. |
| `quantity` | number | Có | Lớn hơn `0` | Số lượng. |
| `unitPrice` | number | Có | Từ `0` trở lên | Đơn giá trước thuế/giảm trừ theo nghiệp vụ của mẫu hóa đơn. |
| `taxRate` | number | Có | `-2`, `-1`, `0`, `3.5`, `5`, `8`, `10` | Thuế suất: `-2` = không kê khai, tính nộp thuế; `-1` = không chịu thuế; các giá trị còn lại là phần trăm thuế. |
| `vatReductionEligible` | boolean | Không | Mặc định `false` | Mặt hàng thuộc chính sách giảm thuế GTGT theo quy định hiện hành. Chỉ bật khi có căn cứ áp dụng. |
| `directVatRate` | number | Có khi `vatReductionEligible: true` | `1`, `2`, `3`, `5` | Tỷ lệ % để tính thuế GTGT theo phương pháp tỷ lệ trên doanh thu. |

> Không dùng chuỗi như `"5%"` cho `taxRate` hoặc `directVatRate`; hãy truyền số `5`. Không gửi giá trị tiền đã định dạng như `"50.000 đ"`; hãy truyền số `50000`.

### Đấu nối với Quy tắc sản phẩm

Quy tắc sản phẩm giúp dữ liệu từ website, POS, WHMCS, WooCommerce hoặc ứng dụng riêng được chuẩn hóa trước khi đưa lên hóa đơn. Quy tắc có thể thay tên hàng, mã hàng, đơn vị tính, thuế suất và cấu hình giảm thuế mà không buộc hệ thống nguồn phải biết định dạng riêng của từng nhà cung cấp hóa đơn.

#### Bước 1: Chọn mã đối chiếu ổn định

- `itemCode` phải đại diện cho **loại sản phẩm**, ví dụ `POS_DRINK_001` hoặc `API_PRODUCT_HOSTING_BASIC`.
- `itemGroupCode` đại diện cho **nhóm sản phẩm**, ví dụ `POS_DRINK` hoặc `API_GROUP_HOSTING`.
- Không dùng Order ID, ID giao dịch ngân hàng hoặc ID riêng của từng dòng đơn hàng vì các mã này thay đổi sau mỗi lần bán và sẽ không tái sử dụng được quy tắc.
- Cùng một sản phẩm phải luôn trả cùng `itemCode`, kể cả tên hiển thị hoặc giá bán thay đổi.

#### Bước 2: Tạo quy tắc trong Pay2S

Mở **Hóa đơn điện tử → kết nối nhà cung cấp → Hàng hóa / Quy tắc sản phẩm**, sau đó:

1. Chọn **Nguồn tích hợp** là `Webhook` cho dữ liệu phản hồi webhook; chọn `Mọi nguồn` nếu muốn dùng chung quy tắc cho các luồng khác.
2. Chọn **Mã sản phẩm** để đối chiếu `itemCode`, **Mã nhóm sản phẩm** để đối chiếu `itemGroupCode`, hoặc **Tên sản phẩm** khi hệ thống nguồn không có mã ổn định.
3. Với mã, nhập chính xác mã nguồn gửi lên. Với tên, Pay2S khớp toàn bộ sau khi bỏ khoảng trắng thừa và không phân biệt chữ hoa/thường; tên có dấu và không dấu vẫn là hai giá trị khác nhau.
4. Chọn phạm vi **toàn tài khoản** hoặc một **cửa hàng cụ thể**.
5. Khai báo tên, mã, đơn vị, thuế suất và chính sách giảm thuế muốn hiển thị trên hóa đơn.
6. Lưu quy tắc rồi gửi một giao dịch thử; kiểm tra kết quả tại **Hàng chờ** trước khi bật tự phát hành.

#### Thứ tự ưu tiên khi nhiều quy tắc cùng khớp

Pay2S chọn đúng một quy tắc theo thứ tự:

1. Quy tắc của cửa hàng hiện tại ưu tiên hơn quy tắc toàn tài khoản.
2. Quy tắc nguồn `webhook` ưu tiên hơn nguồn `any` (Mọi nguồn).
3. Trong cùng phạm vi và nguồn: quy tắc theo `itemCode` ưu tiên hơn `itemGroupCode`, và `itemGroupCode` ưu tiên hơn tên sản phẩm.
4. Nếu vẫn bằng nhau, quy tắc được tạo sau cùng (ID lớn hơn) được chọn.

Ví dụ endpoint trả:

```json
{
  "itemCode": "POS_DRINK_001",
  "itemGroupCode": "POS_DRINK",
  "itemName": "nuoc ngot lon",
  "unit": "item",
  "quantity": 2,
  "unitPrice": 15000,
  "taxRate": 0
}
```

Và Pay2S có quy tắc `POS_DRINK_001` đổi thành **Nước ngọt lon**, đơn vị **Lon**, thuế suất **8%**, hóa đơn sẽ dùng dữ liệu đã chuẩn hóa từ quy tắc. Số lượng và đơn giá vẫn lấy từ giao dịch hiện tại. Nếu không có quy tắc nào khớp, Pay2S giữ `itemName`, `unit` và `taxRate` trong response; yêu cầu không bị từ chối chỉ vì thiếu quy tắc.

### Quy trình kiểm thử khuyến nghị

1. Tạo kết nối nhà cung cấp ở môi trường thử nghiệm và kiểm tra kết nối thành công.
2. Đồng bộ mẫu hóa đơn, chọn mẫu mặc định và gán đúng cửa hàng.
3. Bật **Nhận thông tin xuất hóa đơn từ phản hồi webhook** cho webhook nhận tiền vào.
4. Cho endpoint trả một `invoiceMetadata` có một mặt hàng và tổng `quantity × unitPrice` bằng số tiền giao dịch.
5. Kiểm tra bản ghi xuất hiện ở **Hàng chờ**, xem thông tin người mua và hàng hóa đã được Quy tắc sản phẩm chuẩn hóa đúng chưa.
6. Tạo bản nháp, kiểm tra PDF và tổng tiền; sau khi ổn định mới bật tự tạo nháp hoặc tự phát hành.
7. Theo dõi **Đối soát** và **Nhật ký** khi có chênh lệch hoặc lỗi nhà cung cấp.

### Các lỗi thường gặp

| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
| --- | --- | --- |
| Giao dịch nhận thành công nhưng không có yêu cầu hóa đơn | Chưa bật tùy chọn trên webhook, `requested` không phải `true`, tài khoản dưới gói Basic hoặc giao dịch là tiền ra | Kiểm tra lại 6 điều kiện tiếp nhận ở đầu mục này. |
| Quy tắc sản phẩm không được áp dụng | Sai nguồn, sai phạm vi cửa hàng, nhầm `itemCode` với `itemGroupCode`, mã thay đổi theo từng đơn hoặc tên không khớp toàn bộ | Dùng mã ổn định; nếu khớp theo tên, kiểm tra `sourceProductName`/`itemName` và đúng dấu tiếng Việt. |
| Metadata bị từ chối | Thiếu `itemName`, `items` rỗng/quá 50 dòng, số lượng/đơn giá sai kiểu hoặc thuế suất ngoài danh sách | So sánh response với bảng tham số và xem lỗi tại Hàng chờ/Nhật ký. |
| Tổng hóa đơn không khớp giao dịch | Tổng các dòng hàng không bằng số tiền chuyển khoản hoặc quy tắc làm thay đổi thuế/giảm trừ | Kiểm tra lại giá, thuế và chính sách giảm thuế trước khi phát hành. |
| Có webhook gửi lại nhiều lần | Timeout hoặc cơ chế retry của Pay2S | Giữ endpoint idempotent; Pay2S chống tạo trùng hóa đơn theo ID giao dịch. |


## Request và response đối chiếu ngày 11/10/2026

Các mẫu dưới đây giữ cấu trúc trường và kiểu dữ liệu. Khóa, chữ ký, URL phiên đã được thay bằng placeholder; danh sách chỉ giữ tối đa hai phần tử và dữ liệu ảnh/PDF/XML dài được rút gọn. Không sao chép placeholder để gọi API thật.

### Webhook giao dịch · Payload & ACK

<div class="doc-api-actions" data-doc-api-actions><a class="try-button" data-doc-playground href="/playground/?api=transaction-webhook">Thử API trong Playground ↗</a><a class="try-button experience-button" data-doc-experience href="/demos/?flow=webhook&api=transaction-webhook">Trải nghiệm nhận webhook ↗</a></div>

**Đối chiếu mã nguồn backend; giá trị minh họa.** Đối chiếu webhooksender.js và immediate-account-webhook.js. paymentCode có khi trích được mã khớp mẫu; extraData chỉ có khi bật dữ liệu bổ sung. Giá trị là minh họa.

`POST {webhook_url}`

Body:

```json
{
  "transactions": [
    {
      "id": 10001,
      "gateway": "ACB",
      "transactionDate": "2026-10-11 09:00:00",
      "transactionNumber": "DEMO10001",
      "accountNumber": "P2S99999999",
      "content": "THANHTOAN DH10001",
      "transferType": "IN",
      "transferAmount": 2000,
      "checksum": "demo-user-webhook-10001",
      "paymentCode": "DH10001",
      "extraData": {
        "remitterName": "NGUYEN VAN A",
        "remitterAccountNumber": "0123456789",
        "issuerBankName": "Ngan hang TMCP Quan Doi",
        "reciprocalBankCode": "970422"
      }
    }
  ]
}
```

HTTP 200. Response:

```json
{
  "success": true
}
```
