# SCS-009　分期服務（installment-service）程式設計規格

| 項目 | 內容 |
|---|---|
| 文件編號 | SCS-009 |
| 版本 | **1.6**（`CR-043 §2.4`：`InstallmentPeriod.principalAmount` **補契約與回應**——原僅存於領域模型（回應 `PeriodView` 未暴露、契約未宣告）；現補契約 schema ＋ `PeriodView` ＋ `InstallmentViewAssembler`；契約重生 0 錯誤；`verify_contract_dto` **不符 0 處**）。前版 **1.5**（§9.4 WebUI 端到端實測重跑：真實瀏覽器＋Keycloak 登入，7 情境全數成立，含雙軸下拉與「未記錄」；原 1.4「CR-008：通路／業務型態**雙軸拆分**完成，並經真實 Oracle 端到端實測） |
| 狀態 | **已核定** |
| 對應需求 | `ISS-2.7`（信用卡分期） |
| 服務埠號 | **8089**（`CSP-006 §4`；領域服務 8081–8089） |
| 套件根 | `com.beam.cardcore.inst` |
| 權威 schema | `CC_INST`（Oracle 26ai Free／PDB `FREEPDB1`） |
| 關聯文件 | `SDD-001 §3.9`（**1.5**）、`SDD-003 §12`、`SDD-005 §6`（**1.2**）、`SDD-006 §3.4`、`SDD-007 §5.4.8`（**1.2**）、`GOV-005`、**`CR-002`**、**`CR-008`** |

> **本版差異**：1.0 為「規格先行」版（埠號與類別名為規劃值，且 §1.1 之章節引用有誤）。
> 1.1 依實作結果與**真實 Oracle 實測**回填，並修正章節引用。
> 1.2 依 **CR-002** 修正契約值域（`installmentType` 原列舉 6 值實測全部不可用），
> 補本地種子檔，並更正 §10.1 之來源計數錯誤。
> 1.3 `SDD-005` **1.2** 已完成 §6 狀態機更正（移除無法持久化之 `PENDING`／`REJECTED`、
> `SETTLED`／`EARLY_SETTLED` 更名），**值域四方（BRD／ER-DDL／狀態機／契約）全部對齊**；
> 本節 §10.1 隨之改為「已全部對齊」。
> **1.4 依 `CR-008`（BEAM PMO 2026-09-20 核定）完成雙軸拆分**：
> `installment_type` → **`channel_type`**（來源通路，值域擴充為 **7 值**），
> 新增 **`business_type`**（業務型態，BRD §5.6 之 3 值，**允許 NULL**）。
> 領域列舉 `InstallmentType` → `ChannelType` ＋ 新增 `BusinessType`；契約、種子、
> 前端同步；並以 `V3__installment_channel_business_type.sql` 遷移。
> §10.2 之建模缺陷與 §10.7 #16「業務型態無處儲存」**皆已解除**。

---

## 1. 對應需求

| FR 編號 | 需求名稱 | 來源 |
|---|---|---|
| `ISS-2.7` | 信用卡分期 | `BRD-002` |

### 1.1 上層設計依據（本規格**不重複**這些圖，僅引用）

| 圖種 | 文件 | 本服務相關 |
|---|---|---|
| **ER-Diagram** | `SDD-001_領域模型與ER-Diagram` | **`§3.9 installment`**——`INSTALLMENT_PLANS`／`PERIODS`／`PRODUCTS` |
| **DFD** | `SDD-002_DFD與流程分解` | 分期處理流 |
| **Sequence** | `SDD-003_Sequence-Diagrams` | **`§12 分期建立與佔用`** |
| **狀態機** | `SDD-005_狀態機設計` | ✅ **`§6 分期計畫狀態機`**（8 條轉換，7 個狀態） |
| **API Contract** | `SDD-006` ＋ `contracts/openapi/installment-service.yaml` | 本服務 3 支端點 |

> ⚠️ **1.0 之章節引用有誤，已更正**：原引用 `SDD-001 §5.5`（**SDD-001 只有 §3.x，無 §5.5**）
> 與 `SDD-003 §7`（實為「繳款即時恢復額度」，非分期）。此為**懸空引用**，
> 與 rewards 域（`SDD-001 §5.7`）同類，屬章節重編後未同步之歷史遺留。

---

## 2. 服務職責與邊界

**承作**：
- 分期計畫（`INSTALLMENT_PLANS`：商戶／銀行／循環／帳單分期）
- 分期期數攤還（`INSTALLMENT_PERIODS`：每期本金／手續費／到期日／剩餘本金）
- 分期產品參數（`INSTALLMENT_PRODUCTS`：期數上下限、費率、適用商戶）
- 分期結清（提前清償／違約處理）

**不做（屬他服務）**：
- 實際入帳（每期扣款）→ `posting-service`
- 費率試算 → `pricing-service`
- 產品主檔 → `product-service`

---

## 3. 領域模型（實作檔案為準）

| 類別 | 職責 | 不變式 |
|---|---|---|
| `InstallmentPlan`（聚合根） | 分期計畫；含攤還試算 | 各期本金加總**恰等於**原始金額；`originalAmount > 0`；`periodCount ∈ {3,6,12,24}`；僅 `ACTIVE` 可結清 |
| `InstallmentPeriod` | 單期攤還 | `periodNo ≥ 1`；`amount ≥ 0`；`remainingPrincipal` 遞減**至末期恰為 0**。★ 欄位：`periodNo`／`amount`／**`principalAmount`**／`feeAmount`／`dueDate`／`status`／`remainingPrincipal`（`CR-043 §2.4`：`principalAmount` 原僅存於本領域模型，契約漏宣告，本次**併補契約與回應**） |
| `ChannelType`（CR-008 由 `InstallmentType` 改名） | **來源通路**（誰提供，DDL **7 值**） | `MERCHANT`／`BANK`／`REVOLVING`／`BILL`／`ACQUIRER`／`NCCC`／`QRCODE`；**必填** |
| `BusinessType`（CR-008 新增） | **業務型態**（怎麼發起，`BRD-001 §5.6` 3 值） | `SINGLE`／`RECURRING`／`STATEMENT`；**可為 `null`**（當初未記錄，合法狀態） |
| `PlanStatus` | 計畫狀態（DDL 5 值） | `ACTIVE`／`COMPLETED`／`PRE_CLOSED`／`DEFAULTED`／`CANCELLED`；僅 `ACTIVE` 可結清 |
| `PeriodStatus` | 期數狀態（DDL 5 值） | `DUE`／`PAID`／`OVERDUE`／`WAIVED`／`REVERSED` |
| `SettlementType` | 結清類型（**契約列舉**） | `EARLY`／`DEFAULT`；**未定義值一律拒絕** |

