# Hướng dẫn chuyển đổi Legacy → v1

## Tổng quan

API v1 thay thế các route legacy `/create_order`, `/capture_order`, v.v. bằng chữ ký
Ed25519, quản lý key theo từng đối tác, và bảo mật được cải thiện. Hướng dẫn này
giúp bạn chuyển đổi từ SDK legacy sang v1.

## Danh sách host

| Mục đích | Host |
|---|---|
| Shield API live mặc định (production) | `live.vkl-shield.com` |
| Shield API sandbox mặc định (testing) | `sandbox.vkl-shield.com` |
| Shield host riêng (nếu được cấp cho tài khoản đối tác của bạn) | do đội vận hành shield cung cấp — dùng host này ở mọi nơi hướng dẫn này ghi host mặc định (script `src`, `selectBase`, các lời gọi API) |
| JWKS (luôn dùng — bất kể request được phục vụ bởi shield host nào) | `keys.vkl-shield.com/v1/keys` (live) / `keys.vkl-shield.com/sandbox/v1/keys` (sandbox) |

## 1. Cập nhật URL script

Thay SDK legacy bằng v1:

### PayPal

```html
<!-- BEFORE (legacy) -->
<script src="https://live.vkl-shield.com/assets/paypal-shield-sdk.js"></script>

<!-- AFTER (v1) -->
<script src="https://live.vkl-shield.com/assets/v1/paypal-shield-sdk.js"></script>
```

### Stripe

```html
<!-- BEFORE (legacy) -->
<script src="https://live.vkl-shield.com/assets/stripe-shield-sdk.js"></script>

<!-- AFTER (v1) -->
<script src="https://live.vkl-shield.com/assets/v1/stripe-shield-sdk.js"></script>
```

