B
BLUM PRO × UrBox
Kiến trúc tích hợp eVoucher
👥Bản Phòng ban
Tài liệu kiến trúc · BLUM_PRO

Tích hợp UrBox eVoucher vào BLUM PRO
Kiến trúc, chữ ký số & luồng đổi quà

Bản đồ kỹ thuật đầy đủ để nối kho quà UrBox vào hệ sinh thái loyalty của BLUM PRO: component topology, pipeline ký RSA-SHA256, đồng bộ catalog 2 tầng, giao dịch đổi quà có khoá hàng, máy trạng thái chống mất tiền kép và ánh xạ schema dữ liệu.

Nguồn: UrBox Developer Center NestJS · PostgreSQL · Redis RSA 2048 · PKCS#1 v1.5 Offline-safe · Không CDN cho sơ đồ 👥 Chuyển sang Bản dành cho Phòng ban (Phi kỹ thuật) →
7
trường bắt buộc trong
payload ký signature
60s
request timeout
khuyến nghị của UrBox
500
quà tối đa / 1 request
gift/lists → phải phân trang
30
item tối đa trong
1 request đổi quà
80/phút
rate limit đổi quà
theo FAQ UrBox
1/ngày
tần suất đồng bộ
catalog khuyến nghị
01

Kiến trúc tổng thể

System context & component topology — từ client tới cổng UrBox

01

Sơ đồ 1 — System Context & Component Topology

Ba vùng tin cậy: Client → BLUM Backend (NestJS) → UrBox Gateway. Kho dữ liệu nằm sau backend, không bao giờ lộ ra client.

CLIENT BLUM BACKEND · TRUST BOUNDARY URBOX GATEWAY Mobile App Blum Connect React Native Admin Portal portal-web Next.js Ops / CSKH Tra cứu đơn theo transaction_id Đối soát thủ công api-service · NestJS RewardsController REST · guards · DTO validation RewardsService trừ điểm · refund · tier eligibility UrBoxClientService HTTP client · RSA signer · retry policy UrBoxSyncService catalog sync · upsert rewards ReconciliationCron @Cron · đối soát đơn PENDING PostgreSQL user_profiles rewards · reward_translations redemption_orders point_transactions Redis Distributed lock Rate limit Sync mutex UrBox eVoucher API RSA-SHA256 Signer 2048-bit · PKCS#1 v1.5 Private key (server only) Xác thực app_id · app_secret campaign_code Catalog API /2.0/category/catbyparent /4.0/gift/lists · brand · detail Redemption API /2.0/cart/cartPayVoucher Header: Signature Whitelist IP (PROD) HTTPS bắt buộc 1 2 3 4 5 6 7

Walkthrough

  1. ① Mobile → RewardsController. POST /api/rewards/redeem. Client chỉ gửi rewardId + qty; không bao giờ gửi giá điểm, mã quà UrBox hay transaction_id — backend tự sinh.
  2. ② Admin Portal → UrBoxSyncService. Nút “Đồng bộ kho quà” hoặc trigger thủ công; đồng thời cron chạy hằng ngày.
  3. ③ Admin Portal → RewardsController. CRUD metadata quà (bật/tắt, điểm yêu cầu, tier) — tách hoàn toàn khỏi dữ liệu đồng bộ từ UrBox.
  4. ④ Backend → PostgreSQL + Redis. Mọi mutation điểm đi qua transaction SQL; Redis giữ distributed lock theo user_id và mutex cho job sync.
  5. ⑤ UrBoxClientService → Redemption API. Ký payload bằng private key RSA rồi gọi cartPayVoucher kèm header Signature.
  6. ⑥ UrBoxSyncService → Catalog API. Chỉ đọc, không cần chữ ký — theo tài liệu UrBox: “Chỉ cần chữ ký khi thực hiện đổi voucher”.
  7. ⑦ ReconciliationCron → UrBox. Gọi lại đúng transaction_id cũ cho các đơn còn PENDING; không bao giờ tạo mã mới.
Luồng người dùng Luồng quản trị Gọi ra UrBox (HTTPS) Đối soát / vận hành Truy cập dữ liệu
🔐
Ranh giới bảo mật. Private key RSA chỉ tồn tại trong UrBoxClientService (env/secret manager, không commit). app_secret nằm cùng chỗ. Client và Admin Portal không có đường nào chạm tới hai bí mật này — mọi request đổi quà đều đi qua backend.
02

Chữ ký điện tử RSA-SHA256

7 bước tạo signature cho cartPayVoucher

02

Sơ đồ 2 — Pipeline ký số & Inspector payload

Chỉ 7 trường được đưa vào chữ ký. Sai một trường, sai thứ tự, hay lệch kiểu dữ liệu → UrBox trả 211 / 212.