> ⚠️ `PlanStatus` 之值域與 `SDD-005 §6` 狀態機、契約 `InstallmentView.status` **皆不同**，
> 三方不一致見 §10.1。

---

## 4. 程式結構（實作檔案為準）

| 套件／類別 | 職責 | 對應需求 |
|---|---|---|
| `domain/model/InstallmentPlan` `InstallmentPeriod` | 聚合根與期數 | `ISS-2.7` |
| `domain/model/ChannelType` `BusinessType` `PlanStatus` `PeriodStatus` `SettlementType` | 值物件／列舉 | — |
| `domain/port/InstallmentRepository` | 持久化埠 | — |
| `application/InstallmentService` | 成立／查詢／結清 | `ISS-2.7` |
| `application/InstallmentViewAssembler` | 檢視組裝 | — |
| `infrastructure/jdbc/JdbcInstallmentRepository` `OracleRawId` | JDBC 實作（含樂觀鎖） | — |
| `interfaces/rest/InstallmentController` | REST 端點（3 支） | `ISS-2.7` |

---

## 5. 資料模型（約束為 Oracle 實測值）

### 5.1 V1 基線（3 表，`V1__baseline.sql`）

| 表 | 關鍵欄位 | 約束（實測） |
|---|---|---|
| `INSTALLMENT_PLANS` | `PLAN_NO` VARCHAR2(24) **UNIQUE**；`ORIGINAL_AMOUNT` NUMBER(19)；`PERIOD_COUNT` NUMBER(5)；`PER_PERIOD_AMOUNT`／`FIRST_PERIOD_AMOUNT`／`LAST_PERIOD_AMOUNT`；`FEE_RATE`／`ANNUAL_RATE` NUMBER(9,6)；`STATUS` DEFAULT `'ACTIVE'`；`PAID_PERIODS`／`REMAINING_PERIODS`；`PRINCIPAL_OUTSTANDING` | ⚠️ `installment_type` 已於 **V3** 改名為 `channel_type`（值域擴充為 7 值）；`original_amount > 0`；**`period_count BETWEEN 1 AND 60`**；`per_period_amount > 0`；`status IN ('ACTIVE','COMPLETED','PRE_CLOSED','DEFAULTED','CANCELLED')` |
| `INSTALLMENT_PERIODS` | `PLAN_ID`（FK→`PLANS.ID`）；`PERIOD_NO` NUMBER(5)；`AMOUNT`；`PRINCIPAL_AMOUNT`；`FEE_AMOUNT`；`DUE_DATE`；`REMAINING_PRINCIPAL`；`STATUS` DEFAULT `'DUE'` | `period_no >= 1`；`amount >= 0`；`status IN ('DUE','PAID','OVERDUE','WAIVED','REVERSED')`；FK(`PLAN_ID`) |
| `INSTALLMENT_PRODUCTS` | `PRODUCT_CODE` VARCHAR2(16) **UNIQUE**；`MIN_PERIODS`／`MAX_PERIODS`；`FEE_RATE`／`ANNUAL_RATE`；`MERCHANT_ELIGIBLE`；`STATUS` | ⚠️ `installment_type` 已於 **V3** 改名為 `channel_type`（7 值）；`status IN ('ACTIVE','INACTIVE')` |

> 含 `CREATED_AT`／`UPDATED_AT` TIMESTAMP(6) WITH TIME ZONE、`VERSION` 樂觀鎖。

### 5.2 V2（`V2__installment_plan_no_seq.sql`）

`CREATE SEQUENCE INSTALLMENT_PLAN_NO_SEQ`——分期編號須**連號可稽核**
（同 posting／statement／payment／rewards 之理由），不得以隨機值替代。

### 5.3 V3（`V3__installment_channel_business_type.sql`）——**CR-008 雙軸拆分**

| 表 | 變更 | 遷移後（Oracle 實測） |
|---|---|---|
| `INSTALLMENT_PRODUCTS` | `installment_type` → **`channel_type`**；新增 **`business_type`** | `CHANNEL_TYPE` VARCHAR2(16 CHAR) **NOT NULL**；`BUSINESS_TYPE` VARCHAR2(16 CHAR) **NULL** |
| `INSTALLMENT_PLANS` | 同上 | 同上 |

**實測約束（具名，非系統自動命名）**：

| 約束 | 條件 |
|---|---|
| `CK_INST_PROD_CHANNEL` / `CK_INST_PLAN_CHANNEL` | `channel_type IN ('MERCHANT','BANK','REVOLVING','BILL','ACQUIRER','NCCC','QRCODE')` |
| `CK_INST_PROD_BUSINESS` / `CK_INST_PLAN_BUSINESS` | `business_type IS NULL OR business_type IN ('SINGLE','RECURRING','STATEMENT')` |

> ⚠️ **為何以動態 PL/SQL 卸除舊約束**：V1 以行內 CHECK 建表，Oracle 給了
> **系統自動命名**之約束（實測 `SYS_C0010885`／`SYS_C0010896`）。若硬寫這些名字，
> 遷移會**綁死在特定環境**。且該欄實測有 **2 個** `constraint_type='C'` 約束
> （NOT NULL ＋ 值域 CHECK），只刪一個會留下舊值域——故依
> `user_constraints ⋈ user_cons_columns` **動態定位並全部卸除**，再顯式重建。
> 約束名刻意 ≤30 字元，相容 12.2 以前之上限。

### 5.4 實測物件清單（`CC_INST`）

