# GameZero API — Merchant Integration Guide (v1)

เอกสารสำหรับ merchant ที่ต้องการเชื่อมต่อกับ GameZero แบบ **Seamless Wallet** — เงินของผู้เล่นอยู่ในระบบของคุณตลอด ไม่มีการโอนเข้า/ออก ทุกครั้งที่ผู้เล่นเดิมพันหรือรับรางวัล ระบบเราจะเรียก endpoint กระเป๋าเงินของคุณแบบเรียลไทม์

> มีสองขาที่ต้องเข้าใจ: **(1) API ที่คุณเรียกเรา** ผ่าน gateway (สร้างผู้เล่น, เปิดเกม) และ **(2) Wallet callback ที่เราเรียกคุณ** (ตัดเงิน/จ่ายเงิน/คืนเงิน) คุณต้อง implement เพียง endpoint เดียวคือ wallet callback

> **ทิปสำหรับทีม dev:** ไฟล์นี้คู่กับหน้า docs บนเว็บ — แนบให้ AI พร้อมบอกภาษา/stack ของคุณ (เช่น PHP, Node, Go) เพื่อ generate โค้ด wallet / signature ได้เร็วขึ้น

---

## ภาพรวมการทำงาน

```
ผู้เล่นกดเล่นเกม
   │
   ├─ 1) คุณเรียก  POST /v1/launch          → ได้ game_url ส่งผู้เล่นเข้าเกม
   │
เกมเริ่มเดิน (ผู้เล่นแทง / ได้รางวัล / โดนยกเลิก)
   │
   └─ 2) เราเรียก  POST {your_wallet_url}    → action = bet | settle | cancel | balance
             คุณขยับยอดเงินแล้วตอบ balance กลับ
```

---

## การยืนยันตัวตน (HMAC Signature)

ทั้งสองขาใช้สูตร signature เดียวกัน ต่างกันแค่ key ที่ใช้:

```
signature = hex( HMAC_SHA256( secret, timestamp + "." + rawBody ) )
```

| ขา | secret ที่ใช้ | ใครตรวจ |
|---|---|---|
| คุณ → เรา | `API Secret` (ออกให้ในหน้า Admin) | gateway ของเรา |
| เรา → คุณ | `Wallet Secret` (ตั้งร่วมกันตอนเปิดบัญชี) | ระบบของคุณ |

ทุก request แนบ 3 header:

| Header | ค่า |
|---|---|
| `X-API-Key` | API key ของคุณ (เฉพาะขาที่คุณเรียกเรา) |
| `X-Timestamp` | Unix timestamp (วินาที) — คลาดเคลื่อนได้ไม่เกิน **5 นาที** ไม่งั้นถือว่า replay |
| `X-Signature` | ผลลัพธ์จากสูตรด้านบน |

**สำคัญ:** `rawBody` คือ bytes ดิบของ body ที่ส่งจริง — ต้อง sign กับ string เดียวกับที่ส่งเป๊ะ (อย่า re-marshal / อย่าจัดเรียง key ใหม่หลัง sign) สำหรับ GET ที่ไม่มี body ให้ใช้ body ว่าง (`""`)

---

## Environment & Base URL

| Environment | Base URL (gateway) |
|---|---|
| Staging | `https://api.gamezero-tech.com` (API key แบบ staging) |
| Production | `https://api.gamezero-tech.com` (API key แบบ production) |

environment ถูกกำหนดโดย API key ที่ใช้ ไม่ใช่ URL — key คนละชุดคนละ environment

---

## API ที่คุณเรียกเรา

### สร้างผู้เล่น

`POST /v1/players`

```json
{ "username": "member_test" }
```

Idempotent — เรียกซ้ำด้วย username เดิมได้ ระบบจะผูก player_prefix ของคุณให้เอง (เช่น `m001_member_test`) ก่อน launch ครั้งแรกต้องมีผู้เล่นก่อน

### รายการเกม

`GET /v1/games` — ตัวอย่างเกมต่อค่ายสำหรับหน้า lobby (สูงสุด 20 เกมต่อค่าย สุ่มคงที่ต่อวัน; ค่าย Live dealer / Sport book ส่งครบ)