1 Payload gốc Object request đầy đủ do service dựng chỉ 7 khoá hợp lệ mới được ký 2 Loại bỏ trường signature Không ký chính chữ ký; bỏ mọi field ngoài whitelist app_id, app_secret, campaign_code, dataBuy, 3 Sắp xếp key A → Z Tương đương ksort() của PHP Object.keys(p).sort() 4 Chuẩn hoá mảng dataBuy Giữ nguyên thứ tự phần tử & thứ tự khoá trong item JSON.stringify — không sort mảng 5 Serialize thành chuỗi JSON compact: không space, không newline UTF-8 bytes là đầu vào băm 6 Băm SHA-256 & ký RSA Private key 2048-bit, padding PKCS#1 v1.5 crypto.sign('sha256', buf, key) 7 Base64 → header Signature Chuỗi ASCII gắn vào header, không nằm trong body signature.toString('base64') ĐẦU VÀO — chỉ 7 trường này const signPayload = { app_id: "20", app_secret: "a0418bda…3ee8f00a", campaign_code: "UG516704", dataBuy: [ { priceId: "2913", quantity: 1 } ], isSendSms: 0, site_user_id: "341721226", transaction_id: "BLUM-8f2c41d9" } SAU BƯỚC 3 + 5 — chuỗi đem đi băm {"app_id":"20","app_secret":"a0418bda…", "campaign_code":"UG516704", "dataBuy":[{"priceId":"2913","quantity":1}], "isSendSms":0,"site_user_id":"341721226", "transaction_id":"BLUM-8f2c41d9"} ↑ khoá xếp A→Z · không khoảng trắng · UTF-8 BƯỚC 6 — băm & ký digest = SHA256(utf8(encoded)) sig = RSA_sign_PKCS1_v1_5(digest, privateKey) // tương đương: openssl dgst -sha256 -sign key.pem Private key: PEM PKCS#8 / PKCS#1, 2048-bit. Không dùng public key để ký. BƯỚC 7 — header gửi đi POST /2.0/cart/cartPayVoucher Signature: MEUCIQDx…Base64…== (344 ký tự)

Điểm chết người

  • Ký cả object request là sai. Tài liệu UrBox ghi rõ “CHỈ mã hoá các trường thông tin dưới đây”. Thêm ttphone, shorten, pin vào payload ký sẽ làm chữ ký lệch.
  • Thứ tự khoá phải xác định. JSON.stringify của JS giữ thứ tự chèn — phải sort trước khi stringify, không dựa vào may mắn.
  • Kiểu số vs chuỗi. isSendSms: 0 (number) khác "0" (string) trong chuỗi băm.
  • Mảng không được sort. dataBuy phải giữ đúng thứ tự item như lúc gửi; chỉ sort khoá của object, không sort phần tử mảng.
  • Không newline, không indent. Dùng JSON.stringify(obj) trần, không truyền tham số format.
  • Base64 là bắt buộc. Gửi raw Buffer sẽ hỏng header.
⚠️
Khác biệt giữa các ngôn ngữ. Ví dụ PHP dùng json_encode sau ksort; Node.js dùng JSON.stringify sau Object.keys().sort(). Hai bên cho ra cùng chuỗi chỉ khi không có ký tự Unicode đặc biệt và không có số thực. Với dữ liệu tiếng Việt, hãy đảm bảo cả hai phía dùng UTF-8 không escape (JSON_UNESCAPED_UNICODE phía PHP).
✅
Self-check trước khi gửi. Log sha256(encoded) ở môi trường sandbox và so với chữ ký verify bằng public key cùng cặp — nếu verify fail thì lỗi nằm ở bước 1–5, không phải ở UrBox.
03

Đồng bộ kho quà & thương hiệu

Catalog Sync — 4 lane, 4 endpoint, upsert 2 bảng

03

Sơ đồ 3 — Catalog Sync Sequence

Đồng bộ một chiều UrBox → BLUM. Không có chữ ký, nhưng phải phân trang vì giới hạn 500 quà/request.

Admin Portal hoặc @Cron UrBoxSyncService BLUM Backend PostgreSQL rewards · translations UrBox API 2.0 · 4.0 ① trigger đồng bộ (thủ công / cron 02:00) ② acquire lock urbox:sync (Redis, TTL 30m) ③ GET /2.0/category/catbyparent parent_id=2 · lang=vi data[] { id, title, images } ④ GET /4.0/gift/brand cat_id · page_no · per_page items[] { id, title, gift_count } ⑤ GET /4.0/gift/lists cat_id · brand_id · stock=1 · page_no data.items[] — tối đa 500 / request ⑥ GET /4.0/gift/detail id · field=content,note,office data { content, note, office[] } ⑦ UPSERT rewards sku = 'URBOX:' + id reward.id ⑧ UPSERT reward_translations locale='vi' + fallback 'en' OK · rows affected ⑨ release lock · ghi sync_run ⑩ quà không còn trong giftset → status = INACTIVE (không xoá cứng)