- 表 4：`INSTALLMENT_PLANS`、`INSTALLMENT_PERIODS`、`INSTALLMENT_PRODUCTS`、`flyway_schema_history`
- 序列 1：`INSTALLMENT_PLAN_NO_SEQ`
- ⚠️ **本服務無本地種子檔**（`ops/local/init/oracle/` 僅 02／04–06／08／09），
  `CC_INST` 之資料係**測試過程建立**（見 §9.3）

---

## 6. API 設計（來自 `contracts/openapi/installment-service.yaml`）

| 端點 | 方法 | 說明 | 對應 FR |
|---|---|---|---|
| `/api/v1/installments` | POST | 分期計畫成立（含攤還表） | `ISS-2.7` |
| `/api/v1/installments/{planId}` | GET | 計畫查詢（含期數） | `ISS-2.7` |
| `/api/v1/installments/{planId}/settlements` | POST | 分期結清（提前清償／違約） | `ISS-2.7` |

**必填標頭**（兩個 POST）：`X-Channel`、`X-Idempotency-Key`（長度 8–120）。
**回應信封**：`{traceId, data, error, success}`；成功之 POST 回 **201**。

> ★ **期數欄位（`CR-043 §2.4`）**：`InstallmentView.periods[]` 之每期含
> `periodNo`／`amount`／**`principalAmount`**／`feeAmount`／`dueDate`／`status`／`remainingPrincipal`。
> ★ `principalAmount` 原僅存在於領域模型（回應 `PeriodView` 未暴露、契約未宣告）→ 本次**併補契約與回應**
> （`PeriodView` ＋ `InstallmentViewAssembler`）。★ 現行實作之 `amount` 與 `principalAmount` **同值**
> （DDL `AMOUNT`／`PRINCIPAL_AMOUNT` 分列；生成器 `InstallmentPlan` 以同一 `principal` 填入兩者）。

### 6.1 錯誤碼（`SDD-006 §3.4`：本服務服務代碼為 `INS`）

| 錯誤碼 | HTTP | 觸發情境 | 實測 |
|---|---|---|---|
| `CC-INS-1001` | 422 | 期數不支援（非 3／6／12／24） | ✅ |
| `CC-INS-1002` | 422 | 總約未簽署 | ✅ |
| `CC-INS-3001` | 409 | 計畫非 `ACTIVE` 仍嘗試結清 | ✅ |
| `CC-INS-4000` | 400 | 請求驗證錯誤（含**未定義之來源通路／業務型態／結清類型**；`channelType` 為 `null`） | ✅ |
| `CC-INS-4040` | 404 | 查無分期計畫 | ✅ |
| `CC-INS-3002` | 409 | 終態不可轉換（分期已結清仍嘗試攤還） | — **未實作**（無攤還端點） |

> ⚠️ **缺必填標頭或 Bean Validation 失敗時回 500**（契約要求 400）——**平台缺口**，見 §10.6。

---

## 7. 關鍵邏輯

- **攤還試算（成立時一次算完）**：每期本金＝`originalAmount ÷ periodCount`（整數除），
  **尾差由末期吸收**（如 100,000 ÷ 3 → 33,333／33,333／**33,334**）；
  手續費依費率計算後平均分攤，尾差同樣由末期吸收。
  - 手續費率本期為**固定值** `0.005`（`InstallmentService.DEFAULT_FEE_RATE`），
    待 `pricing-service` 費率引擎就緒後改為參數化（見 §10.5）。
  - **剩餘本金＝原始金額 −（至本期為止累計已攤本金）**，故**末期恰為 0**。
    不可寫成 `originalAmount − perPeriodAmount × i`（未計入末期尾差 → 末期殘留尾差金額）。
- **期數值域**：**僅 3／6／12／24**（契約列舉），非 DDL 之 `BETWEEN 1 AND 60`（見 §10.3）。
- **分期類型**：以 **DDL 4 值**為準（`MERCHANT`／`BANK`／`REVOLVING`／`BILL`），
  非契約之 `SINGLE`／`RECURRING`／…（見 §10.2）。
- **結清**：`settlementType` **僅 `EARLY`／`DEFAULT`**；未定義值一律以 `CC-INS-4000` 拒絕。
  僅 `ACTIVE` 可結清（`CC-INS-3001`）。
  - `EARLY` → `PRE_CLOSED`、`earlySettlementFlag=true`、剩餘期數與剩餘本金歸零
  - `DEFAULT` → `DEFAULTED`
  - **本期僅改變狀態**，未計算結清金額（剩餘本金＋違約金）亦未減免後續手續費（見 §10.5）。
- **冪等**：由 `X-Idempotency-Key` 承載（契約要求）；⚠️ 本服務**尚未**落庫冪等鍵表
  （見 §10.5）。
- **樂觀鎖**：`UPDATE … WHERE ID = ? AND VERSION = ?`。

---

## 8. 外部依賴與降級

| 依賴 | 失敗處理（Stand-in） |
|---|---|
| `pricing-service`（費率） | **未接**；本期費率固定 `0.005` |
| `posting-service`（每期入帳） | **未接**；每期扣款排程未實作（見 §10.5） |

> ⚠️ 1.0 所述之「經事件協調、入帳失敗重試」目前**無實作可驗證**。

---

## 9. 測試與驗收（`TRP-001`）

### 9.1 單元測試（`./gradlew :services:installment-service:test`）

**33 tests, 0 failures**（實測；1.3 版為 22）

| 測試類別 | 案例數 | 對應 |
|---|---|---|
| `InstallmentPlanTest` | 13 | 攤還試算、尾差、剩餘本金遞減至零、期數驗證、結清、＋ **CR-008 雙軸 3 例**（正交可組合／`businessType` 未記錄應為 null／`channelType` 不得為空） |
| `ChannelTypeBusinessTypeTest`（1.4 新增） | 8 | 通路軸 7 值、業務型態軸 3 值、**業務型態值不得被通路軸接受**（`CR-002` 根因回歸）、`fromCodeOrNull` 之 NULL 與未知值分流 |
| `SettlementTypeTest` | 8 | 結清類型值域（含 2 項缺陷回歸測試） |
| `LayeringTest`（ArchUnit） | 4 | `CSP-002 §6.3` |

