Link thẻ bằng OTP SĐT
Trang này dành cho FE trang web công khai mở từ Zalo OA: người dùng không đăng nhập, chỉ chứng minh hai thứ:
- SĐT là của mình — card-core gửi OTP tới số đó.
- Thẻ là của mình — số thẻ đầy đủ + PIN in kèm thẻ, như luồng tự link thẻ bằng số thẻ + PIN.
Xong thì thẻ được chia sẻ (card_users) cho staff mang SĐT đó trong bảng corporate_staffs của
card-core. SĐT chưa có staff nào thì card-core tự tạo staff trong công ty sở hữu thẻ — chỉ sau
khi số thẻ + PIN đúng — rồi link thẻ vào staff đó.
Người được link dùng thẻ thế nào, công ty bỏ link ra sao: xem Chia sẻ thẻ & tự link thẻ.
Luồng này chỉ đọc và ghi corporate_staffs của card-core. Không tra tài khoản Store, không tạo
staff bên backend corporate. Hệ quả ở §6.
| Base URL staging | https://api-staging-core-card.uts.network |
| Base URL local | http://localhost:3000 |
| Header | Content-Type: application/json — không có Authorization |
| Spec máy đọc được | docs/openapi.generated.json trong repo card-core, tag card-link |
Cả 4 route đều public. Thứ cho phép là link_token mà bước 2 trả về.
1. Flow
Nhập SĐT ──► [1] POST /card-link/otp ──► SMS mã 6 số
│
Nhập mã ──► [2] POST /card-link/otp/confirm ──► link_token (sống 5 phút)
│
[3] POST /card-link/accounts ──► accounts[]
│
┌────────────┼──────────────────┐
0 staff 1 staff nhiều staff
hỏi họ tên không hỏi gì cho chọn user_id
└────────────┼──────────────────┘
▼
Nhập số thẻ + PIN ──► [4] POST /card-link/cards ──► thẻ đã link
(SĐT chưa có staff: card-core tạo staff
sau khi PIN đúng, rồi mới link)
2. Endpoint
2.1 Gửi mã OTP
POST /card-link/otp
{ "phone": "0901234567" }
200
{
"challenge_id": "36df0bc0-4189-4ee5-ae2f-2c9eddd9407a",
"purpose": "link_card_by_phone",
"method": "sms_otp",
"destination_masked": "****4567",
"expires_at": "2026-10-13T08:07:50.984Z",
"attempts_remaining": 5
}
- SĐT gõ kiểu nào cũng được (
0901234567,+84 901 234 567), nhưng chỉ số di động Việt Nam (10 số). Số nước ngoài →422 phone_not_supported. - Bước này không tra gì cả: mã được gửi dù SĐT đã có staff hay chưa.
- "Gửi lại mã" = gọi lại API này; mỗi lần là một
challenge_idmới.
2.2 Xác nhận mã
POST /card-link/otp/confirm
{ "challenge_id": "36df0bc0-…", "code": "123456" }
200
{ "link_token": "7c0dda7b…(64 ký tự hex)", "expires_at": "2026-10-13T08:12:51.099Z" }
link_token sống OTP_GRANT_TTL_SECONDS (mặc định 5 phút). Hết hạn thì làm lại từ bước 2.1.
Giữ token trong bộ nhớ (biến JS) — không localStorage, không đưa lên URL.
2.3 Staff mang SĐT này
Chỉ đọc, không tiêu token — gọi lại bao nhiêu lần cũng được.
POST /card-link/accounts
{ "link_token": "7c0dda7b…" }
200
{
"phone_masked": "****4567",
"accounts": [
{ "user_id": "4242", "name": "Lê Hoàng Tuấn", "email_masked": "t***@acme.vn" },
{ "user_id": "phone:+84901234567", "name": "Lê Hoàng Tuấn", "email_masked": null }
]
}
accounts | Màn tiếp theo | Bước 2.4 gửi thêm |
|---|---|---|
[] — SĐT chưa có staff | Form họ tên + số thẻ + PIN | first_name, last_name |
| 1 phần tử | Form số thẻ + PIN | — |
| Nhiều phần tử (một SĐT nhiều tài khoản — có thật) | Chọn người → form số thẻ + PIN | user_id đã chọn |
SĐT được so theo chữ số, nên staff lưu +84 / 0912… hay 84 / 912… đều khớp.
2.4 Link thẻ
Tiêu token khi thành công.
POST /card-link/cards
{
"link_token": "7c0dda7b…",
"pan": "9686860012345678",
"pin": "246810",
"user_id": "4242",
"first_name": "Tuấn",
"last_name": "Lê Hoàng"
}
| Field | Bắt buộc | Mô tả |
|---|---|---|
link_token | ✅ | Từ bước 2.2 |
pan | ✅ | 16 chữ số in trên thẻ |
pin | ✅ | 4–8 chữ số in kèm thẻ |
user_id | Khi SĐT có nhiều staff | Một user_id từ bước 2.3 |
first_name, last_name | Khi SĐT chưa có staff | 1–50 ký tự mỗi field |
200
{
"user_id": "phone:+84901234567",
"staff_created": true,
"card": {
"card_id": "3c0e0e0e-…",
"display_name": "Thẻ taxi KD",
"pan_last4": "5678",
"status": "active",
"access": "linked",
"balance": { "available": "1200000.00", "…": "…" },
"…": "…"
}
}
cardcùng shape vớiGET /my/cards/{card_id}.staff_created: true= SĐT chưa có staff, card-core vừa tạo staff trong công ty của thẻ, vớiuser_iddạngphone:+84…(xem §6).- Công ty sở hữu thẻ nhận event
card.user_linked(via: card_pin) và bỏ link được.
3. Token: khi nào còn, khi nào hết
| Kết quả bước 2.4 | Token |
|---|---|
| Thành công | Hết — muốn link thẻ khác thì làm lại từ bước 2.1 |
| Bất kỳ lỗi nào (sai PIN, chưa chọn staff, thiếu tên…) | Còn — sửa rồi gửi lại, không cần mã mới |
Sai PIN vẫn bị đếm dù token còn (xem §5). Ô số thẻ + PIN vẫn xoá sau mỗi lần gửi; người dùng nhập lại khi thử lại.
4. Mã lỗi
Body lỗi: { "code": "...", "message": "...", "details"?: {...} }.
| Bước | HTTP | code | Khi nào | FE làm gì |
|---|---|---|---|---|
| mọi bước | 422 | validation_failed | Sai định dạng field | Báo ở ô nhập |
| mọi bước | 429 | — | Quá giới hạn theo IP | "Thao tác quá nhanh, thử lại sau" |
| 2.1 | 422 | phone_not_supported | Không phải số di động VN | "Chỉ hỗ trợ số di động Việt Nam" |
| 2.1 | 429 | otp_velocity_exceeded | 5 mã tới một SĐT trong 10 phút | Báo thử lại sau |
| 2.1 | 502 | otp_delivery_failed | Không gửi được SMS | Cho bấm gửi lại |
| 2.2 | 422 | otp_invalid | Sai mã — details.attempts_remaining | Cho nhập lại |
| 2.2 | 422 | otp_locked / otp_expired / otp_not_pending | Hết lượt / hết hạn / đã dùng | Quay lại bước 2.1 |
| 2.3, 2.4 | 422 | otp_grant_invalid | Token sai, hết hạn hoặc đã dùng | Quay lại bước 2.1 |
| 2.4 | 409 | account_choice_required | SĐT có nhiều staff mà không gửi user_id | Về màn chọn người |
| 2.4 | 422 | account_not_on_phone | user_id không phải staff của SĐT | Cho chọn lại |
| 2.4 | 422 | name_required | SĐT chưa có staff mà thiếu họ tên | Hỏi họ tên |
| 2.4 | 422 | card_pin_invalid | Sai số thẻ hoặc sai PIN | "Số thẻ hoặc PIN không đúng" |
| 2.4 | 409 | card_locked_too_many_attempts | Thẻ đang khoá — details.locked_until | Báo thử lại sau giờ đó |
| 2.4 | 429 | too_many_pin_attempts | SĐT đã sai 5 lần trong 1 giờ | Báo thử lại sau |
| 2.4 | 422 | card_has_no_pin | Thẻ không có PIN in kèm | Hướng dẫn liên hệ admin công ty |
| 2.4 | 422 | card_not_active | Thẻ bị khoá / hết hạn — details.status | Hướng dẫn liên hệ admin công ty |
account_choice_required, account_not_on_phone và name_required được trả trước khi kiểm PIN,
nên không tốn lượt PIN.
5. Chống lạm dụng
| Luật | Hệ quả |
|---|---|
| Mỗi IP: 5 lần gửi mã / phút; 10 lần / phút cho mỗi route còn lại | 429 |
| Mỗi SĐT: 5 mã / 10 phút | 429 otp_velocity_exceeded |
| Chỉ số di động VN | Không gửi SMS ra số quốc tế (chống SMS toll fraud) |
| Mỗi mã: 5 lần nhập | otp_locked |
| Sai PIN tính theo SĐT: 5 lần sai / 1 giờ | SĐT bị tạm chặn (too_many_pin_attempts) |
| Sai PIN trên một thẻ: 3 lần | Thẻ khoá 15 phút — khoá dùng chung với tự link thẻ và merchant |
| Staff chỉ được tạo sau khi số thẻ + PIN đúng | Không tạo staff rác cho SĐT không cầm thẻ |
card-core không ghi body của các route /card-link/* vào activity log, và không lưu số thẻ hay
PIN ở đâu cả. FE cũng vậy: không lưu, không gửi lên analytics hay crash log, xoá ô số thẻ + PIN ngay
sau khi gửi.
6. Staff tạo ở card-core
SĐT chưa có dòng nào trong corporate_staffs thì card-core thêm một dòng:
| Cột | Giá trị |
|---|---|
user_id | phone: + SĐT E.164, ví dụ phone:+84901234567 |
corporate_id | Công ty sở hữu thẻ vừa được chứng minh |
country_calling_code, phone_number | 84, 901234567 |
first_name, last_name | Họ tên gửi ở bước 2.4 |
role, active | STAFF, true |
user_idsuy ra từ SĐT, nên gửi lại hay hai request chạy song song cũng chỉ tạo một dòng.- Tiền tố
phone:không bao giờ trùng một user id Store (luôn là số). - SĐT đã có staff (kể cả staff của công ty khác, hay đã nghỉ) thì không tạo gì thêm: thẻ được link vào staff đó. Một user chỉ có một dòng staff, và card-core không ghi đè dòng cache từ backend corporate.
- Staff
phone:+84…chưa đăng nhập được ở đâu: app dùng user id Store, nên người này chưa thấy thẻ trong app cho tới khiphone:+84…được gắn với user Store thật (đổiuser_idtrongcorporate_staffsvàcard_users). Màn thành công nên báo "Đã liên kết thẻ", đừng hướng dẫn mở app để xem thẻ. - Backend corporate không biết staff này. Nó không có trong danh sách nhân viên trên portal corporate.
- Corporate admin vẫn thấy người được link trên thẻ (
GET /cards/{card_id}/users,user_iddạngphone:+84…) và bỏ link được.
7. Cấu hình & kiểm tra
- Thêm origin của trang web vào biến
CORS_ORIGINScủa card-core. - Các route này chỉ có trên staging sau khi bản card-core mới được deploy. Chưa có bộ API test Bruno.
- Staging/dev: card-core ghi mã OTP vào log (
non-production OTP for ****4567: …) để tester không cầm đúng SĐT vẫn đi hết luồng. Production thì không.
8. Chưa có
| Hạng mục | Tình trạng |
|---|---|
Gắn staff phone:+84… với user Store thật | Chưa có — tới lúc đó người được link chưa thấy thẻ trong app |
Đồng bộ staff phone:+84… sang backend corporate | Chưa có |
| Tra tài khoản Store theo SĐT | Không làm (tạm thời) — chỉ dùng corporate_staffs của card-core |
| Link nhiều thẻ trong một lần xác thực | Chưa có — mỗi link_token link được một thẻ |