Skip to main content

Thanh toán chuyến xe bằng thẻ

Trang này dành cho:

FEPhầ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.

Trang này mô tả cái đang có, không phải cái nên có

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ườngURL
Staginghttps://api-staging-core-card.uts.network
Localhttp://localhost:3000
CredentialHeaderDùng cho
System admin (tổng đài viên)Authorization: <raw Store access token> (profile.is_admin=true)Mọi route /dispatch/*
Platform keyX-API-Key: <PLATFORM_API_KEY>Server gọi /dispatch/*; không tra được khách theo SĐT (§3)
UserAuthorization: <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ếnNgườ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 đặtThẻ 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_idThẻ 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ẻ​

Request
POST /dispatch/bookings/{id}/complete
Authorization: <Store token của tổng đài viên>

{ "final_fare": "245000" }
FieldTypeRequiredMô tả
final_farestring thập phân—Số tiền trừ. Bỏ trống → giá báo của xe (vehicle.sell_price)
skip_chargeboolean—true = đóng mà không trừ thẻ (khách trả cách khác)
skip_reasonstringkhi skip_chargeLý do, lưu vào lịch sử ticket

Khi chuyến có người trả, backend làm theo thứ tự:

  1. Kiểm ticket đóng được không (assigned / riding, đúng người đang giữ ticket).
  2. 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.
  3. 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.
  4. Đó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 trong GET /my/payments của họ. Với khách chưa có tài khoản thì không hiện ở đâu.

2.1 Mã lỗi​

HTTPcodeKhi nàoCS làm gì
409payment_declinedThẻ bị từ chối — details.reason (insufficient_funds, card_blocked, service_payment_insufficient_funds, card_outside_validity_window…), details.auth_idTicket vẫn mở. Khách trả cách khác → skip_charge
409no_payment_cardNgười trả không có thẻ active nàoAdd thẻ (§3) hoặc skip_charge
409payment_card_ambiguousCó nhiều thẻ mà chưa chọnGắn lại người trả kèm card_id (§3)
409payment_card_not_foundThẻ đã chọn không còn dùng được (đã bị bỏ chia sẻ)Chọn thẻ khác
422final_fare_requiredKhông có final_fare lẫn giá báo của xeHỏi giá chốt
503payments_unavailableServer chưa nối phần trừ thẻBáo kỹ thuật
409invalid_booking_state, claimed_by_otherTicket 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ếnThu tiền thế nàoKhá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 thuCS 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
200
{
"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" }
]
}
]
}
FieldNghĩa
usersMọ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_statusmember = 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
cardsThẻ họ đang dùng được (access: owner / linked), kèm số dư available
linkable_cardsThẻ 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_idpan + pin
Thẻ nàoMột thẻ trong linkable_cards: thẻ của đúng công ty kháchBất kỳ thẻ nào khách đang cầm
Bằng chứngBackend 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 corporateCó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ả
FieldTypeMô tả
user_idstringTài khoản của khách (từ §3.1). Gửi user_id hoặc pan + pin, không cả hai
card_iduuidVới user_id: một thẻ trong cards của khách. Bỏ trống → thẻ active duy nhất của họ
panstring, 16 chữ sốKhách chưa có tài khoản: số thẻ khách đọc
pinstring, 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).
Số thẻ và PIN

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​

HTTPcodeKhi nào
422validation_failedGử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
503customer_lookup_unavailableKhông tra được Store theo SĐT — details.upstream
503corporate_lookup_unavailableKhông hỏi được backend corporate khi add thẻ — không link gì
422card_not_in_user_corporateAdd 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ẻ đó
422user_not_in_corporateKhách không còn là nhân viên active — details.corporate_status
422invalid_payment_cardcard_id không phải thẻ khách dùng được
422card_pin_invalid, card_has_no_pin, card_not_activeNhánh số thẻ + PIN
409card_locked_too_many_attemptsThẻ đang khoá vì sai PIN — details.locked_until
429too_many_pin_attemptsOps đã sai số thẻ / PIN 5 lần trong 1 giờ khi add thẻ
409payer_is_requesterĐổi người trả của chuyến đặt qua app
409invalid_booking_stateTicket đã đóng

3.5 Lịch sử ticket​

event_typemetadataCâ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? }, notepaid_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": "…" }
FieldTypeMô tả
card_iduuid, tuỳ chọnThẻ 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_id khô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/rides và GET /my/rides/{id} có thêm payment_card_id, payment_card_last4, paid_auth_id. Khi completed, final_fare là số đã trừ.
  • Trợ lý MCP (request_ride) chưa nhận card_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ụcTình trạng
Giữ tiền (hold) lúc gắn người trả, chốt số thật lúc completeChư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ĐTChư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 adminChư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ý MCPChư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