> **負樣本實測（1.4）**：刻意把 `SINGLE` 加回 `ChannelType`、並讓
> `BusinessType.fromCodeOrNull` 對未知值靜默回 `null`，結果**精準轉紅 3 例**
> （`業務型態值不得被通路軸接受`、`未知業務型態碼不得靜默視為未記錄`、`通路軸應為七值`），
> 其餘 30 例不受影響——證明新測試有判定力，非「怎麼改都綠」。

### 9.2 品質關卡（正規範圍：逐服務 ＋ 只掃 `db/migration/`）

| 掃描器 | 範圍 | 結果 |
|---|---|---|
| `scan_scaffold.py` | `backend/services/installment-service` | **0 命中** |
| `scan_mockdata.py` | 同上 | **0 命中** |
| `scan_faketest.py` | 同上 | **0 命中** |
| `validate_sql.py` | `…/db/migration` | **0 違規**（2 檔） |
| `validate_mermaid.py` | `docs` | **0 錯誤** |

> ⚠️ **1.0 交付時未執行品質關卡**。本版補跑時 `scan_scaffold` 命中 1 處
> （`InstallmentPlan.Builder()` 空無參建構子）——**與本輪修改無關，屬既有遺留**，
> 已依專案既有模式修正（預設值移入建構子本體）。此為第 5 次遇到同一模式。

### 9.3 真實 Oracle 實測（8089，`FREEPDB1`）

| # | 情境 | 期望 | 實測結果 |
|---|---|---|---|
| 1 | `POST` 建立分期（120,000 分／12 期／MERCHANT） | 攤還表 12 期 | **201**，`INST2026090007`，每期 10,000、總手續費 600 ✅ |
| 2 | 各期本金加總不變式 | ＝原始金額 | `Σ = 120000` ✅ |
| 3 | **尾差由末期吸收**（100,000 分／3 期） | 333.33／333.33／**333.34** | ✅ |
| 4 | **末期剩餘本金**（同上） | **0** | `0.00` ✅（修正前為 `0.01`，見 §10.4） |
| 5 | `GET` 查詢分期 | 200，含 12 期 | ✅ |
| 6 | `POST` 結清 `EARLY` | `PRE_CLOSED` | ✅ |
| 7 | 已結清再結清 | 409 `CC-INS-3001` | ✅ |
| 8 | 期數 7 | 422 `CC-INS-1001` | ✅ |
| 9 | 總約未簽署 | 422 `CC-INS-1002` | ✅ |
| 10 | 查無計畫 | 404 `CC-INS-4040` | ✅ |
| 11 | **結清類型未定義 `FOO`** | 400 `CC-INS-4000`，**且不得結清** | ✅ 400；計畫**仍為 `ACTIVE`**（修正前為 201 且靜默結清，見 §10.4） |
| 12 | 結清類型小寫 `early` | 容忍大小寫 → `PRE_CLOSED` | ✅ |
| 13 | 分期類型用契約值 `SINGLE` | 400 `CC-INS-4000` | ✅ |
| 14 | **V3 遷移**（Flyway 於 8089 啟動時套用） | `channel_type` NOT NULL、`business_type` 可 NULL，四個具名 CHECK 成立 | ✅ Flyway `Successfully applied 1 migration … now at version v3`；`CHANNEL_TYPE` N／`BUSINESS_TYPE` Y；`CK_INST_{PLAN,PROD}_{CHANNEL,BUSINESS}` 皆存在 |
| 15 | **雙軸寫入**：`MERCHANT` ＋ `STATEMENT` | 201，且**落庫為兩欄** | ✅ `INST2026090006` → DB `MERCHANT`／`STATEMENT`，攤還 12 期 |
| 16 | **業務型態省略**（`channelType=QRCODE`，不帶 `businessType`） | 201，DB `business_type` **為 NULL** | ✅ `INST2026090007` → DB `QRCODE`／`<NULL>`，攤還 6 期 |
| 17 | 查詢既有計畫（種子 0003，業務型態未記錄） | `businessType` 回傳 **`null`** | ✅ `channelType=BANK`、`businessType=null`（**非**空字串、**非**預設值） |
| 18 | 誤將業務型態值填入通路軸（`channelType=SINGLE`） | 400 `CC-INS-4000` | ✅ `未知分期來源通路：SINGLE` —— `CR-002` 根因仍被正確擋下 |

### 9.4 WebUI 端到端實測（`SDD-007 §5.4.8`）

**實測路徑**：瀏覽器 → **Vite 5173** → `/installment-api` 代理（rewrite 去前置）→ **8089** → **Oracle `CC_INST`**。

#### 9.4.1 情境與結果（2026-09-21 14:12–14:22 重跑，**真實瀏覽器 ＋ Keycloak 登入**）

| # | 情境 | 實測結果 |
|---|---|---|
| 1 | 建立分期（1,000.00 元／3 期，`MERCHANT`＋`SINGLE`） | `INST2026090008`；攤還表 3 期：本金 **333.33／333.33／333.34**，剩餘本金 **666.67／333.34／0.00** ✅（手續費 1.66／1.66／**1.68**＝5.00，尾差亦由末期吸收） |
| 2 | 執行結清 `EARLY` | 狀態轉 **`PRE_CLOSED`**（畫面「提前結清」），提示「已完成提前結清（PRE_CLOSED）」；DB 複核 `remaining_periods=0` ✅ |
| 3 | 總約未簽署 | 畫面顯示 **`CC-INS-1002`「總約未簽署，不得成立分期」** ✅ |
| 4 | **雙軸下拉（`CR-008`）** | 「來源通路」**7 值**（`MERCHANT`／`BANK`／`REVOLVING`／`BILL`／`ACQUIRER`／`NCCC`／`QRCODE`）；「業務型態」**3 值 ＋「（不指定 → 記錄為「未記錄」）」** ✅ 與 `SDD-007 §5.4.8` 一致 |
| 5 | **業務型態選「不指定」→ 顯示「未記錄」** | 建立 `INST2026090009`；畫面業務型態顯示 `badge badge--muted :: 未記錄`（**灰色標記**，非 `SINGLE`）✅ |
| 6 | **重新查詢（GET）後仍為「未記錄」** | `INST2026090009` 重新查詢 → 業務型態仍顯示「未記錄」✅ —— 證明**後端確實存 NULL**，非畫面暫時狀態 |
| 7 | **DB 直接複核（`sqlplus`）** | `select plan_no, channel_type, nvl(business_type,'<NULL>')`：<br>`INST2026090009　MERCHANT　<NULL>` ✅<br>`INST2026090008　MERCHANT　SINGLE` ✅<br>狀態：`INST2026090008=PRE_CLOSED／remaining_periods=0`、`INST2026090009=ACTIVE／3` ✅ |