Quy tắc đồng bộ

  • Idempotent bằng khoá tự nhiên. rewards.sku = 'URBOX:' || urbox_gift_id — chạy lại bao nhiêu lần cũng ra một bản ghi.
  • Không xoá cứng. Quà biến mất khỏi giftset → status='INACTIVE'. Xoá cứng sẽ mồ côi redemption_orders cũ.
  • Phân trang bắt buộc. 500 quà/request; nếu catalog lớn hơn phải lặp page_no tới totalPage.
  • Dịch & fallback. Ghi reward_translations cho vi; nếu UrBox không trả bản dịch, copy bản vi làm nguồn và đánh dấu fallback trong log.
  • HTML từ UrBox. content / note là HTML thô — phải sanitize trước khi render ở mobile.
  • Ảnh. Dùng images / images_rectangle; UrBox khuyến nghị dùng ảnh của họ thay vì tự thiết kế.
⚠️
Tần suất. FAQ UrBox khuyến nghị 1 lần/ngày cho API danh sách quà. Đừng sync theo mỗi request người dùng — vừa vi phạm khuyến nghị vừa tạo tải không cần thiết.
04

Đổi điểm nhận eVoucher tức thời

Redemption sequence — khoá hàng, trừ điểm, gọi UrBox, lưu voucher

04

Sơ đồ 4 — Redemption Sequence

Điểm bị trừ trước khi gọi UrBox (trong cùng transaction), nhưng đơn ở trạng thái PENDING cho tới khi có voucher thật.

User · Mobile App Blum Connect RewardsService NestJS PostgreSQL transaction UrBox API cartPayVoucher ① POST /api/rewards/redeem { rewardId, qty } ② validate qty 1..30 · tier · status ACTIVE ③ BEGIN ④ SELECT … FOR UPDATE user_profiles WHERE user_id = $1 current_points · tier_level ⑤ current_points ≥ required_points × qty ? ⑥ UPDATE user_profiles current_points = current_points − (pts × qty) ⑦ INSERT point_transactions points_earned = −(pts × qty) · source='REDEEM' ⑧ INSERT redemption_orders status='PENDING' · id = transaction_id order.id ⑨ COMMIT ⑩ ký payload RSA-SHA256 ⑪ POST /2.0/cart/cartPayVoucher transaction_id · site_user_id · ttphone · isSendSms · dataBuy · Signature done=1 · pay=2 · cart.code_link_gift[] ⑫ UPDATE redemption_orders status='COMPLETED' · voucher_payload=jsonb ⑬ 200 { code, pin, serial, link, expired, code_image } ⑭ Mobile render QR (type 1) · Barcode 128 (type 2) · Text code (type 4) · Barcode+QR (type 5) — luôn hiển thị kèm text code

Vì sao trừ điểm trước khi gọi UrBox?

  • Chống double-spend. Nếu gọi UrBox trước rồi mới trừ điểm, hai request song song có thể cùng vượt qua bước kiểm tra số dư và cùng phát hành voucher.
  • Khoá hàng có kiểm soát. SELECT … FOR UPDATE trên đúng hàng user_profiles giữ khoá tới COMMIT. Redis lock là lớp phụ, không thay thế được row lock.
  • Điểm trừ là “đã giữ chỗ”, không phải “đã tiêu”. Trạng thái PENDING là bằng chứng để hoàn điểm khi lỗi nghiệp vụ xác định.
🚫
Không gọi UrBox bên trong transaction SQL. Timeout 60s của UrBox sẽ giữ row lock suốt 60s, nghẽn toàn bộ luồng đổi quà của user đó. Commit trước, gọi UrBox sau, cập nhật kết quả ở transaction thứ hai.
ℹ️
Tham số gửi UrBox. Tài liệu dùng isSendSms (không phải send_sms), dataBuy[].priceId (không phải gift_id), site_user_id, transaction_id, campaign_code, app_id, app_secret.
05

Máy trạng thái & xử lý lỗi

Đường nào an toàn, đường nào phải chờ đối soát

05

Sơ đồ 5 — State Machine & Fault Tolerance

Nguyên tắc duy nhất: không bao giờ hoàn điểm và tạo transaction_id mới cùng lúc trên một đơn chưa xác định.

User Redeem POST /redeem PENDING đã trừ điểm đã ghi point_transactions transaction_id = order.id Đánh giá response done / pay / err PATH A → COMPLETED done=1 · pay=2 Lưu code/pin/serial/link/expired vào redemption_orders.voucher_payload Trả voucher cho user ngay PATH B → HOLDING_RECONCILE done=1·pay=1 / 504 / 408 / timeout GIỮ NGUYÊN PENDING — không hoàn điểm không sinh transaction_id mới Chờ cron đối soát cùng mã cũ PATH C → CANCELLED + REFUND done=0 + mã lỗi nghiệp vụ hết kho · sai mệnh giá · hết hạn mức refundPoints(): current_points += pts point_transactions.reason='REDEEM_REFUND' ReconciliationCron quét PENDING > 2 phút mỗi 5 phút · backoff 1h gọi UrBox A B C ↻ đối soát lại cùng transaction_id (không tạo mã mới) CẤM TUYỆT ĐỐI ✕ Sinh transaction_id mới khi chưa rõ kết quả ✕ Hoàn điểm ngay khi gặp timeout ✕ Đánh dấu COMPLETED khi chưa có code thật ✕ Gọi lại khi đang có request in-flight