```json
{
  "status": "ok",
  "providers": [
    {
      "provider": "evolution",
      "category": "Live dealer",
      "total": 1,
      "gamelist": [{"game_code": "1", "name": "Evolution Lobby"}]
    },
    {
      "provider": "pragmatic_slot",
      "category": "Slot",
      "total": 412,
      "gamelist": [
        {"game_code": "226:123", "name": "Sweet Bonanza"},
        {"game_code": "226:456", "name": "Gates of Olympus"}
      ]
    }
  ]
}
```

`GET /v1/games/{provider}` — รายการเกมทั้งหมดของค่ายนั้น รองรับ `?category=Slot` กรองหมวด

```json
{
  "status": "ok",
  "provider": "pragmatic_slot",
  "category": "Slot",
  "total": 412,
  "gamelist": [
    {"game_code": "226:123", "name": "Sweet Bonanza"}
  ]
}
```

ใช้ค่า `provider` + `game_code` จาก `gamelist` ส่งต่อไปที่ `/v1/launch` ตรงๆ (ไม่ต้อง hardcode) — รายชื่อค่ายจะเพิ่ม/ลดได้ตามที่เปิดให้คุณ

ฟิลด์ `category` ใช้ชุดหมวดมาตรฐานต่อไปนี้ สำหรับจัดกลุ่มเกมในล็อบบี้ของคุณ:

| category | ความหมาย |
|---|---|
| `Live dealer` | คาสิโนสด มีดีลเลอร์จริง (บาคาร่า/รูเล็ต/แบล็คแจ็ค ถ่ายทอดสด) |
| `Table game` | เกมโต๊ะ/ไพ่แบบ RNG ไม่มีดีลเลอร์จริง |
| `Slot` | สล็อต / เกม RNG |
| `fishing` | เกมยิงปลา |
| `Sport book` | กีฬา |
| `Virtual sport` | กีฬาจำลอง (Virtual) |
| `Lotto` | หวย |
| `E-Sport` | อีสปอร์ต |
| `Arcade` | เกมอาเขต / instant game |

### เปิดเกม (Launch)

`POST /v1/launch`

```json
{
  "username": "member_test",
  "provider": "allbet",
  "game_code": "0",
  "lang": "th",
  "return_url": "https://your-site.com/lobby"
}
```

Response:

```json
{
  "status": "ok",
  "game_url": "https://...",
  "session_token": "..."
}
```

(`game_url` = redirect ผู้เล่นไปที่นี่, `session_token` = เราผูก token นี้กับผู้เล่น ใช้ตอน callback)

### ดูยอดเงิน

`GET /v1/players/{username}/balance` — เราจะไปถามกระเป๋าเงินของคุณให้

### รายการเดินเงิน

- `GET /v1/transactions?from=&to=&provider=&type=&limit=`
- `GET /v1/players/{username}/statement`

---

## โปรโตคอล Wallet Callback (เราเรียกคุณ)

คุณเปิด endpoint 1 ตัว (เช่น `POST https://your-site.com/wallet`) แล้วแจ้ง URL นี้ให้เราตอนเปิดบัญชี ทุก request เป็น JSON โครงเดียวกัน:

```json
{
  "action": "bet",
  "tx_id": "6f1c...",
  "round_id": "512345678",
  "provider": "allbet",
  "username": "member_test",
  "game_code": "0",
  "amount": "10.50",
  "timestamp": 1720000000
}
```

| ฟิลด์ | ความหมาย |
|---|---|
| `action` | `balance` \| `bet` \| `settle` \| `cancel` |
| `tx_id` | unique ต่อ 1 การขยับเงิน ใช้กันซ้ำ |
| `round_id` | รหัสรอบเกม (อ้างอิงกลุ่ม bet/settle/cancel เดียวกัน) |
| `amount` | เป็นบวกเสมอ (ทิศทางดูจาก action) |

ตอบกลับ (HTTP 200 เสมอ แม้ error ทางธุรกิจ):

```json
// สำเร็จ
{ "status": "ok", "balance_before": "1000.00", "balance": "989.50" }

// ซ้ำ (tx_id เดิม) — ต้องคืน balance เดิม ห้ามขยับเงิน
{ "status": "ok", "balance": "989.50", "duplicate": true }

// ผิดพลาด
{ "status": "error", "code": "insufficient_funds", "message": "not enough balance" }
```

### action = balance