#### 9.4.2 本次實測之姿勢（誠實揭露）

| 項目 | 本次 | 說明 |
|---|---|---|
| **認證** | ✅ **啟用**（SSO） | 以 Keycloak `demo.csr`（`cardcore-csr`）經 **PKCE 登入**後操作；後端 `installment-service` 亦為啟用態（8089 無憑證回 **401**） |
| 使用者 token 與服務間 ACL | 放行 | 使用者 token 之 `azp=cardcore-web`（非 `cc-`），依 `CR-011`／`SCS-022 §7.10` 不受服務間 ACL 管制 ✅ 與設計一致 |
| 前端建置 | ✅ | `npm run typecheck` **0 錯誤**；`npm run build` 成功（**101 modules**，289.74 kB／gzip 83.98 kB） |

> ⚠️ **1.4 版「尚未重跑瀏覽器實測」之待辦，本次解除**——#1–3 既有情境**已於真實瀏覽器重跑並仍成立**，
> 且「雙軸下拉與空值顯示」（#4–6）亦已實際操作驗證，並以 **DB 直接查詢**佐證（#7）。

#### 9.4.3 截圖存證（`docs/08-evidence/`）

| 檔案 | 內容 |
|---|---|
| `webui-installments-01-dual-axis-form.png` | 雙軸下拉：通路 7 值、業務型態 3 值 ＋「不指定」 |
| `webui-installments-02-amortization-1000x3.png` | 1,000.00 元／3 期之攤還表（尾差 333.34、剩餘本金 0.00） |
| `webui-installments-03-early-settlement.png` | 提前結清 → `PRE_CLOSED` ＋ 提示訊息 |
| `webui-installments-04-agreement-not-signed.png` | 總約未簽署 → `CC-INS-1002` |
| `webui-installments-05-business-type-unrecorded.png` | 業務型態「不指定」→ 畫面顯示**未記錄**（灰色標記） |

#### 9.4.4 本次實測抓到之環境／工具事項（非程式缺陷）

| # | 事項 | 處置 |
|---|---|---|
| 1 | Keycloak 回 `Invalid parameter: redirect_uri` | `cardcore-web` 之 `redirectUris` 僅 `http://localhost:5173/*`——以 **`127.0.0.1`** 開啟會被拒。改用 `localhost` 即正常 |
| 2 | `agent-browser click @ref` 對畫面下方按鈕**無反應**（回 `Done` 卻未觸發） | 須先 `scrollintoview` 再 `click`；未捲入視窗時點擊不落地（本次「執行結清」即因此卡住） |
| 3 | `docker exec … sqlplus` 直連查不到 `CC_INST` 表 | 資料在 **PDB `FREEPDB1`**；CDB root 查不到。須 `alter session set container=FREEPDB1`（另：欄位名為 `remaining_periods`，無 `remaining_principal`） |

---

## 10. 已知限制（誠實揭露）

### 10.1 ✅ 計畫狀態值域**已全部對齊**（原多方不一致，CR-002 修正完成）

**修正後之單一值域**：`ACTIVE`／`COMPLETED`／`PRE_CLOSED`／`DEFAULTED`／`CANCELLED`（5）

| 來源 | 修正前 | 修正後 |
|---|---|---|
| **`SDD-005 §6` 狀態機**（設計） | `PENDING`／`ACTIVE`／`SETTLED`／`EARLY_SETTLED`／`DEFAULTED`／`REJECTED`／`CANCELLED`（7） | ✅ 已於 **`SDD-005` 1.2** 更正為 5 值 |
| **契約 `InstallmentView.status`** | 同 `SDD-005 §6` 之 7 值 | ✅ 已於 **CR-002** 更正為 5 值 |
| **`SDD-001 §3.9` ER/DDL**（設計來源） | 5 值（**權威**） | 不變 |
| **實體 DDL `INSTALLMENT_PLANS.status`**（實測） | 5 值（**權威**） | 不變 |
| **實作列舉 `PlanStatus`** | 5 值（**權威**） | 不變 |

> ⚠️ **本規格 1.1 版原寫「2 比 1，DDL 是少數」——該敘述有誤，已更正。**
> 錯誤原因：只計入「`SDD-005 §6` ＋ 契約」而**漏計 `SDD-001 §3.9` ER/DDL**。
> 正確為 **4 源（BRD＋ER/DDL＋實體 DDL＋實作）vs 1 源（`SDD-005 §6`）**。
> 教訓：**值域衝突要數全部來源，不能只數「文件 vs 程式」兩類。**

**權威判定（CR-002 §3.1）**：以 **ER/DDL 為權威**。理由——
`BRD-001 §5.6` 之分期流程為「申請 → 檢核 → {拒絕－額度不足 ／ 總約簽署 → 首期入帳}」，
其中「拒絕」為**同步交易拒絕**，**不存在**「已申請、待核准」之持久狀態；
故 `PENDING`／`REJECTED` 無 ER/DDL 落點，且 `view` 只能回傳存得下的值。