Bảng quyết định

Điều kiện nhận đượcÝ nghĩaHành độngTrạng thái đơn
done=1, pay=2Đã thanh toán & xuất quà thành côngLưu code_link_gift[] vào voucher_payload, trả cho userCOMPLETED
done=1, pay=1Đơn đã tạo nhưng chưa thanh toán / chưa xuất quàGiữ nguyên, đưa vào hàng đợi đối soátPENDING
HTTP 504 / 408 / timeout 60sKhông rõ UrBox đã xử lý hay chưaGọi lại cùng transaction_id; nếu đã tồn tại UrBox trả quà đã xuấtPENDING
done=0 + 220/221/223/225/226/407Lỗi nghiệp vụ xác định, chắc chắn chưa xuất quàrefundPoints() + ghi REDEEM_REFUNDCANCELLED
done=0 + 210/211/212/213Lỗi xác thực / chữ ký — lỗi hệ thống của BLUMKhông hoàn điểm tự động; alert on-call, giữ PENDINGPENDING
done=0 + 306/309Ví UrBox của đối tác không đủ tiềnAlert tài chính; hoàn điểm cho userCANCELLED
done=0 + 408“Hệ thống hiện tại không tạo được đơn hàng” — không xác địnhKhông hoàn điểm ngay; đối soát trướcPENDING
💸
Vì sao Path B không được hoàn điểm? Nếu hoàn điểm rồi user đổi lại, còn UrBox đã xuất quà ở lần gọi trước → BLUM mất tiền thật cho một voucher mà user vẫn giữ điểm. Đối soát theo transaction_id là cách duy nhất xác định sự thật.
⚠️
Enum hiện tại chưa có hai trạng thái này. OrderStatus trong schema hiện tại là PENDING | PROCESSING | SHIPPED | DELIVERED | CANCELLED — không có COMPLETED và HOLDING_RECONCILE. Cần migration bổ sung giá trị enum trước khi implement (xem Sơ đồ 6).
06

Ánh xạ schema dữ liệu

UrBox payload ↔ BLUM_PRO tables · cột hiện có vs cột mở rộng

06

Sơ đồ 6 — ERD & Field Mapping

Bên trái là shape dữ liệu UrBox trả về; bên phải là bảng BLUM hiện có. Đường nét đứt là cột chưa tồn tại và phải thêm.

URBOX PAYLOAD BLUM_PRO · POSTGRESQL Brand id · title · cat_id cat_title · gift_count parent_cat_id · images nguồn: /4.0/gift/brand Gift (priceId) id → priceId khi mua gift_id · title · type price · stock · quantity code_display_type expire_duration · image nguồn: /4.0/gift/lists + detail Cart id · cartNo · pay money_total · transaction_id link_gift[] · linkCart cart_created nguồn: cartPayVoucher response Cart Detail cart_detail_id · code pin · serial · token link · code_image expired · expired_time code_display_type · price đơn vị nguyên tử: 1 code = 1 voucher product_categories id · name · slug parent_id · sort_order dùng chung cho sản phẩm & quà + bảng product_category_translations rewards id · name · sku · image_url required_points · earning_points tier_requirement · redemption_limit status · category_id created_at · updated_at + provider + external_gift_id + external_brand_id + external_cat_id 4 cột đề xuất — chưa có trong schema reward_translations id · reward_id · locale name · description created_at · updated_at UNIQUE(reward_id, locale) redemption_orders id · user_id · reward_id product_code · delivery_type shipping_address · status quantity · tracking_number + provider_order_id (cart.id) + provider_transaction_id + voucher_payload (jsonb) hoặc bảng riêng redemption_vouchers point_transactions user_id · points_earned · source source · reason · qr_code (NOT NULL) brand_id → external_brand_id priceId → external_gift_id title → name (locale vi) cart.id → provider_order_id code_link_gift[] → voucher_payload điểm bị trừ

Bảng ánh xạ chi tiết