ถามยอดปัจจุบันของผู้เล่น ไม่ขยับเงิน ตอบ `{"status":"ok","balance":"..."}`

### action = bet

**ตัดเงิน** เท่ากับ `amount` ถ้าเงินไม่พอ ตอบ `insufficient_funds` ห้ามตัดติดลบ

### action = settle

**จ่ายรางวัล** โดยบวก `amount` เข้ากระเป๋า (amount = ยอดที่ต้องจ่าย ไม่ใช่กำไรสุทธิ) ค่ายที่แยก bet/settle จะยิง 2 ครั้งใน round เดียวกัน

### action = cancel

ยกเลิก/คืนเงินของ **bet เดิม** โดยบวก `amount` (เท่ายอด bet ที่เคยตัด) กลับเข้ากระเป๋า

**ข้อผิดพลาดที่พบบ่อยกับการ cancel:**

- **ต้องอ้างอิงให้ตรง bet เดิม** — เราส่ง `round_id` ชุดเดียวกับ bet ที่จะยกเลิกมาให้ ใช้จับคู่ในระบบคุณ ห้ามคืนมั่ว
- **cancel เป็น tx_id ใหม่** (คนละตัวกับ bet) แต่ผูกกับ round เดิม — ถ้าคุณ dedup ผิดชั้น อาจคืนซ้ำหรือไม่คืนเลย
- **cancel ต้อง idempotent เช่นกัน** — ถ้าเรายิง cancel ซ้ำ (เพราะ timeout แล้ว retry) ต้องคืน balance เดิม ไม่บวกเงินซ้ำ
- **ถ้าไม่เคยเห็น bet นั้น** (bet มาไม่ถึง/หาย) ให้ตอบสำเร็จแบบ no-op หรือ `round_not_found` ตามนโยบายคุณ — อย่าบวกเงินลอย

ฝั่งเราเช็คก่อนอยู่แล้วว่า bet เดิมต้องมีจริง (ผ่าน dedup key `provider_id + provider_tx_id + type`) และถ้า cancel ซ้ำเราจะส่ง `duplicate` กลับให้ค่าย แต่**ฝั่งคุณก็ต้องกันซ้ำด้วย**เพราะเป็นด่านสุดท้ายของเงินจริง

---

## Idempotency (กันเงินซ้ำ)

กุญแจคือ `tx_id`: **ทุก action ที่ขยับเงินมี tx_id ไม่ซ้ำ**

```
ได้รับ wallet request:
  ถ้า tx_id เคยเห็นแล้ว  → คืน balance ที่บันทึกไว้ตอนนั้น + duplicate:true  (ห้ามขยับเงิน)
  ถ้ายังไม่เคยเห็น        → ขยับเงิน, บันทึก (tx_id → balance หลังทำ) ในทรานแซกชันเดียวกัน
```

การบันทึก tx_id กับการขยับ balance ต้องอยู่ใน **DB transaction เดียวกัน** — ไม่งั้นเครื่องดับกลางคันจะเงินเพี้ยน

---

## Race condition & Queue

บางค่ายยิง callback ของผู้เล่นคนเดียวกัน **พร้อมกันหลายเส้น** หรือส่ง bet กับ settle ไล่กันเร็วมากจนมาถึงคุณเกือบพร้อมกัน ถ้าคุณอ่าน balance → คำนวณ → เขียนกลับ แบบไม่ล็อก จะเกิด **lost update** (เงินหาย/เกิน)

### กติกาบังคับฝั่ง merchant

- **ต้อง serialize ต่อผู้เล่น** — คำสั่งที่ขยับเงินของ `username` เดียวกันต้องทำทีละอันเท่านั้น

เลือกวิธีใดวิธีหนึ่ง:

| วิธี | ทำยังไง | เหมาะกับ |
|---|---|---|
| Row lock ใน DB | `SELECT ... FOR UPDATE` ที่แถว wallet ของ user ก่อนคำนวณ แล้ว commit | ง่ายสุด ทำได้เลยด้วย SQL |
| Atomic update | `UPDATE wallet SET bal = bal - :amt WHERE user=:u AND bal >= :amt` เช็คแถวที่กระทบ | bet เดี่ยวๆ เร็ว |
| Per-user queue | เข้าคิวตาม hash(username) → worker เดียวต่อ user | ค่ายที่ยิงถี่/เป็นชุด |