| # | 項目 | 狀態 |
|---|---|---|
| 1 | 值域不一致 | ✅ **已解決**。`SDD-005` 1.2 ＋ CR-002 皆已對齊 ER/DDL；並新增 `SDD-005` 通用規則 **SM-08「狀態值域以可持久化之 DDL CHECK 為權威」** |
| 2 | 語意對照（歷史） | `SETTLED` → `COMPLETED`；`EARLY_SETTLED` → `PRE_CLOSED`；`PENDING`／`REJECTED` **移除**（無法持久化） |
| 3 | `SDD-005 §6` 原轉換 1～4（申請 → 核准；拒絕；補償） | **未實作**。本服務之 `create` 直接建立 `ACTIVE` 並產生期數，**無核准流程**（見 §10.5 #4）。`SDD-005` 1.2 已將狀態機更正為「建立即 `ACTIVE`」，與實作一致 |
| 4 | `COMPLETED` 之產生者 | **未實作**（每期扣款排程未實作，見 §10.5 #7）。`SDD-005` 1.2 已將此列為保留事項 |
| 5 | 若日後需「申請—核准」流程 | **須另提 CR** 於 `SDD-001 §3.9` ER/DDL 補狀態並附遷移腳本，**不得僅改文件**（`SDD-005` 1.2 §6 已明訂） |

> ⚠️ **1.0 之 §10 原寫「狀態轉換未建模為 `SDD-005` 正式狀態機」——此為錯誤敘述**：
> `SDD-005 §6` 即「分期計畫狀態機」。已更正為本表。

### 10.2 ✅ 分期分類雙軸**已拆分**（原建模缺陷，CR-008 解除）

**原缺陷**：兩個正交分類軸被塞進同一欄。

| 來源 | 分類軸 | 值域 |
|---|---|---|
| `BRD-001 §5.6`（需求源頭） | **業務型態** | 單筆消費分期／約定分期／帳單分期 |
| 契約（1.1 版以前） | **業務型態** | `SINGLE`／`RECURRING`／`STATEMENT`／`ACQUIRER`／`NCCC`／`QRCODE` |
| `SDD-001 §3.9` 敘述 | **來源通路** | 商家分期（`MERCHANT`）／銀行分期（`BANK`） |
| `SDD-001 §3.9` DDL ＋ 實體 DDL ＋ 實作列舉 | **來源通路** | `MERCHANT`／`BANK`／`REVOLVING`／`BILL` |

> 嚴重性（當時）：**契約 6 值全部不可用**——實測 `SINGLE`／`RECURRING`／`QRCODE` 皆回
> **400 `CC-INS-4000`**，僅 `MERCHANT`／`BANK` 回 201（§9.3 #13），
> 亦即**照契約開發之呼叫端 100% 無法建立分期**（阻斷性缺陷）。

**兩階段處理**：

| 階段 | 處置 | 狀態 |
|---|---|---|
| `CR-002` | 契約**先對齊 DDL** 以解除阻斷（`MERCHANT`／`BANK`／`REVOLVING`／`BILL`） | ✅ 已完成 |
| **`CR-008`** | **雙軸拆分**：`installment_type`→`channel_type`（改名，7 值）＋ 新增 `business_type`（3 值，允許 NULL） | ✅ **已完成（本版）** |

**拆解後之權威值域**：

| 欄位 | 軸 | 值域 | NULL |
|---|---|---|---|
| `channel_type` | 來源通路（誰提供） | `MERCHANT`／`BANK`／`REVOLVING`／`BILL`／`ACQUIRER`／`NCCC`／`QRCODE` | ❌ NOT NULL |
| `business_type` | 業務型態（怎麼發起） | `SINGLE`／`RECURRING`／`STATEMENT`（`BRD-001 §5.6`） | ✅ **允許**，語意為「當初未記錄」 |

> ⚠️ **殘留揭露**：`ACQUIRER`／`NCCC`／`QRCODE` 歸入通路軸，係**依字面語意判定**，
> 此 3 值既不見於 `BRD-001 §5.6` 亦不見於既有 DDL，**非客戶確認之規格**。
> 客戶規格確認時應覆核（見 `CR-008 §4.2`）。
>
> ⚠️ **報表與篩選必須處理 `business_type` 空值**——不得以 `COALESCE`
> 默認成某一型態；「未記錄」與「單筆」是**不同事實**。

### 10.3 期數值域不一致（契約 vs DDL）

| 來源 | 值域 |
|---|---|
| 契約 `periodCount` | `enum: [3, 6, 12, 24]` |
| DDL `period_count`（實測） | `BETWEEN 1 AND 60` |

以**契約**為準（業務上分期期數須為約定之級距）；DDL 之 1–60 為較寬之實體約束。
兩者不衝突但範圍不同（**非值域衝突，屬約束層級差異**）。
> 另：期數狀態 `InstallmentPeriod.status` 於 1.1 版以前**未定義列舉**且範例誤植為
> `PENDING`；已於 CR-002 補上列舉 `DUE`／`PAID`／`OVERDUE`／`WAIVED`／`REVERSED` 並更正範例。

### 10.4 ★ 本輪修復之 2 項缺陷（皆有實測證據與回歸測試）

| # | 缺陷 | 嚴重性 | 修法 |
|---|---|---|---|
| 1 | **結清類型靜默降級**：`InstallmentService.settle` 以 `!"DEFAULT".equalsIgnoreCase(type)` 判定，**任何非 `DEFAULT` 之字串**（含錯字、未定義值）都被靜默視為**提前結清**並實際執行。實測 `settlementType="FOO"` → **201** 且計畫轉 `PRE_CLOSED` | **高**：金融操作在非法輸入下照做。整合端欄位錯置會把「違約處理」執行成「提前結清」，兩者之帳務與催收後果完全不同 | 新增 `SettlementType` 列舉（值域＝契約 enum），未定義值以 `CC-INS-4000` 拒絕；`InstallmentServiceTest` 迴歸測試 |
| 2 | **末期剩餘本金殘留尾差**：`remainingPrincipal` 以 `originalAmount − perPeriodAmount × i` 計算，未計入末期吸收之尾差 → 100,000 ÷ 3 之末期剩 **1 分（0.01 元）** | **中**：客戶繳完所有期數後帳上仍顯示欠款 0.01 元，屬對帳缺口 | 改為累計已攤本金相減（末期恰為 0）；加 2 項回歸測試 |

> 缺陷 2 係**由 WebUI 實測發現**——後端單測與 E2E 皆未察覺，因兩者皆未斷言末期剩餘本金。
> 教訓：**畫面會把資料攤開給人看，比端點回 201 更能暴露語意錯誤**。