> Các ví dụ trên dùng host live mặc định. Dùng `sandbox.vkl-shield.com` khi test, hoặc shield host riêng nếu tài khoản đối tác của bạn được cấp một host như vậy — xem [Danh sách host](#danh-sch-host) ở trên.

## 2. Cập nhật định dạng shield key

Key legacy: `chuỗi đã mã hóa` (không có tiền tố)
Key v1: `shpk_test_*` (sandbox) / `shpk_live_*` (live)

Yêu cầu key v1 mới từ đội vận hành PayPal Shields. Partner ID phải khớp `[a-z0-9]{2,32}` (ví dụ: `acme`, `mystore`, không phải `test_mystore`).

## 3. Cập nhật hình dạng payload callback

### PayPal: tham số `onCompleted`

**Legacy:**
```javascript
onCompleted: function (capture, orderId) {
  // capture = {id, purchase_units: [...]}
  // Chữ ký legacy: không có bằng chứng; store tự tin tưởng vào nội dung body
}
```

**v1:**
```javascript
onCompleted: function (envelope, orderId) {
  // envelope = {
  //   error: false,
  //   data: {id, purchase_units: [...]},
  //   signed_payload: "{...}",
  //   sig: "base64url_sig",
  //   kid: "shsig_xxxxxxxxxxxxxxxxxxxxxxxx",
  //   alg: "Ed25519",
  //   v: 1
  // }

  // PHẢI xác minh sig trước khi dùng data
}
```

### Stripe: tham số `onCompleted`

**Legacy:**
```javascript
onCompleted: function (result, orderId) {
  // result = {
  //   signed_fields: {...},
  //   signature: "hex_signature",
  //   contact: {...}
  // }
  // Legacy: xác minh bằng HMAC
}
```

**v1:**
```javascript
onCompleted: function (envelope, orderId) {
  // envelope = {
  //   error: false,
  //   data: {...signed_fields},
  //   signed_payload: "{...}",
  //   sig: "base64url_sig",
  //   kid: "shsig_xxxxxxxxxxxxxxxxxxxxxxxx",
  //   alg: "Ed25519",
  //   v: 1,
  //   contact: {...}
  // }

  // PHẢI xác minh sig trước khi dùng data
}
```

## 4. Thay thế xác minh bằng chứng (proof)

### Xác minh legacy (HMAC)

```javascript
// Node.js legacy (nếu vẫn chạy endpoint legacy)
const crypto = require('crypto');
const canonicalJson = require('canonical-json');
const signature = crypto.createHmac('sha256', SIGNATURE_PRIVATE_KEY)
  .update(canonicalJson(result.signed_fields))
  .digest('base64');
const valid = signature === result.signature;
```

(SIGNATURE_PRIVATE_KEY là secret dùng chung ở phía server do đội vận hành shield cấp. Xem [test vector HMAC legacy](../../../tests/fixtures/legacy-hmac-test-vector-stripe.json) để xác minh byte-chính-xác (byte-for-byte).)

### Xác minh v1 (Ed25519/JWKS)

Xem `docs/api/examples/verify-signed-payload.mjs` để có tham chiếu Node.js đầy đủ (cũng có ví dụ PHP, Python).

**Endpoint JWKS chuẩn:**
- Live: `https://keys.vkl-shield.com/v1/keys`
- Sandbox: `https://keys.vkl-shield.com/sandbox/v1/keys`

**Thứ tự xác minh (quan trọng — xác minh chữ ký TRƯỚC khi parse):**

1. Xác minh `envelope.sig` dựa trên các byte UTF-8 chính xác của `envelope.signed_payload` bằng JWKS URL chuẩn (không bao giờ lấy từ envelope)
2. Chỉ sau khi chữ ký hợp lệ, mới parse: `payload = JSON.parse(envelope.signed_payload)`
3. Khẳng định (assert) mọi field từ payload đã parse (cả PayPal & Stripe):
   - `typ` = `'paypal_capture'` (PayPal) hoặc `'stripe_complete'` (Stripe), `v === 1`
   - `status === 'COMPLETED'` (PayPal) hoặc `'succeeded'` (Stripe)
   - `env === 'live'` (trên live) hoặc `'sandbox'` (trên sandbox)
   - `partner_id === your_partner_id`
   - `key_id ∈ your_whitelisted_keys`
   - `invoice_id === your_order.invoice_id`
   - PayPal: `amount === order_total` (chuỗi decimal), `currency === order_currency`; `capture_id` chưa từng gặp trước đó
   - Stripe: `amount_received === order_total_cents` (số nguyên), chỉ USD; `id` (PI id) chưa từng gặp trước đó
   - `iat` nằm trong khoảng [now - 600s, now + 300s]
   - Tùy chọn: `reference_id` khớp với reference đơn hàng của bạn nếu bạn đã cung cấp

## 5. Lịch trình xoay (rotation) key

**Hạn dùng key đối tác:** 180 ngày sau khi cấp

**Quy tắc xoay key:** Trước khi hết hạn, thêm key ID mới vào allowlist trong config shield (không cần deploy), chuyển SDK sang key mới, chờ hết TTL cache trang + 10 phút, rồi xóa key ID cũ

**Ví dụ mốc thời gian:**
- Key được cấp: 2026-01-01
- Hết hạn: 2026-07-01
- **Bắt đầu xoay key: 2026-06-20** (11 ngày trước khi hết hạn) — thêm key mới vào config
- **Chuyển key SDK: 2026-06-21** (deploy với key mới, xóa key cũ khỏi SDK)
- **Xóa khỏi config: 2026-06-25** (sau khi hết TTL cache trang tối đa + 10 phút dự phòng)

**Nếu bạn bỏ lỡ việc xoay key:** Sau khi hết hạn, mọi lời gọi select trả về 401 SHIELD_KEY_EXPIRED (KHÔNG bị charge)

## 6. Không bao giờ nạp cả hai phiên bản trên cùng một trang

```html
<!-- SAI: cả legacy và v1 -->
<script src="https://live.vkl-shield.com/assets/paypal-shield-sdk.js"></script>
<script src="https://live.vkl-shield.com/assets/v1/paypal-shield-sdk.js"></script>
<!-- Cả hai đều dùng window.PaypalShield; hành vi không xác định -->
```

Chỉ chọn một phiên bản cho mỗi trang. Để chuyển đổi:

1. **Trên staging:** đổi URL + test xác minh dựa trên JWKS sandbox
2. **Chờ 7 ngày** để TTL cache trang hết hiệu lực
3. **Trên live:** cập nhật URL script SDK trong HTML
4. **Theo dõi** tỉ lệ lỗi trong 1 tuần
5. **Yêu cầu vô hiệu hóa** key legacy từ đội vận hành sau khi đã tự tin

## 7. Thay đổi trong xử lý lỗi

### CAPTURED_PROOF_UNAVAILABLE (502)

**Quan trọng: Lỗi này có nghĩa là người mua ĐÃ bị trừ tiền trên PayPal.**

```javascript
onFailed: function (error) {
  if (error.issue === 'CAPTURED_PROOF_UNAVAILABLE' || error.code === 'CAPTURED_PROOF_UNAVAILABLE') {
    // KHÔNG hiển thị: "Payment failed, please retry"
    // KHÔNG retry capture — người mua đã bị trừ tiền

    // ĐÚNG: hiển thị "Đã nhận thanh toán, đang xác nhận..."
    showModal('Đã nhận thanh toán! Chúng tôi đang xác nhận đơn hàng của bạn.');

    // Người mua sẽ nhận email xác nhận từ PayPal
    // Chờ webhook AIO transaction.completed (khớp theo settle_ref)
    // Hỏi đội vận hành shield về notification feed của integration của bạn
  }
}
```

### Chính sách retry — PayPal SDK vs. Stripe SDK

**PayPal SDK (v1):** Không tự động retry. Lỗi trước-capture của shield nghĩa là KHÔNG bị charge; lỗi capture không có field `issue` = trạng thái charge KHÔNG XÁC ĐỊNH.

| Tình huống lỗi | Ý nghĩa | Hành động của bạn |
|---|---|---|
| Lỗi trước-capture (`SHIELD_KEY_INVALID`, `POOL_MISMATCH`, `SELECT_TIMEOUT`, v.v.) | Shield không truy cập được hoặc lỗi key; người mua KHÔNG bị charge | Có thể hiển thị "Thử lại" sau khi sửa |
| Lỗi capture có field `issue` (`SIGNING_UNAVAILABLE`, `SHIELD_REGISTRY_UNAVAILABLE`) | Lỗi tạm thời của shield; người mua KHÔNG bị charge | Hiển thị "Thử lại"; PayPal SDK không tự retry |
| Capture trả về 502 `CAPTURED_PROOF_UNAVAILABLE` | Người mua BỊ CHARGE nhưng tạo bằng chứng thất bại | Hiển thị "Payment received"; không bao giờ retry; đối soát qua feed |
| Capture trả về 500 với object lỗi PayPal (ví dụ: `INSTRUMENT_DECLINED`) | Người mua bị từ chối; KHÔNG bị charge | Hiển thị lý do từ chối cho người mua (`details[0].issue`) |
| Endpoint capture không truy cập được (network/proxy/timeout/non-JSON) | Trạng thái charge KHÔNG XÁC ĐỊNH; không có field `issue` nào được gửi | Đối soát qua feed đã cấu hình; không bao giờ tự retry |

**Stripe SDK (v1):** Chỉ route `/complete` có tự động retry (tiền đã bị trừ rồi); create/select không được retry.

## Đối soát & khôi phục CAPTURED_PROOF_UNAVAILABLE

> **Mô hình xác nhận — bằng chứng (proof) là chính, AIO là fallback dự phòng.**
> 1. **Chính (đồng bộ):** xác minh bằng chứng Ed25519 ở phía server ngay sau callback (trang này) → **hoàn tất đơn hàng ngay** và hiển thị trang cảm ơn. Không chờ bất kỳ webhook nào.
> 2. **Fallback / đối soát:** **webhook AIO `transaction.completed`** (ký bằng HMAC `X-Signature: t=…,v1=…`, khớp theo `settle_ref` = `custom_id` của bạn) hoàn tất các đơn hàng mà bằng chứng chưa từng đến — PayPal `CAPTURED_PROOF_UNAVAILABLE`, Stripe `charged_but_unconfirmed`, hoặc người mua đã đóng tab — và có thể dùng để đối soát sau. Nó phải idempotent (lặp lại an toàn) với bước 1 (một đơn hàng đã hoàn tất từ bằng chứng thì webhook này là no-op).
> Không bao giờ charge người mua lần nữa khi đơn hàng đang chờ fallback.


Khi không thể tạo bằng chứng capture (PayPal `CAPTURED_PROOF_UNAVAILABLE`, Stripe `charged_but_unconfirmed`) thì người mua **đã bị charge**.

Đối soát qua **webhook AIO `transaction.completed`**. AIO nhận các webhook từ nhà cung cấp PayPal/Stripe cho các tài khoản merchant của shield và thông báo cho platform của bạn (ký HMAC, header `X-Signature: t=…,v1=…`; endpoint, secret và hợp đồng payload do đội vận hành shield/AIO cấp). Khớp với đơn hàng của bạn bằng **`settle_ref`** bạn đã gửi (PayPal `purchase_units[0].custom_id`; Stripe: cùng `custom_id` trong `getOrderData`, lưu dưới dạng metadata PaymentIntent `settle_ref`). Chỉ đơn hàng KHÔNG có bằng chứng đã xác minh mới chờ webhook này (hiển thị *đã nhận thanh toán, đang xác nhận*); không bao giờ yêu cầu người mua trả tiền lại.

- KHÔNG retry capture/complete và KHÔNG hiển thị "thanh toán thất bại".
- Luôn gửi `settle_ref` (`custom_id`) — nếu thiếu, webhook sẽ không thể khớp với đơn hàng của bạn.

## Rollback

Nếu bạn cần quay lại legacy:

1. Giữ cả hai SDK được phục vụ (legacy vẫn hoạt động)
2. Đổi URL script về lại legacy
3. Chờ 7 ngày để TTL cache trang hết hiệu lực
4. Theo dõi lỗi
5. Yêu cầu vô hiệu hóa key v1 từ đội vận hành sau khi đã tự tin

## Test trên sandbox

Test chuyển đổi v1 trên sandbox trước:

1. Yêu cầu key sandbox từ đội vận hành (định dạng: `shpk_test_*`)
2. Cập nhật HTML với URL script v1 + key sandbox
3. Triển khai xác minh dùng endpoint JWKS sandbox: `https://keys.vkl-shield.com/sandbox/v1/keys`
4. Chạy test end-to-end với key `shpk_test_*` (luồng đầy đủ create → capture → verify)
5. Test các tình huống lỗi: key hết hạn, partner ID sai, payload bị giả mạo
6. Khi đã tự tin trên sandbox, yêu cầu key live và test trên staging production

## Hỗ trợ

Nếu có câu hỏi hoặc vấn đề:
- Xem `docs/api/sdk/paypal.md` và `docs/api/sdk/stripe.md` để tham khảo SDK
- Xem lại mã lỗi trong các hướng dẫn SDK
- Liên hệ hỗ trợ đối tác PayPal Shields