**ค่ายกลุ่มที่ต้องใช้ queue เรียงลำดับ (เช่น SA Gaming):** จะส่งผล **เป็นชุด/เบิ้ลพร้อมกัน** และบางครั้ง settle มาถึงก่อน bet ที่คู่กัน ถ้าประมวลผลสลับลำดับจะได้ยอดผิด ให้:

- เข้าคิว **ต่อผู้เล่น** (partition by username) ประมวลผลเรียงตามลำดับที่รับเข้ามา — worker 1 ตัวต่อ 1 user ห้ามขนานภายใน user เดียว
- ถ้า settle อ้าง round ที่ยัง**ไม่มี bet** ให้ **พักไว้แล้ว retry** (หรือ hold ในคิวสั้นๆ) อย่าเพิ่งปฏิเสธทันที เพราะ bet อาจตามมาในเสี้ยววินาที
- ยังต้อง idempotent ด้วย tx_id เสมอ — queue ช่วยเรื่องลำดับ แต่ retry ยังทำให้ของซ้ำได้

ฝั่งเรากันซ้ำระดับ platform ให้ชั้นหนึ่งด้วย dedup key `(provider_id, provider_tx_id, type)` และ settle/cancel ที่ยิงไม่ถึงคุณจะถูกเรา retry แบบ exponential backoff อัตโนมัติ — แต่**ด่านเงินจริงคือกระเป๋าคุณ** จึงต้องกันซ้ำ+เรียงลำดับที่ฝั่งคุณด้วยเสมอ

---

## กติกา Cancel แยกตามลักษณะค่าย

| รูปแบบค่าย | พฤติกรรม cancel ที่คุณต้องรองรับ |
|---|---|
| bet/settle แยกจังหวะ (เช่น AllBet) | cancel อ้าง bet ด้วย `round_id` เดิม → คืนยอด bet. ถ้า settle ไปแล้วอาจมี cancel ตามมาเพื่อ reverse ทั้งรอบ |
| bet+settle รวมจังหวะเดียว | cancel = reverse ทั้งก้อน (คืน bet, หัก payout ที่จ่ายไป) — จับคู่ด้วย round_id |
| กีฬา/โพลผลทีหลัง (เช่น M8) | ไม่มี settle แบบ push — เราดึงผลเองแล้วส่ง settle/cancel มาให้ทีหลัง อาจห่างจาก bet เป็นชั่วโมง คุณต้องหา bet เดิมด้วย round_id ให้เจอแม้เวลาผ่านไปนาน |

สรุปหลักการเดียวที่ใช้ได้ทุกค่าย: **จับคู่ด้วย `round_id`, กันซ้ำด้วย `tx_id`, ขยับเงินใต้ล็อกต่อ user**

---

## Error codes ที่คุณควรตอบกลับ

| code | เมื่อไหร่ |
|---|---|
| `insufficient_funds` | ยอดไม่พอตัด bet |
| `invalid_parameter` | amount/field ผิดรูปแบบ |
| `round_not_found` | cancel/settle อ้าง round ที่ไม่มี |
| `bad_signature` | signature ไม่ผ่าน |

---

## Checklist ก่อนขึ้นจริง

```
[ ] ตรวจ signature ทุก wallet request (secret + skew 5 นาที)
[ ] balance / bet / settle / cancel ครบ และตอบ HTTP 200 เสมอ
[ ] tx_id idempotent: ซ้ำ → คืน balance เดิม + duplicate:true (บันทึก tx_id ใน tx เดียวกับการขยับเงิน)
[ ] cancel: จับคู่ round_id เดิม, idempotent, ไม่คืนเงินลอยเมื่อไม่พบ bet
[ ] serialize ต่อ username (row lock / atomic update / per-user queue)
[ ] ค่ายกลุ่ม batch (SA ฯลฯ): queue เรียงลำดับต่อ user + hold settle ที่มาก่อน bet
[ ] ทดสอบครบ: bet → settle, bet → cancel, ยิงซ้ำทุก action, ยิงพร้อมกัน 2 เส้น
```

---

*เอกสารนี้อ้างอิงโปรโตคอลจริงของระบบ GameZero v1 — มีคำถามติดต่อทีม GameZero*