### 10.5 未實作 / 部分實作

| # | 項目 | 現況 |
|---|---|---|
| 4 | ~~`SDD-005 §6` 轉換 1～4：`PENDING` 申請 → 核准 `ACTIVE`／拒絕 `REJECTED`／補償 `CANCELLED`~~ | ✅ **已釐清（非「未實作」而是「設計未採納」）**。`SDD-005` **1.2** 已移除 `PENDING`／`REJECTED`——DDL 無法持久化該二值，且 `BRD-001 §5.6` 之拒絕為同步交易拒絕。狀態機現為「建立即 `ACTIVE`」，**與實作一致**。惟 `ACTIVE` → `CANCELLED`（Saga 補償）**仍未實作** |
| 5 | 結清**金額計算**（剩餘本金＋違約金） | **未實作**。僅改狀態；`SCS-009 §7` 原述「經 `pricing-service` 計算」尚無實作 |
| 6 | 提前結清之**後續手續費減免**（`SDD-005 §6` 轉換 3） | **未實作**。期數之手續費不因結清而調整 |
| 7 | 每期自動扣款／攤還排程（與 `payment-service`／`common-batch` 協調） | **未實作**。期數狀態恆為 `DUE`；`OVERDUE`／`PAID` 無觸發來源 |
| 8 | **冪等鍵落庫** | **未實作**。`X-Idempotency-Key` 收了但未儲存、未比對；同鍵重送會**再建一筆計畫**。<br>**✅ 已實測（2026-09-19）**：以同一把 `X-Idempotency-Key` 連送兩次 `POST /v1/installments`，回應為**兩筆不同計畫**——第 1 次 `INST2026090012`（`planId=e8d39768-c53a-4219-92bb-83710e795126`）、第 2 次 `INST2026090013`（`planId=b746aded-8858-47af-8372-ee9f0a8bdb38`），`CC_INST` 計畫總數由 8 → **10**。**冪等鍵確實未落庫**，重送即重複建檔 |
| 9 | `pricing-service` 費率引擎 | **未接**。費率固定 `0.005` |
| 10 | `CC-INS-3002`（終態不可轉換） | 無對應端點可觸發 |
| 11 | 樂觀鎖 `CC-INS-2001` 等衝突碼 | 契約未定義；`update` 之版本比對失敗處理未實測 |

### 10.6 平台缺口（非本服務特有）

| # | 缺口 | 影響與對策 |
|---|---|---|
| 12 | `GlobalExceptionHandler` 未攔截 `MethodArgumentNotValidException`／`MissingRequestHeaderException`／`HttpMessageNotReadableException` | **實測**：缺 `X-Channel` → **500 `CC-PLT-5000`**；缺 `X-Idempotency-Key` → **500**；`@Valid` 欄位缺漏 → **500**。契約一律要求 **400**。本服務之 controller 仍用 `@RequestHeader`（`required=true`）＋ `@Valid`，故**全部落入此缺口** |
| ↳ | 本服務之權宜處理 | **本輪未修**（屬平台層議題，影響全部 17 個服務，須另立 CR）。`rewards-service` 係以「不用 Bean Validation ＋ 標頭 `required=false` ＋ 程式內明確檢核」規避；本服務尚未跟進。**建議於平台層統一修復**（新增 3 個 `@ExceptionHandler` 對應 400），一次解決全部服務 |

### 10.7 其他

| # | 項目 | 說明 |
|---|---|---|
| 13 | ~~**無本地種子檔**~~ | ✅ **已補**：`ops/local/init/oracle/10-dev-installment-seed.sql`（4 產品／5 計畫／57 期數）。涵蓋 `ACTIVE`（12／3／24 期）、`PRE_CLOSED`、`DEFAULTED`；**刻意不種 `COMPLETED`**——該狀態無程式路徑可產生，種入會讓畫面看似「功能已存在」。期數以 `CONNECT BY` 依與 `InstallmentPlan.create` 同一公式生成，並重設編號序列為 6 |
| 14 | 1.0 之埠號欄位為「待骨架產生時分配」 | 已回填 **8089** |
| 15 | `INSTALLMENT_PRODUCTS` 無維護端點 | 契約未定義；`MIN_PERIODS`／`MAX_PERIODS` 未被 `InstallmentService` 讀取（期數以契約 enum 檢核）。**已於 CR-002 契約對齊後維持揭露** |
| 16 | ~~`installment_type` 之**業務型態**分類無處儲存~~ | ✅ **已解除（CR-008）**：新增 `business_type` 欄承載業務型態，`SDD-001` 1.5 已完成建模、`V3` 已遷移、程式／契約／種子／前端皆已同步，並經真實 Oracle 端到端實測（§9.3 #14–18）。殘留限制見 §10.2（空值處理、3 值歸類待客戶覆核） |

---

## 11. 修訂記錄

