Chuyển tới nội dung chính

Promotion Checkout Integration API

Overview

API tích hợp với Promotion Service để áp dụng promotion trong quá trình thanh toán.

Quan trọng: Các hệ thống OMS của PnL kết nối với Promotion Service đi qua MuleSoft, không gọi trực tiếp tới Promotion Service.

Onboard MiniApp với Promotion Service

MiniApp khi onboard

  • Tạo Merchant trên VPortal
  • Promotion Service cấp credentials:
  • Promotion-API-Key: Xác định merchant, có expiry và status
  • Mini App/OMS lưu credentials để authenticate
  • Promotion Service xác định merchant dựa trên Promotion-API-Key trong request header

Thông tin credentials

FieldMô tảQuản lý bởi
Promotion-API-KeyAPI key xác định merchantPromotion Service

Routing qua Mule (Promotion Service)

Base URL trên Mule (endpoint tới Loyalty/Promotion Service):

Môi trườngBase URL
DEV / SIT / UAThttps://test2-api-internal.vingroup.net:8090/loyalty/
Productionhttps://api-cloud-internal.vingroup.net/loyalty/

Môi trường DEV/SIT/UAT dùng cùng Base URL; phân biệt qua header env (dev, sit, uat).

Ví dụ full URL cho từng endpoint:

EndpointDEV/SIT/UAT
applicablePOST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/applicable
preview-discountPOST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/preview-discount
reservePOST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/reserve
confirmPOST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/confirm
releasePOST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/release
get-reservationPOST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/get-reservation
refundPOST https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/refund

Lưu ý: Với môi trường Production, các bên kết nối sẽ được team Mule cấp key riêng (client_idclient_secret). Với môi trường DEV/SIT/UAT dùng chung key.

Endpoints

EndpointMethodPurpose
v1/promotion/applicablePOSTLấy danh sách promotions có thể áp dụng
v1/promotion/preview-discountPOSTTính preview discount (lightweight)
v1/promotion/reservePOSTReserve promotion + Calculate final discount
v1/promotion/confirmPOSTConfirm promotion usage sau payment thành công
v1/promotion/releasePOSTRelease promotion khi checkout fail/cancel
v1/promotion/get-reservationPOSTLấy thông tin reservation (mã đã reserve)
v1/promotion/refundPOSTHoàn trả lại promotion khi refund từ payment

Sequence diagram

MiniApp/OMS          Promotion Service
| |
| POST /applicable |
|----------------------->|
| applicable_promotions |
|<-----------------------|
| |
| POST /preview-discount|
|----------------------->|
| total_discount, |
| breakdown |
|<-----------------------|
| |
| POST /reserve | issued_promotion: ISSUED -> RESERVED
|----------------------->|
| reservation_key, |
| discount |
|<-----------------------|
| |
| [Payment success] |
| POST /confirm | issued_promotion: RESERVED -> USED
|----------------------->|
| confirmed |
|<-----------------------|
| |
| [Payment fail/cancel] |
| POST /release | issued_promotion: RESERVED -> ISSUED
| (reason: ...) |
|----------------------->|
| promotions |
|<-----------------------|
| |
| [Payment success] |
| POST /refund | issued_promotion: USED -> ISSUED
| (reason: ...) |
|----------------------->|
| promotions |
|<-----------------------|

Issued promotion state transition

Trạng thái issued promotion (mã đã cấp cho user) chuyển theo flow reserve → confirm/release. Khi POST /release, trạng thái chuyển sang ISSUED:

state_machine.png

StateÝ nghĩa
ISSUEDMã đã cấp, chưa dùng; có thể chọn để reserve
RESERVEDĐã reserve cho order; chờ confirm hoặc release
USEDĐã confirm sau payment thành công, không dùng lại
CANCELLEDPromotion bị huỷ bởi admin/user
EXPIREDPromotion hết hạn

Integration Notes

Discount Calculation Base

Promotion Service tính discount dựa trên subtotal (tổng tiền hàng), không bao gồm shipping, thuế, hoặc phí khác.

  • min_order_value là trường để kiểm tra điều kiện áp dụng khuyến mãi → so sánh với subtotal
  • Discount amount → tính % hoặc số tiền cố định trên subtotal
  • final_amount trong response → là subtotal sau khi trừ discount => cân nhắc là chuyển thành final_subtotal_amount

OMS/MiniApp cần tính lại tổng thanh toán = final_amount (từ Promotion) + shipping + thuế + phí.

Promotion Selection

Hiện tại mỗi lần checkout chỉ hỗ trợ chọn 1 promotion.