UrBox fieldKiểuBảng BLUMCột đíchTrạng thái
Brand.idstringproduct_categoriesslug = 'urbox-brand-' || idcó sẵn
Brand.titlestringproduct_category_translationsnamecó sẵn
Gift.id (priceId)stringrewardssku = 'URBOX:' || idcó sẵn
Gift.idstringrewardsexternal_gift_idcần thêm
Gift.brand_idstringrewardsexternal_brand_idcần thêm
Gift.cat_idstringrewardscategory_id → product_categories.idcó sẵn
Gift.titlestringreward_translationsname (locale vi)có sẵn
Gift.content / noteHTMLreward_translationsdescription (sanitized)có sẵn
Gift.priceint (VNĐ)rewardsrequired_points (quy đổi 1:1 hoặc tỷ lệ cấu hình)có sẵn
Gift.stock1 / 2rewardsstatus (ACTIVE khi stock=1)có sẵn
Gift.image / imagesURLrewardsimage_urlcó sẵn
Gift.code_display_type1–5rewards—metadata, có thể lưu trong voucher_payload
Cart.idstringredemption_ordersprovider_order_idcần thêm
Cart.transaction_idstringredemption_ordersprovider_transaction_id (dùng đối soát)cần thêm
Cart.pay1 / 2redemption_ordersstatus — map sang enum mớicần mở rộng enum
Cart.money_totalintredemption_ordersvoucher_payload.totalJSONB
code_link_gift[].codestringredemption_ordersvoucher_payload.codes[]cần thêm
code_link_gift[].pinstringredemption_ordersvoucher_payload.codes[].pincần thêm
code_link_gift[].serialstringredemption_ordersvoucher_payload.codes[].serialcần thêm
code_link_gift[].linkURLredemption_ordersvoucher_payload.codes[].linkcần thêm
code_link_gift[].expireddd/mm/yyyyredemption_ordersvoucher_payload.codes[].expiredcần thêm
code_link_gift[].code_imageURLredemption_ordersvoucher_payload.codes[].code_imagecần thêm
—intpoint_transactionspoints_earned = −(pts × qty)có sẵn
—textpoint_transactionssource = 'REDEEM' / 'REDEEM_REFUND'có sẵn
🧱
Ràng buộc qr_code NOT NULL + UNIQUE. point_transactions.qr_code là TEXT NOT NULL và có unique index. Giao dịch đổi quà không có QR, nên codebase đã dùng sentinel 'REDEEM_' || order.id (và 'REDEEM_REFUND_' || order.id khi hoàn). Đây là pattern sẵn có trong RewardsService, không phải cột cần sửa.
⚠️
PointSource đã có REDEEM. Migration 20260803000000_redeem_point_sources đã thêm REDEEM và REDEEM_REFUND; RewardsService.redeem() ghi source: 'REDEEM' và refundPoints() ghi 'REDEEM_REFUND'. Không cần migration cho enum này.
📐
Khuyến nghị: voucher_payload JSONB thay vì bảng redemption_vouchers. Một đơn có thể trả nhiều code (tối đa 30 item). Lưu nguyên khối cart.code_link_gift[] dạng JSONB giữ được tính toàn vẹn với response gốc, tránh phải migrate mỗi khi UrBox thêm field. Chỉ tách bảng riêng khi cần truy vấn/tra cứu theo code ở quy mô lớn — lúc đó thêm index GIN hoặc bảng phụ dẫn xuất.
07

API Reference

5 endpoint · request/response mẫu · copy nhanh

