Thanh toán chuyến xe bằng thẻ
Trang này dành cho:
| FE | Phần cần đọc |
|---|---|
| CS Portal (tổng đài viên điều xe) | §2, §3 |
| App xperise / XPay (khách tự đặt xe) | §4 |
Spec đầy đủ của CS Portal (hàng đợi, wizard đặt xe, SSE…) nằm ở CS Portal "Yêu cầu đặt xe". Trang này chỉ gom phần thanh toán.
Mọi field, quyền và mã lỗi đọc thẳng từ code card-core. Chỗ nào chưa có được gom ở §6 — Chưa có.
| Môi trường | URL |
|---|---|
| Staging | https://api-staging-core-card.uts.network |
| Local | http://localhost:3000 |
| Credential | Header | Dùng cho |
|---|---|---|
| System admin (tổng đài viên) | Authorization: <raw Store access token> (profile.is_admin=true) | Mọi route /dispatch/* |
| Platform key | X-API-Key: <PLATFORM_API_KEY> | Server gọi /dispatch/*; không tra được khách theo SĐT (§3) |
| User | Authorization: <raw Store access token> | /my/rides |
1. Ai trả, trừ thẻ nào
Một chuyến được trừ thẻ khi nó có người trả:
| Chuyến | Người trả | Thẻ bị trừ |
|---|---|---|
Khách tự đặt qua app hoặc trợ lý (requested_by_type = "user"), kể cả khi chọn "Tổng đài sắp xếp" | Người đặt | Thẻ chọn lúc đặt (payment_card_id); không chọn → thẻ active duy nhất họ dùng được |
| Hotline, CS đã gắn tài khoản (§3) | payer_user_id | Thẻ CS chọn; không chọn → thẻ active duy nhất của tài khoản đó |
| Hotline, khách chưa có tài khoản đọc số thẻ + PIN (§3) | — | Đúng thẻ đã kiểm PIN |
| Hotline / API chưa gắn người trả | — | Không trừ — đóng ticket như cũ |
"Thẻ dùng được" gồm thẻ của chính họ và thẻ được chia sẻ cho họ. Vì mọi thẻ đều là thẻ bearer, thực tế gần như luôn là thẻ được chia sẻ (xem Chia sẻ thẻ & tự link thẻ).
2. Hoàn tất chuyến — trừ thẻ
POST /dispatch/bookings/{id}/complete
Authorization: <Store token của tổng đài viên>
{ "final_fare": "245000" }
| Field | Type | Required | Mô tả |
|---|---|---|---|
final_fare | string thập phân | — | Số tiền trừ. Bỏ trống → giá báo của xe (vehicle.sell_price) |
skip_charge | boolean | — | true = đóng mà không trừ thẻ (khách trả cách khác) |
skip_reason | string | khi skip_charge | Lý do, lưu vào lịch sử ticket |
Khi chuyến có người trả, backend làm theo thứ tự:
- Kiểm ticket đóng được không (
assigned/riding, đúng người đang giữ ticket). - Nếu tài xế XPay đã thu chuyến này qua app tài xế thì đóng theo giao dịch đó, không trừ lần hai.
- Trừ thẻ như một giao dịch taxi: qua đủ các cổng (trạng thái thẻ, ngân sách công ty, hạn mức, số dư, chính sách) và trừ cả ví xperise.
- Đóng ticket:
status = completed,final_fare= số đã trừ,paid_auth_id= giao dịch.
- Bấm hai lần hay gọi lại sau lỗi mạng: chỉ trừ một lần, lần sau nhận lại đúng giao dịch cũ.
- Giao dịch có
acting_user_id= người trả, nên chuyến hiện trongGET /my/paymentscủa họ. Với khách chưa có tài khoản thì không hiện ở đâu.
2.1 Mã lỗi
| HTTP | code | Khi nào | CS làm gì |
|---|---|---|---|
| 409 | payment_declined | Thẻ bị từ chối — details.reason (insufficient_funds, card_blocked, service_payment_insufficient_funds, card_outside_validity_window…), details.auth_id | Ticket vẫn mở. Khách trả cách khác → skip_charge |
| 409 | no_payment_card | Người trả không có thẻ active nào | Add thẻ (§3) hoặc skip_charge |
| 409 | payment_card_ambiguous | Có nhiều thẻ mà chưa chọn | Gắn lại người trả kèm card_id (§3) |
| 409 | payment_card_not_found | Thẻ đã chọn không còn dùng được (đã bị bỏ chia sẻ) | Chọn thẻ khác |
| 422 | final_fare_required | Không có final_fare lẫn giá báo của xe | Hỏi giá chốt |
| 503 | payments_unavailable | Server chưa nối phần trừ thẻ | Báo kỹ thuật |
| 409 | invalid_booking_state, claimed_by_other | Ticket không ở trạng thái đóng được / người khác đang giữ | Tải lại ticket |
2.2 Xe tính tiền theo đồng hồ (Mai Linh) và tài xế XPay
Khách đã link thẻ vào tài khoản (qua link mời trên OA hay nút "Link thẻ" của trợ lý) thì không phải
nhập lại thẻ, miễn là chuyến có người trả (§1). Nhưng xe tính tiền theo đồng hồ (pricing_mode = metered, như Mai Linh) không có giá lúc đặt, và hãng không báo cước qua API như Xanh SM. Vì vậy
luôn phải có người nhập số đồng hồ. Có số rồi thì backend trừ thẻ ngay, không OTP.
| Ai chạy chuyến | Thu tiền thế nào | Khách có phải đưa thẻ không |
|---|---|---|
| Tài xế của hãng, không dùng app XPay (CS gọi hãng, nhập biển số) | CS hỏi số đồng hồ, bấm Hoàn tất với final_fare → trừ thẻ người trả | Không |
| Tài xế XPay, thu trên app tài xế | App thu theo luồng thanh toán merchant thường bằng payment_template (external_ref = mã chuyến), rồi POST /merchant/rides/{assignment_id}/complete đóng chuyến theo giao dịch đó | Có — template không mang thẻ người trả, khách xác thực thẻ như mọi lần trả tiền merchant |
| Tài xế XPay không thu được, hoặc không thu | CS mở Thanh toán hộ: nhập số đồng hồ, gọi xác nhận với cả tài xế và khách, rồi Hoàn tất → trừ thẻ người trả | Không |
Xanh SM thì khác: hãng báo cước khi chuyến xong, nên backend tự hoàn tất và trừ thẻ (giá hãng + markup), CS không phải nhập gì.
3. Khách gọi hotline — tra SĐT, add thẻ, gắn người trả
3.1 Tra SĐT
GET /dispatch/customers?phone=0901234567
{
"phone": "+84901234567",
"users": [
{
"user_id": "4242",
"name": "Lê Hoàng Tuấn",
"email": "tuan@acme.vn",
"corporate": { "corporate_id": "2anPOVxABjabA6Cfz1h9JnE2e6b", "name": "ACME" },
"corporate_status": "member",
"cards": [
{ "card_id": "…", "display_name": "Thẻ taxi KD", "pan_last4": "4421", "status": "active",
"corporate_id": "2anPOVx…", "access": "linked", "available": "1200000.00" }
],
"linkable_cards": [
{ "card_id": "…", "display_name": "Thẻ taxi KT", "pan_last4": "7788", "status": "active",
"corporate_id": "2anPOVx…", "access": null, "available": "800000.00" }
]
}
]
}
| Field | Nghĩa |
|---|---|
users | Mọi tài khoản đăng ký bằng SĐT. [] = chưa có tài khoản. Một SĐT có thể có nhiều tài khoản (đã gặp trên staging) — cho CS chọn đúng người theo tên / email |
corporate_status | member = nhân viên active hoặc owner của corporate · inactive = đã nghỉ · none = không thuộc công ty nào (khách lẻ) · unavailable = không hỏi được backend corporate |
cards | Thẻ họ đang dùng được (access: owner / linked), kèm số dư available |
linkable_cards | Thẻ active của công ty họ mà họ chưa dùng được — chỉ có khi member |
- SĐT gõ kiểu nào cũng được (
0901…,+84 901…). Backend chuẩn hoá về+84…, Store tìm khớp chính xác cả số. - Backend hỏi Store và backend corporate bằng chính token system admin của tổng đài viên. Gọi
bằng platform key không có token đó, nên
503 customer_lookup_unavailable. corporate_status = unavailable: vẫn hiện tài khoản và thẻ đang có, nhưng ẩn nút add thẻ.
3.2 Add thẻ cho khách
POST /dispatch/customers/{user_id}/cards { "card_id": "…" } → chọn từ linkable_cards
POST /dispatch/customers/{user_id}/cards { "pan": "9686860012345678", "pin": "246810" } → khách đọc số thẻ + PIN
200: khách, như một phần tử của users, thẻ đã sang cards. Gửi một trong hai dạng, không cả
hai.
card_id | pan + pin | |
|---|---|---|
| Thẻ nào | Một thẻ trong linkable_cards: thẻ của đúng công ty khách | Bất kỳ thẻ nào khách đang cầm |
| Bằng chứng | Backend corporate xác nhận khách là nhân viên công ty đó | Khách đọc được PIN in kèm thẻ |
| Khách lẻ / thẻ công ty khác | ❌ | ✅ |
| Cần hỏi backend corporate | Có | Không |
Với pan + pin, chống dò đoán giống luồng tự link:
- Sai số thẻ hay sai PIN trả cùng một lỗi.
- Sai 3 lần trên một thẻ thì khoá thẻ 15 phút.
- Một ops sai 5 lần trong 1 giờ thì bị tạm chặn (
429), để token ops có bị lộ cũng không dò số thẻ hàng loạt được.
Sau khi link, khách cũng thấy thẻ trong app và tự dùng được.
3.3 Gắn người trả vào ticket
PUT /dispatch/bookings/{id}/payer { "user_id": "4242", "card_id": "…" } → có tài khoản
PUT /dispatch/bookings/{id}/payer { "pan": "9686860012345678", "pin": "246810" } → chưa có tài khoản
DELETE /dispatch/bookings/{id}/payer → bỏ người trả
| Field | Type | Mô tả |
|---|---|---|
user_id | string | Tài khoản của khách (từ §3.1). Gửi user_id hoặc pan + pin, không cả hai |
card_id | uuid | Với user_id: một thẻ trong cards của khách. Bỏ trống → thẻ active duy nhất của họ |
pan | string, 16 chữ số | Khách chưa có tài khoản: số thẻ khách đọc |
pin | string, 4–8 chữ số | PIN in kèm thẻ |
Ticket trả về có payer_user_id (null với nhánh số thẻ + PIN), payment_card_id,
payment_card_last4 → hiện badge "XCard •••• 4421".
Nhánh số thẻ + PIN:
- Backend kiểm ngay lúc CS gửi. Sai số thẻ hay sai PIN đều trả
422 card_pin_invalid. - 3 lần sai PIN trong 15 phút thì thẻ khoá 15 phút (
409 card_locked_too_many_attempts,details.locked_until). Khoá này dùng chung với luồng merchant và luồng tự link. - Lúc complete không cần số thẻ hay PIN nữa: ticket chỉ giữ
payment_card_id, và backend trừ tiền theo id đó. PIN được kiểm trước khi biết số tiền: tiền chưa bị giữ, nên lúc complete thẻ vẫn có thể hết số dư (payment_declined).
card-core không lưu số thẻ hay PIN, và không ghi body của các route …/payer và
/dispatch/customers/{user_id}/cards vào log. FE không
được lưu hay log hai giá trị này; xoá ô nhập ngay sau khi gửi.
Chuyến khách tự đặt qua app: người trả luôn là người đặt. CS chỉ được đổi thẻ của họ, với
user_id phải là người đặt.
3.4 Mã lỗi
| HTTP | code | Khi nào |
|---|---|---|
| 422 | validation_failed | Gửi cả user_id lẫn pan (gắn người trả) hoặc cả card_id lẫn pan (add thẻ); SĐT / số thẻ / PIN sai định dạng |
| 503 | customer_lookup_unavailable | Không tra được Store theo SĐT — details.upstream |
| 503 | corporate_lookup_unavailable | Không hỏi được backend corporate khi add thẻ — không link gì |
| 422 | card_not_in_user_corporate | Add bằng card_id một thẻ của công ty khác. Dùng pan + pin nếu khách cầm thẻ đó |
| 422 | user_not_in_corporate | Khách không còn là nhân viên active — details.corporate_status |
| 422 | invalid_payment_card | card_id không phải thẻ khách dùng được |
| 422 | card_pin_invalid, card_has_no_pin, card_not_active | Nhánh số thẻ + PIN |
| 409 | card_locked_too_many_attempts | Thẻ đang khoá vì sai PIN — details.locked_until |
| 429 | too_many_pin_attempts | Ops đã sai số thẻ / PIN 5 lần trong 1 giờ khi add thẻ |
| 409 | payer_is_requester | Đổi người trả của chuyến đặt qua app |
| 409 | invalid_booking_state | Ticket đã đóng |
3.5 Lịch sử ticket
event_type | metadata | Câu gợi ý |
|---|---|---|
payer_set | { via, user_id?, card_id?, pan_last4? } | via = account → Gắn người trả {user_id}, thẻ •••• {pan_last4}; card_pin → Khách đọc thẻ •••• {pan_last4}, đã kiểm PIN |
payer_cleared | — | "Bỏ người trả" |
completed | { final_fare?, auth_id?, paid_via?, card_id?, charge_skipped? }, note | paid_via = card → Trừ {final_fare} vào thẻ; merchant_payment → Tài xế thu {final_fare} qua XPAY; charge_skipped → Hoàn tất, không trừ thẻ: {note} |
4. App — chọn thẻ khi đặt xe
POST /my/rides { "request_id": "…", "pickup_name": "…", "card_id": "…" }
| Field | Type | Mô tả |
|---|---|---|
card_id | uuid, tuỳ chọn | Thẻ active khách dùng được (của mình hoặc được chia sẻ). Bỏ trống → thẻ active duy nhất; có nhiều thẻ thì phải chọn, không thì lúc complete lỗi payment_card_ambiguous |
422 invalid_payment_card:card_idkhông phải thẻ active khách dùng được, hoặc đang ngoài hạn thẻ hay hạn link (Chia sẻ thẻ §2.3).GET /my/ridesvàGET /my/rides/{id}có thêmpayment_card_id,payment_card_last4,paid_auth_id. Khicompleted,final_farelà số đã trừ.- Trợ lý MCP (
request_ride) chưa nhậncard_id, nên luôn dùng thẻ active duy nhất của khách.
5. Kiểm tra trên staging
Đã chạy thử trên staging (2026-10-12) các API bên ngoài mà §3.1 dùng, bằng token system admin: tìm user theo SĐT, hỏi công ty của user, đọc bản ghi nhân viên. Các route card-core trong trang này chỉ có trên staging sau khi bản card-core mới được deploy. Bộ API test Bruno sẽ thêm sau đó.
6. Chưa có
| Hạng mục | Tình trạng |
|---|---|
| Giữ tiền (hold) lúc gắn người trả, chốt số thật lúc complete | Chưa có — hiện trừ một lần lúc complete |
| Tạo nhân viên (corporate staff) ngay lúc tổng đài tra SĐT | Chưa có — cần backend corporate có API tạo staff theo corporate_id, chỉ cần SĐT, không bắt buộc email |
| Token system admin hỏi công ty của user ở công ty khác với admin | Chưa kiểm được trên staging (mới kiểm với user cùng công ty) |
card_id cho request_ride của trợ lý MCP | Chưa có |
| Tài xế XPay trừ thẳng thẻ người trả của chuyến (khách không đưa thẻ) | Chưa có — cần chốt cách chặn cước khống: khách xác nhận số tiền qua OA/ZNS trước khi trừ, hoặc trần theo giá ước tính |