LESS SDK — คู่มือสำหรับนักพัฒนา EDC
คู่มือทางเทคนิคสำหรับต่อเครื่อง EDC Android เข้ากับระบบสลิปดิจิทัล LESS
สิ่งที่เปลี่ยนจากฉบับก่อน (260819)
ถ้าเคยอ่านฉบับ 260819 มาแล้ว อ่านหัวข้อนี้ก่อน — พฤติกรรมของ server
เปลี่ยนจริงในรอบ D88 ที่ deploy ขึ้น UAT วันที่ 2026-08-22 (core-api:2026-08-22c)
และ SDK ขึ้น v2.3.0 ซึ่งเป็น breaking change
serviceType ไม่ใช่ประตูตรวจสิทธิ์อีกแล้วเดิม server ปฏิเสธสลิปที่ service_type ไม่ตรง binding ด้วย 403
ตอนนี้ ไม่ปฏิเสธแล้ว — มันกลายเป็นตัวเลือก template ของสลิป
แทน (D88) เหตุผล: acquiring host อนุมัติรายการก่อนสลิปจะเกิด
การปฏิเสธสลิปจึงเป็นการเอาปัญหา config ไปวางไว้หน้าลูกค้าที่เคาน์เตอร์
ผลกับฝั่ง EDC: ส่ง serviceType ผิด
ไม่ได้ error แล้ว — ได้สลิปที่ผิดรูปแบบไปอยู่ในมือลูกค้า โดยไม่มีสัญญาณเตือนที่ไหนเลย
จุดเดียวที่ยังจับความผิดนี้ได้คือ build() ของ SDK ซึ่ง throw ให้ตั้งแต่บนเครื่อง
อย่า bypass builder แล้วประกอบ JSON เอง
| เรื่อง | ฉบับ 260819 บอกว่า | ความจริงตอนนี้ |
|---|---|---|
| SDK version | v2.2.0 | v2.3.0 — pull ใหม่จาก Gitea แล้ว build ก่อนทดสอบรอบต่อไป |
mainMid / mainTid |
ไม่บังคับ ส่งก็ได้ไม่ส่งก็ได้ | บังคับ — build() throw ถ้าขาด และ server ตอบ
400 MISSING_MERCHANT_OWNER ดูข้อยกเว้นที่ § เจ้าของสลิป |
403 TERMINAL_INACTIVE |
code เดียวครอบ 4 สาเหตุ | แยกเป็น TERMINAL_INACTIVE · TERMINAL_NOT_FOUND ·
MERCHANT_REVOKED · MISSING_MERCHANT_OWNER —
code เดียวสี่สาเหตุเคยทำให้ส่งช่างไปผิดทีมมา 2 ครั้ง |
MERCHANT_MISMATCH |
ได้ 403 นี้เมื่อ main_mid ไม่ตรง |
server ไม่ส่ง code นี้อีกแล้ว — ยุบรวมเป็น MERCHANT_REVOKED
(constant ยังอยู่ใน SDK เพื่อ server รุ่นเก่าเท่านั้น) |
mid / tid ราย service |
ถูกตรวจเทียบ binding ทั้งชุด 4 ค่า | ไม่ถูกตรวจแล้ว — ส่งอะไรมาก็ถูกเก็บและพิมพ์ตามนั้น
ความเป็นเจ้าของสลิปตรวจที่ main_mid/main_tid จุดเดียว |
paymentType |
ไม่ได้ระบุไว้ | เพิ่ม adjustment (เมนู TIP) และ verify (เมนู CardVerify)
ครบชุดคือ sale · cash_advance · preauth ·
preauth_completion · adjustment · verify |
KTBPayment |
ยกเป็นตัวอย่างค่า bankCode |
เป็น customer ที่ถูกลบไปแล้วใน TMS (อ่านจาก TMS2 จริง 2026-08-21) เอกสารไหนที่ยังอ้างค่านี้ผิด |
extensions |
ไม่มี | ช่องใหม่ฝั่ง server สำหรับ field ที่ core-api ยังไม่รู้จัก — ดู § Extensions (SDK v2.3.0 ยังไม่เปิด method ให้) |
EDC_UAT_Request_Contract_260818.md ที่ส่งไปทาง chat ก็เก่าไปแล้วบางส่วน —
ที่ต้องอ่านใหม่คือ main_mid (เอกสารนั้นบอกว่าไม่บังคับ), MERCHANT_MISMATCH
และ service_type ส่งค่าอื่น = 403 ทั้งสามข้อไม่จริงแล้ว ส่วน REPLAY_DETECTED
เอกสารนั้นเขียนว่า 409 — ของจริงคือ 401
ใครทำอะไร
✅ LESS SDK จัดการให้แล้ว
- HMAC v1.2 sign + UUID nonce + timestamp อัตโนมัติทุก request
- Clock-skew auto-retry (เวลาเครื่อง EDC คลาดจาก server) — retry 1 ครั้ง
- Connection pooling + retry ของ OkHttp
provision()— แลก bootstrap code เป็น secret ถาวรpostSlip()/syncBatch()/getSyncStatus()- Typed response models (
trustedUrl,claimCode,token…) - Typed error parsing (
ApiError.isReplayDetected()ฯลฯ) SlipRequestBuilder+BatchRequestBuilderพร้อม validation บนเครื่อง ที่ดักความผิดที่ server ไม่ดักให้อีกแล้ว
🔨 ฝั่ง EDC ต้องทำเอง
- อ่าน bootstrap code จาก TMS channel (ระยะ UAT: ขอจากทีม LESS)
- เรียก
provision()แล้วเก็บsecretลง Android KeyStore - Map ข้อมูลจาก EDC hardware เข้า field ของ Builder —
รวมถึง map เมนูที่พนักงานกด →
serviceType - Render QR จาก
trustedUrl+ แสดงclaimCodeบนจอ - Offline queue บนเครื่อง + batch sync ตอนเน็ตกลับมา
- Error handling (catch
LESSException→ parseApiError) - PDPA consent UI (ถ้าจะส่งสลิปทาง sms)
serviceType ยังไม่มีใครจดไว้เป็นเอกสารEnvironment & Network
Base URL
ค่านี้ส่งเข้าทุก SDK call (provision(), postSlip(),
syncBatch(), getSyncStatus()):
val baseUrl = "https://less-api.loxbit.co.th"
less-customer.loxbit.co.th เป็น baseUrltrustedUrl) ไม่ใช่ API
ถ้าตั้งผิด ทุก request จะได้ HTTP 200 พร้อม HTML แทน JSON แล้ว SDK จะ parse ไม่ผ่าน
โดยไม่มี error code บอกสาเหตุ — เป็น failure mode ที่เสียเวลาหาที่สุด
ค่าที่ผูกไว้กับเครื่องทดสอบ UAT
ค่าชุดนี้อ่านจากตารางบน UAT จริง (less_devices / less_terminal_bindings /
less_device_merchants) ไม่ใช่จากเอกสาร
| field | ค่า |
|---|---|
serial (= X-API-Key) | P302246N10589 |
bankCode | KTB — เป็น TMS customerName ไม่ใช่เลขธนาคาร |
mid | UAT-DUMMY-MID-01 |
tid | UATDUM01 |
serviceType | PURCHASE — binding เดียวที่ผูกไว้กับเครื่องนี้ |
mainMid / mainTid | UAT-DUMMY-MID-01 / UATDUM01 |
ชื่อร้านที่จะขึ้นบนสลิป (ร้านทดสอบ UAT (ไม่ใช่ร้านจริง)) ฝั่ง server ใส่ให้เอง
จาก registration — ห้ามส่งมา field merchant_name เป็น
json:"-" คือเครื่องตั้งชื่อร้านตัวเองไม่ได้โดยการออกแบบ
bankCode คือรหัส customer ของ TMS ไม่ใช่เลขธนาคารcustomerName เช่น
KTB, KTC, TSB, KTBSPAY
ลูกค้า LESS ไม่ได้เป็นธนาคารทุกราย (KTC เป็นบริษัทบัตรเครดิต TSB เป็นผู้ให้บริการขนส่ง)
เลข BOT จึงแทนลูกค้าเหล่านี้ไม่ได้และไม่เคยใช้ที่นี่ —
ส่ง 004 คือผิด ทั้งที่ 004 เป็นรหัส BOT ของ KTB จริง
รายชื่อ customer จริงใน TMS มี 9 ค่า (อ่านจาก TMS2 UAT 2026-08-21):
KTBSPAY · TSBPaymentWelfare · DEV · TSB ·
SameBIN · KTC · KTB2DR · KTBPVT · KTB
KTBPayment ถูกลบไปแล้ว อย่าใช้
UAT-DUMMY-MID-01 / UATDUM01 เป็นค่าทดสอบ ไม่ใช่ merchant จริง
เปลี่ยนเป็นเลขจริงภายหลังได้โดยไม่ต้อง provision ใหม่ — เครื่องเก็บ
secret เดิมไว้ ไม่ต้องขอ bootstrap code ใหม่ ไม่ต้องแก้โค้ด ไม่ต้องล้างแอป
สิ่งที่เปลี่ยนคือชื่อร้านและเลข MID/TID ที่ขึ้นบนสลิปเท่านั้น
Network
| รายการ | ค่า |
|---|---|
| Protocol | HTTPS เท่านั้น — firewall เปิดเฉพาะ TCP 443, HTTP/80 ไม่ผ่าน |
| TLS | TLS 1.2+ · cert public (Sectigo) — ไม่ต้องติดตั้ง CA เอง ไม่ต้องปิด cert validation ไม่ต้องทำ pinning |
| DNS | resolve ได้เลย ไม่ต้องแก้ hosts file |
| Wi-Fi (ที่ออฟฟิศ) | loxbit-edc — ขอรหัสจากทีม Infra |
| ทางไกล | ผ่าน VPN ได้ (ACL เปิดให้แล้ว 2026-08-18) ไม่จำเป็นต้องเข้าออฟฟิศ |
เช็คก่อนเริ่มเขียนโค้ด
รันจากเครื่อง EDC หรือเครื่องที่อยู่วงเดียวกัน ถ้าไม่ได้ 200 คือติดที่ network ไม่ใช่ที่โค้ด:
curl -sS -o /dev/null -w '%{http_code}\n' https://less-api.loxbit.co.th/healthz
172.29.220.200:443 ผ่าน VPN
เคยไม่นิ่ง (2026-08-18 เจอ timeout แล้วกลับมาเองสองรอบในไม่กี่นาที ขณะที่ host อื่นใน subnet เดียวกันเปิดตลอด)
ให้ retry ก่อน แล้วเทียบกับ /healthz ข้างบน: ได้ 200 = เครือข่ายปกติ
ปัญหาอยู่ที่ payload/signature · ไม่ตอบเลย = network
ขอ Onboard เครื่องใหม่ (ทำก่อน provision ทุกครั้ง)
เครื่องที่ LESS ยังไม่รู้จัก เรียก provision() จะได้ 404 DEVICE_NOT_FOUND ทันที
เพราะ endpoint นั้นทำหน้าที่แลก bootstrap code เท่านั้น ไม่ได้สร้าง device ให้
DEVICE_NOT_FOUND ไม่ใช่ bug ฝั่งคุณ — มันแปลว่าเรายังไม่ได้ seed serial นั้น
ขั้นตอน
| # | ใคร | ทำอะไร |
|---|---|---|
| 1 | EDC Dev | แจ้ง serial number ของเครื่องมาที่ทีม LESS พร้อมบอกว่าจะเริ่มทดสอบเมื่อไหร่ |
| 2 | ทีม LESS | onboard serial เข้าระบบ (POST /api/v1/tms/terminals ฝั่งเรา) แล้วส่ง bootstrap code กลับทาง chat |
| 3 | EDC Dev | เรียก LESSClient.provision(baseUrl, serial, bootstrapCode) ครั้งเดียว แล้วเก็บ secret ลง Android KeyStore ก่อนทำอย่างอื่น |
401 INVALID_BOOTSTRAP_CODE
ซึ่งหน้าตาเหมือน bug ใหม่ทั้งที่แค่หมดอายุ ขอใหม่ได้ทันทีไม่มีข้อจำกัด
และไม่ต้องแก้อะไรบนเครื่อง
ยังไม่มีเลข MID/TID จริงก็เริ่มได้
ถ้าเลข merchant จริงยังไม่นิ่ง ทีม LESS ผูกเครื่องกับ MID/TID ทดสอบให้ก่อนได้
ทดสอบครบทั้ง flow (provision → postSlip → ลูกค้าเปิดสลิป) แล้วเปลี่ยนเป็นเลขจริงทีหลัง
การเปลี่ยน binding ไม่ทำให้ secret ของเครื่องหาย (D78)
ถ้า secret หายบนเครื่อง
เช่น reinstall แอปแล้วไม่ได้ backup KeyStore — เรียก provision() ด้วย code เดิม
จะได้ 409 DEVICE_ALREADY_PROVISIONED ไม่ใช่ secret
ต้องแจ้งทีม LESS ให้ reset ฝั่ง server แล้วออก code ใหม่
(ฝั่งเรามี POST /api/v1/admin/devices/reissue-bootstrap ให้แล้ว ไม่ต้องแก้ DB มืออีก)
P302246N10589 — provision ไปแล้ว อย่าเรียกซ้ำLESS-SEC-* อยู่ — ใช้ secret ที่เก็บไว้ ไม่ต้อง provision ใหม่
ข้อควรระวัง: ฝั่ง TMS ยังเก็บค่า LESS_BC
(bootstrap code ที่ถูกใช้ไปแล้ว) เป็น plaintext อยู่บนเครื่องนี้ ถ้า factory reset แล้วแอปไปอ่านค่านั้นมา provision
จะได้ 401 INVALID_BOOTSTRAP_CODE — อย่าให้แอป trust ค่าใน TMS parameter ตัวนั้น
Flow ของระบบ
1. Device Provisioning — ทำครั้งเดียวตลอดอายุเครื่อง
-
TMS → LESS core-apiลงทะเบียนเครื่องผ่าน
POST /api/v1/tms/terminalsด้วย serial + ข้อมูล merchant
ระยะ UAT: ทีม LESS ทำขั้นนี้ให้มือ — ดู § ขอ Onboard -
LESS core-api → TMSLESS ออก bootstrap code ใช้ครั้งเดียว (TTL 48 ชม. ผูกกับ serial)
-
EDC App → LESS core-apiแอปเรียก
LESSClient.provision(baseUrl, serial, bootstrapCode)เพื่อแลกเป็น secret ถาวร — endpoint นี้ไม่ต้องเซ็น HMAC ตัว code คือหลักฐานในตัวเอง -
EDC Appเก็บ
secretลง Android KeyStore — server คืนให้ครั้งเดียว
2. Transaction — Online และ Offline Queue
-
ลูกค้า → EDC Apppayment host อนุมัติรายการเรียบร้อยแล้ว
ลำดับนี้สำคัญ: รายการถูกอนุมัติก่อนสลิปจะเกิด นี่คือเหตุผลที่ D88 เลิกให้ LESS ปฏิเสธสลิป -
EDC App → LESS core-apiประกอบ payload ด้วย
SlipRequestBuilderแล้วส่งPOST /api/v1/slipsพร้อม HMAC -
LESS core-api → EDC Appตรวจ signature → resolve เจ้าของสลิป → เลือก template → ตอบ
trusted_url,claim_code,token -
Offline Failoverถ้าเน็ตล่ม เขียน payload ลง local queue ของเครื่อง พอเน็ตกลับมาส่งรวมทีเดียวด้วย
client.syncBatch()(POST /api/v1/sync/batch, ไม่เกิน 50 รายการ/call)
ติดตั้ง SDK
1. Git Submodule
git submodule add http://172.29.220.3:3000/panuwat/terminal-sdk-android.git terminal-sdk-android
cd terminal-sdk-android && git fetch && git checkout main && git pull
v2.3.0 rebase ทับ commit 3506d1c ของฝั่ง EDC ไว้แล้ว งานที่ push ไปไม่หาย
2. Gradle
ใน settings.gradle.kts:
include(":terminal-sdk-android")
project(":terminal-sdk-android").projectDir = File("./terminal-sdk-android")
ใน app/build.gradle.kts:
dependencies {
implementation(project(":terminal-sdk-android"))
}
ใน AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
เวอร์ชันที่ต้องใช้
| รายการ | ขั้นต่ำ | หมายเหตุ |
|---|---|---|
| JDK | 17+ | จำเป็นสำหรับ AGP 8.2.2+ |
| Android minSdk | API 26 (Android 8.0) | KeyStore รุ่นใหม่ |
| Android compileSdk | API 34 | |
| Kotlin plugin | 2.2.21+ | ต้องตรงกันทุก Gradle module |
| OkHttp | 5.4+ |
./gradlew test ไม่ได้ —
unit test ที่แก้ตามพฤติกรรมใหม่ (WireContractTest) ยังไม่เคยถูกรัน
ขอให้รัน ./gradlew test รอบแรกฝั่งคุณ ถ้าเจอ compile error แจ้งกลับมาได้เลย
— ถือเป็นข้อบกพร่องฝั่งเรา ไม่ใช่ฝั่งคุณ
ขั้นตอนทีละ Step
แลก bootstrap code (ใช้ครั้งเดียว, TTL 48 ชม.) เป็น secret ถาวร
val result = LESSClient.provision(
baseUrl = baseUrl,
serial = deviceSerial,
bootstrapCode = code
)
// result.serial -> echo serial กลับมา
// result.secret -> เก็บลง Android KeyStore ทันที ห้ามค้างใน memory
secret อยู่ที่ data.secret
ไม่ใช่ secret ชั้นบนสุด ทุก endpoint ของ LESS ห่อด้วย envelope
{ status, code, message, data }
val client = LESSClient(
serial = deviceSerial,
secret = loadFromKeyStore()
)
val payload = SlipRequestBuilder()
.header("1.0", requestId = UUID.randomUUID().toString(), timestamp = Instant.now())
.terminalSerialNo(deviceSerial)
.transaction(rrn = "123456789012", refCode = "REF-1001", amount = 350.00, currency = "THB")
.txnDateTime(Instant.now())
.txnStatus("approved")
.merchant(
bankCode = "KTB", // TMS customerName — ห้ามใส่ "004"
mid = "UAT-DUMMY-MID-01",
tid = "UATDUM01",
serviceType = "PURCHASE", // ตัวเลือก template — ผิดแล้วไม่มี error ให้เห็น
mainMid = "UAT-DUMMY-MID-01", // บังคับตั้งแต่ v2.3.0
mainTid = "UATDUM01" // บังคับตั้งแต่ v2.3.0
)
.deliveryQr()
.buildJson()
val slip = client.postSlip(baseUrl, payload)
// ทำ QR จาก: slip.trustedUrl
// แสดงบนจอ: slip.claimCode
rrn คือ idempotency key ของ LESS — ส่ง rrn เดิมซ้ำด้วย payload
เหมือนเดิมจะได้ผลลัพธ์เดิมกลับมา (message เปลี่ยนเป็น Duplicate transaction accepted)
แต่ถ้า rrn เดิม + payload ต่างจากเดิม จะได้ 409 DUPLICATE_CONFLICT
val batch = BatchRequestBuilder()
.header(requestId = UUID.randomUUID().toString(), timestamp = Instant.now())
.merchant(
bankCode = "KTB", mid = "UAT-DUMMY-MID-01", tid = "UATDUM01",
serviceType = "PURCHASE",
mainMid = "UAT-DUMMY-MID-01", mainTid = "UATDUM01"
)
.addItem { txn("RRN001", "REF001", 120.00, "THB").txnStatus("approved").deliveryQr() }
.addItem { txn("RRN002", "REF002", 500.00, "THB").txnStatus("approved").deliveryQr() }
.buildJson()
val syncResult = client.syncBatch(baseUrl, batch)
syncResult.results.forEach { item ->
if (item.isSuccess) removeFromLocalQueue(item.rrn)
else logSyncFailure(item.rrn, item.errorCode)
}
merchant อยู่บน envelope ไม่ใช่ราย itemmainMid/mainTid ที่ผิดหรือขาด
ทำให้ทุกรายการใน batch (ได้ถึง 50) พลาดทั้งชุด ไม่ใช่แค่รายการเดียว
item ใน batch เป็น flattened structure (txn fields อยู่ root ไม่ nested) —
BatchRequestBuilder จัดการให้แล้ว
รองรับ partial success: ลบออกจาก queue เฉพาะตัวที่
isSuccess
private fun getOrCreateSecretKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
if (!ks.containsAlias("less_device_secret")) {
val kg = KeyGenerator.getInstance("AES", "AndroidKeyStore")
kg.init(
KeyGenParameterSpec.Builder(
"less_device_secret",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
)
kg.generateKey()
}
return (ks.getEntry("less_device_secret", null) as KeyStore.SecretKeyEntry).secretKey
}
val status = client.getSyncStatus(baseUrl, tid) // -> 404 TERMINAL_NOT_FOUND
GET /api/v1/terminals/{tid}/sync-status จะได้ 404 TERMINAL_NOT_FOUND
สำหรับเครื่องที่ provision ผ่าน bootstrap code — ตัวนับ sync อยู่ใน terminal registry แบบเก่า
(less_terminals) ซึ่ง path device/binding ไม่เคยเขียน record ลงไป
อ่านว่า "รายงาน sync state ไม่ได้" ไม่ใช่ "provision ล้มเหลว" ให้นับ queue ที่ฝั่งเครื่องไปก่อน เช็คด้วย
ApiError.isTerminalNotFound()
HMAC v1.2 — SDK เซ็นให้แล้ว แต่ต้องรู้ไว้ debug
LESSSecurityInterceptor ทำให้อัตโนมัติทุก signed request ไม่ต้องเขียนเอง
หัวข้อนี้มีไว้เพื่อไล่หาสาเหตุตอนเจอ 401 เท่านั้น
string-to-sign =
{METHOD}\n{PATH}\n{X-API-Key}\n{X-Timestamp}\n{X-Nonce}\n{HEX(SHA256(BODY))}
| header | ค่า |
|---|---|
X-API-Key | serial ของเครื่อง เช่น P302246N10589 — ไม่ใช่ API key แยก นี่คือสิ่งที่ทำให้ zero-touch provisioning เป็นไปได้ |
X-Timestamp | RFC3339 เช่น 2026-08-25T14:46:00Z — ไม่ใช่ epoch ส่ง epoch มาจะได้ 401 REPLAY_DETECTED ซึ่งอ่านแล้วเข้าใจผิดว่านาฬิกาเพี้ยน |
X-Nonce | unique ทุก request (ซ้ำ = 401 REPLAY_DETECTED) |
X-Signature | hex ตัวพิมพ์เล็ก |
PATHที่เซ็น ไม่รวม query stringHEX(SHA256(BODY))ของ body เปล่า = sha256 ของ empty string- Replay window = 600 วินาที นาฬิกาคลาดเกิน 10 นาที = ถูกปฏิเสธ (SDK แก้ skew แล้ว retry ให้ 1 ครั้งอยู่แล้ว)
- ขาด header ตัวใดตัวหนึ่งใน 4 ตัว =
401 MISSING_AUTH_HEADER - signature ไม่ตรง =
401 INVALID_SIGNATURE
POST /api/v1/terminals/provision เป็น endpoint เดียวที่ไม่ต้องเซ็น
— ตัว bootstrap code คือหลักฐานในตัวเอง
เจ้าของสลิป: mainMid / mainTid
สองค่านี้ตอบคำถาม "สลิปใบนี้เป็นของร้านไหน" และไม่มี field อื่นในทั้ง payload ที่ตอบคำถามนี้ได้
— main_mid คือ merchant_ref ตัวเดียวกับที่ merchant portal ใช้ scope ข้อมูล
(WHERE mid = $1 OR merchant_ref = $1) สลิปที่ไม่มีสองค่านี้จึงไม่มีเจ้าของ
checkDeviceMerchantRegistration มี if binding.MainMID == "" { return nil }
คือ ไม่ส่ง main_mid มา = ผ่านการตรวจ และการตรวจนั้นยังเรียกอยู่แค่ในสาขา fail ของ
ValidateTerminal — เครื่องที่ registry แบบเก่ารู้จักจึงไม่ถูกตรวจเลย และ
ยิงสลิปเข้าบัญชีร้านไหนก็ได้ที่มันพิมพ์ชื่อมา
กฎที่ใช้ตอนนี้
| ส่งอะไรมา | ผลลัพธ์ |
|---|---|
ครบทั้งคู่ และตรงกับ registration ที่ active | ✅ ผ่าน — ชื่อร้าน + merchant_ref มาจาก registration นั้น |
| ครบทั้งคู่ แต่ไม่ตรง registration | 403 MERCHANT_REVOKED |
| ส่งมาแค่ตัวเดียว | 400 MISSING_MERCHANT_OWNER — key ของ registration คือ triple (serial, main_mid, main_tid) ครึ่ง key ค้นไม่ได้ |
ไม่ส่งทั้งคู่ + เครื่องมี registration active ใบเดียว | ⚠️ ผ่าน — server เติมให้จาก registration ใบนั้น (เป็นไปไม่ได้ที่จะเติมผิด) และเขียน log ทุกครั้ง |
ไม่ส่งทั้งคู่ + เครื่องมี registration active ตั้งแต่ 2 ใบ | 400 MISSING_MERCHANT_OWNER — ไม่เดา เพราะเดาผิดคือรายการจริงไปอยู่บัญชีร้านจริงที่ไม่ใช่เจ้าของ |
build() ของ v2.3.0 บังคับอยู่แล้ว
และเครื่องที่รับหลาย merchant จะพังทันทีถ้าพึ่งการเติมให้
mainMid/mainTid ไม่ใช่ mid/tid —
mid/tid คือเลขราย service ที่พิมพ์บนสลิป ส่วน mainMid/mainTid
คือเลขหลักของร้านที่ TMS ลงทะเบียนไว้ บน UAT ค่าทั้งสองชุดเท่ากันเพราะเป็น placeholder ชุดเดียว
บน production จะไม่เท่ากัน
Builder Reference
ตั้งแต่ D88 ฝั่ง server เลิกปฏิเสธความผิดหลายอย่างที่เคยปฏิเสธ —
build() จึงไม่ใช่ทางลัดกันเสีย round-trip อีกแล้ว มันเป็นจุดสุดท้ายที่ยังจับความผิดได้
ทุก require() ที่ throw คือความผิดที่ถ้าปล่อยผ่านไป จะไม่มี error กลับมาเลย
SlipRequestBuilder
| method | พารามิเตอร์ | บังคับ | หมายเหตุ |
|---|---|---|---|
.header(...) | schemaVersion, requestId, timestamp | Required | schemaVersion = "1.0" · requestId unique ต่อ request |
.terminalSerialNo(v) | String | Optional | ใส่ลง header เพื่อ trace ย้อนหลัง |
.transaction(...) | rrn, refCode, amount, currency | Required | amount ต้อง > 0 · currency = "THB" · rrn เป็น idempotency key |
.txnDateTime(v) / .txnStatus(v) | Instant / String | Optional | ไม่ส่ง build() เติม Instant.now() และ "approved" ให้ |
.merchant(...) | bankCode, mid, tid, serviceType, mainMid, mainTid | Required | ทั้ง 6 ค่าบังคับตั้งแต่ v2.3.0 — serviceType เลือก template, mainMid/mainTid บอกเจ้าของสลิป ดู § เจ้าของสลิป |
.deliveryQr() | - | Required* | *เลือกอย่างใดอย่างหนึ่งจาก delivery |
.deliverySms(...) | mobileNumber, consentTimestamp | Required* | ต้องมี consent PDPA จริง — server บังคับ ดู § Delivery |
.deliveryEmail(...) | emailAddress | Required* | ตรวจรูปแบบตาม RFC 5322 · ยังส่งไม่ออกจริง ดู § Delivery |
.cardData(pan, scheme) | String, String | Optional | PAN ต้อง mask แล้ว เช่น 524172XXXXXX5566 — PAN เต็มถูกปฏิเสธด้วย 400 FULL_PAN_REJECTED |
.approvalCode(v) · .traceNo(v) · .batchNo(v) | String | Optional | ค่าจาก host / เครื่อง |
.feeAmount(v) · .entryMode(v) · .txnType(v) | Double / String | Optional | txnType = SALE / VOID / REFUND — เป็นตัวเลือก template ลำดับสูงสุด |
.paymentType(v) | String | Optional | sale · cash_advance · preauth · preauth_completion · adjustment · verify — ค่าอื่น = 400 |
.parentRrn(v) | String | Optional | บังคับ เมื่อ paymentType = "preauth_completion" |
.additionalRefs(...) | vararg String | Optional | ไม่เกิน 3 ตัว เกินนั้น 400 |
.evidenceSignature(...) · .evidenceGps(...) | base64 / lat,lon | Optional | ลายเซ็น / พิกัด |
.taxInvoice(...) | taxId, vatIncluded, traceNo, batchNo | Optional | ใบกำกับภาษี |
.dcc(...) | ccy, amount, rate, consent, feePct | Optional | customerConsent ต้องเป็น true ไม่งั้น 400 DCC_CONSENT_REQUIRED |
.wallet(...) | provider, providerTxnId, … | Optional | e-wallet (Alipay ฯลฯ) — ใช้แทน cardData · provider ว่างไม่ได้ |
.installment(...) | plan, termMonths, … | Optional | KTC Flexi / Proud — plan และ termMonths > 0 บังคับถ้าใส่ block นี้ |
.buildJson() | - | - | ตรวจแล้ว serialize เป็น JSON String · throw IllegalStateException ถ้าขาด field บังคับ |
DisallowUnknownFields400 INVALID_PAYLOAD — ถ้าจะส่งข้อมูลที่ core-api ยังไม่รู้จัก
ต้องใส่ผ่าน extensions เท่านั้น ดู § Extensions
BatchRequestBuilder
กฎเดียวกัน ต่างที่ merchant อยู่บน envelope ใบเดียวสำหรับทุก item
และ item ใน batch เป็น flat structure · ไม่เกิน 50 item ต่อ call
(เกิน = 413 BATCH_TOO_LARGE)
Service Type Codes
เดิม: service_type คือกุญแจตรวจสิทธิ์ — ผิดหรือไม่ส่ง = 403
ตอนนี้: เป็น ตัวเลือก template ของสลิป บวกกับการตรวจความสอดคล้องของ payload — ส่งค่าที่ไม่ได้ผูกไว้ หรือค่าที่ยังไม่ได้ลงทะเบียน ก็ถูกรับ เก็บตามที่ส่งมา และรายงานลง log
ผิดแล้วเกิดอะไรขึ้นจริง ๆ
| กรณี | ผลลัพธ์ |
|---|---|
| ส่งค่าที่ลงทะเบียนไว้และผูกกับเครื่อง | ✅ สลิปได้ template ที่ถูกต้อง |
| ส่งค่าที่ลงทะเบียนไว้ แต่ไม่ได้ผูกกับเครื่องนี้ | ✅ ถูกรับ (เดิมคือ 403) — template ถูกเลือกตามค่าที่ส่งมา |
| ส่งค่าที่ยังไม่ได้ลงทะเบียน (พิมพ์ผิด/service ใหม่) | ✅ ถูกรับ เก็บดิบไว้ บันทึกเป็น inactive ให้คนมาตรวจ (โหมด learn) → สลิปได้ template GENERIC_SALE |
| ไม่ส่งเลย จาก builder | ⛔ build() throw บนเครื่อง — จับได้ตรงนี้ที่เดียว |
| ไม่ส่งเลย โดยประกอบ JSON เอง (เครื่องนี้) | ⛔ 403 TERMINAL_NOT_FOUND — UATDUM01 ไม่มีใน terminal registry แบบเก่า และไม่มี service_type ก็เข้า path binding ไม่ได้ |
| ไม่ส่งเลย บนเครื่องที่อยู่ใน registry แบบเก่า | ⚠️ ถูกรับเงียบ ๆ ได้ template GENERIC_SALE = ลูกค้าถือสลิปผิดรูปแบบ ไม่มีสัญญาณเตือนที่ไหน |
^[A-Z][A-Z0-9_]{1,31}$ และเป็น prefix ของ TMS parameterName
ทั้งก้อน — ห้ามตัดที่ _ (D81) เช่น WELFARE1_MID ตัด _MID ออก
ได้ WELFARE1 ไม่ใช่ WELFARE
ไม่มี code ที่ชื่อ
WELFARE — ของจริงคือ WELFARE1 / WELFARE2 / WELFARE3
16 code ที่ลงทะเบียนไว้ในระบบ LESS (migration 013_service_types.sql):
| code | category | template ที่ได้ | คำอธิบาย |
|---|---|---|---|
PURCHASE | purchase | PURCHASE | รายการขายปกติ |
ONUS | purchase | PURCHASE | On-us |
WAY4 | purchase | PURCHASE | Way4 |
WELFARE1 | welfare | WELFARE | สวัสดิการ 1 |
WELFARE2 | welfare | WELFARE | สวัสดิการ 2 — ไม่มีบน UAT |
WELFARE3 | welfare | WELFARE | สวัสดิการ 3 — ไม่มีบน UAT |
HCARE | welfare | WELFARE | Health Care |
QR | qr | QR | QR Payment |
ALIPAY | wallet | WALLET | ชำระผ่าน Alipay |
ALIPAY_PLUS | wallet | WALLET | Alipay+ |
FLEXI | installment | INSTALLMENT | ผ่อนชำระโดยบัตร KTC |
PROUD | installment | INSTALLMENT | KTC PROUD |
CASHADVANCE | cash_advance | CASH_ADVANCE | เบิกเงินสด |
FOREVER | loyalty | GENERIC_SALE | ใช้คะแนน KTC FOREVER — ยังไม่มี block สำหรับ loyalty |
BMTA | transit | GENERIC_SALE | BMTA — ยังไม่มี block สำหรับ transit |
BMTASRT | transit | GENERIC_SALE | BMTA + SRT — ยังไม่มี block สำหรับ transit |
NHSO มี rule ของ template อยู่แล้ว (→ WELFARE) แต่ยังไม่อยู่ใน registry 16 ตัวข้างบน
ส่งได้ ถูกรับ และจะถูกบันทึกเป็น code ใหม่ให้เราตรวจ · ใน TMS ตัวนี้มี NHSO_TID แต่ไม่มี NHSO_MID
ซึ่งหักล้างสมมติฐานว่า "ทุก service มีทั้ง MID และ TID"
FOREVER / BMTA / BMTASRT จงใจไม่มี rule เฉพาะ:
template ของมันต้องมี block loyalty / transit ที่ยังไม่มี field ใน payload รองรับ
จึงให้ตกไป GENERIC_SALE ซึ่งพิมพ์ออกมาถูกแต่เรียบ ดีกว่าอ้าง block ที่ renderer เติมไม่ได้
ป้ายภาษาไทยของ service (คำถามที่ปิดไปแล้ว)
10 จาก 16 code ยังไม่มี label_th — เลิกถามไปแล้ว ไม่ต้องหาคำตอบมาให้ (D90)
เพราะไม่มีอะไรใน LESS ที่ render ค่านี้เลย สลิปแสดงชนิดรายการจาก template ไม่ใช่จากป้าย
และตาราง EDC-Menu ของ TMS ก็จ่ายหนี้นี้ไม่ได้ (WELFARE1, HCARE, ONUS,
NHSO, BMTA, BMTASRT, WAY4, PROUD,
ALIPAY_PLUS ไม่มีแถวเมนูเลย) registry จะเติมตัวเองจาก traffic จริงในโหมด learn
Endpoints & Models
ทุก response ห่อด้วย envelope เดียวกัน — ข้อมูลจริงอยู่ใน data:
{ "status": "success", "code": 200, "message": "...", "data": { ... } }
แลก bootstrap code (ใช้ครั้งเดียว) เป็น HMAC secret ถาวร · ไม่ต้องเซ็น HMAC
// request
{ "serial": "P302246N10589", "bootstrap_code": "<ส่งให้ทาง chat>" }
// data
{ "serial": "P302246N10589", "secret": "LESS-SEC-..." }
server คืน secret ให้ครั้งเดียว เรียกซ้ำได้ 409 DEVICE_ALREADY_PROVISIONED
· serial ที่ยังไม่ได้ seed ได้ 404 DEVICE_NOT_FOUND (endpoint นี้ไม่สร้าง device)
ส่งสลิปแบบ online · payload เป็น nested envelope
(header / transaction / merchant / delivery)
ไม่ใช่ flat fields
// request (ตัวเต็มที่ใช้ได้จริงกับเครื่อง UAT)
{
"header": {
"schema_version": "1.0",
"request_id": "<unique ต่อ request>",
"timestamp": "2026-08-25T14:46:00Z",
"terminal_serial_no": "P302246N10589"
},
"transaction": {
"rrn": "<unique — idempotency key>",
"ref_code": "EDC-1001",
"approval_code": "123456",
"trace_no": "000123",
"batch_no": "000003",
"amount": 100.50,
"currency": "THB",
"txn_date_time": "2026-08-25T14:46:00Z",
"txn_status": "approved",
"txn_type": "SALE",
"payment_type": "sale",
"entry_mode": "CONTACTLESS_CHIP"
},
"card_data": { "masked_pan": "524172XXXXXX5566", "card_scheme": "MASTERCARD" },
"merchant": {
"bank_code": "KTB",
"mid": "UAT-DUMMY-MID-01",
"tid": "UATDUM01",
"service_type": "PURCHASE",
"main_mid": "UAT-DUMMY-MID-01",
"main_tid": "UATDUM01"
},
"delivery": { "delivery_method": "qr" }
}
// data
{
"transaction_id": "...",
"token": "ZV726CJ6JB",
"ref_code": "EDC-1001",
"claim_code": "62VUNF",
"trusted_url": "https://less-customer.loxbit.co.th/v/ZV726CJ6JB",
"expires_at": "...",
"notification_status": "queued" // มีเฉพาะเมื่อ delivery = sms
}
trusted_url → ทำ QR ให้ลูกค้าสแกน · claim_code → แสดงบนจอ EDC
· รูปแบบ URL คือ {public_base_url}/v/{token}
field ที่บังคับ (ขาดตัวใดตัวหนึ่ง = 400 VALIDATION_FAILED)
header.schema_version · header.request_id · header.timestamp ·
transaction.rrn · transaction.ref_code · transaction.currency ·
transaction.txn_status · transaction.amount (> 0) ·
transaction.txn_date_time · merchant.bank_code · merchant.mid ·
merchant.tid · delivery.delivery_method
merchant.service_type ไม่อยู่ในรายการบังคับของ server — ไม่ใช่เพราะไม่สำคัญ
แต่เพราะ D88 เลิกปฏิเสธมัน ให้ถือว่าบังคับจากฝั่งคุณเสมอ ดู § Service Type Codes
merchant.main_mid / main_tid ตรวจแยกต่างหาก ดู § เจ้าของสลิป
Enum ที่ server รับ (ค่าอื่น = 400)
| field | ค่าที่รับ |
|---|---|
txn_status | approved · voided · refunded · reversed · failed_sync |
delivery_method | qr · sms · email · qr_and_sms · qr_and_email |
payment_type | ว่าง · sale · cash_advance · preauth · preauth_completion · adjustment · verify |
ส่ง offline queue รวมทีเดียว ไม่เกิน 50 item · รองรับ partial success
// data
{
"total": 2,
"accepted": 2,
"rejected": 0,
"results": [
{ "rrn": "RRN001", "result": "accepted", "retryable": false,
"transaction_id": "...", "token": "...", "ref_code": "REF001",
"claim_code": "...", "trusted_url": "https://less-customer.loxbit.co.th/v/..." },
{ "rrn": "RRN002", "result": "rejected", "retryable": true,
"error_code": "SERVICE_UNAVAILABLE", "message": "..." }
]
}
ลบออกจาก local queue เฉพาะรายการที่ result = "accepted"
· retryable = true คือส่งซ้ำได้ · false คือ payload ผิด ส่งซ้ำก็ผิดเหมือนเดิม
Error Codes
ทุก code ในตารางนี้อ่านจาก source ของ core-api:2026-08-22c ตรง ๆ
| code | HTTP | แปลว่าอะไร และทำอะไรต่อ |
|---|---|---|
MISSING_AUTH_HEADER | 401 | header 4 ตัว (X-API-Key / X-Timestamp / X-Nonce / X-Signature) ไม่ครบ |
REPLAY_DETECTED | 401 | nonce ซ้ำ · นาฬิกาคลาด > 600 วินาที · หรือส่ง timestamp ผิดรูปแบบ (ต้องเป็น RFC3339 ไม่ใช่ epoch) SDK แก้ skew แล้ว retry ให้ 1 ครั้งเอง |
INVALID_SIGNATURE | 401 | HMAC ไม่ตรง — เช็ค secret, body ที่เซ็นต้องเป็น byte เดียวกับที่ส่ง, PATH ห้ามรวม query string |
INVALID_API_KEY | 401 | serial ไม่รู้จัก หรือ serial/secret ไม่เข้าคู่กัน |
DEVICE_NOT_FOUND | 404 | ตอน provision: serial พิมพ์ผิด ยิงผิด environment หรือ ทีม LESS ยังไม่ได้ seed serial นี้ — ไม่ใช่ bug ฝั่งคุณ |
INVALID_BOOTSTRAP_CODE | 401 | code หมดอายุ (48 ชม.) หรือถูกใช้ไปแล้ว — ขอใหม่ได้ ไม่ต้องแก้อะไรบนเครื่อง |
DEVICE_ALREADY_PROVISIONED | 409 | เครื่องนี้ redeem ไปแล้ว → ใช้ secret ที่เก็บไว้ · ถ้า secret หายต้องแจ้งทีม LESS reset |
แยกใหม่MISSING_MERCHANT_OWNER | 400 | ขาด main_mid/main_tid หรือส่งมาแค่ตัวเดียว หรือเครื่องรับหลาย merchant จนเดาไม่ได้ ดู § เจ้าของสลิป |
MERCHANT_REVOKED | 403 | ส่ง main_mid/main_tid ที่ไม่มี registration ที่ active — ครอบทั้งกรณี "อ้างร้านที่ไม่ได้ลงทะเบียนไว้" และ "registration ถูก revoke" (เดิมกรณีแรกคือ MERCHANT_MISMATCH ซึ่งเลิกใช้แล้ว) |
TERMINAL_NOT_FOUND | 403 | ไม่มี terminal ที่ผูกกับ tid นี้ในทั้งสอง registry และไม่ได้ส่ง service_type มา → เช็ค service_type ก่อนอย่างอื่น |
TERMINAL_INACTIVE | 403 | ความหมายแคบลงแล้ว: tid นั้นมีอยู่ใน registry แบบเก่า แต่ไม่ใช่ของเครื่องคุณ หรือ status ไม่ใช่ active |
TERMINAL_NOT_FOUND | 404 | บน GET /terminals/{tid}/sync-status — ข้อจำกัดที่ทราบแล้ว ไม่ใช่ provision ล้มเหลว ดู Step 06 |
VALIDATION_FAILED | 400 | field บังคับขาด หรือ enum ผิด — เทียบกับรายการใน § Endpoints |
INVALID_PAYLOAD | 400 | JSON ผิด หรือ มี field เกิน (DisallowUnknownFields) |
FULL_PAN_REJECTED | 400 | ส่ง PAN ที่ยังไม่ mask — mask ก่อนเข้า builder |
DCC_CONSENT_REQUIRED | 400 | block dcc ขาด foreign_currency / exchange_rate > 0 / customer_consent: true |
DUPLICATE_CONFLICT | 409 | rrn เดิมแต่ payload ต่างจากเดิม — ส่งซ้ำต้องเหมือนเดิมทุก byte |
BATCH_TOO_LARGE | 413 | เกิน 50 item ต่อ batch |
PAYLOAD_TOO_LARGE | 413 | body ใหญ่เกินลิมิต |
ใหม่INVALID_EXTENSION_NAMESPACE | 400 | ชื่อ namespace ใน extensions ไม่ตรง ^[a-z][a-z0-9_]{1,31}$ |
ใหม่EXTENSION_TOO_LARGE | 413 | namespace เดียวเกิน 4KB หรือรวมกันเกิน 16KB |
SERVICE_UNAVAILABLE | 503 | backend ล่มชั่วคราว — retry แบบ exponential backoff (retryable = true) |
INTERNAL_ERROR | 500 | แจ้งทีม LESS พร้อม request_id |
MERCHANT_MISMATCH — ยุบรวมเข้า MERCHANT_REVOKED constant ค้างไว้เพื่อ server รุ่นเก่าเท่านั้น
อย่าเขียน logic ใหม่ที่พึ่ง code นี้
Delivery & PDPA Consent
| method | field ที่บังคับ | เงื่อนไข |
|---|---|---|
qr | ไม่มี | ทำ QR จาก trusted_url ขึ้นจอเครื่อง — ใช้ตัวนี้สำหรับ UAT รอบนี้ |
sms | mobile_number + consent_sms: true + consent_timestamp | ครบ 3 ตัว ไม่งั้น 400 — server บังคับตาม PDPA ที่ระดับ code ก่อนส่ง SMS |
email | email_address | ตรวจรูปแบบด้วย net/mail.ParseAddress (RFC 5322) — "@", "a@" ไม่ผ่าน |
qr_and_sms | เหมือน sms | ส่งทั้งสองทาง |
qr_and_email | เหมือน email | ส่งทั้งสองทาง |
email ยังส่งออกจริงไม่ได้deliveryEmail() มีให้เรียก แต่ notification-service
ยังไม่มี SMTP/SendGrid connector — request ผ่าน แต่อีเมลไม่ถูกส่ง
รอบ UAT นี้ให้ใช้ qr
consent_sms /
consent_timestamp ถูกตรวจใน server.go ก่อนส่ง SMS ทุกครั้ง
ค่านี้ต้องมาจากการที่ลูกค้ากดยินยอมจริงบนจอ EDC ไม่ใช่ hardcode true
Extensions — ส่งข้อมูลที่ LESS ยังไม่รู้จัก
ปัญหาเดิม: DisallowUnknownFields ทำให้ field ที่ core-api ยังไม่ model ไว้กลายเป็น
400 INVALID_PAYLOAD — ฝั่ง EDC จึงส่งข้อมูลใหม่ไม่ได้เลยจนกว่าเราจะ deploy
extensions เปิดทางให้ส่งมาก่อนได้
"extensions": {
"welfare": { "entitlement_id": "...", "balance_before": 500.00 },
"loyalty": { "points_earned": 25 }
}
| กฎ | รายละเอียด |
|---|---|
| ชื่อ namespace | ต้องตรง ^[a-z][a-z0-9_]{1,31}$ — ตัวพิมพ์เล็กเท่านั้น (Loyalty กับ loyalty จะกลายเป็นสองก้อนที่ไม่มีใคร reconcile) |
| ขนาด | ไม่เกิน 4KB ต่อ namespace และ 16KB รวม |
| เนื้อข้างใน | ไม่ถูกตรวจ — จะตรวจได้ต้องมี schema ที่ลงทะเบียนไว้ก่อน ซึ่งเป็นจุดประสงค์ของกลไกนี้ |
| ปลายทาง | เก็บใน quarantine แยกจากตัวสลิป · ไม่ถูก render บนสลิป · ไม่ออกใน export · TTL 30 วัน |
| การแจ้งเตือน | namespace ที่ไม่มี schema จะถูกเขียน log ให้คนมาตรวจ แล้วมาคุยกันว่าจะลงทะเบียนหรือให้หยุดส่ง |
extensionsSlipRequestBuilder ยังไม่เปิด .extensions() — ถ้าจำเป็นต้องใช้รอบนี้
แจ้งมา จะเพิ่มให้ อย่าประกอบ JSON เองเพื่อเลี่ยง builder เพราะจะเสีย validation ที่กันสลิปผิดอยู่
service ไหนควรส่ง block อะไร
ทุก service_type ประกาศไว้ว่าสลิปของมันควรมี block อะไร
ค่านี้ถูกตรวจและรายงาน แต่ไม่เคยบังคับ — block ที่ขาดคือปัญหา payload
การปฏิเสธสลิปเพราะเรื่องนี้คือการเอาปัญหาไปวางไว้หน้าลูกค้าที่เคาน์เตอร์ ซึ่งเป็นความผิดที่ D88 แก้
| service_type | block ที่ประกาศไว้ |
|---|---|
FLEXI · PROUD | installment |
WELFARE1 · WELFARE2 · WELFARE3 · HCARE | welfare |
BMTA · BMTASRT | transit |
FOREVER | loyalty |
QR | qr_biller |
SDK Source & AAR
Build release AAR
cd terminal-sdk-android
./gradlew assembleRelease
# ผลลัพธ์: ./build/outputs/aar/terminal-sdk-android-release.aar
โครงสร้าง package
com.loxbit.less.sdk/
├── LESSClient.kt — client หลัก (VERSION = "2.3.0")
├── LESSSecurityInterceptor.kt — HMAC v1.2 + clock-skew retry
├── LESSException.kt — exception ที่ห่อ ApiError
├── SlipRequestBuilder.kt — builder ของ POST /api/v1/slips
├── BatchRequestBuilder.kt — builder ของ POST /api/v1/sync/batch
├── SlipCreateResponse.kt — response model ของสลิป
├── SyncBatchResponse.kt — response model ของ batch
├── TerminalSyncStatus.kt — response model ของ sync status
├── ApiError.kt — error code + predicate (isMerchantRevoked() ฯลฯ)
└── ProvisionResult.kt — ผลลัพธ์ของ provision
request_id ที่ส่งไปมาด้วยทุกครั้ง — ฝั่งเรา trace จากค่านี้ได้ตรง ๆ
ถ้าเป็น 403 หรือ 400 ให้แนบ block merchant ที่ส่งไปมาด้วย
(ไม่ต้องแนบ secret หรือ signature)