GET https://sandapi.urbox.dev/2.0/category/catbyparent
Lấy danh mục theo danh mục cha. parent_id=2 → quà eVoucher, parent_id=136 → quà vật lý. Không truyền thì mặc định parent_id=2.
{
  "app_id": "20",
  "app_secret": "a0418bdafbe61807b6caf34b3c5cbcab3ee8f00a",
  "parent_id": 2,
  "lang": "vi"
}
// Response 200
{
  "done": 1,
  "msg": "success",
  "microtime": "0.25283s",
  "status": 200,
  "data": [
    {
      "images": "https://632466.sgp1.digitaloceanspaces.com/.../640/1560252025_5cff8e7988ef1.png",
      "id": "117",
      "title": "Dessert"
    },
    {
      "images": "https://632466.sgp1.digitaloceanspaces.com/.../640/1560252345_5cff8fb9ee50e.png",
      "id": "10",
      "title": "Restaurant"
    }
  ]
}
GET https://sandapi.urbox.dev/4.0/gift/brand
Danh sách thương hiệu. Lọc theo cat_id; hỗ trợ per_page + page_no. Không truyền cat_id → trả toàn bộ.
{
  "app_secret": "a0418bdafbe61807b6caf34b3c5cbcab3ee8f00a",
  "app_id": "20",
  "cat_id": "8"
}
// Response 200
{
  "done": 1,
  "msg": "success",
  "microtime": "0.20396s",
  "status": 200,
  "data": {
    "items": [
      {
        "banner": "",
        "description": "KFC là cụm từ viết tắt của Kentucky Fried Chicken...",
        "images": "https://632466.sgp1.digitaloceanspaces.com/.../160/1667293902_6360e2cea56f4.png",
        "id": "531",
        "cat_id": "10",
        "title": "KFC",
        "gift_count": 1,
        "cat_title": "Nhà Hàng",
        "parent_cat_id": "8"
      }
    ],
    "textTitle": "Tất cả thương hiệu",
    "brand_count": "3",
    "totalPage": 1
  }
}
GET https://sandapi.urbox.dev/4.0/gift/lists
Danh sách quà trong giftset của đối tác. Tối đa 500 quà/request — vượt phải dùng page_no + per_page. Thêm stock=1 để nhận cả quà hết hàng kèm trạng thái tồn.
{
  "app_secret": "a0418bdafbe61807b6caf34b3c5cbcab3ee8f00a",
  "app_id": "20",
  "brand_id": 500,
  "cat_id": 157,
  "field": "content,note,office",
  "stock": 1,
  "page_no": 1,
  "per_page": 100,
  "lang": "vi"
}
// Response 200 (rút gọn)
{
  "done": 1,
  "msg": "success",
  "status": 200,
  "data": {
    "items": [
      {
        "id": "4837",
        "brand": "500",
        "brand_id": "500",
        "cat_id": "157",
        "cat_title": "Nha khoa thẩm mỹ",
        "gift_id": "2362",
        "title": "E-voucher Chăm sóc nha khoa kiểm tra, chụp film 3D và trám 3 răng",
        "type": "10",
        "price": "500000",
        "point": "500000",
        "stock": 1,
        "quantity": "98",
        "image": "https://632466.sgp1.digitaloceanspaces.com/.../640/1663670122_6329976a386a0.png",
        "expire_duration": "Tối thiểu 30 ngày",
        "code_display": "QR code",
        "code_display_type": 1,
        "brand_name": "ORACLE BEAUTY CLINIC VIETNAM",
        "brand_online": "1",
        "parent_cat_id": "4",
        "code_quantity": "98"
      }
    ]
  },
  "totalPage": 1
}
GET https://sandapi.urbox.dev/4.0/gift/detail
Chi tiết 1 quà theo id. Dùng field=content,note,office để lấy mô tả, điều kiện sử dụng và danh sách cửa hàng.
{
  "app_secret": "a0418bdafbe61807b6caf34b3c5cbcab3ee8f00a",
  "app_id": "20",
  "id": "709",
  "lang": "vi"
}
// Response 200 (rút gọn)
{
  "done": 1,
  "msg": "success",
  "status": 200,
  "data": {
    "id": "709",
    "brand": "ORACLE BEAUTY CLINIC VIETNAM",
    "brand_id": "500",
    "cat_id": "157",
    "parent_cat_id": "4",
    "gift_id": "2362",
    "title": "E-voucher Chăm sóc nha khoa",
    "type": "10",
    "price": "500000",
    "code_display": "QR code",
    "code_display_type": 1,
    "expire_duration": "Tối thiểu 30 ngày",
    "brand_online": "1",
    "content": "<p>LÝ DO BẠN NÊN TẨY TRẮNG RĂNG...</p>",
    "note": "<ul><li>Khách hàng đến và xác nhận e-voucher...</li></ul>",
    "office": [
      {
        "id": "167435",
        "brand_id": "500",
        "code": "ESYN",
        "address": "79 Ngô Thời Nhiệm, Phường 6, Quận 3, Thành phố Hồ Chí Minh",
        "title_city": "Hồ Chí Minh",
        "phone": "",
        "isApply": "2"
      }
    ]
  }
}
POST https://sandapi.urbox.dev/2.0/cart/cartPayVoucher
Tạo đơn đổi quà eVoucher. Bắt buộc header Signature (Base64 của RSA-SHA256 trên 7 trường đã sort). Idempotent theo transaction_id: gọi lại cùng mã sẽ trả về đơn đã tạo.
// Request body
{
  "app_id": "20",
  "app_secret": "a0418bdafbe61807b6caf34b3c5cbcab3ee8f00a",
  "campaign_code": "UG516704",
  "site_user_id": "341721226",
  "ttphone": "0979999067",
  "transaction_id": "BLUM-8f2c41d9-20261023",
  "isSendSms": 0,
  "shorten": 0,
  "dataBuy": [
    { "priceId": "2913", "quantity": 1 }
  ]
}

// Header
Content-Type: application/json
Signature: MEUCIQDx...Base64...==
// Response 200 — thành công, đã xuất quà
{
  "done": 1,
  "msg": "success",
  "microtime": "1.41339s",
  "status": 200,
  "data": {
    "pay": 2,
    "transaction_id": "00000000160test67891",
    "cart_created": "1681271943",
    "linkCart": "https://sand.urbox.dev/cart/...html?cart_no=199305",
    "linkCombo": "",
    "cart": {
      "id": "199305",
      "cartNo": "199305",
      "money_total": "50000",
      "money_ship": "0",
      "link_gift": [
        "https://sand.urbox.dev/nhan-qua/3655c208fb2c1468b4368a2e89e8efcf.html"
      ],
      "code_link_gift": [
        {
          "cart_detail_id": "393790",
          "code_display": "Barcode, QRcode",
          "code_display_type": 5,
          "link": "https://sand.urbox.dev/nhan-qua/3655c208fb2c1468b4368a2e89e8efcf.html",
          "code": "UB931542726",
          "pin": "",
          "serial": "4053627",
          "priceId": "89",
          "gift_id": "39",
          "token": "3655c208fb2c1468b4368a2e89e8efcf",
          "expired": "11/06/2023",
          "expired_time": 1686502799,
          "code_image": "https://dev.urbox.dev/v1/api/qrbarcode?code=UB931542726",
          "price": 50000
        }
      ]
    }
  }
}
// Response 200 — transaction đã tồn tại nhưng đơn thất bại trước đó
{
  "done": 1,
  "msg": "success",
  "status": 200,
  "data": {
    "pay": 1,
    "transaction_id": "2031010820240751AM",
    "cart_created": "1722473500",
    "linkCart": "",
    "cart": {
      "id": "39075519",
      "cartNo": "39075519",
      "money_total": "100000",
      "link_gift": null
    }
  }
}
// → KHÔNG có code_link_gift ⇒ chưa có quà ⇒ giữ PENDING, đối soát
⚠️
Chú ý phiên bản endpoint. Tài liệu chính thức dùng /2.0/cart/cartPayVoucher và /4.0/gift/lists — không phải /4.0/cart/cartPayVoucher hay /2.0/gift/getlist. Hãy đối chiếu lại với UrBox trước khi hard-code; nếu môi trường PROD cấp URL khác, đưa vào biến môi trường URBOX_BASE_URL.
08