Stacking (chọn nhiều promotion cùng lúc) sẽ được hỗ trợ trong phiên bản sau.

Reservation TTL

Promotion Service không có TTL cho reservation. Reservation tồn tại cho đến khi được /confirm hoặc /release explicitly.

OMS/MiniApp chịu trách nhiệm gọi /release khi checkout timeout hoặc bị huỷ. Nên gắn release vào checkout TTL phía OMS (ví dụ: checkout expire sau 15 phút → gọi /release).

Atomicity

/reserveatomic: tất cả promotions trong request thành công hoặc tất cả rollback. Không có partial reservation. Khi nhận error, OMS nên gọi lại /applicable để refresh danh sách promotions rồi cho user chọn lại.

Idempotency

Các mutation endpoints (/reserve, /confirm, /release) hỗ trợ idempotency qua header X-Idempotency-Key:

  • Gửi lại request với cùng X-Idempotency-Key sẽ trả về response giống lần đầu, không tạo side effect mới
  • Mỗi idempotency key chỉ hợp lệ cho cùng endpoint + cùng request body
  • OMS/MiniApp nên luôn gửi X-Idempotency-Key cho 3 endpoints trên để safe retry khi gặp network error hoặc timeout

Common Headers

Header Definitions:

HeaderRequiredDescription
client_idYesClient ID để authenticate với MuleSoft
client_secretYesClient secret để authenticate với MuleSoft
envYesMôi trường: dev, sit, uat, prod (bắt buộc set đúng theo môi trường gọi MuleSoft)
X-Auth-AudienceYesPlatform identifier, default = v-app
X-Merchant-IdYesMerchant identifier
X-App-IdYesApp identifier (hiện tại: miniapp Id)
X-App-User-IdYesApp user Id
X-Promotion-API-KeyYesApp api key (do Promotion Service cấp)
X-Request-IdYesUnique request ID for tracing (UUID)
X-Idempotency-KeyYes (/reserve)Prevents duplicate operations. Format: opaque UUID. Gửi lại cùng key sẽ trả response giống lần đầu

1. POST v1/promotion/applicable

applicable.png

cURL Example (qua Mule)

curl --location 'https://test2-api-internal.vingroup.net:8090/loyalty/v1/promotion/applicable' \
--header 'client_id: YOUR_CLIENT_ID' \
--header 'client_secret: YOUR_CLIENT_SECRET' \
--header 'Content-Type: application/json' \
--header 'X-App-User-Id: YOUR_APP_USER_ID' \
--header 'X-App-Id: v-app' \
--header 'X-Request-Id: 550e8400-e29b-41d4-a716-446655440000' \
--header 'X-Auth-Audience: v-app' \
--header 'X-Promotion-API-Key: YOUR_PROMOTION_API_KEY' \
--header 'X-Merchant-Id: vinfast' \
--header 'env: dev' \
--data '{
"orders": [
{
"merchant_id": "merchant_ugreen",
"subtotal": 1260000,
"items": [
{
"item_id": "item_001",
"sku": "CABLE-USBC-001",
"name": "Item name",
"quantity": 1,
"unit_price": 1260000,
"categories": ["electronics", "cables"]
}
]
}
]
}'

Request

{
"orders": [
{
"merchant_id": "merchant_ugreen",
"subtotal": 1260000,
"items": [
{
"item_id": "item_001",
"sku": "CABLE-USBC-001",
"name": "Item name",
"quantity": 1,
"unit_price": 1260000,
"categories": [
"electronics",
"cables"
]
}
]
}
]
}

Request Fields:

FieldTypeRequiredDescription
ordersarrayYesDanh sách đơn hàng theo merchant (mỗi merchant một phần tử). Dùng để kiểm tra promotion nào áp dụng được.
orders[].merchant_idstringYesMã merchant (vd. merchant_ugreen). Dùng để match promotion theo merchant và phân scope MERCHANT.
orders[].subtotalnumberYesTổng tiền hàng (VND) = sum(items[].unit_price × quantity). Promotion tính discount và check min_order_value dựa trên field này
orders[].itemsarrayYesDanh sách dòng hàng trong đơn. Dùng để check applicability theo category/SKU và (sau này) phân bổ discount theo item.
orders[].items[].item_idstringYesID sản phẩm/dòng hàng (unique trong đơn).
orders[].items[].skustringNoMã SKU/variant. Dùng cho rule áp dụng theo sản phẩm.
orders[].items[].namestringNoTên sản phẩm
orders[].items[].quantitynumberYesSố lượng mua.
orders[].items[].unit_pricenumberYesGiá item. Cho phép âm
orders[].items[].categoriesarray[string]NoDanh mục sản phẩm. Dùng cho rule “chỉ áp dụng cho category X”.