| 版本 | 日期 | 修訂內容 |
|---|---|---|
| 1.0 | — | 規格先行版（埠號與類別名為規劃值） |
| 1.1 | 2026-09-19 | ① 埠號回填 **8089**；② **修正 §1.1 懸空章節引用**（`SDD-001 §5.5`→**§3.9**、`SDD-003 §7`→**§12**、補 `SDD-005 §6`）；③ §3／§4 改為實作檔案；④ §6 補錯誤碼實測表；⑤ §7 補攤還與剩餘本金不變式；⑥ §9 補 22 單測／5 關卡／13 項 Oracle 實測／3 項 WebUI 端到端；⑦ **§10.1 更正原「未建模狀態機」之錯誤敘述**，改為「值域三方不一致，且 DDL 為少數」；⑧ §10.4 新增**本輪修復之 2 項缺陷**（結清類型靜默降級、末期剩餘本金殘額）；⑨ §10.6 新增**平台缺口 500-vs-400 實測證據**；⑩ §10.5 #8 補**冪等鍵未落庫之實測證據**（同鍵重送產生兩筆計畫）；⑪ 補 §11 修訂記錄 |
| 1.2 | 2026-09-19 | ① 依 **CR-002** 執行契約修正：`installmentType` 對齊 DDL（原列舉 6 值實測全部回 400，屬阻斷性缺陷）、`InstallmentView.status` 對齊 ER/DDL、`InstallmentPeriod.status` 補列舉並更正範例；② **更正 §10.1 原「2 比 1，DDL 是少數」之錯誤敘述**——漏計 `SDD-001 §3.9` ER/DDL，正確為 **4 源 vs 1 源**；③ §10.2 改述為**建模缺陷**（兩正交分類軸共用一欄）；④ §10.7 新增 #16 業務型態無處儲存；⑤ #13 補種子檔完成；⑥ 新增 `10-dev-installment-seed.sql` |
| 1.3 | 2026-09-19 | ① **`SDD-005` 1.2 完成 §6 狀態機更正**（移除無法持久化之 `PENDING`／`REJECTED`；`SETTLED`／`EARLY_SETTLED` → `COMPLETED`／`PRE_CLOSED`；轉換 8→6；新增通用規則 **SM-08**「狀態值域以可持久化之 DDL CHECK 為權威」；§12.2 矩陣連動更正為 80／103）；② **§10.1 改為「值域已全部對齊」**——四方（BRD／ER-DDL／狀態機／契約）一致，殘留項僅剩「`COMPLETED` 無產生者」與「`ACTIVE`→`CANCELLED` 未實作」；③ `SDD-007` 1.1 同步更新 §5.4.8 值域說明；④ 契約描述與 `InstallmentView` javadoc 同步更正 |
| 1.4 | 2026-09-20 | ① 依 **`CR-008`（BEAM PMO 核定）完成雙軸拆分**：`installment_type`→`channel_type`（7 值）＋ 新增 `business_type`（3 值、允許 NULL）；② 領域列舉 `InstallmentType`→`ChannelType` ＋ 新增 `BusinessType`；③ `InstallmentPlan`／`JdbcInstallmentRepository`／`InstallmentService`／`CreateInstallmentRequest`／`InstallmentView` 同步（`InstallmentView` 新增 2 欄供讀回雙軸）；④ 新增 **`V3__installment_channel_business_type.sql`**（動態 PL/SQL 卸除系統命名約束，避免綁死環境）；⑤ 契約由 `gen_openapi.py` 重產（17 服務／72 端點／0 錯誤）；⑥ 種子補雙軸值，**刻意保留 0003 為 NULL** 以驗證空值路徑；⑦ 前端 `installments.ts`／`InstallmentConsole.tsx` 同步（空值顯示「未記錄」，不得默認成單筆）；⑧ **單測 22→33**，含 `CR-002` 根因回歸測試，並以**負樣本實測**證明判定力（精準轉紅 3 例）；⑨ **真實 Oracle 端到端實測 #14–18**（V3 遷移、雙軸落庫、NULL 還原、誤填業務型態值仍被拒）；⑩ §10.2 建模缺陷與 §10.7 #16 **皆標為已解除** |
| 1.6 | 2026-10-15 | **`CR-043 §2.4`：`InstallmentPeriod.principalAmount` 補契約與回應**——★ 緣由：`verify_contract_dto` 回報「DTO 有、契約無」（實作領域模型 `InstallmentPeriod` 有 `principalAmount`，契約 schema 漏宣告）。★ **盤點（不照清單字面做）**：該欄原僅在**領域模型**；**回應** `InstallmentView.PeriodView` **未暴露** → 若僅補契約，將宣告「回應不存在之欄位」＝**新的不一致**。★ **處置**：① 契約 `InstallmentPeriod` schema 補 `principalAmount`；② 回應 `PeriodView` 併補暴露（`InstallmentViewAssembler` 傳 `period.principalAmount()`）——使**契約＝回應＝領域模型**一致。★ 契約重生 **0 錯誤**；`verify_contract_dto` **不符 0 處**。★ **如實揭露**：現行實作之 `amount` 與 `principalAmount` **同值**（DDL 分列 `AMOUNT`／`PRINCIPAL_AMOUNT`，生成器 `InstallmentPlan` 以同一 `principal` 填入兩者）——兩欄是否應有語意差異（如 `amount＝本金＋手續費`）**屬業務語意**，未於本次認定 |
| 1.5 | 2026-09-21 | **§9.4 WebUI 端到端實測重跑（真實瀏覽器 ＋ Keycloak 登入）**——解除 1.4 版「本次僅 `typecheck`＋`build`、尚未重跑瀏覽器實測」之待辦。★ **7 情境全數成立**：①建立分期 1,000.00／3 期 → `INST2026090008`，攤還表 **333.33／333.33／333.34**、剩餘本金 **666.67／333.34／0.00**（手續費 1.66／1.66/**1.68**＝5.00，尾差由末期吸收）；②結清 `EARLY` → **`PRE_CLOSED`** ＋ 提示「已完成提前結清」，DB 複核 `remaining_periods=0`；③總約未簽署 → **`CC-INS-1002`**；④**雙軸下拉**：通路 7 值、業務型態 3 值 ＋「不指定」，與 `SDD-007 §5.4.8` 一致；⑤業務型態「不指定」→ 畫面顯示 **`badge--muted :: 未記錄`**（灰色標記，**非** `SINGLE`）；⑥重新查詢後**仍為「未記錄」**——證明後端存 NULL 非畫面暫態；⑦**DB 直接複核**：`INST2026090009 MERCHANT <NULL>`／`INST2026090008 MERCHANT SINGLE`。⚠️ **姿勢揭露**：本次為**認證啟用**（Keycloak `demo.csr` 經 PKCE 登入；8089 無憑證回 401），使用者 token 之 `azp=cardcore-web` 不受服務間 ACL 管制，與 `CR-011` 設計一致。截圖存證 5 張（`docs/08-evidence/webui-installments-01～05`）。另記錄三項**環境／工具**事項（非程式缺陷）：`redirect_uri` 僅允許 `localhost` 不能用 `127.0.0.1`、`agent-browser` 對下方按鈕須先 `scrollintoview` 再 `click`、Oracle 資料在 **PDB `FREEPDB1`** 須切 container 才查得到 |