Bảng tham chiếu

type · code_display_type · pay · trạng thái

Trường type — loại quà

Bảng đầy đủ theo tài liệu UrBox. Với tích hợp eVoucher của BLUM, chỉ một số giá trị là khả dụng.

Giá trịLoại quàDùng cho BLUM
1Voucher tiền mặtcó thể
2Giftsetkhông
3Combokhông
4Thẻ balancecó thể
5Thẻ điện thoạikhông
7Thẻ điểmkhông
8Topup điểmkhông
9Vật lýkhông
10Item (sản phẩm cụ thể)có thể
11Voucher khuyến mãicó thể
12Bảo hiểmcần xác nhận
14Lượt quay sốkhông
15Premium Servicecần xác nhận
16Dealkhông
19Link quà UrCardkhông
22Thẻ quà UrCardkhông
24Quà lockitem SKUkhông

Giá trị 13 không xuất hiện trong bảng chính thức của UrBox — nếu gặp trong dữ liệu thật, log lại và xác nhận với UrBox thay vì tự suy diễn.

Trường code_display_type — hình thức mã

Quyết định cách mobile render voucher. Text code luôn hiển thị với 3 hình thức đầu.

Giá trịHình thứcRender trên mobile
1QR codeQR + text code
2BarcodeBarcode 128 + text code
3Vật lýChỉ hiển thị hướng dẫn nhận quà
4Text codeChỉ text, cho phép copy
5Barcode + QR codeHiển thị cả hai + text code

Tài liệu chính thức chỉ định nghĩa 1–5. Các giá trị 6, 7 không có trong spec — phải xử lý phòng thủ bằng fallback “hiển thị text code + link”.

Trường pay — trạng thái thanh toán

Giá trịÝ nghĩaXử lý
1Chưa thanh toán / chưa xuất quàGiữ PENDING, đối soát
2Đã thanh toán — quà đã xuấtLưu voucher, COMPLETED
🚨
Đọc kỹ chiều của pay. Theo tài liệu UrBox: 1 = chưa thanh toán, 2 = đã thanh toán. Một số tài liệu nội bộ ghi ngược (coi pay=1 là thành công) — nếu implement theo chiều sai, hệ thống sẽ trả voucher rỗng cho user và không bao giờ đối soát.
09

Ma trận mã lỗi

Mã từ tài liệu UrBox + phân loại xử lý phía BLUM

Mã lỗi chính thức

Cột “Xử lý BLUM” là quyết định thiết kế, không phải nội dung tài liệu UrBox.