Response

{
"code": "0",
"message": "Success",
"data": {
"applicable_promotions": {
"discount_promotions": [
{
"promotion_master_id": 202,
"promotion_id": 2001,
"code": "FEB30",
"promotion_type": "VOUCHER",
"title": "Giảm 30% tháng 2",
"min_order_value": 1000000,
"expires_at": 1730160000000,
"is_eligibility": true,
"discount_config": {
"type": "PERCENTAGE",
"percentage": 30,
"max_discount": 500000
},
"code_type": "UNIQUE"
},
{
"promotion_master_id": 200,
"promotion_id": 2002,
"code": "UGREEN20",
"promotion_type": "VOUCHER",
"scope": "MERCHANT",
"merchant_id": "merchant_ugreen",
"title": "Giảm 20% tối đa 100K",
"min_order_value": 500000,
"expires_at": 1742025600000,
"is_eligibility": false,
"discount_config": {
"type": "PERCENTAGE",
"percentage": 20,
"max_discount": 100000
},
"code_type": "UNIQUE",
"pin_required": true,
"pin_code": "123456",
"logo_url": "https://example.com/",
"banner_url": "https://example.com/"
}
]
}
}
}

Response Fields:

FieldTypeDescription
applicable_promotions.discount_promotionsarrayDanh sách promotion
discount_promotions[].promotion_master_idnumberPromotion master ID
discount_promotions[].promotion_idnumberUser's issued promotion ID
discount_promotions[].promotion_typestringPromotion type
discount_promotions[].is_eligibilitybooleantrue = đủ điều kiện áp dụng với order hiện tại; false = không đủ (vd. chưa đạt min_order_value, hết quota) — hiển thị nhưng không cho chọn.
discount_promotions[].scopestring(Optional) "PLATFORM" hoặc "MERCHANT"
discount_promotions[].merchant_idstring(Nếu scope=MERCHANT)
discount_promotions[].discount_configobjecttype, percentage, max_discount, amount
discount_promotions[].expires_atnumber (long)Unix timestamp milliseconds — hết hạn dùng mã
discount_promotions[].code_typestring"UNIQUE" hoặc "SHARED"
discount_promotions[].pin_requiredbooleantrue hoặc false
discount_promotions[].pin_codestringMã pin sử dụng khuyến mãi, nếu voucher có yêu cầu nhập mã pin
discount_promotions[].logo_urlstringẢnh logo url
discount_promotions[].banner_urlstringẢnh banner url

2. POST v1/promotion/preview-discount

preview.png

Request

Checkout gửi cùng thông tin order như /applicable: subtotal, items (và merchant_id) để promotion service tính preview discount trên subtotal.

{
"selected_promotions": [
{
"promotion_id": 1001,
"code_type": "UNIQUE"
},
{
"promotion_id": 1002,
"code_type": "SHARED"
}
],
"orders": [
{
"merchant_id": "merchant_ugreen",
"subtotal": 1260000,
"items": [
{
"item_id": "item_001",
"sku": "CABLE-USBC-001",
"name": "Item name",
"quantity": 1,
"unit_price": 1260000,
"categories": [
"electronics",
"cables"
]
}
]
}
]
}

Request Fields:

FieldTypeRequiredDescription
selected_promotionsobjectYesThông tin về về promotions
selected_promotions[].promotion_idnumberYesUser's issued promotion ID (unique identifier) hoặc mã voucher shared
selected_promotions[].code_typestringYes"UNIQUE", "SHARED" (hiện tại chưa hỗ trợ SHARED)
ordersarrayYesCùng cấu trúc như /applicable (merchant_id, subtotal, items)

Response

{
"code": "0",
"message": "Success",
"data": {
"total_discount": 126000,
"original_total": 1260000,
"final_amount": 1134000,
"breakdown": [
{
"promotion": {
"promotion_master_id": 101,
"promotion_id": 1001,
"code": "VAPP10",
"promotion_type": "VOUCHER",
"code_type": "UNIQUE"
},
"discount": {
"amount": 126000,
"scope": "PLATFORM"
}
}
]
}
}

Response Fields:

FieldTypeDescription
total_discountnumberTổng tiền được giảm (VND). Ví dụ: voucher giảm 10% trên đơn 1.260.000₫ → total_discount = 126.000
original_totalnumberTổng tiền hàng trước khi giảm = sum(orders[].subtotal). Đây là tiền hàng, không bao gồm ship/thuế/phí
final_amountnumberTiền hàng sau khi trừ discount = original_totaltotal_discount.
breakdownarrayChi tiết discount từng promotion (thứ tự = thứ tự áp dụng)
breakdown[].promotionobjectPromotion info
breakdown[].promotion.promotion_typestring"SHIPPING", "VOUCHER", "COIN_CASHBACK"
breakdown[].promotion.code_typestring"UNIQUE", "SHARED"
breakdown[].discountobjectDiscount details (extensible)
breakdown[].discount.amountnumberDiscount amount (VND)
breakdown[].discount.scopestring"PLATFORM" hoặc "MERCHANT"
breakdown[].discount.merchant_idstringMerchant ID (nếu scope=MERCHANT)

3. POST v1/promotion/reserve

reserve.png

Request

{
"order_id": "ORDER-20260205-001",
"selected_promotions": [
{
"promotion_id": 1001,
"code_type": "UNIQUE"
},
{
"promotion_id": 1002,
"code_type": "SHARED",
"pin_code": "123456"
}
],
"orders": [
{
"merchant_id": "merchant_ugreen",
"subtotal": 1260000,
"items": [...]
}
]
}

Request Fields:

FieldTypeRequiredDescription
order_idstringYesOrder ID - Single Merchant: Order ID đơn lẻ. Multi Merchant: Master Order ID (Group Order ID)
selected_promotionsobjectYesThông tin về về promotions
selected_promotions[].promotion_idnumberYesUser's issued promotion ID (unique identifier) hoặc mã voucher shared
selected_promotions[].code_typestringYes"UNIQUE", "SHARED" (hiện tại chưa hỗ trợ SHARED)
selected_promotions[].pin_codestringNoMã pin sử dụng khuyến mãi, nếu voucher có yêu cầu nhập mã pin
ordersarrayYesCùng cấu trúc như /applicable (merchant_id, subtotal, items)

Response

{
"code": "0",
"message": "Success",
"data": {
"reservation_key": "RSV-{your-reservation-key}",
"discount": {
"total_discount": 126000,
"original_total": 1260000,
"final_amount": 1134000,
"breakdown": [
{
"promotion": {
"title": "Promotion title",
"short_title": "Promotion short title",
"promotion_master_id": 101,
"promotion_id": 1001,
"code": "VAPP10",
"promotion_type": "VOUCHER",
"code_type": "UNIQUE"
},
"discount": {
"amount": 126000,
"scope": "PLATFORM"
}
}
]
}
}
}

Response Fields:

FieldTypeDescription
reservation_keystringUUID unique cho reservation
discountobjectThông tin giảm giá
discount.total_discountnumberTổng tiền được giảm (VND)
discount.original_totalnumberTổng tiền hàng trước khi giảm = sum(orders[].subtotal). Không bao gồm ship/thuế/phí
discount.final_amountnumberTiền hàng sau khi trừ discount = original_totaltotal_discount. OMS/MiniApp dùng số này để tính tiền thanh toán
discount.breakdownarrayChi tiết discount từng promotion (thứ tự = thứ tự áp dụng)
breakdown[].promotionobjectPromotion infos
breakdown[].promotion.titlestringTiêu đề promotion (hiển thị đầy đủ)
breakdown[].promotion.short_titlestringTiêu đề ngắn gọn của promotion
breakdown[].promotion.promotion_typestring"SHIPPING" | "VOUCHER" | "COIN_CASHBACK"
breakdown[].promotion.code_typestring"UNIQUE", "SHARED"
breakdown[].discountobjectDiscount details (same structure như /preview-discount)

4. POST v1/promotion/confirm

confirm_release.png

Request

{
"reservation_key": "RSV-{your-reservation-key}",
"order_id": "ORDER-20260205-001"
}

Request Fields:

FieldTypeRequiredDescription
reservation_keystringYesReservation key từ /reserve
order_idstringYesOrder ID (phải khớp với lúc reserve) để verify

Response

{
"code": "0",
"message": "Success",
"data": {
"promotions": [
{
"promotion_master_id": 101,
"promotion_id": 1001,
"code": "VAPP10",
"status": "USED",
"code_type": "UNIQUE"
}
]
}
}

Response Fields:

FieldTypeDescription
promotionsarrayDanh sách promotions đã confirm
promotions[].promotion_master_idnumberPromotion ID
promotions[].promotion_idnumberIssued promotion ID
promotions[].codestringPromotion code
promotions[].statusstringStatus mới = "USED"
promotions[].code_typestring"UNIQUE", "SHARED"