StatusMô tả (UrBox)Phân loạiXử lý BLUM
200Thành côngOKKiểm tra thêm pay trước khi coi là xong
210Không tìm thấy app_id hoặc app_secretCấu hìnhKhông hoàn điểm; alert on-call; kiểm tra secret
211Access Urbox không đúngCấu hìnhNhư trên — sai credential hoặc IP chưa whitelist
212Thông tin xác thực không chính xácChữ kýKiểm tra pipeline ký (Sơ đồ 2); giữ PENDING
213Xảy ra lỗi xác thựcChữ kýNhư trên
220Kho quà đang hết, vui lòng quay lại sauTạm thờiKhông hoàn điểm ngay; retry có backoff
221Không tìm thấy sản phẩm muaNghiệp vụCANCELLED + hoàn điểm
222Không tìm thấy sản phẩmNghiệp vụCANCELLED + hoàn điểm
223Quà đã hết hạn, chọn quà khácNghiệp vụCANCELLED + hoàn điểm; set rewards.status=INACTIVE
224Không tìm thấy quà tặngNghiệp vụCANCELLED + hoàn điểm
225Sản phẩm đang hết, bỏ khỏi giỏNghiệp vụCANCELLED + hoàn điểm; sync lại stock
226Quà không nằm trong chương trìnhNghiệp vụCANCELLED + hoàn điểm; rà lại giftset
304Email không đúng định dạngInputValidate trước khi gọi — lỗi phía BLUM
306 / 309Hệ thống khách hàng không đủ tiềnTài chínhCANCELLED + hoàn điểm + alert ví UrBox
307Request thiếu campaign_codeCấu hìnhLỗi lập trình — giữ PENDING, alert
308Mã chương trình không thuộc app_idCấu hìnhNhư trên
403Không tìm thấy mã giao dịchĐối soátĐối soát thủ công theo transaction_id
404Số lượng phải lớn hơn 0InputValidate trước khi gọi
405Vui lòng chọn quà muốn tặng trướcInputValidate dataBuy không rỗng
406Mã khuyến mại không đúngCấu hìnhKiểm tra campaign_code; giữ PENDING
407Số lượng sản phẩm không đủNghiệp vụCANCELLED + hoàn điểm
408Hệ thống hiện tại không tạo được đơn hàngKhông xác địnhKhông hoàn điểm ngay — đối soát trước
409Không tìm thấy dữ liệuĐối soátĐối soát theo transaction_id
601–610Lỗi địa chỉ / tỉnh thành / quận huyện / phường xã / SĐTVật lýKhông áp dụng cho eVoucher (luồng giao hàng vật lý)
🔎
Mã vận hành ngoài tài liệu. Các mã -1 (system busy), 999 (holding), 401, 500, 504 không có trong bảng chính thức của UrBox. Chúng thường xuất hiện ở tầng gateway/hạ tầng. Nguyên tắc xử lý vẫn giữ nguyên: nếu không chắc đơn đã tạo hay chưa → giữ PENDING và đối soát, không hoàn điểm, không đổi transaction_id.
🧯
Phân loại quyết định hoàn điểm. Chỉ hoàn điểm khi chắc chắn UrBox chưa xuất quà. Mọi mã thuộc nhóm “không xác định” (timeout, 408, 409, 403, lỗi mạng) đều phải qua đối soát trước.
10

Đối chiếu & cảnh báo sai lệch

Những điểm brief nội bộ khác tài liệu UrBox — phải chốt trước go-live

Hạng mụcBrief nội bộ ghiTài liệu UrBox xác nhậnKết luận
Endpoint đổi quà /4.0/cart/cartPayVoucher /2.0/cart/cartPayVoucher Dùng 2.0, xác nhận lại với UrBox
Endpoint danh sách quà /2.0/gift/getlist /4.0/gift/lists Brief sai — dùng 4.0/gift/lists
Endpoint thương hiệu /2.0/brand/get /4.0/gift/brand Brief sai — dùng 4.0/gift/brand
Tham số danh mục parent_id=0, type=12 parent_id=2 (eVoucher) hoặc 136 (vật lý); không có type Brief sai — dùng parent_id=2
Ý nghĩa pay pay=1 = thành công; pay=4 = chờ 1 = chưa thanh toán; 2 = đã thanh toán Brief sai — đảo chiều sẽ mất tiền
Tên tham số SMS send_sms isSendSms Brief sai
code_display_type 1..7 1..5 Chỉ 5 giá trị được định nghĩa
Bước 4 pipeline ký “escape " thành \"” Spec không yêu cầu escape thủ công Không escape trong Node.js — dùng JSON.stringify trần
Trạng thái đơn COMPLETED, HOLDING_RECONCILE Enum hiện tại không có hai giá trị này Phải migration bổ sung enum
Mã lỗi -1, 999, 401, 500, 504 Không có trong bảng chính thức Xử lý phòng thủ, không map cứng
📌
Nguyên tắc. Mọi điểm lệch ở trên đều đã được ghi theo tài liệu UrBox trong các sơ đồ phía trên. Trước khi go-live, cần UrBox xác nhận bằng văn bản cho 3 mục: phiên bản endpoint đổi quà, chiều của pay, và danh sách mã lỗi thực tế trên PROD.

Checklist trước khi go-live

  • Private key RSA 2048 nạp qua secret manager, không có trong repo hay image.
  • Public key đã gửi UrBox; IP PROD đã whitelist.
  • campaign_code tách riêng cho sandbox và PROD.
  • Job đồng bộ catalog chạy 1 lần/ngày, có mutex Redis và cảnh báo khi lỗi.
  • Cron đối soát chạy mỗi 5 phút, xử lý đơn PENDING > 2 phút.
  • Test idempotency: gọi cartPayVoucher hai lần cùng transaction_id → phải nhận cùng một cart.id.
  • Test rollback: giả lập lỗi nghiệp vụ → điểm hoàn đúng, có bản ghi REDEEM_REFUND.
  • Rate limit nội bộ ≤ 80 request/phút cho luồng đổi quà.
  • Giới hạn qty ≤ 30 và tổng item ≤ 30 mỗi request.
  • Mobile hiển thị đủ: logo UrBox, tên quà, giá trị, text code, hình code, điều kiện, địa chỉ, PIN/serial, hạn dùng.