5. POST v1/promotion/release

confirm_release.png

Request

{
"reservation_key": "RSV-{your-reservation-key}",
"order_id": "ORDER-20260205-001",
"reason": "PAYMENT_FAILED"
}

Request Fields:

FieldTypeRequiredDescription
reservation_keystringYesReservation key từ /reserve
order_idstringYesOrder ID (phải khớp với lúc reserve) để verify
reasonstringNoLý do release (logging only)

Response

{
"code": "0",
"message": "Success",
"data": {
"promotions": [
{
"promotion_master_id": 101,
"promotion_id": 1001,
"code": "VAPP10",
"status": "ISSUED",
"code_type": "UNIQUE"
}
]
}
}

Response Fields:

FieldTypeDescription
promotionsarrayDanh sách promotions đã release
promotions[].promotion_master_idnumberPromotion ID
promotions[].promotion_idnumberIssued promotion ID
promotions[].codestringPromotion code
promotions[].statusstringStatus mới = "ISSUED"
promotions[].code_typestring"UNIQUE", "SHARED"

6. POST v1/promotion/get-reservation

Lấy thông tin reservation đã tạo.

Request

{
"reservation_key": "RSV-{your-reservation-key}",
"order_id": "ORDER-20260205-001"
}

Request Fields:

FieldTypeRequiredDescription
reservation_keystringYesReservation key trả về từ POST /reserve
order_idstringYesOrder ID / Master Order ID (khớp với lúc reserve) — dùng để verify reservation thuộc đúng đơn

Response

Trả về thông tin reservation + danh sách promotions (status = RESERVED).

{
"code": "0",
"message": "Success",
"data": {
"reservation_key": "RSV-{your-reservation-key}",
"order_id": "ORDER-20260205-001",
"promotions": [
{
"promotion_master_id": 101,
"promotion_id": 1001,
"code": "VAPP10",
"status": "RESERVED",
"code_type": "UNIQUE"
}
]
}
}

Response Fields:

FieldTypeDescription
reservation_keystringReservation key (trùng request)
order_idstringOrder ID / Master Order ID gắn với reservation
promotionsarrayDanh sách issued promotions đang ở trạng thái RESERVED trong reservation này
promotions[].promotion_master_idnumberPromotion master ID
promotions[].promotion_idnumberIssued promotion ID
promotions[].codestringMã promotion
promotions[].statusstringTrạng thái issued promotion = "RESERVED"
promotions[].code_typestring"UNIQUE", "SHARED"

7. POST v1/promotion/refund

Request

{
"reservation_key": "RSV-{your-reservation-key}",
"order_id": "ORDER-20260205-001",
"reason": "REFUND"
}

Request Fields:

FieldTypeRequiredDescription
reservation_keystringYesReservation key từ /reserve
order_idstringYesOrder ID (phải khớp với lúc reserve) để verify
reasonstringNoLý do refund (logging only)

Response

{
"code": "0",
"message": "Success",
"data": {
"promotions": [
{
"promotion_master_id": 101,
"promotion_id": 1001,
"code": "VAPP10",
"status": "ISSUED",
"code_type": "UNIQUE"
}
]
}
}

Response Fields:

FieldTypeDescription
promotionsarrayDanh sách promotions đã release
promotions[].promotion_master_idnumberPromotion ID
promotions[].promotion_idnumberIssued promotion ID
promotions[].codestringPromotion code
promotions[].statusstringStatus mới = "ISSUED"
promotions[].code_typestring"UNIQUE", "SHARED"

V. Phụ lục

A. Bảng mã lỗi Promotion

HTTPCodeMessage (EN)Message (VI)Retryable
2000SuccessThành côngN/A
4008001Voucher not applicableVoucher không áp dụng đượcNo
4008002Invalid voucher codeMã voucher không hợp lệNo
4048003Voucher not foundKhông tìm thấy voucherNo
4098004Voucher already reservedVoucher đã được giữ chỗNo
4098005Voucher out of stockVoucher đã hếtNo
4228006Order minimum not metChưa đạt giá trị đơn hàng tối thiểuNo
4228007Voucher expiredVoucher đã hết hạnNo
4228008Usage limit exceededVượt giới hạn sử dụngNo
4228009User not eligibleUser không đủ điều kiệnNo
4048010Reservation not foundKhông tìm thấy reservationNo
4098011Reservation expiredReservation đã hết hạnNo
4098012Reservation already confirmedReservation đã được xác nhậnNo
5008500Promotion service errorLỗi hệ thống PromotionYes
5028501Promotion service timeoutTimeout với Promotion serviceYes