# SDD-001　領域模型與 ER-Diagram

| 項目 | 內容 |
|---|---|
| 文件編號 | SDD-001 |
| 版本 | **1.18**（`CR-035 §2.4`：**§3.4 新增 `special_limit_temp_adjustments`**（`V9` 遷移）——8-6／8-7 之**臨時額度串動紀錄**（`BR-04-02`）。載明於「變更交付說明」，**本節 SQL 區塊不得改動**。前版 **1.17**（`CR-035 §2.3`：§3.4 新增 `special_limit_policies`（`V8` 遷移）） |
| 狀態 | 已核定 |
| 撰寫者 | SD / DA |
| 審查者 | SA / TL |
| 對應需求 | ISS-2.1~2.13、AUT-1.1~1.3、ACQ-4.1~4.6、RWD-5.1~5.3、SYS-2.1、COM-6.1~6.2 |

> **§3.9 分期雙軸拆分（`CR-008`，BEAM PMO 2026-09-20 核定）**
> `installment_type` 原同時被要求承載兩個正交軸（來源通路／業務型態），
> 且欄名字面（「分期類型」）易被誤讀——此即 `CR-002` 契約值域 100% 被拒之根源。
> 現拆分為 **`channel_type`（來源通路，7 值）** ＋ **`business_type`（業務型態，3 值、允許 NULL）**。
> ⚠️ `business_type` **允許 NULL** 係因既有資料未記錄此維度——
> 誠實表達「未記錄」勝過回填一個猜測值；報表與篩選須處理空值。

---

## 1. 說明

本文件定義全系統之**領域模型與資料模型**，並提供**可直接執行之 DDL**。
ER 圖為設計視圖；DDL 為實作視圖，兩者必須一致。

> **v1.1 方言聲明（重要）**
> 本文件之 DDL 以 **PostgreSQL 語法**書寫，作為**邏輯模型之記法**。
> 本案生產環境之**權威 OLTP 為 Oracle**（`CSP-006 §2.4`、`SAD-003 §3`），
> 因此**實際 Flyway 遷移腳本須以 Oracle 方言撰寫**，其型別與語法對照見 **§1.3**。
> 二者不一致時，以 §1.3 之 Oracle 對照為實作依據。

### 1.1 通用欄位（原則上所有表皆有）

| 欄位 | 型別 | 說明 |
|---|---|---|
| `id` | `uuid` | 主鍵（ULID 亦可，本設計採 UUID v7） |
| `created_at` | `timestamptz` | 建立時間（UTC） |
| `updated_at` | `timestamptz` | 更新時間（UTC） |
| `version` | `integer` | 樂觀鎖版本 |

#### 1.1.1 例外條款與豁免機制

上述四欄為**預設**，非絕對。下列情形得不具備，但**必須逐表明示豁免並寫出理由**：

| 情形 | 得不具備之欄位 | 理由類型 |
|---|---|---|
| **append-only 表**（`outbox`、`idempotency_records`、`audit_trail` 等） | `updated_at`、`version` | 寫入後不再變更，無「更新」與「樂觀鎖」語意 |
| **自然主鍵之註冊／去重表**（如 `card.issued_card_numbers`） | `id` | 已有自然主鍵（`card_no_hash`），加代理鍵徒增負擔 |
| **技術慣用單數名之表**（如 `outbox`） | —（表名例外） | 業界慣用名，強改複數反而降低可讀性 |

**豁免寫法**：於 `CREATE TABLE` 前 5 行內加註（規則見 `CSP-001 §5.2`）：

```sql
-- lint:skip=SQL-04,SQL-05  理由：append-only 事件輸出表，寫入後僅標記 published_at
CREATE TABLE :schema.outbox ( ... );
```

**紀律**：豁免須逐表為之、理由須具體。若同一理由反覆出現於多張表，
表示**規則本身需要修訂**（應提變更申請修 `CSP-001 §5.1`），而非無限累積豁免。

> **本節之由來（2026-09-18）**：本文件原述「通用欄位（**所有表**皆有）」，
> 與其自身 §3.15 之 `outbox`／`idempotency_records` DDL 相牴觸
> （該二表為 append-only，本就不應有 `updated_at`／`version`）。
> 經決議採**逐表豁免**：規則維持不變，例外逐表明示並附理由，使例外可稽核。

### 1.2 型別規範

| 用途 | 型別 | 說明 |
|---|---|---|
| 金額 | `bigint` | **最小單位（分）**，禁止浮點 |
| 幣別 | `char(3)` | ISO 4217 |
| 比率 | `numeric(9,6)` | 如 0.185000 表 18.5% |
| 時間 | `timestamptz` | UTC |
| 日期 | `date` | 業務日期 |
| 列舉 | `varchar(32)` + CHECK | 不使用 DB enum（避免遷移困難） |
| 彈性結構 | `jsonb` | 僅用於參數與稽核前後值 |

### 1.3 資料型別方言對照（PostgreSQL → Oracle）

| 用途 | 邏輯模型（PostgreSQL） | 實作（Oracle） | 備註 |
|---|---|---|---|
| 主鍵 | `uuid` | `RAW(16)` | 以 `SYS_GUID()` 產生。Oracle 23ai+ 支援原生 `UUID` 型別；若生產為 19c 則須用 `RAW(16)` |
| 時間 | `timestamptz` | `TIMESTAMP(6) WITH TIME ZONE` | 預設值 `now()` → **`SYSTIMESTAMP`** |
| 整數 | `integer` | `NUMBER(10)` | 或 `INTEGER` |
| 金額 | `bigint` | `NUMBER(19,0)` | 最小單位（分），**禁止 `BINARY_DOUBLE`／`BINARY_FLOAT`** |
| 比率 | `numeric(9,6)` | `NUMBER(9,6)` | — |
| 幣別 | `char(3)` | `CHAR(3 CHAR)` | 明確標示字元語意，避免 NLS 影響 |
| 字串 | `varchar(n)` | `VARCHAR2(n CHAR)` | Oracle 無 `VARCHAR` 語意差異，一律用 `VARCHAR2` |
| 長文字 | `text` | `CLOB` | — |
| 日期 | `date` | `DATE` | ⚠️ **Oracle `DATE` 含時、分、秒**，與 PostgreSQL 之 `DATE` 不同。業務日期建議改用 `NUMBER(8)`（`YYYYMMDD`）或 `DATE` 但僅使用日期部分並於資料字典註明 |
| 布林 | `boolean` | `NUMBER(1)` + `CHECK (x IN (0,1))` | Oracle 23ai+ 支援 SQL `BOOLEAN`；為相容 19c 採 `NUMBER(1)` |
| 列舉 | `varchar(32)` + CHECK | `VARCHAR2(32 CHAR)` + CHECK | 一致 |
| 彈性結構 | `jsonb` | `JSON`（21c+） | 若生產為 19c，改用 `CLOB` + `CHECK (x IS JSON)` |
| 自增 | `bigserial` | `GENERATED BY DEFAULT AS IDENTITY` | 或獨立 sequence |

**語法對照（常見）**

| 情境 | PostgreSQL | Oracle |
|---|---|---|
| 取當前時間 | `now()` | `SYSTIMESTAMP` |
| 分頁 | `LIMIT n OFFSET m` | `OFFSET m ROWS FETCH NEXT n ROWS ONLY`（12c+） |
| 佇列搶佔（Outbox 用） | `FOR UPDATE SKIP LOCKED` | `FOR UPDATE SKIP LOCKED`（11g+，**語法相同**） |
| 大小寫不敏感比對 | `ILIKE` | `REGEXP_LIKE(x, '...', 'i')` 或 `LOWER(x) LIKE` |
| Upsert | `ON CONFLICT ... DO UPDATE` | `MERGE INTO ... USING ...` |
| JSON 索引 | `USING GIN` | `CREATE SEARCH INDEX ... FOR JSON` |
| 字串串接 | `\|\|` | `\|\|`（相同） |

**Oracle 特有陷阱（實作必讀）**

| # | 陷阱 | 影響與對策 |
|---|---|---|
| T-01 | **未加引號之識別碼一律轉為大寫** | 本專案採 snake_case 小寫命名（`GOV-001 §4.3`），在 Oracle 內將成為 `POSTING.TRANSACTIONS`。**對策**：DDL 不加引號（避免全部 SQL 需加引號之維護負擔），並於 Hibernate／JPA 設定命名策略對應大小寫 |
| T-02 | **空字串 `''` 等於 `NULL`** | 金額、狀態等欄位不得以空字串表示「未設定」。**對策**：所有必填欄位加 `NOT NULL`，並於 CHECK 約束拒絕空字串 |
| T-03 | `DATE` 含時間 | 見上表「日期」列 |
| T-04 | 交易**隱式提交** | DDL 語句會隱式 commit，不可與 DML 混在同一交易。**對策**：Flyway 腳本一律 DDL 與 DML 分檔 |
| T-05 | 預設隔離等級為 `READ COMMITTED` | 與本案樂觀鎖設計相容；**不得**改用 `SERIALIZABLE`（會大幅增加 ORA-08177） |
| T-06 | `NUMBER` 比較之隱式轉換 | 金額欄位與字串比較會導致索引失效。**對策**：JDBC 綁定一律用對應數值型別 |
| T-07 | **`RESOURCE` 為 Oracle 26ai 之保留字** | `v$reserved_words.reserved = Y`。用作欄位名會報 `ORA-03050: invalid identifier: "RESOURCE" is a reserved word`。**對策**：本文件 §3.15 之 `audit_trail` 採 `resource_type` / `resource_id`，**不得**簡化為單一 `resource` 欄位 |
| T-08 | `JSON` 型別與 `RAW(16)` 之可用性 | 已於 Oracle 26ai Free（23.26.0）**實測可用**：`CREATE TABLE ... (id RAW(16), payload JSON)` 成功，`SYS_GUID()` 可作為預設值。故 §1.3 之型別對照表可直接落實，不需退回 `CLOB` + `IS JSON` |

> **T-07 之來源**：2026-09-18 本地環境建置時實測發現。原 `audit_trail` 草稿使用 `resource VARCHAR2(256)`，
> 於 26ai 建表失敗；改採本文件 §3.15 原設計之 `resource_type` / `resource_id` 後通過。
> **此為「文件原本是對的、實作偏離文件」之案例**——再次印證實作須以本文件為準。

---

## 2. 領域模型總覽（Bounded Context 關係）

```mermaid
erDiagram
  ACCOUNT ||--o{ PARTY : contains
  PARTY ||--o{ CARD : holds
  CARD ||--o{ TOKEN : tokenized_as
  ACCOUNT ||--|| CREDIT_LIMIT : has
  ACCOUNT ||--o{ SPECIAL_LIMIT : has
  CREDIT_LIMIT ||--o{ LIMIT_HOLD : holds
  CARD ||--o{ AUTHORIZATION : authorizes
  AUTHORIZATION ||--o| LIMIT_HOLD : creates
  AUTHORIZATION ||--o| TRANSACTION : settles_into
  ACCOUNT ||--o{ TRANSACTION : posts
  TRANSACTION ||--o{ SUB_ACCOUNT_ENTRY : splits_into
  ACCOUNT ||--|| BALANCE : has
  ACCOUNT ||--o{ PAYMENT : receives
  ACCOUNT ||--o{ STATEMENT : billed
  ACCOUNT ||--o{ INSTALLMENT_PLAN : plans
  ACCOUNT ||--o{ POINTS_ACCOUNT : earns
  MERCHANT ||--o{ MERCHANT_SETTLEMENT : settles
  MERCHANT ||--o{ TRANSACTION : acquires
  PRODUCT ||--o{ PARAMETER : defines
  PRODUCT ||--o{ CARD : issues
```

---

## 3. 各限界上下文之 ER 與 DDL

### 3.1 `party`　持卡人與歸戶

```mermaid
erDiagram
  ACCOUNT {
    uuid id PK
    varchar account_no UK
    varchar cif_no
    varchar account_type
    varchar block_code
    varchar statement_cycle
    date statement_day
    varchar status
    integer version
  }
  PARTY {
    uuid id PK
    uuid account_id FK
    varchar party_no UK
    varchar party_type
    varchar name
    varchar id_no_enc
    varchar id_no_masked
    date birth_date
    varchar mobile_enc
    varchar email_enc
    varchar relation_type
    jsonb flags
    varchar status
    integer version
  }
  BLOCK_CODE_HISTORY {
    uuid id PK
    uuid account_id FK
    varchar block_code
    varchar reason
    timestamptz effective_at
    timestamptz released_at
    varchar operator_id
  }
  ACCOUNT ||--o{ PARTY : contains
  ACCOUNT ||--o{ BLOCK_CODE_HISTORY : records
```

**DDL**

```sql
CREATE SCHEMA IF NOT EXISTS party;

CREATE TABLE party.accounts (
  id              uuid PRIMARY KEY,
  account_no      varchar(20)  NOT NULL UNIQUE,
  cif_no          varchar(20)  NOT NULL,
  account_type    varchar(16)  NOT NULL CHECK (account_type IN ('PERSONAL','CORPORATE')),
  block_code      varchar(16)  NOT NULL DEFAULT 'NORMAL'
                  CHECK (block_code IN ('NORMAL','LOST','OVERLIMIT','DELINQUENT','FRAUD','CLOSED')),
  statement_cycle varchar(8)   NOT NULL,
  statement_day   smallint     NOT NULL CHECK (statement_day BETWEEN 1 AND 31),
  status          varchar(16)  NOT NULL DEFAULT 'ACTIVE'
                  CHECK (status IN ('ACTIVE','SUSPENDED','CLOSED')),
  created_at      timestamptz  NOT NULL DEFAULT now(),
  updated_at      timestamptz  NOT NULL DEFAULT now(),
  version         integer      NOT NULL DEFAULT 0
);

CREATE TABLE party.parties (
  id            uuid PRIMARY KEY,
  account_id    uuid NOT NULL REFERENCES party.accounts(id),
  party_no      varchar(20) NOT NULL UNIQUE,
  party_type    varchar(16) NOT NULL CHECK (party_type IN ('PRIMARY','SUPPLEMENTARY','COMPANY')),
  name          varchar(80) NOT NULL,
  id_no_enc     bytea,                       -- 身分證字號（AES-256-GCM）
  id_no_masked  varchar(20) NOT NULL,        -- A12345****
  birth_date    date,
  mobile_enc    bytea,
  email_enc     bytea,
  relation_type varchar(16),
  flags         jsonb NOT NULL DEFAULT '{}'::jsonb,
  status        varchar(16) NOT NULL DEFAULT 'ACTIVE',
  created_at    timestamptz NOT NULL DEFAULT now(),
  updated_at    timestamptz NOT NULL DEFAULT now(),
  version       integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_parties_account ON party.parties(account_id);

-- lint:skip=SQL-04  理由：history 為集合名詞，*_history 為異動歷程表之標準後綴，不加複數 s
CREATE TABLE party.block_code_history (
  id           uuid PRIMARY KEY,
  account_id   uuid NOT NULL REFERENCES party.accounts(id),
  block_code   varchar(16) NOT NULL,
  reason       varchar(200),
  effective_at timestamptz NOT NULL,
  released_at  timestamptz,
  operator_id  varchar(40) NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),
  updated_at   timestamptz NOT NULL DEFAULT now(),
  version      integer NOT NULL DEFAULT 0
);
```

> **變更交付說明（`pty_idempotency`，2026-09-21 回寫｜`HANDOVER #C-18`）**
> 本表於 `V1__baseline.sql` **不存在**，係由後續遷移建立；上列 ER 圖未含之（屬**服務內部狀態**，
> 非業務主檔，故不列入領域 ER 圖）。
>
> | 遷移 | 內容 |
> |---|---|
> | `V2__pty_idempotency.sql` | 建表：`key`(PK)／`fingerprint`／`entity_type`／`entity_id`／`created_at` |
>
> **用途**：API 冪等鍵（防重複寫入）。與 §3.15 之 `IDEMPOTENCY_RECORD` 為**同一模式之各 schema 實作**
> ——後者為平台層之邏輯模型，前者為 `party` schema 之實體表。
> ⚠️ **不含 `id`／`updated_at`／`version`**：冪等鍵一經寫入即不再更新，
> 硬補樂觀鎖欄位屬**無意義欄位（假綠）**，故依 `CSP-001 §5.3` 以外部登錄檔豁免（`SQL-05`）。

### 3.2 `card`　卡片生命週期

```mermaid
erDiagram
  CARD {
    uuid id PK
    uuid account_id FK
    uuid party_id FK
    varchar card_no_enc
    varchar card_no_masked
    varchar card_no_hash
    varchar product_code FK
    varchar bin
    date expiry_date
    varchar block_code
    varchar status
    varchar token_ref
    boolean instant_issued
    varchar ticket_type
    integer version
  }
  CARD_APPLICATION {
    uuid id PK
    uuid account_id FK
    varchar apply_no UK
    varchar channel
    varchar product_code
    varchar status
    varchar approval_no
    boolean fast_issue_flag
    timestamptz approved_at
    integer version
  }
  TOKEN {
    uuid id PK
    uuid card_id FK
    varchar token_no UK
    varchar platform
    varchar device_ref
    varchar status
    timestamptz bound_at
    integer version
  }
  CARD_NO_POOL {
    uuid id PK
    varchar bin
    varchar range_start
    varchar range_end
    bigint used_count
    bigint total_count
    varchar status
  }
  ACCOUNT_CARD {
    uuid id PK
  }
  CARD_APPLICATION ||--o| CARD : results_in
  CARD ||--o{ TOKEN : tokenized_as
  CARD_NO_POOL ||--o{ CARD : allocates
```

**DDL（節錄關鍵表）**

```sql
CREATE SCHEMA IF NOT EXISTS card;

CREATE TABLE card.card_applications (
  id              uuid PRIMARY KEY,
  account_id      uuid NOT NULL,
  apply_no        varchar(24) NOT NULL UNIQUE,
  channel         varchar(16) NOT NULL
                  CHECK (channel IN ('WEB','MOBILE','API','BRANCH','MERCHANT')),
  product_code    varchar(16) NOT NULL,
  status          varchar(16) NOT NULL DEFAULT 'RECEIVED'
                  CHECK (status IN ('RECEIVED','REVIEWING','SUPPLEMENT','APPROVED','REJECTED')),
  approval_no     varchar(24),
  -- CR-020 F1（V7 遷移）：發卡後回填之卡片識別碼，使卡片可追溯其來源申請案件；
  -- 開卡（ACTIVATE）時以此反查核准序號驗證三要素。可為 NULL＝未發卡。
  card_id         uuid,
  fast_issue_flag boolean NOT NULL DEFAULT false,
  approved_at     timestamptz,
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now(),
  version         integer NOT NULL DEFAULT 0
);

CREATE TABLE card.cards (
  id              uuid PRIMARY KEY,
  account_id      uuid NOT NULL,
  party_id        uuid NOT NULL,
  card_no_enc     bytea NOT NULL,             -- 加密卡號
  card_no_masked  varchar(20) NOT NULL,       -- 411111****1234
  card_no_hash    char(64) NOT NULL,          -- SHA-256，供比對用（唯一）
  product_code    varchar(16) NOT NULL,
  bin             char(8) NOT NULL,
  expiry_date     date NOT NULL,
  block_code      varchar(16) NOT NULL DEFAULT 'INACTIVE'
                  CHECK (block_code IN ('INACTIVE','ACTIVE','LOCKED','LOST','REISSUED','EXPIRED','CLOSED')),
  status          varchar(16) NOT NULL DEFAULT 'UNACTIVATED'
                  CHECK (status IN ('UNACTIVATED','ACTIVE','SUSPENDED','CLOSED')),
  instant_issued  boolean NOT NULL DEFAULT false,
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now(),
  version         integer NOT NULL DEFAULT 0,
  CONSTRAINT uq_card_hash UNIQUE (card_no_hash)
);
CREATE INDEX idx_cards_account ON card.cards(account_id);
CREATE INDEX idx_cards_party   ON card.cards(party_id);

CREATE TABLE card.tokens (
  id         uuid PRIMARY KEY,
  card_id    uuid NOT NULL REFERENCES card.cards(id),
  token_no   varchar(64) NOT NULL UNIQUE,
  platform   varchar(24) NOT NULL
             CHECK (platform IN ('APPLE_PAY','GOOGLE_PAY','SAMSUNG_PAY','GARMIN_PAY','FITBIT_PAY','TAIWAN_PAY','QR_CODE')),
  device_ref varchar(80),
  status     varchar(16) NOT NULL DEFAULT 'ACTIVE'
             CHECK (status IN ('ACTIVE','SUSPENDED','DELETED')),
  bound_at   timestamptz NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  version    integer NOT NULL DEFAULT 0
);

-- lint:skip=SQL-04  理由：pool 為集合名詞，表單一邏輯資源池，不加複數 s
CREATE TABLE card.card_no_pool (
  id          uuid PRIMARY KEY,
  bin         char(8) NOT NULL,
  range_start varchar(19) NOT NULL,
  range_end   varchar(19) NOT NULL,
  used_count  bigint NOT NULL DEFAULT 0,
  total_count bigint NOT NULL,
  status      varchar(16) NOT NULL DEFAULT 'ACTIVE',
  created_at  timestamptz NOT NULL DEFAULT now(),
  updated_at  timestamptz NOT NULL DEFAULT now(),
  version     integer NOT NULL DEFAULT 0
);

-- 已配發卡號（避免亂數給號重複）
-- lint:skip=SQL-05  理由：卡號雜湊之去重註冊表。以 card_no_hash 為自然主鍵，
--                    記錄一經寫入即不變，故無 id／updated_at／version 之語意。
CREATE TABLE card.issued_card_numbers (
  card_no_hash char(64) PRIMARY KEY,
  card_id      uuid NOT NULL,
  issued_at    timestamptz NOT NULL DEFAULT now()
);
```

> **變更交付說明（`card_idempotency`，2026-09-21 回寫｜`HANDOVER #C-18`）**
> 本表於 `V1__baseline.sql` **不存在**，係由後續遷移建立；屬**服務內部狀態**，不列入領域 ER 圖。
>
> | 遷移 | 內容 |
> |---|---|
> | `V2__card_idempotency.sql` | 建表：`key`(PK)／`fingerprint`／`entity_type`／`entity_id`／`created_at` |
>
> 同 §3.1 `pty_idempotency`——為 §3.15 `IDEMPOTENCY_RECORD` 於 `card` schema 之實體實作。
> ⚠️ 無 `id`／`updated_at`／`version`，理由同上（一經寫入即不再更新）。
>
> （`cards.ticket_type` 已於 1.4 版由 `V3__card_ticket_type.sql` 回寫，見 §10 修訂記錄。）

### 3.3 `product`　產品與參數

```mermaid
erDiagram
  PRODUCT {
    uuid id PK
    varchar product_code UK
    varchar card_name
    varchar org
    varchar card_grade
    varchar ticket_type
    varchar status
    date effective_from
    date effective_to
    integer version
  }
  PARAMETER {
    uuid id PK
    varchar namespace
    varchar param_key
    jsonb param_value
    varchar value_type
    date effective_from
    date effective_to
    integer version
  }
  RULE_SET {
    uuid id PK
    varchar rule_code UK
    varchar rule_type
    jsonb condition
    jsonb action
    integer priority
    varchar status
    integer version
  }
  CHARSET_MAPPING {
    uuid id PK
    varchar internal_char
    varchar external_code
    varchar org
  }
  PRODUCT ||--o{ PARAMETER : defines
  PRODUCT ||--o{ RULE_SET : applies
```

**DDL（節錄）**

```sql
CREATE SCHEMA IF NOT EXISTS product;

CREATE TABLE product.products (
  id             uuid PRIMARY KEY,
  product_code   varchar(16) NOT NULL UNIQUE,
  card_name      varchar(60) NOT NULL,
  org            varchar(16) NOT NULL CHECK (org IN ('VISA','MASTERCARD','JCB','AMEX','UNIONPAY','DINERS','DISCOVER')),
  card_grade     varchar(24) NOT NULL,
  ticket_type    varchar(24),
  status         varchar(16) NOT NULL DEFAULT 'DRAFT'
                 CHECK (status IN ('DRAFT','ACTIVE','SUSPENDED','RETIRED')),
  effective_from date NOT NULL,
  effective_to   date,
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now(),
  version        integer NOT NULL DEFAULT 0
);

CREATE TABLE product.parameters (
  id             uuid PRIMARY KEY,
  namespace      varchar(40) NOT NULL,
  param_key      varchar(80) NOT NULL,
  param_value    jsonb NOT NULL,
  value_type     varchar(16) NOT NULL CHECK (value_type IN ('STRING','NUMBER','BOOLEAN','JSON','AMOUNT','RATE')),
  effective_from date NOT NULL,
  effective_to   date,
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now(),
  version        integer NOT NULL DEFAULT 0,
  CONSTRAINT uq_param UNIQUE (namespace, param_key, effective_from)
);

CREATE TABLE product.rule_sets (
  id         uuid PRIMARY KEY,
  rule_code  varchar(40) NOT NULL UNIQUE,
  rule_type  varchar(24) NOT NULL
             CHECK (rule_type IN ('AUTH_CHECK','REWARD','FEE','INTEREST','DEDUCT','ELIGIBILITY')),
  condition  jsonb NOT NULL,
  action     jsonb NOT NULL,
  priority   integer NOT NULL DEFAULT 100,
  status     varchar(16) NOT NULL DEFAULT 'ACTIVE'
             CHECK (status IN ('ACTIVE','INACTIVE')),
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  version    integer NOT NULL DEFAULT 0
);

CREATE TABLE product.charset_mappings (
  id            uuid PRIMARY KEY,
  internal_char varchar(8) NOT NULL,
  external_code varchar(8) NOT NULL,
  org           varchar(16) NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now(),
  updated_at    timestamptz NOT NULL DEFAULT now(),
  version       integer NOT NULL DEFAULT 0,
  CONSTRAINT uq_charset UNIQUE (org, internal_char)
);
```

### 3.4 `limit`　額度

```mermaid
erDiagram
  CREDIT_LIMIT {
    uuid id PK
    uuid account_id UK
    bigint permanent_amount
    bigint temporary_amount
    date temp_start_date
    date temp_end_date
    bigint shadow_amount
    bigint used_amount
    bigint available_amount
    char currency
    integer version
  }
  SPECIAL_LIMIT {
    uuid id PK
    uuid account_id FK
    char currency
    varchar purpose
    bigint amount
    bigint used_amount
    date effective_from
    date effective_to
    varchar status
    integer version
  }
  LIMIT_HOLD {
    uuid id PK
    uuid account_id FK
    uuid authorization_id FK
    bigint hold_amount
    bigint released_amount
    varchar hold_type
    varchar status
    timestamptz expires_at
    integer version
  }
  LIMIT_ADJUSTMENT {
    uuid id PK
    uuid account_id FK
    varchar adjust_type
    bigint before_amount
    bigint after_amount
    varchar reason
    varchar applicant_id
    varchar approver_id
    varchar status
    integer version
  }
  CREDIT_LIMIT ||--o{ SPECIAL_LIMIT : has
  CREDIT_LIMIT ||--o{ LIMIT_HOLD : holds
  CREDIT_LIMIT ||--o{ LIMIT_ADJUSTMENT : adjusts
```

**DDL（節錄）**

```sql
CREATE SCHEMA IF NOT EXISTS lim;   -- 避免與 SQL 保留字 LIMIT 衝突

CREATE TABLE lim.credit_limits (
  id                uuid PRIMARY KEY,
  account_id        uuid NOT NULL UNIQUE,
  permanent_amount  bigint NOT NULL CHECK (permanent_amount >= 0),
  temporary_amount  bigint NOT NULL DEFAULT 0 CHECK (temporary_amount >= 0),
  temp_start_date   date,
  temp_end_date     date,
  shadow_amount     bigint NOT NULL DEFAULT 0 CHECK (shadow_amount >= 0),
  used_amount       bigint NOT NULL DEFAULT 0 CHECK (used_amount >= 0),
  available_amount  bigint NOT NULL CHECK (available_amount >= 0),
  currency          char(3) NOT NULL DEFAULT 'TWD',
  created_at        timestamptz NOT NULL DEFAULT now(),
  updated_at        timestamptz NOT NULL DEFAULT now(),
  version           integer NOT NULL DEFAULT 0,
  CONSTRAINT chk_limit_positive CHECK (available_amount >= 0)
);

CREATE TABLE lim.special_limits (
  id             uuid PRIMARY KEY,
  account_id     uuid NOT NULL,
  purpose        varchar(24) NOT NULL
                 CHECK (purpose IN ('TAX','INSURANCE','TUITION','MEDICAL','OTHER')),
  amount         bigint NOT NULL CHECK (amount >= 0),
  used_amount    bigint NOT NULL DEFAULT 0,
  effective_from date NOT NULL,
  effective_to   date,
  status         varchar(16) NOT NULL DEFAULT 'ACTIVE'
                 CHECK (status IN ('ACTIVE','EXPIRED','CANCELLED')),
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now(),
  version        integer NOT NULL DEFAULT 0
);

CREATE TABLE lim.limit_holds (
  id               uuid PRIMARY KEY,
  account_id       uuid NOT NULL,
  authorization_id uuid,
  hold_amount      bigint NOT NULL CHECK (hold_amount > 0),
  released_amount  bigint NOT NULL DEFAULT 0 CHECK (released_amount >= 0),
  hold_type        varchar(16) NOT NULL CHECK (hold_type IN ('GENERAL','SPECIAL','INSTALLMENT')),
  status           varchar(16) NOT NULL DEFAULT 'HELD'
                   CHECK (status IN ('HELD','PARTIALLY_RELEASED','RELEASED','EXPIRED')),
  expires_at       timestamptz,
  created_at       timestamptz NOT NULL DEFAULT now(),
  updated_at       timestamptz NOT NULL DEFAULT now(),
  version          integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_holds_account ON lim.limit_holds(account_id, status);
CREATE INDEX idx_holds_auth    ON lim.limit_holds(authorization_id);
```

> **額度扣減以條件更新實作**（避免超額）：
> ```sql
> UPDATE lim.credit_limits
>    SET available_amount = available_amount - :amount,
>        used_amount      = used_amount + :amount,
>        version          = version + 1,
>        updated_at       = now()
>  WHERE id = :id
>    AND version = :version
>    AND available_amount >= :amount;
> -- 影響筆數 0 → 餘額不足或併發衝突
> ```

> **變更交付說明（`account_limits`／`limit_adjustments`，2026-09-21 回寫｜`HANDOVER #C-18`）**
> 二表於 `V1__baseline.sql` **不存在**，係由後續遷移建立。
> ⚠️ 本節 DDL 區塊**尚未含此二表**——本次先以本說明區塊記載目標狀態之欄位，
> 避免「實物有、文件無」；ER 圖之補繪列為後續（見文末「未竟事項」）。
>
> | 遷移 | 內容 |
> |---|---|
> | `V2__account_limits.sql` | 建 `lim.account_limits`：`id`(PK)／`account_id`／`currency`／`permanent_amount`／`temporary_amount`／`temp_start_date`／`temp_end_date`／`shadow_amount`／`used_amount`／`status`(預設 `ACTIVE`)／`created_at`／`updated_at`／`version`；`UNIQUE(account_id, currency)`；索引 `(status)` |
> | `V3__limit_adjustments.sql` | 建 `lim.limit_adjustments`：`id`(PK)／`account_id`／`currency`／`adjust_type`／`before_amount`／`after_amount`／`status`(預設 `DRAFT`)／`temp_start_date`／`temp_end_date`／`purpose`／`reason`／`applicant_id`／`approver_id`／`effective_at`／`created_at`／`updated_at`／`version`；索引 `(account_id, status)` |
>
> **值域（`CHECK` 約束，實測為準）**：
> - `adjust_type` ∈ `PERMANENT`｜`TEMPORARY`｜`SPECIAL`｜`SHADOW`
> - `status` ∈ `DRAFT`｜`PENDING_APPROVAL`｜`EFFECTIVE`｜`REJECTED`｜`EXPIRED`｜`REVOKED`
>
> **為何 `account_limits` 與 `credit_limits` 並存**：後者為**產品／帳戶層之授信額度**，
> 前者為 **`CR-001` 新增之帳戶信用額度主檔**（帳戶 × 幣別，含永久／臨時／彈性／已用四分額）。
> 二者語意不同，**非重複建表**。

> **變更交付說明（`special_limit_temp_adjustments`，2026-10-02｜`CR-035 §2.4`／`#A-156`）**
>
> 同上原則（**本節 SQL 區塊不得改動**），以新遷移交付：
>
> | 遷移 | 內容 |
> |---|---|
> | `V9__special_limit_temp_adjustments.sql` | 建 `lim.special_limit_temp_adjustments`：`id`(PK)／`account_id`／`currency`／`special_limit_id`／`authorization_id`／`posting_id`／`raised_amount`(`> 0`)／`recovered_amount`(`>= 0`)／`raised_on`／`raise_until`／`due_date_source`(`DERIVED`／`STATEMENT`)／`status`(`OPEN`／`CLOSED`)／通用欄位；`UNIQUE INDEX uk_special_temp_posting`(posting_id)；索引 `(account_id, status)` |
>
> **語意**（`BR-04-02`）：8-6 請款時**調高卡人臨時額度**（迄日＝繳款截止日＋7 日）之**逐筆紀錄**，
> 供 8-7 繳款時**精確扣回**。★ **必須逐筆**——`account_limits.temporary_amount` 可能**同時**承載
> 「人工核准之臨時額度」，直接歸零會**誤砍客戶經核准之額度**。詳見 `SCS-002 §7.12`。

> **變更交付說明（`special_limit_policies`，2026-10-02｜`CR-035 §2.3`／`#A-155`）**
>
> 同上原則（**本節 SQL 區塊不得改動**），以新遷移交付：
>
> | 遷移 | 內容 |
> |---|---|
> | `V8__special_limit_policies.sql` | 建 `lim.special_limit_policies`：`id`(PK)／`role_code`／`segment_code`／`currency`／`max_amount`(`>= 0`)／`status`(`ACTIVE`／`INACTIVE`)／`created_at`／`updated_at`／`version`；`UNIQUE (role_code, segment_code, currency)`；索引 `(role_code, status)` |
>
> **語意**（`BR-04-03`）：某「**角色 × 客層 × 幣別**」下**可設定**之專用額度上限。
> ★ **角色之權威為「已驗證之權杖」**（服務權杖之 `azp`／使用者權杖之 realm roles），
> **不接受請求帶入**；`segment_code` 因平台無客層來源而由請求帶入（如實揭露）。
> 詳見 `SCS-002 §7.11`。

> **變更交付說明（`special_limits` 之「池」與用途範圍，2026-10-02｜`CR-035 §2.1／§2.2`／`#A-154`）**
>
> 同上原則（**本節 SQL 區塊不得改動**），以新遷移交付：
>
> | 遷移 | 內容 |
> |---|---|
> | `V7__special_limit_pool.sql` | ①`special_limits ADD (scope_ref VARCHAR2(64 CHAR))`——**用途範圍（MCC 白名單，逗號分隔）**，`NULL` ＝ 不限定；②`limit_adjustments ADD (scope_ref VARCHAR2(64 CHAR))`——**稽核軌跡**同步留痕；③`limit_holds ADD (special_limit_id RAW(16))`——非 `NULL` ＝ 該佔用屬**專用額度池**（`hold_type='SPECIAL'` 為其粗粒度標記，該值域 `V1` 即有）；④`idx_holds_special_limit` |
>
> **語意**：專用額度池之佔用**完全不動 `account_limits`** → `BR-04-01`「專用額度不佔卡人額度」為**結構性成立**，
> 非靠事後沖銷（`SCS-001 §7.16`、`SCS-002 §7.10`）。
> `scope_ref` 之格式為逗號清單，**刻意不加 `CHECK`**（用 `CHECK` 表達會又長又易錯）——
> 合法性由應用層於寫入前檢核（**不合法即拒絕**，非靜默接受）。

> **變更交付說明（`special_limits`，2026-10-02｜`CR-014 v1.1`／`#A-153`）**
>
> 上方 SQL 區塊之 `lim.special_limits` **即 `V1__baseline.sql` 之來源**（由 `gen_flyway_baseline.py` 產生，
> 已於 2026-09-19 以 Flyway 基線套用至真機）。**本節之 SQL 區塊不得改動**——
> 一改，`--force` 重生 V1 將造成 **checksum 失配、服務起不來**（`CSP-001 §5.3`／`#C-36` 之教訓）。
> 結構變更一律以**新遷移**交付，並於此以說明區塊記載目標狀態（比照上表 `account_limits` 之做法）。
>
> | 遷移 | 內容 |
> |---|---|
> | `V6__special_limits_currency_and_index.sql` | ①`ADD (currency CHAR(3 CHAR))` ＋ 由 `account_limits` 回填 ＋ `MODIFY (currency NOT NULL)`（**不設預設值**，不把「TWD」寫進 DDL；無法歸屬幣別之列將使遷移 **fail-closed**）；②`CREATE INDEX idx_special_limits_account ON special_limits(account_id, status)`；③`CREATE UNIQUE INDEX uk_special_limits_active ON special_limits(account_id, currency, purpose, CASE WHEN status='ACTIVE' THEN 'ACTIVE' END)` |
>
> **為何補 `currency`**：`account_limits` 之鍵為 `(account_id, currency)`，**多幣別為本設計之既有能力**；
> 無幣別之專用額度**無法歸屬**，且 upsert 之業務鍵會跨幣別碰撞（`CR-014 v1.1 §5`）。
>
> **為何加 `uk_special_limits_active`**：語意為「**一歸戶 × 一幣別 × 一用途，至多一筆 `ACTIVE`**」。
> 若容許兩筆 `ACTIVE` 同用途，授權端之專用額度檢核將**重複計算**（可用額度被高估）→ 屬**超額放行風險**。
> Oracle B-tree 唯一索引**不索引全 `NULL` 鍵** → 非 `ACTIVE` 之歷史列（`EXPIRED`／`CANCELLED`）可留多筆。
>
> **本表之既有值域即權威**（**不自由化**）：`purpose IN ('TAX','INSURANCE','TUITION','MEDICAL','OTHER')`、
> `amount >= 0`、`status IN ('ACTIVE','EXPIRED','CANCELLED')`（真機實測，`CR-001 §2.4` 亦已載明）。
> `limit_adjustments`（申請／覆核**軌跡**）與本表（**現行定義**）之分工見 `SCS-002 §7.9.1`。

### 3.5 `authorization`　授權

```mermaid
erDiagram
  AUTHORIZATION {
    uuid id PK
    varchar auth_no UK
    varchar stan
    uuid card_id FK
    uuid account_id FK
    varchar card_no_masked
    varchar tx_type
    varchar channel
    bigint amount
    char currency
    bigint billing_amount
    char billing_currency
    varchar mcc
    varchar merchant_id
    varchar merchant_name
    varchar resp_code
    varchar decision
    varchar entry_mode
    varchar standin_flag
    bigint hold_amount
    timestamptz tx_time
    integer version
  }
  AUTH_CHECK_RESULT {
    uuid id PK
    uuid authorization_id FK
    varchar check_code
    smallint check_order
    varchar result
    varchar reason_code
    jsonb detail
  }
  AUTH_REVERSAL {
    uuid id PK
    uuid authorization_id FK
    varchar reversal_type
    bigint amount
    varchar reason
    varchar status
    timestamptz reversed_at
  }
  AUTHORIZATION ||--o{ AUTH_CHECK_RESULT : evaluated_by
  AUTHORIZATION ||--o| AUTH_REVERSAL : reversed_by
```

**DDL（節錄）**

```sql
CREATE SCHEMA IF NOT EXISTS auth;

CREATE TABLE auth.authorizations (
  id               uuid PRIMARY KEY,
  auth_no          varchar(24) NOT NULL UNIQUE,
  stan             varchar(12) NOT NULL,
  card_id          uuid,
  account_id       uuid,
  card_no_masked   varchar(20) NOT NULL,
  tx_type          varchar(16) NOT NULL
                   CHECK (tx_type IN ('PURCHASE','CASH_ADVANCE','REFUND','REVERSAL','INSTALLMENT','TAX','FEE','REDEEM')),
  channel          varchar(16) NOT NULL CHECK (channel IN ('POS','EC','ATM','MOBILE','WEB','API')),
  amount           bigint NOT NULL CHECK (amount > 0),
  currency         char(3) NOT NULL,
  billing_amount   bigint NOT NULL,
  billing_currency char(3) NOT NULL,
  mcc              varchar(8),
  merchant_id      varchar(24),
  merchant_name    varchar(80),
  resp_code        varchar(8) NOT NULL,
  decision         varchar(16) NOT NULL CHECK (decision IN ('APPROVED','DECLINED','REFERRAL','STANDIN')),
  entry_mode       varchar(16),
  emv_data         jsonb,
  standin_flag     boolean NOT NULL DEFAULT false,
  hold_amount      bigint NOT NULL DEFAULT 0,
  tx_time          timestamptz NOT NULL,
  created_at       timestamptz NOT NULL DEFAULT now(),
  updated_at       timestamptz NOT NULL DEFAULT now(),
  version          integer NOT NULL DEFAULT 0
) PARTITION BY RANGE (tx_time);

CREATE INDEX idx_auth_card   ON auth.authorizations(card_id, tx_time DESC);
CREATE INDEX idx_auth_acct   ON auth.authorizations(account_id, tx_time DESC);
CREATE INDEX idx_auth_stan   ON auth.authorizations(stan);

CREATE TABLE auth.auth_check_results (
  id               uuid PRIMARY KEY,
  authorization_id uuid NOT NULL REFERENCES auth.authorizations(id),
  check_code       varchar(32) NOT NULL,
  check_order      smallint NOT NULL,
  result           varchar(16) NOT NULL CHECK (result IN ('PASS','FAIL','SKIP')),
  reason_code      varchar(16),
  detail           jsonb,
  created_at       timestamptz NOT NULL DEFAULT now(),
  updated_at       timestamptz NOT NULL DEFAULT now(),
  version          integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_check_auth ON auth.auth_check_results(authorization_id, check_order);
```

> **變更交付說明（授權讀模型快取 ＋ `hold_id`，2026-09-21 回寫｜`HANDOVER #C-18`）**
> 下列各項於 `V1__baseline.sql` **不存在**，係由後續遷移建立。
>
> | 遷移 | 內容 |
> |---|---|
> | `V2__read_model.sql` | 建 **3 張讀模型表**：`auth.card_status_caches`／`auth.account_limit_caches`／`lim.limit_holds`(已於 §3.4 記載) |
> | `V3__authorization_hold_id.sql` | `auth.authorizations` **新增 `hold_id`** |
> | `V10__authorization_entry_emv.sql` | `auth.authorizations` **新增 `emv_data`（`JSON`）**——`CR-043 §2.3`／`SCS-001 §7.17`。★ 併記：既有 `merchant_name`／`entry_mode`（本節 DDL 已載）於 `V1__baseline.sql` 早已存在，惟**至本次方由實作接線落庫**（此前恆 NULL） |
>
> **為何讀模型表不列入領域 ER 圖**：`card_status_caches`／`account_limit_caches` 係
> **CQRS 讀模型**（授權熱路徑為壓低延遲，於本地快取卡狀態與額度），
> **權威來源仍在 `card`／`limit`**，可由事件重建——非業務主檔，故不入 ER 圖，
> 以免與權威實體混淆。
>
> **`hold_id` 之語意**：關聯 `lim.limit_holds`，使授權與其所佔用之額度**可直接對照**
> （原僅能由 `authorization_id` 反查）。屬**可追溯性**補強，非新業務欄位。
>
> ⚠️ 本節 ER 圖**尚未含 `hold_id`**——本次先以本說明區塊記載，ER 圖補繪列為後續
> （見文末「未竟事項」）。

### 3.6 `posting`　帳務

```mermaid
erDiagram
  TRANSACTION {
    uuid id PK
    varchar tx_no UK
    uuid account_id FK
    uuid card_id FK
    uuid authorization_id FK
    varchar tx_code
    varchar tx_type
    bigint amount
    char currency
    varchar idempotency_key UK
    varchar source_type
    varchar status
    date posting_date
    date value_date
    timestamptz posted_at
    varchar reversal_of
    integer version
  }
  SUB_ACCOUNT_ENTRY {
    uuid id PK
    uuid transaction_id FK
    varchar sub_account
    bigint amount
    bigint before_amount
    bigint after_amount
    char currency
  }
  BALANCE {
    uuid id PK
    uuid account_id UK
    bigint principal_amount
    bigint interest_amount
    bigint fee_amount
    bigint tax_amount
    bigint total_amount
    bigint minimum_due
    date payment_due_date
    smallint aging_bucket
    char currency
    integer version
  }
  BALANCE_HISTORY {
    uuid id PK
    uuid account_id FK
    date as_of_date
    bigint principal_amount
    bigint interest_amount
    bigint fee_amount
    bigint total_amount
  }
  TRANSACTION ||--o{ SUB_ACCOUNT_ENTRY : splits_into
  BALANCE ||--o{ BALANCE_HISTORY : snapshots
```

**DDL（節錄）**

```sql
CREATE SCHEMA IF NOT EXISTS posting;

CREATE TABLE posting.transactions (
  id               uuid PRIMARY KEY,
  tx_no            varchar(24) NOT NULL UNIQUE,
  account_id       uuid NOT NULL,
  card_id          uuid,
  authorization_id uuid,
  tx_code          varchar(16) NOT NULL,
  tx_type          varchar(24) NOT NULL
                   CHECK (tx_type IN ('PURCHASE','CASH_ADVANCE','FEE','INTEREST','PAYMENT','REFUND',
                                      'ADJUSTMENT','REVERSAL','INSTALLMENT','REWARD_REDEEM','CHARGEBACK')),
  amount           bigint NOT NULL,
  currency         char(3) NOT NULL,
  idempotency_key  varchar(120) NOT NULL,
  source_type      varchar(16) NOT NULL CHECK (source_type IN ('CLEARING','ONLINE','BATCH','MANUAL','SYSTEM')),
  status           varchar(16) NOT NULL DEFAULT 'POSTED'
                   CHECK (status IN ('PENDING','POSTED','REVERSED','FAILED')),
  posting_date     date NOT NULL,
  value_date       date NOT NULL,
  posted_at        timestamptz NOT NULL,
  reversal_of      uuid,
  created_at       timestamptz NOT NULL DEFAULT now(),
  updated_at       timestamptz NOT NULL DEFAULT now(),
  version          integer NOT NULL DEFAULT 0,
  CONSTRAINT uq_posting_idem UNIQUE (idempotency_key)
) PARTITION BY RANGE (posting_date);

CREATE INDEX idx_tx_account ON posting.transactions(account_id, posting_date DESC);
CREATE INDEX idx_tx_auth    ON posting.transactions(authorization_id);

CREATE TABLE posting.sub_account_entries (
  id             uuid PRIMARY KEY,
  transaction_id uuid NOT NULL REFERENCES posting.transactions(id),
  sub_account    varchar(16) NOT NULL
                 CHECK (sub_account IN ('PRINCIPAL','INTEREST','FEE','TAX')),
  amount         bigint NOT NULL,
  before_amount  bigint NOT NULL,
  after_amount   bigint NOT NULL,
  currency       char(3) NOT NULL,
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now(),
  version        integer NOT NULL DEFAULT 0
);

CREATE TABLE posting.balances (
  id                uuid PRIMARY KEY,
  account_id        uuid NOT NULL UNIQUE,
  principal_amount  bigint NOT NULL DEFAULT 0,
  interest_amount   bigint NOT NULL DEFAULT 0,
  fee_amount        bigint NOT NULL DEFAULT 0,
  tax_amount        bigint NOT NULL DEFAULT 0,
  total_amount      bigint NOT NULL DEFAULT 0,
  minimum_due       bigint NOT NULL DEFAULT 0,
  payment_due_date  date,
  aging_bucket      smallint NOT NULL DEFAULT 0,
  currency          char(3) NOT NULL DEFAULT 'TWD',
  created_at        timestamptz NOT NULL DEFAULT now(),
  updated_at        timestamptz NOT NULL DEFAULT now(),
  version           integer NOT NULL DEFAULT 0
);

-- lint:skip=SQL-04  理由：history 為集合名詞，*_history 為餘額快照歷程表之標準後綴
CREATE TABLE posting.balance_history (
  id               uuid PRIMARY KEY,
  account_id       uuid NOT NULL,
  as_of_date       date NOT NULL,
  principal_amount bigint NOT NULL,
  interest_amount  bigint NOT NULL,
  fee_amount       bigint NOT NULL,
  tax_amount       bigint NOT NULL,
  total_amount     bigint NOT NULL,
  created_at       timestamptz NOT NULL DEFAULT now(),
  updated_at       timestamptz NOT NULL DEFAULT now(),
  version          integer NOT NULL DEFAULT 0,
  CONSTRAINT uq_balance_history UNIQUE (account_id, as_of_date)
);
```

### 3.7 `payment`　繳款

```mermaid
erDiagram
  PAYMENT {
    uuid id PK
    varchar payment_no UK
    uuid account_id FK
    bigint amount
    char currency
    varchar payment_method
    varchar channel
    varchar idempotency_key UK
    varchar status
    date payment_date
    timestamptz received_at
    timestamptz posted_at
    boolean limit_restored
    varchar unmatched_flag
    varchar reversal_reason
    integer version
  }
  PAYMENT_ALLOCATION {
    uuid id PK
    uuid payment_id FK
    varchar sub_account
    bigint amount
    smallint allocation_order
  }
  AUTO_DEBIT_ENROLLMENT {
    uuid id PK
    uuid account_id FK
    varchar debit_bank
    varchar debit_account_masked
    blob debit_account_enc
    bigint debit_amount
    date effective_from
    varchar status
    varchar idempotency_key
    integer version
  }
  PAYMENT ||--o{ PAYMENT_ALLOCATION : allocates
```

**DDL（節錄）**

```sql
CREATE SCHEMA IF NOT EXISTS payment;

CREATE TABLE payment.payments (
  id               uuid PRIMARY KEY,
  payment_no       varchar(24) NOT NULL UNIQUE,
  account_id       uuid NOT NULL,
  amount           bigint NOT NULL CHECK (amount > 0),
  currency         char(3) NOT NULL DEFAULT 'TWD',
  payment_method   varchar(16) NOT NULL
                   CHECK (payment_method IN ('AUTO_DEBIT','ATM','COUNTER','CONVENIENCE_STORE','MOBILE','WEB')),
  channel          varchar(16) NOT NULL,
  idempotency_key  varchar(120) NOT NULL UNIQUE,
  status           varchar(16) NOT NULL DEFAULT 'RECEIVED'
                   CHECK (status IN ('RECEIVED','ALLOCATED','POSTED','REVERSED','FAILED','UNMATCHED')),
  payment_date     date NOT NULL,
  received_at      timestamptz NOT NULL,
  posted_at        timestamptz,
  limit_restored   boolean NOT NULL DEFAULT false,
  -- 與 status 為**正交維度**：UNMATCHED_FLAG 表「本次受理未取得沖抵來源」（資料品質訊號），
  -- status = 'UNMATCHED' 表「已分配、逾三日未收銷帳檔」（作業狀態）。
  -- 二者可同時成立，不得互相取代（SDD-005 §5、SCS-008 §10）。
  unmatched_flag   boolean NOT NULL DEFAULT false,
  -- 沖正原因（2026-09-21 回寫 `HANDOVER #C-18`；實體交付 V3__reversal_reason_and_autodebit_idem.sql）
  -- 帳務異動之必要留痕：原 V1/V2 無處可存，契約要求之 reason「收了也存不了」＝假資料。
  reversal_reason  varchar(200),
  created_at       timestamptz NOT NULL DEFAULT now(),
  updated_at       timestamptz NOT NULL DEFAULT now(),
  version          integer NOT NULL DEFAULT 0
);

CREATE TABLE payment.payment_allocations (
  id               uuid PRIMARY KEY,
  payment_id       uuid NOT NULL REFERENCES payment.payments(id),
  sub_account      varchar(16) NOT NULL
                   CHECK (sub_account IN ('FEE','INTEREST','PRINCIPAL','TAX')),
  amount           bigint NOT NULL CHECK (amount >= 0),
  allocation_order smallint NOT NULL,
  created_at       timestamptz NOT NULL DEFAULT now(),
  updated_at       timestamptz NOT NULL DEFAULT now(),
  version          integer NOT NULL DEFAULT 0
);

CREATE TABLE payment.auto_debit_enrollments (
  id                   uuid PRIMARY KEY,
  account_id           uuid NOT NULL,
  debit_bank           varchar(8) NOT NULL,
  -- 對外顯示用之遮蔽值（僅留後 4 碼，PiiMasker.maskAccount）；永不回傳明文
  debit_account_masked varchar(20) NOT NULL,
  -- 內部扣款用之 AES-256-GCM 密文（v1:<keyId>:<iv>:<ct>，UTF-8 位元組）；
  -- 僅供內部扣款流程解密，不經 API 回傳。可為 NULL（V5 前之既有資料）
  debit_account_enc    bytea,
  debit_amount         bigint,
  effective_from       date NOT NULL,
  status               varchar(16) NOT NULL DEFAULT 'ACTIVE'
                       CHECK (status IN ('ACTIVE','CANCELLED')),
  idempotency_key      varchar(120) NOT NULL UNIQUE,
  created_at           timestamptz NOT NULL DEFAULT now(),
  updated_at           timestamptz NOT NULL DEFAULT now(),
  version              integer NOT NULL DEFAULT 0
);
```

> **變更交付說明（`AUTO_DEBIT_ENROLLMENTS`，2026-09-19）**
> 本表於 `V1__baseline.sql` **不存在**，係由後續遷移建立；上列為**目標狀態**。
>
> | 遷移 | 內容 |
> |---|---|
> | `V2__auto_debit_enrollments_and_no_seq.sql` | 建表（含 `debit_account_masked`）＋ `payment_no_seq` |
> | **`V5__autodebit_account_ciphertext.sql`** | **新增 `debit_account_enc`**（`W-1` 之 `KeyProvider` 推廣） |
>
> **兩欄並存之理由**：`debit_account_masked` 供**對外顯示**（永不回傳明文）；
> `debit_account_enc` 供**內部扣款**解密取得完整帳號。只有遮蔽值則無法扣款；
> 只有密文則每次顯示都要解密（無謂的金鑰存取與風險暴露）。
>
> ⚠️ **本節 1.0 版之 ER 圖曾列 `debit_account_enc`，但 DDL 區塊漏列本表**
> ——已於本次補齊（文件與實物一致）。ER 圖之欄位亦補上 `debit_account_masked`。
>
> ⚠️ **既有 `V1__baseline.sql` 已凍結、不得修改**（`SAD-003 §9`／`CSP-002 §5`）；
> 重跑 `gen_flyway_baseline.py --force` 會覆寫 V1 並使 Flyway checksum 失配。

> **變更交付說明（`UNMATCHED`，2026-09-19｜`CR-003`）**
> 本節 DDL 為**目標狀態**。`payments.status` 之值域增列 `UNMATCHED`，
> 其實體交付為 **`V4__payments_unmatched_status.sql`**（既有 `V1__baseline.sql` **已凍結、不得修改**，
> 見 `SAD-003 §9`／`CSP-002 §5`）。
> ⚠️ **重跑 `gen_flyway_baseline.py --force` 會覆寫 V1 並使 Flyway checksum 失配**；
> 若確需重建，須先 `flyway repair` 或重建本地庫（`docker compose down -v`）。

> **變更交付說明（`reversal_reason`，2026-09-21 回寫｜`HANDOVER #C-18`）**
> `payments.reversal_reason`（`varchar(200)`，可為 NULL）之實體交付為
> **`V3__reversal_reason_and_autodebit_idem.sql`**；本次已同步補入本節 **ER 圖與 DDL**。
>
> **為何需要**：`CR`（2026-09-19）修約後，契約要求 `POST /payments/{id}/reversals`
> 須帶 request body（`PaymentReversalRequest.reason`）。原 V1／V2 表**無處存放沖正原因** →
> 「收了也存不了」，形同假資料。沖正原因屬**帳務異動之必要留痕**（稽核）。
>
> ⚠️ **更正 `CR-003 §6 #3` 之不實記載**：該處原稱「本次僅加註 §3.7／§3.8／§3.10 三處」，
> 惟 2026-09-21 盤點發現 **`reversal_reason` 於本檔全文出現次數為 0**——
> §3.7 實際**並未**回寫（§3.8／§3.10 則確已完成）。故本次補上，
> 並於 `CR-003` 修訂記錄更正該記載，以免後人據以判定「已完成」而再度略過。

### 3.8 `rewards`　點數（R2 / FR-RWD-5.x）

點數採「累積（pending）→ 確認（confirmed）→ 到期／兌換／沒收」三態生命週期，
依產品參數（P0–P3）與消費維度（MCC／通路／卡別）計算，支援折抵帳單、兌換商品、里程、費用減免。

```mermaid
erDiagram
  POINTS_ACCOUNT {
    uuid id PK
    uuid account_id FK
    varchar points_type
    varchar tier
    bigint balance
    bigint pending_balance
    bigint confirmed_balance
    varchar expiry_policy_code
    date next_expiry_date
    bigint next_expiry_points
    varchar status
    integer version
  }
  POINTS_TIER {
    uuid id PK
    varchar tier_code UK
    varchar product_code FK
    numeric earn_multiplier
    numeric redeem_rate
    bigint qualification_spend
    varchar status
  }
  POINTS_LEDGER {
    uuid id PK
    uuid points_account_id FK
    varchar entry_type
    bigint points
    bigint before_balance
    bigint after_balance
    varchar source_type
    uuid source_id
    varchar earn_rule_code
    varchar idempotency_key UK
    date posting_date
    timestamptz occurred_at
    varchar status
    integer version
  }
  POINTS_EXPIRY_BUCKET {
    uuid id PK
    uuid points_account_id FK
    date expiry_date
    bigint points
    varchar status
    integer version
  }
  REWARD_RULE {
    uuid id PK
    varchar rule_code UK
    varchar points_type
    jsonb dimensions
    jsonb conditions
    varchar period_type
    integer priority
    varchar status
  }
  REDEMPTION {
    uuid id PK
    varchar redemption_no UK
    uuid account_id FK
    varchar redemption_type
    bigint points_used
    bigint cash_value
    bigint amount_paid
    jsonb targets
    varchar channel
    varchar status
    timestamptz redeemed_at
    integer version
  }
  POINTS_ACCOUNT ||--o{ POINTS_LEDGER : records
  POINTS_ACCOUNT ||--o{ POINTS_EXPIRY_BUCKET : buckets
  POINTS_ACCOUNT }o--|| POINTS_TIER : classifies
  POINTS_ACCOUNT ||--o{ REDEMPTION : redeems
```

**DDL**

```sql
CREATE SCHEMA IF NOT EXISTS rw;

CREATE TABLE rw.points_accounts (
  id                   uuid PRIMARY KEY,
  account_id           uuid NOT NULL,
  points_type          varchar(16) NOT NULL CHECK (points_type IN ('EARN','BONUS','REDEEM')),
  tier                 varchar(16) NOT NULL DEFAULT 'CLASSIC',
  balance              bigint NOT NULL DEFAULT 0 CHECK (balance >= 0),
  pending_balance      bigint NOT NULL DEFAULT 0 CHECK (pending_balance >= 0),
  confirmed_balance    bigint NOT NULL DEFAULT 0 CHECK (confirmed_balance >= 0),
  expiry_policy_code   varchar(16) NOT NULL DEFAULT 'FIXED_24M',
  next_expiry_date     date,
  next_expiry_points   bigint NOT NULL DEFAULT 0,
  status               varchar(16) NOT NULL DEFAULT 'ACTIVE'
                       CHECK (status IN ('ACTIVE','FROZEN','CLOSED')),
  created_at           timestamptz NOT NULL DEFAULT now(),
  updated_at           timestamptz NOT NULL DEFAULT now(),
  version              integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_pa_account ON rw.points_accounts(account_id);

CREATE TABLE rw.points_tiers (
  id                  uuid PRIMARY KEY,
  tier_code           varchar(16) NOT NULL UNIQUE,
  product_code        varchar(16) NOT NULL,
  earn_multiplier     numeric(5,2) NOT NULL DEFAULT 1.00,
  redeem_rate         numeric(9,6) NOT NULL,
  qualification_spend bigint NOT NULL DEFAULT 0,
  status              varchar(16) NOT NULL DEFAULT 'ACTIVE'
                      CHECK (status IN ('ACTIVE','INACTIVE')),
  created_at          timestamptz NOT NULL DEFAULT now(),
  updated_at          timestamptz NOT NULL DEFAULT now(),
  version             integer NOT NULL DEFAULT 0
);

-- lint:skip=SQL-04  理由：ledger 為集合名詞，表單一邏輯帳本，不加複數 s
CREATE TABLE rw.points_ledger (
  id                uuid PRIMARY KEY,
  points_account_id uuid NOT NULL REFERENCES rw.points_accounts(id),
  entry_type        varchar(16) NOT NULL
                     CHECK (entry_type IN ('EARN','CONFIRM','FORFEIT','REDEEM','EXPIRE','ADJUST','TRANSFER')),
  points            bigint NOT NULL,
  before_balance    bigint NOT NULL,
  after_balance     bigint NOT NULL,
  source_type       varchar(16) CHECK (source_type IN ('AUTHORIZATION','TRANSACTION','MANUAL','PROMOTION')),
  source_id         uuid,
  earn_rule_code    varchar(40),
  idempotency_key   varchar(120) NOT NULL UNIQUE,
  posting_date      date NOT NULL,
  occurred_at       timestamptz NOT NULL,
  status            varchar(16) NOT NULL DEFAULT 'PENDING'
                     CHECK (status IN ('PENDING','CONFIRMED','REVERSED')),
  created_at        timestamptz NOT NULL DEFAULT now(),
  updated_at        timestamptz NOT NULL DEFAULT now(),
  version           integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_pl_account ON rw.points_ledger(points_account_id, posting_date DESC);

CREATE TABLE rw.points_expiry_buckets (
  id               uuid PRIMARY KEY,
  points_account_id uuid NOT NULL REFERENCES rw.points_accounts(id),
  expiry_date      date NOT NULL,
  points           bigint NOT NULL CHECK (points > 0),
  status           varchar(16) NOT NULL DEFAULT 'ACTIVE'
                     CHECK (status IN ('ACTIVE','EXPIRED','REDEEMED')),
  created_at       timestamptz NOT NULL DEFAULT now(),
  updated_at       timestamptz NOT NULL DEFAULT now(),
  version          integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_pe_account ON rw.points_expiry_buckets(points_account_id, expiry_date);

CREATE TABLE rw.redemptions (
  id              uuid PRIMARY KEY,
  redemption_no   varchar(24) NOT NULL UNIQUE,
  account_id      uuid NOT NULL,
  redemption_type varchar(16) NOT NULL
                   CHECK (redemption_type IN ('CASH','CATEGORY','GOODS','MILEAGE','FEE_WAIVER','DONATION')),
  points_used     bigint NOT NULL CHECK (points_used > 0),
  cash_value      bigint NOT NULL DEFAULT 0,
  amount_paid     bigint NOT NULL DEFAULT 0,
  targets         jsonb,
  channel         varchar(16) NOT NULL,
  status          varchar(16) NOT NULL DEFAULT 'PENDING'
                   CHECK (status IN ('PENDING','CONFIRMED','CANCELLED','REVERSED')),
  redeemed_at     timestamptz NOT NULL,
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now(),
  version         integer NOT NULL DEFAULT 0
);
```

> 點數確認以「帳務週期結束＋N 日」為準（pending → confirmed）；逾期未兌換依 `expiry_policy_code`
> 轉入 `points_expiry_buckets` 並於到期日標記 `EXPIRED`（FORFEIT）。兌換現金（`CASH`）即時沖抵
> `posting.balances` 本金，見 SDD-003 §兌換序列。

> **變更交付說明（V2 遷移補充，2026-09-19）**
> 本節 ER/DDL 為 **V1 基線快照**。以下項目**未列於本節**，係因契約或上層設計已要求、
> 但 V1 無處可存，而於 **V2 遷移**補建（`V2__reward_rules_bucket_stage_and_idempotency.sql`）：
>
> | 物件 | 用途 | 欄位值域（實測 CHECK） |
> |---|---|---|
> | `rw.reward_rules`（表） | 回饋規則（ER 圖有 `REWARD_RULE` 實體，V1 DDL 區塊漏列） | `rule_type IN ('REWARD','FEE','INTEREST','DEDUCT','ELIGIBILITY')`；`status IN ('DRAFT','ACTIVE','RETIRED')` |
> | `POINTS_EXPIRY_BUCKETS.bucket_stage`（欄） | 標示該批點數屬 `CUR`／`AV1`／`AV2` 哪一階段；缺此欄則「先到期先用」與階段搬移無法實作 | `bucket_stage IN ('CUR','AV1','AV2')` |
> | `POINTS_LEDGER.bucket_stage`（欄） | 使兌換之扣抵階段可追溯（稽核缺口） | 同上 |
> | `REDEMPTIONS.idempotency_key`（欄） | 兌換為扣點之財務操作，重送不得重複扣點 | `UNIQUE` |
> | `rw.redemption_no_seq`（序列） | 兌換編號連號可稽核 | — |
>
> 狀態機見 `SDD-005 §10.1`（桶階段）／`§10.2`（桶狀態）／`§10.3`（帳戶）。
>
> **變更交付說明（V4 遷移補充，2026-09-22，`CR-012` 雙欄位）**
>
> | 物件 | 用途 | 欄位值域 |
> |---|---|---|
> | `REDEMPTIONS.settlement_method`（欄，`V4__redemptions_settlement_method.sql`） | 兌換之**折抵方式**維度（怎麼付）；與 `redemption_type`（**標的類別**，換什麼）**正交**——「用點數＋現金換哩程」須 標的＝`MILEAGE` 且 折抵＝`POINTS_PLUS_CASH`，單一欄位無法同時表達（`CR-012 §3`） | `settlement_method IN ('POINTS_ONLY','POINTS_PLUS_CASH','ONLINE_REDEEM','MERGE','EXCHANGE_CENTER')`；`DEFAULT 'POINTS_ONLY' NOT NULL` |
> **V1 基線已凍結，不得修改**（`SAD-003 §9`、`CSP-002 §5`）。

### 3.9 `installment`　分期（R2 / FR-ACQ-4.x）

分期分「商家分期（MERCHANT）」與「銀行分期（BANK，含紅利分期、帳單分期）」兩類；
每期含本金＋手續費，支援提前清償、展期與違約。本金餘額獨立維護，與 `posting.balances`
總額勾稽。

```mermaid
erDiagram
  INSTALLMENT_PRODUCT {
    uuid id PK
    varchar product_code UK
    varchar name
    varchar channel_type
    varchar business_type
    bigint min_amount
    smallint min_periods
    smallint max_periods
    numeric fee_rate
    numeric annual_rate
    boolean merchant_eligible
    varchar status
    integer version
  }
  INSTALLMENT_PLAN {
    uuid id PK
    varchar plan_no UK
    uuid account_id FK
    uuid card_id FK
    uuid original_authorization_id
    uuid original_transaction_id
    varchar channel_type
    varchar business_type
    varchar product_code FK
    bigint original_amount
    char currency
    smallint period_count
    bigint per_period_amount
    bigint first_period_amount
    bigint last_period_amount
    numeric fee_rate
    bigint total_fee
    numeric annual_rate
    bigint down_payment
    bigint processing_fee
    date start_date
    date first_due_date
    bigint principal_outstanding
    smallint paid_periods
    smallint remaining_periods
    boolean early_settlement_flag
    varchar status
    integer version
  }
  INSTALLMENT_PERIOD {
    uuid id PK
    uuid plan_id FK
    smallint period_no
    bigint amount
    bigint principal_amount
    bigint fee_amount
    date due_date
    varchar status
    timestamptz paid_at
    bigint remaining_principal
    integer version
  }
  INSTALLMENT_PRODUCT ||--o{ INSTALLMENT_PLAN : offers
  INSTALLMENT_PLAN ||--o{ INSTALLMENT_PERIOD : schedules
```

**DDL**

```sql
CREATE SCHEMA IF NOT EXISTS inst;

CREATE TABLE inst.installment_products (
  id                   uuid PRIMARY KEY,
  product_code         varchar(16) NOT NULL UNIQUE,
  name                 varchar(60) NOT NULL,
  channel_type         varchar(16) NOT NULL
                        CHECK (channel_type IN ('MERCHANT','BANK','REVOLVING','BILL',
                                                'ACQUIRER','NCCC','QRCODE')),
  business_type        varchar(16)
                        CHECK (business_type IS NULL
                               OR business_type IN ('SINGLE','RECURRING','STATEMENT')),
  min_amount           bigint NOT NULL DEFAULT 0,
  min_periods          smallint NOT NULL DEFAULT 3,
  max_periods          smallint NOT NULL DEFAULT 36,
  fee_rate             numeric(9,6) NOT NULL DEFAULT 0,
  annual_rate          numeric(9,6) NOT NULL DEFAULT 0,
  merchant_eligible    boolean NOT NULL DEFAULT false,
  status               varchar(16) NOT NULL DEFAULT 'ACTIVE'
                        CHECK (status IN ('ACTIVE','INACTIVE')),
  created_at           timestamptz NOT NULL DEFAULT now(),
  updated_at           timestamptz NOT NULL DEFAULT now(),
  version              integer NOT NULL DEFAULT 0
);

CREATE TABLE inst.installment_plans (
  id                         uuid PRIMARY KEY,
  plan_no                    varchar(24) NOT NULL UNIQUE,
  account_id                 uuid NOT NULL,
  card_id                    uuid,
  original_authorization_id  uuid,
  original_transaction_id     uuid,
  channel_type               varchar(16) NOT NULL
                              CHECK (channel_type IN ('MERCHANT','BANK','REVOLVING','BILL',
                                                      'ACQUIRER','NCCC','QRCODE')),
  business_type              varchar(16)
                              CHECK (business_type IS NULL
                                     OR business_type IN ('SINGLE','RECURRING','STATEMENT')),
  product_code               varchar(16),
  original_amount            bigint NOT NULL CHECK (original_amount > 0),
  currency                   char(3) NOT NULL DEFAULT 'TWD',
  period_count               smallint NOT NULL CHECK (period_count BETWEEN 1 AND 60),
  per_period_amount          bigint NOT NULL CHECK (per_period_amount > 0),
  first_period_amount        bigint NOT NULL,
  last_period_amount         bigint NOT NULL,
  fee_rate                   numeric(9,6) NOT NULL DEFAULT 0,
  total_fee                  bigint NOT NULL DEFAULT 0,
  annual_rate                numeric(9,6) NOT NULL DEFAULT 0,
  down_payment               bigint NOT NULL DEFAULT 0,
  processing_fee             bigint NOT NULL DEFAULT 0,
  start_date                 date NOT NULL,
  first_due_date             date NOT NULL,
  principal_outstanding      bigint NOT NULL DEFAULT 0,
  paid_periods               smallint NOT NULL DEFAULT 0,
  remaining_periods          smallint NOT NULL DEFAULT 0,
  early_settlement_flag      boolean NOT NULL DEFAULT false,
  status                     varchar(16) NOT NULL DEFAULT 'ACTIVE'
                              CHECK (status IN ('ACTIVE','COMPLETED','PRE_CLOSED','DEFAULTED','CANCELLED')),
  created_at                 timestamptz NOT NULL DEFAULT now(),
  updated_at                 timestamptz NOT NULL DEFAULT now(),
  version                    integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_ip_account ON inst.installment_plans(account_id);

CREATE TABLE inst.installment_periods (
  id                  uuid PRIMARY KEY,
  plan_id             uuid NOT NULL REFERENCES inst.installment_plans(id),
  period_no           smallint NOT NULL CHECK (period_no >= 1),
  amount              bigint NOT NULL CHECK (amount >= 0),
  principal_amount    bigint NOT NULL DEFAULT 0,
  fee_amount          bigint NOT NULL DEFAULT 0,
  due_date            date NOT NULL,
  status              varchar(16) NOT NULL DEFAULT 'DUE'
                        CHECK (status IN ('DUE','PAID','OVERDUE','WAIVED','REVERSED')),
  paid_at             timestamptz,
  remaining_principal bigint NOT NULL DEFAULT 0,
  created_at          timestamptz NOT NULL DEFAULT now(),
  updated_at          timestamptz NOT NULL DEFAULT now(),
  version             integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_ip_period_plan ON inst.installment_periods(plan_id, period_no);
```

> 每期扣款由 `payment-service` 依 `due_date` 產生應繳明細並沖抵 `principal_outstanding`；
> 提前清償（PRE_CLOSED）依 `annual_rate` 計算剩餘手續費減免後結清，見 SDD-003 §分期扣款序列。

### 3.10 `statement`　帳單（R2 / FR-ACQ-4.x）

帳單為「結帳日當下」的餘額快照，欄位直接對應紙本/電子帳單之每一行；
明細行（`statement_lines`）凍結當期交易，避免後續沖正導致帳單內容漂移。

```mermaid
erDiagram
  STATEMENT {
    uuid id PK
    varchar statement_no UK
    uuid account_id FK
    varchar statement_month
    varchar cycle_code
    date statement_date
    date payment_due_date
    smallint grace_period_days
    bigint prev_balance
    bigint payments_credits
    bigint purchases_debits
    bigint finance_charges
    bigint fees
    bigint adjustments
    bigint new_balance
    bigint minimum_due
    bigint reward_points_earned
    bigint reward_points_balance
    char currency
    varchar send_type
    varchar pdf_ref
    boolean e_statement_flag
    varchar status
    timestamptz generated_at
    timestamptz sent_at
    integer version
  }
  STATEMENT_LINE {
    uuid id PK
    uuid statement_id FK
    integer seq
    date posting_date
    varchar tx_no
    varchar description
    varchar mcc
    varchar tx_type
    varchar card_no_masked
    bigint amount
    char currency
    integer version
  }
  STATEMENT ||--o{ STATEMENT_LINE : details
```

**DDL**

```sql
CREATE SCHEMA IF NOT EXISTS stmt;

CREATE TABLE stmt.statements (
  id                   uuid PRIMARY KEY,
  statement_no         varchar(24) NOT NULL UNIQUE,
  account_id           uuid NOT NULL,
  statement_month      varchar(8)  NOT NULL,         -- YYYYMM
  cycle_code           varchar(8)  NOT NULL,
  statement_date       date NOT NULL,
  payment_due_date     date NOT NULL,
  grace_period_days    smallint NOT NULL DEFAULT 0,
  prev_balance         bigint NOT NULL DEFAULT 0,
  payments_credits     bigint NOT NULL DEFAULT 0,
  purchases_debits     bigint NOT NULL DEFAULT 0,
  finance_charges      bigint NOT NULL DEFAULT 0,
  fees                 bigint NOT NULL DEFAULT 0,
  adjustments          bigint NOT NULL DEFAULT 0,
  new_balance          bigint NOT NULL DEFAULT 0,
  minimum_due          bigint NOT NULL DEFAULT 0,
  reward_points_earned bigint NOT NULL DEFAULT 0,
  reward_points_balance bigint NOT NULL DEFAULT 0,
  currency             char(3) NOT NULL DEFAULT 'TWD',
  send_type            varchar(16) NOT NULL DEFAULT 'ELECTRONIC'
                       CHECK (send_type IN ('PAPER','ELECTRONIC','NONE')),
  pdf_ref              varchar(255),
  e_statement_flag     boolean NOT NULL DEFAULT true,
  status               varchar(16) NOT NULL DEFAULT 'GENERATED'
                       CHECK (status IN ('GENERATED','SENT','PAID','PARTIALLY_PAID','OVERDUE','DISPUTED','CLOSED')),
  generated_at         timestamptz NOT NULL,
  sent_at              timestamptz,
  created_at           timestamptz NOT NULL DEFAULT now(),
  updated_at           timestamptz NOT NULL DEFAULT now(),
  version              integer NOT NULL DEFAULT 0,
  CONSTRAINT uq_stmt_month UNIQUE (account_id, statement_month)
);
CREATE INDEX idx_stmt_account ON stmt.statements(account_id, statement_date DESC);

CREATE TABLE stmt.statement_lines (
  id              uuid PRIMARY KEY,
  statement_id    uuid NOT NULL REFERENCES stmt.statements(id),
  seq             integer NOT NULL,
  posting_date    date NOT NULL,
  tx_no           varchar(24),
  description     varchar(120) NOT NULL,
  mcc             varchar(8),
  tx_type         varchar(24),
  card_no_masked  varchar(20),
  amount          bigint NOT NULL,
  currency        char(3) NOT NULL DEFAULT 'TWD',
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now(),
  version         integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_sl_statement ON stmt.statement_lines(statement_id, seq);
```

> **變更交付說明（V2 遷移補充，2026-09-19）**
> 本節 ER/DDL 為 **V1 基線快照**。以下二表**未列於本節**，係因契約要求
> 「批次狀態與 Checkpoint」而於 **V2 遷移**補建（`V2__statement_runs_and_no_seq.sql`）：
>
> | 表／物件 | 用途 | 欄位值域（實測 CHECK） |
> |---|---|---|
> | `stmt.statement_runs` | 帳單批次執行：狀態／已處理筆數／`checkpoint`（供異常續跑） | `status IN ('GENERATING','GENERATED','FAILED')` |
> | `stmt.statement_cycle_accounts` | 帳單週期帳戶主檔：決定各週期應結帳之歸戶 | `UNIQUE(cycle_code, account_id)` |
> | `stmt.statement_no_seq` | 帳單編號序列（連號可稽核，不得以隨機值替代） | — |
>
> 狀態機見 `SDD-005 §9.2`（批次執行）。**V1 基線已凍結，不得修改**
> （`SAD-003 §9`、`CSP-002 §5`）；本節之 ER 圖亦為 V1 快照，故不含此二表。

### 3.11 `pricing`　費率與利息（R2 / FR-PRC）

依產品（P0–P3）、卡別、幣別定義循環利率、各項手續費、寬限期與計算方式；
利息計算方法（平均日終餘額 ADB／上月餘額／雙週期）直接寫入 `calc_method`，Coder 不必自創。

```mermaid
erDiagram
  PRICING_RULE {
    uuid id PK
    varchar rule_code UK
    varchar rule_type
    varchar product_code FK
    varchar org
    varchar tier
    date effective_from
    date effective_to
    jsonb tiers
    numeric rate
    numeric annual_rate
    smallint grace_period_days
    bigint fee_amount
    bigint min_fee
    bigint cap_amount
    varchar calc_method
    varchar status
    integer version
  }
```

**DDL**

```sql
CREATE SCHEMA IF NOT EXISTS prc;

CREATE TABLE prc.pricing_rules (
  id                uuid PRIMARY KEY,
  rule_code         varchar(40) NOT NULL UNIQUE,
  rule_type         varchar(24) NOT NULL
                    CHECK (rule_type IN ('INTEREST','ANNUAL_FEE','CASH_ADV_FEE','LATE_FEE','FOREIGN_TX_FEE','OVERLIMIT_FEE','FEE_WAIVER')),
  product_code      varchar(16),
  org               varchar(16),
  tier              varchar(16),
  effective_from    date NOT NULL,
  effective_to      date,
  tiers             jsonb,                          -- 金額級距（如現金透支分層費率）
  rate              numeric(9,6) NOT NULL DEFAULT 0,
  annual_rate       numeric(9,6) NOT NULL DEFAULT 0,
  grace_period_days smallint NOT NULL DEFAULT 0,
  fee_amount        bigint NOT NULL DEFAULT 0,
  min_fee           bigint NOT NULL DEFAULT 0,
  cap_amount        bigint NOT NULL DEFAULT 0,
  calc_method       varchar(16) NOT NULL DEFAULT 'ADB'
                    CHECK (calc_method IN ('ADB','PREVIOUS_BALANCE','TWO_CYCLE','FLAT')),
  status            varchar(16) NOT NULL DEFAULT 'ACTIVE'
                    CHECK (status IN ('ACTIVE','INACTIVE')),
  created_at        timestamptz NOT NULL DEFAULT now(),
  updated_at        timestamptz NOT NULL DEFAULT now(),
  version           integer NOT NULL DEFAULT 0
);
```

> **變更交付說明（V2 遷移補充，2026-09-22，C-31：`TX_TYPE` 欄位）**
>
> | 物件 | 用途 | 欄位值域 |
> |---|---|---|
> | `PRICING_RULES.tx_type`（欄，`V2__pricing_rules_tx_type.sql`） | **交易型別**——區分一般消費與預借現行之循環利率（種子 18.5% vs 20%）。原曾暫以 `TIER`（客層級距）挪用作為區分維度（本地慣例），本欄位增設後 `TIER` 回歸本義（`SCS-006 §7.5`、`CR-005 §8 #1` 結案） | `tx_type IN ('GENERAL','CASH_ADVANCE')`；**可為 null**——僅 `INTEREST` 規則具值，費用類規則不硬塞值；既有 `INTEREST` 列依 `TIER` 回填 |
>
> **V1 基線已凍結，不得修改**（`SAD-003 §9`、`CSP-002 §5`）。

### 3.12 `clearing`　清算結算（R1 / FR-CLR）

清算依卡組織（VISA／MC／NCCC／AE／DFS）收送檔（VCF／EMS／SETTLEMENT），
逐筆比對授權與請款，計算交換費（interchange）與網路費，產出結算單。

```mermaid
erDiagram
  CLEARING_BATCH {
    uuid id PK
    varchar batch_no UK
    varchar org
    varchar direction
    varchar file_type
    varchar file_name
    date file_date
    date settlement_date
    bigint tx_count
    bigint total_amount
    bigint fee_amount
    char currency
    varchar checksum
    varchar status
    integer version
  }
  CLEARING_ITEM {
    uuid id PK
    uuid batch_id FK
    varchar tx_ref
    varchar card_no_masked
    bigint amount
    bigint fee
    varchar mcc
    varchar merchant_id
    varchar status
    uuid matched_tx_id
    integer version
  }
  SETTLEMENT {
    uuid id PK
    varchar org
    date settlement_date
    bigint gross_amount
    bigint interchange_fee
    bigint network_fee
    bigint net_amount
    char currency
    varchar status
    integer version
  }
  CLEARING_BATCH ||--o{ CLEARING_ITEM : contains
```

**DDL**

```sql
CREATE SCHEMA IF NOT EXISTS clr;

CREATE TABLE clr.clearing_batches (
  id             uuid PRIMARY KEY,
  batch_no       varchar(32) NOT NULL UNIQUE,
  org            varchar(16) NOT NULL
                 CHECK (org IN ('VISA','MASTERCARD','NCCC','AMEX','DFS','JCB')),
  direction      varchar(8)  NOT NULL CHECK (direction IN ('INBOUND','OUTBOUND')),
  file_type      varchar(16) NOT NULL
                 CHECK (file_type IN ('VCF','EMS','SETTLEMENT','CHARGEBACK','FEES')),
  file_name      varchar(120) NOT NULL,
  file_date      date NOT NULL,
  settlement_date date NOT NULL,
  tx_count       bigint NOT NULL DEFAULT 0,
  total_amount   bigint NOT NULL DEFAULT 0,
  fee_amount     bigint NOT NULL DEFAULT 0,
  currency       char(3) NOT NULL DEFAULT 'TWD',
  checksum       varchar(64),
  status         varchar(16) NOT NULL DEFAULT 'RECEIVED'
                 CHECK (status IN ('RECEIVED','PARSED','MATCHED','SETTLED','REJECTED')),
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now(),
  version        integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_clr_batch_org_date ON clr.clearing_batches(org, file_date);

CREATE TABLE clr.clearing_items (
  id             uuid PRIMARY KEY,
  batch_id       uuid NOT NULL REFERENCES clr.clearing_batches(id),
  tx_ref         varchar(32) NOT NULL,
  card_no_masked varchar(20),
  amount         bigint NOT NULL DEFAULT 0,
  fee            bigint NOT NULL DEFAULT 0,
  mcc            varchar(8),
  merchant_id    varchar(24),
  status         varchar(16) NOT NULL DEFAULT 'UNMATCHED'
                 CHECK (status IN ('MATCHED','UNMATCHED','EXCEPTION')),
  matched_tx_id  uuid,
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now(),
  version        integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_clr_item_batch ON clr.clearing_items(batch_id);

CREATE TABLE clr.settlements (
  id              uuid PRIMARY KEY,
  org             varchar(16) NOT NULL,
  settlement_date date NOT NULL,
  gross_amount    bigint NOT NULL DEFAULT 0,
  interchange_fee bigint NOT NULL DEFAULT 0,
  network_fee     bigint NOT NULL DEFAULT 0,
  net_amount      bigint NOT NULL DEFAULT 0,
  currency        char(3) NOT NULL DEFAULT 'TWD',
  status          varchar(16) NOT NULL DEFAULT 'PENDING'
                  CHECK (status IN ('PENDING','SETTLED')),
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now(),
  version         integer NOT NULL DEFAULT 0
);
```

> **變更交付說明（V2 遷移補充，2026-09-22，`CR-016`：清算執行／對帳結果／org 值域／結算方向）**
>
> | 物件 | 用途 | 欄位值域 |
> |---|---|---|
> | `CLEARING_RUNS`（新表，`V2__clearing_runs_and_reconciliations.sql`） | **清算執行**——一次執行涵蓋**多個批次檔**（多組織／多檔型），與 `CLEARING_BATCHES`（一檔一列）為**一對多**；契約 `POST /clearing/runs` 之 `runId` 所繫 | `status IN ('RUNNING','MATCHED','DIFFERENCE','FAILED')`；`RUN_NO` UNIQUE；`ORGS`（逗號分隔） |
> | `CLEARING_RECONCILIATIONS`（新表） | **對帳結果**——依「執行 × 組織」記載差異；契約 `GET /clearing/reconciliations/{runId}` 所繫 | `difference_type IN ('TIMING','FX_RATE','SUBSTANTIVE')`；FK(`RUN_ID`) REFERENCES `CLEARING_RUNS`(`ID`) |
> | `CLEARING_BATCHES.ORG` 值域擴充（具名約束 `CK_CLEARING_BATCHES_ORG`） | 契約之 `orgs` 列舉含 `DINERS`／`DISCOVER`，原 DDL 為 6 值 | `org IN ('VISA','MASTERCARD','NCCC','AMEX','DFS','JCB','DISCOVER','DINERS')`——**保留 `DFS`**（與 `DISCOVER` 為同一組織之兩種寫法，不為相容而刪值） |
> | `SETTLEMENTS.DIRECTION`（欄） | 結算方向（契約必要欄位） | `direction IN ('ACQUIRER_PAYOUT','ISSUER_RECEIVABLE')`；可為 null（既有列無此值） |
>
> **V1 基線已凍結，不得修改**（`SAD-003 §9`、`CSP-002 §5`）。

### 3.13 `dispute`　爭議帳款（R1 / FR-DIS）

爭議依卡組織時限與階段機制（Inquiry → 1st/2nd Presentment → Pre-Arb → Arbitration），
全程留存處理軌跡；暫付款與扣回產生對應會計分錄（與清算及帳務勾稽）。

```mermaid
erDiagram
  DISPUTE_CASE {
    uuid id PK
    varchar dispute_no UK
    uuid account_id FK
    uuid card_id FK
    uuid transaction_id FK
    uuid authorization_id FK
    varchar org
    varchar arn
    varchar reason_code
    varchar dispute_type
    varchar stage
    varchar currency
    bigint amount
    jsonb evidence_required
    jsonb evidence_submitted
    smallint representment_count
    varchar liability
    timestamptz response_deadline
    varchar status
    timestamptz opened_at
    timestamptz closed_at
    varchar assigned_to
    integer version
  }
  DISPUTE_TIMELINE {
    uuid id PK
    uuid dispute_id FK
    varchar stage
    varchar action
    varchar actor
    timestamptz occurred_at
    jsonb detail
    integer version
  }
  DISPUTE_CASE ||--o{ DISPUTE_TIMELINE : tracks
```

**DDL**

```sql
CREATE SCHEMA IF NOT EXISTS dis;

CREATE TABLE dis.dispute_cases (
  id                    uuid PRIMARY KEY,
  dispute_no            varchar(32) NOT NULL UNIQUE,
  account_id            uuid NOT NULL,
  card_id               uuid,
  transaction_id        uuid,
  authorization_id      uuid,
  org                   varchar(16) NOT NULL
                        CHECK (org IN ('VISA','MASTERCARD','NCCC','AMEX','DFS','JCB','INTERNAL')),
  arn                   varchar(32),
  reason_code           varchar(8),
  dispute_type          varchar(24) NOT NULL
                        CHECK (dispute_type IN ('CHARGEBACK','INQUIRY','RETROACTIVE','FRAUD','DUPLICATE')),
  stage                 varchar(24) NOT NULL DEFAULT 'INQUIRY'
                        CHECK (stage IN ('INQUIRY','FIRST_PRESENTMENT','SECOND_PRESENTMENT','PRE_ARB','ARBITRATION','CLOSED')),
  currency              char(3) NOT NULL DEFAULT 'TWD',
  amount                bigint NOT NULL CHECK (amount >= 0),
  evidence_required     jsonb NOT NULL DEFAULT '[]'::jsonb,
  evidence_submitted    jsonb NOT NULL DEFAULT '[]'::jsonb,
  representment_count   smallint NOT NULL DEFAULT 0,
  liability             varchar(16) CHECK (liability IN ('ISSUER','ACQUIRER','MERCHANT','PENDING')),
  response_deadline     timestamptz NOT NULL,
  status                varchar(16) NOT NULL DEFAULT 'OPEN'
                        CHECK (status IN ('OPEN','EVIDENCE_PENDING','REPRESENTED','WON','LOST','CLOSED')),
  opened_at             timestamptz NOT NULL,
  closed_at             timestamptz,
  assigned_to           varchar(40),
  created_at            timestamptz NOT NULL DEFAULT now(),
  updated_at            timestamptz NOT NULL DEFAULT now(),
  version               integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_dispute_account ON dis.dispute_cases(account_id);
CREATE INDEX idx_dispute_tx ON dis.dispute_cases(transaction_id);

CREATE TABLE dis.dispute_timelines (
  id           uuid PRIMARY KEY,
  dispute_id    uuid NOT NULL REFERENCES dis.dispute_cases(id),
  stage         varchar(24) NOT NULL,
  action        varchar(32) NOT NULL,
  actor         varchar(40) NOT NULL,
  occurred_at   timestamptz NOT NULL,
  detail        jsonb,
  created_at    timestamptz NOT NULL DEFAULT now(),
  updated_at    timestamptz NOT NULL DEFAULT now(),
  version       integer NOT NULL DEFAULT 0
);
```

> **變更交付說明（V2 遷移補充，2026-09-22，`CR-017`：爭議雙軸拆分＋扣款類型）**
>
> | 物件 | 用途 | 欄位值域 |
> |---|---|---|
> | `DISPUTE_CASES.DISPUTE_REASON`（欄，`V2__dispute_reason_workflow_stage_chargeback.sql`） | **爭議原因**——與 `DISPUTE_TYPE`（**程序類型**）為**正交兩軸**（原契約之 `disputeType` 實指原因，而 DDL 之 `dispute_type` 混用兩者，交集僅 `DUPLICATE`） | `dispute_reason IN ('UNAUTHORIZED','DUPLICATE','GOODS_SERVICES','OTHER')`（`CK_DISPUTE_CASES_REASON`） |
> | `DISPUTE_CASES.WORKFLOW_STAGE`（欄） | **本行內部作業階段**——與 `STAGE`（**卡組織程序階段**，`SDD-005 §7.1`）為**正交兩軸**（交集僅 `ARBITRATION`／`CLOSED`） | `workflow_stage IN ('OPENED','EVIDENCE_COLLECTING','UNDER_REVIEW','ARBITRATION','RESOLVED_ISSUER','RESOLVED_ACQUIRER','CLOSED')`（`CK_DISPUTE_CASES_WORKFLOW_STAGE`） |
> | `DISPUTE_CASES.CHARGEBACK_TYPE`／`CHARGEBACK_REASON`（欄） | 扣款／仲裁之提交類型與原因（稽核） | `chargeback_type IN ('FIRST','SECOND','ARBITRATION')`（`CK_DISPUTE_CASES_CHARGEBACK_TYPE`） |
> | `DISPUTE_CASES.DISPUTE_TYPE` 之 CHECK 具名化 | 值域**不變**（回歸程序類型語意；`FRAUD`／`DUPLICATE` 為過渡期遺留值） | `CK_DISPUTE_CASES_TYPE`：`dispute_type IN ('CHARGEBACK','INQUIRY','RETROACTIVE','FRAUD','DUPLICATE')` |
>
> **V1 基線已凍結，不得修改**（`SAD-003 §9`、`CSP-002 §5`）。

### 3.14 `merchant`　特約商店與收單（R1 / FR-ACQ）

```mermaid
erDiagram
  MERCHANT {
    uuid id PK
    varchar merchant_id UK
    varchar merchant_name
    varchar merchant_type
    varchar mcc
    varchar org
    varchar acquirer_id
    jsonb terminal_ids
    varchar settlement_bank
    varchar settlement_account_enc
    varchar risk_level
    varchar kmc_category
    varchar status
    timestamptz onboarded_at
    integer version
  }
  MERCHANT_SETTLEMENT {
    uuid id PK
    varchar settlement_no UK
    uuid merchant_id FK
    varchar org
    date settlement_date
    integer batch_count
    bigint gross_amount
    bigint fee_amount
    bigint tax_amount
    bigint net_amount
    char currency
    varchar status
    integer version
  }
  MERCHANT ||--o{ MERCHANT_SETTLEMENT : settles
```

**DDL**

```sql
CREATE SCHEMA IF NOT EXISTS mch;

CREATE TABLE mch.merchants (
  id                    uuid PRIMARY KEY,
  merchant_id           varchar(24) NOT NULL UNIQUE,
  merchant_name         varchar(120) NOT NULL,
  merchant_type         varchar(24) NOT NULL
                          CHECK (merchant_type IN ('RETAIL','EC','MOTO','RESTAURANT','TRAVEL','SUPERMARKET','OTHER')),
  mcc                   varchar(8),
  org                   varchar(16),
  acquirer_id           varchar(24),
  terminal_ids          jsonb NOT NULL DEFAULT '[]'::jsonb,
  settlement_bank       varchar(16),
  settlement_account_enc bytea,
  risk_level            varchar(8) NOT NULL DEFAULT 'LOW'
                          CHECK (risk_level IN ('LOW','MEDIUM','HIGH')),
  kmc_category          varchar(24),
  status                varchar(16) NOT NULL DEFAULT 'ACTIVE'
                          CHECK (status IN ('ACTIVE','SUSPENDED','TERMINATED')),
  onboarded_at          timestamptz NOT NULL,
  created_at            timestamptz NOT NULL DEFAULT now(),
  updated_at            timestamptz NOT NULL DEFAULT now(),
  version               integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_mch_mcc ON mch.merchants(mcc);

CREATE TABLE mch.merchant_settlements (
  id              uuid PRIMARY KEY,
  settlement_no   varchar(32) NOT NULL UNIQUE,
  merchant_id     uuid NOT NULL REFERENCES mch.merchants(id),
  org             varchar(16),
  settlement_date date NOT NULL,
  batch_count     integer NOT NULL DEFAULT 0,
  gross_amount    bigint NOT NULL DEFAULT 0,
  fee_amount      bigint NOT NULL DEFAULT 0,
  tax_amount      bigint NOT NULL DEFAULT 0,
  net_amount      bigint NOT NULL DEFAULT 0,
  currency        char(3) NOT NULL DEFAULT 'TWD',
  status          varchar(16) NOT NULL DEFAULT 'PENDING'
                  CHECK (status IN ('PENDING','SETTLED')),
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now(),
  version         integer NOT NULL DEFAULT 0
);
CREATE INDEX idx_ms_merchant ON mch.merchant_settlements(merchant_id, settlement_date DESC);
```

> **變更交付說明（V2 遷移補充，2026-09-22，`CR-015`：雙軸拆分＋狀態值域擴充＋缺欄）**
>
> | 物件 | 用途 | 欄位值域 |
> |---|---|---|
> | `MERCHANTS.MERCHANT_FORM`（欄，`V2__merchant_form_and_status.sql`） | **商店型態**——與 `MERCHANT_TYPE`（**業別**）為**正交兩軸**（一間「超市」可以是「連鎖」）；契約之 `merchantType` 即對映本欄 | `merchant_form IN ('SINGLE','FRANCHISE','CHAIN','GROUP')` |
> | `MERCHANTS.PARENT_MERCHANT_ID`（欄） | 集團店上層（自參考 FK） | `RAW(16)`，`NULL` 表非集團店 |
> | `MERCHANTS.ACCEPTED_CARD_TYPES`（欄） | 受理卡種 | `JSON DEFAULT JSON('[]') NOT NULL`（比照 `TERMINAL_IDS` 手法） |
> | `MERCHANTS.STATUS` 值域擴充（具名約束 `CK_MERCHANTS_STATUS`） | 補「申請中」與「已結案」 | `status IN ('APPLIED','ACTIVE','SUSPENDED','TERMINATED','CLOSED')` |
> | `MERCHANT_SETTLEMENTS.BATCH_NO`（欄） | 請款批次號（`BATCH_COUNT` 為**筆數**，語意不同） | `VARCHAR2(32)` |
> | `MERCHANT_SETTLEMENTS.EXCEPTION_REASON`（欄） | 異常原因（稽核） | `VARCHAR2(240)` |
> | `MERCHANT_SETTLEMENTS.STATUS` 值域擴充（`CK_MERCHANT_SETTLEMENTS_STATUS`） | 契約之請款流程 4 值併入 DDL 之入帳 2 值 | `status IN ('SUBMITTED','VERIFIED','EXCEPTION','PAID','PENDING','SETTLED')` |
>
> **兩具名約束之建立方式**：V1 之 CHECK 為內聯宣告（Oracle 自動命名 `SYS_C*`），
> V2 以資料字典**動態尋得後重建為具名**（比照 `V4__payments_unmatched_status.sql`），
> 使後續值域變更得穩定引用；尋得條件須同時比對 ` IN (` 以免誤刪同表之
> `"STATUS" IS NOT NULL` 約束。
>
> **V1 基線已凍結，不得修改**（`SAD-003 §9`、`CSP-002 §5`）。

### 3.15 `platform`　平台表（所有服務共用結構）

```mermaid
erDiagram
  OUTBOX {
    uuid id PK
    varchar aggregate_type
    varchar aggregate_id
    varchar event_type
    integer event_version
    jsonb payload
    varchar trace_id
    timestamptz published_at
    integer retry_count
  }
  IDEMPOTENCY_RECORD {
    uuid id PK
    varchar idempotency_key UK
    varchar request_fingerprint
    varchar resource_type
    varchar resource_id
    smallint response_status
    jsonb response_body
    timestamptz expires_at
  }
  AUDIT_TRAIL {
    uuid id PK
    varchar trace_id
    timestamptz occurred_at
    varchar actor_type
    varchar actor_id
    varchar actor_role
    varchar action
    varchar resource_type
    varchar resource_id
    jsonb before_image
    jsonb after_image
    varchar result
    varchar source_ip
    varchar prev_hash
    varchar row_hash
  }
```

**DDL**

```sql
-- 以下三張表在每個服務之 schema 內各建一份（平台共通結構）
-- lint:skip=SQL-04,SQL-05  理由：append-only 事件輸出表。outbox 為技術慣用單數名；
--                          寫入後僅由發布器標記 published_at，無一般更新語意，
--                          故無 updated_at／version。
CREATE TABLE :schema.outbox (
  id             uuid PRIMARY KEY,
  aggregate_type varchar(40) NOT NULL,
  aggregate_id   varchar(64) NOT NULL,
  event_type     varchar(80) NOT NULL,
  event_version  integer NOT NULL,
  payload        jsonb NOT NULL,
  trace_id       varchar(40) NOT NULL,
  published_at   timestamptz,
  retry_count    integer NOT NULL DEFAULT 0,
  created_at     timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_outbox_unpublished ON :schema.outbox(published_at, created_at)
  WHERE published_at IS NULL;

-- lint:skip=SQL-05  理由：冪等紀錄一經寫入即不變（僅於到期後清除），
--                    無 updated_at／version 之語意。
CREATE TABLE :schema.idempotency_records (
  id                  uuid PRIMARY KEY,
  idempotency_key     varchar(120) NOT NULL UNIQUE,
  request_fingerprint char(64) NOT NULL,
  resource_type       varchar(40) NOT NULL,
  resource_id         varchar(64),
  response_status     smallint NOT NULL,
  response_body       jsonb NOT NULL,
  expires_at          timestamptz NOT NULL,
  created_at          timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_idem_expiry ON :schema.idempotency_records(expires_at);

-- lint:skip=SQL-04,SQL-05  理由：稽核軌跡為 append-only（SAD-003 DP-04 原始紀錄不可變）。
--                          audit_trail 為技術慣用單數名；寫入後不得 UPDATE／DELETE，
--                          故無 updated_at／version。
-- 註：本表之 DDL 於 v1.2 補齊——原 v1.0/v1.1 之 ER 圖已定義本表，
--     但下方 DDL 僅列 outbox 與 idempotency_records 兩張（文字卻稱「三張表」），
--     屬文件缺口。欄位依本節 ER 圖還原。
CREATE TABLE :schema.audit_trail (
  id            uuid PRIMARY KEY,
  trace_id      varchar(40),
  occurred_at   timestamptz NOT NULL DEFAULT now(),
  actor_type    varchar(24),
  actor_id      varchar(64),
  actor_role    varchar(32),
  action        varchar(96) NOT NULL,
  resource_type varchar(40) NOT NULL,
  resource_id   varchar(64),
  before_image  jsonb,
  after_image   jsonb,
  result        varchar(16) NOT NULL,
  source_ip     varchar(45),
  prev_hash     varchar(64),
  row_hash      varchar(64)
);
CREATE INDEX idx_audit_trail_time ON :schema.audit_trail(occurred_at);
CREATE INDEX idx_audit_trail_res  ON :schema.audit_trail(resource_type, resource_id);
```

---

### 3.16 `report`　報表（R3 / `ACQ-4.4`）——**V2 新增（`CR-017` 裁定 D）**

> **背景**：本 schema 於 V1 基線**完全無表**（`SCS-017 §5` 原載「DDL 待補」）。
> BEAM PMO 2026-09-22 裁定 C-7 **含 Postgres 讀模型**，本節為其**設計權威**。

**架構（`SCS-017 §5.1`）**：Oracle `CC_RPT` ＝**作業指標**（權威事務資料）；
Postgres `rpt` ＝**讀模型**（CQRS 讀側）。

**DDL（Oracle `CC_RPT`，實體交付 `V2__report_definitions_and_jobs.sql`）**

```sql
CREATE TABLE report_definitions (
  id                uuid PRIMARY KEY,
  report_code       varchar(40) NOT NULL UNIQUE,
  report_name       varchar(120) NOT NULL,
  description       varchar(400),
  default_format    varchar(8) NOT NULL DEFAULT 'CSV'
                    CHECK (default_format IN ('CSV','XLSX','PDF','JSON')),
  status            varchar(16) NOT NULL DEFAULT 'ACTIVE'
                    CHECK (status IN ('ACTIVE','INACTIVE')),
  created_at        timestamptz NOT NULL DEFAULT now(),
  updated_at        timestamptz NOT NULL DEFAULT now(),
  version           integer NOT NULL DEFAULT 0
);

CREATE TABLE report_jobs (
  id                uuid PRIMARY KEY,
  job_no            varchar(32) NOT NULL UNIQUE,
  report_code       varchar(40) NOT NULL,
  parameters        jsonb NOT NULL DEFAULT '{}'::jsonb,
  format            varchar(8) NOT NULL DEFAULT 'CSV'
                    CHECK (format IN ('CSV','XLSX','PDF','JSON')),
  status            varchar(16) NOT NULL DEFAULT 'QUEUED'
                    CHECK (status IN ('QUEUED','RUNNING','COMPLETED','FAILED')),
  row_count         bigint NOT NULL DEFAULT 0,
  download_url      varchar(400),
  error_detail      varchar(400),
  started_at        timestamptz,
  finished_at       timestamptz,
  created_at        timestamptz NOT NULL DEFAULT now(),
  updated_at        timestamptz NOT NULL DEFAULT now(),
  version           integer NOT NULL DEFAULT 0
);
```

**讀模型（PostgreSQL `rpt`，實體交付 `db/migration-postgres/V1__rpt_read_model.sql`）**

```sql
CREATE TABLE rpt.daily_auth_summary (
  business_date   date          NOT NULL,
  channel         varchar(16)   NOT NULL,
  tx_type         varchar(16)   NOT NULL,
  decision        varchar(16)   NOT NULL,
  tx_count        bigint        NOT NULL DEFAULT 0,
  total_amount    numeric(19,0) NOT NULL DEFAULT 0,
  currency        char(3)       NOT NULL DEFAULT 'TWD',
  refreshed_at    timestamptz   NOT NULL DEFAULT now(),
  CONSTRAINT pk_daily_auth_summary PRIMARY KEY (business_date, channel, tx_type, decision)
);
```

> **⚠️ 讀模型表刻意不含 `id`／`version`**：其為**彙總投影**，主鍵為業務維度組合且以
> upsert 覆寫——硬補通用欄位屬**無意義欄位＝假綠**（比照 `tools/sql_exemptions.yaml` 之原則）。
>
> **⚠️ CQRS 讀側之接線慣例（本服務為首例）**：第二資料源與 JdbcTemplate **皆須明確定義**
> 且主側標 `@Primary`（Boot 之 `@ConditionalOnSingleCandidate(DataSource.class)` 於多資料源時
> **整體退讓**）；Postgres 之 Flyway **不註冊 bean**（由 `RptReadModelMigrator` 程式執行，
> 以免抑制 Boot 之 Oracle 遷移）；Flyway 需資料庫專屬模組
> （`flyway-database-postgresql`）。詳 `SCS-017 §5.4` 與 `CR-017 §9.2`。

---

### 3.17 `migration`　遷移波次（R3 / `SYS-2.8`、`SYS-3`）——**V2 新增（`CR-017` 裁定 E）**

> **背景**：本 schema 於 V1 基線**完全無表**（`SCS-015 §5` 原載「DDL 待補」）。
> BEAM PMO 2026-09-22 裁定採**兩層結構**（`WAVE` ＋ `JOB`）——契約之「Job（一次執行）」
> 與 SCS 之「Wave（波次 `W0`–`W4`）」為**不同粒度**，兩者並存（一波次含多個作業）。
> 實體交付：`V2__migration_waves_jobs_and_legacy_map.sql`。

**DDL**

```sql
CREATE TABLE migration_waves (
  id                uuid PRIMARY KEY,
  wave_code         varchar(16) NOT NULL UNIQUE,     -- W0–W4
  wave_name         varchar(120) NOT NULL,
  status            varchar(16) NOT NULL DEFAULT 'PENDING'
                    CHECK (status IN ('PENDING','RUNNING','PAUSED','COMPLETED','ROLLED_BACK','FAILED')),
  scope             varchar(400),
  started_at        timestamptz,
  finished_at       timestamptz,
  created_at        timestamptz NOT NULL DEFAULT now(),
  updated_at        timestamptz NOT NULL DEFAULT now(),
  version           integer NOT NULL DEFAULT 0
);

CREATE TABLE migration_jobs (
  id                uuid PRIMARY KEY,
  job_no            varchar(32) NOT NULL UNIQUE,
  wave_code         varchar(16) NOT NULL,            -- 兩層結構：掛於波次
  migration_type    varchar(16) NOT NULL
                    CHECK (migration_type IN ('ACCOUNT','CARD','BALANCE','POINTS','MERCHANT')),
  source_ref        varchar(64) NOT NULL,
  dry_run           smallint NOT NULL DEFAULT 0 CHECK (dry_run IN (0,1)),
  status            varchar(16) NOT NULL DEFAULT 'RUNNING'
                    CHECK (status IN ('RUNNING','COMPLETED','FAILED')),
  processed_count   bigint NOT NULL DEFAULT 0,
  success_count     bigint NOT NULL DEFAULT 0,
  error_count       bigint NOT NULL DEFAULT 0,
  error_detail      varchar(400),
  started_at        timestamptz,
  finished_at       timestamptz,
  created_at        timestamptz NOT NULL DEFAULT now(),
  updated_at        timestamptz NOT NULL DEFAULT now(),
  version           integer NOT NULL DEFAULT 0
);

CREATE TABLE legacy_account_mappings (
  id                uuid PRIMARY KEY,
  legacy_id         varchar(32) NOT NULL UNIQUE,     -- 如 LEGACY_ACCT_0001
  account_no        varchar(32),
  migrated          smallint NOT NULL DEFAULT 0 CHECK (migrated IN (0,1)),
  migrated_at       timestamptz,
  snapshot          jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at        timestamptz NOT NULL DEFAULT now(),
  updated_at        timestamptz NOT NULL DEFAULT now(),
  version           integer NOT NULL DEFAULT 0
);
```

> **⚠️ 命名（SQL-04）**：對映表原名 `legacy_account_map`（單數）違反「表名須為複數」，
> 已於**本輪開發中更正**為 `legacy_account_mappings`（該遷移檔僅本機套用過，未離開本機
> ——`CR-017 §9.3 (b)` 有完整揭露）。
>
> **⚠️ 本期語意（誠實揭露）**：舊系統（Legacy ECS）資料源**未接入** → 作業**不搬移任何
> 帳務資料**，僅就 `legacy_account_mappings` 之待處理列計數與標記
> （`dryRun=true` → 僅計數、不變更）；計數為**對映處理數**（`SCS-015 §5.1`）。

---

### 3.18 `notification`　通知（R3 / `ISS-2.10`）——**V2 新增（`CR-017` 裁定 F／G）**

> **背景**：本 schema 於 V1 基線**完全無表**（`SCS-016 §5` 原載「DDL 待補」）。
> BEAM PMO 2026-09-22 裁定 **F：通道抽象先行**（`NotificationChannelPort` ＋ 本地 Log 通道
> stand-in；**實際 SMS／Email／推播閘道未接**——屬鐵則所稱 3 類真阻塞之一）。
> 實體交付：`V2__notification_templates_and_notifications.sql`。

**DDL**

```sql
CREATE TABLE notification_templates (
  id                uuid PRIMARY KEY,
  template_code     varchar(40) NOT NULL,          -- 如 AUTH_APPROVED
  channel           varchar(16) NOT NULL
                    CHECK (channel IN ('SMS','EMAIL','PUSH','LINE_BC','LINE_AOA')),
  language          varchar(8) NOT NULL,           -- 如 zh-TW
  subject           varchar(200),                  -- EMAIL 用
  body_template     varchar(2000) NOT NULL,        -- 內文範本，變數以 {name} 佔位
  variables         jsonb NOT NULL DEFAULT '{}'::jsonb,   -- 變數定義
  enabled           smallint NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
  created_at        timestamptz NOT NULL DEFAULT now(),
  updated_at        timestamptz NOT NULL DEFAULT now(),
  version           integer NOT NULL DEFAULT 0,
  CONSTRAINT uq_notif_tpl_code_channel_lang UNIQUE (template_code, channel, language)
);

CREATE TABLE notifications (
  id                 uuid PRIMARY KEY,
  notification_no    varchar(32) NOT NULL UNIQUE,  -- 如 NOTIF202609000001
  account_id         uuid NOT NULL,
  party_id           uuid,                         -- 收件人參照（非 PII 本體）
  template_code      varchar(40) NOT NULL,
  language           varchar(8) NOT NULL DEFAULT 'zh-TW',
  variables          jsonb NOT NULL DEFAULT '{}'::jsonb,
  preferred_channels jsonb NOT NULL DEFAULT '[]'::jsonb,  -- 請求之通道偏好
  channels           jsonb NOT NULL DEFAULT '[]'::jsonb,  -- 實際選定之通道
  amount             bigint,
  status             varchar(16) NOT NULL DEFAULT 'QUEUED'
                     CHECK (status IN ('QUEUED','SENT','FAILED')),
  retry_count        integer NOT NULL DEFAULT 0,
  fail_reason        varchar(400),
  sent_at            timestamptz,
  created_at         timestamptz NOT NULL DEFAULT now(),
  updated_at         timestamptz NOT NULL DEFAULT now(),
  version            integer NOT NULL DEFAULT 0
);
```

> **⚠️ 狀態值域以契約為權威（`QUEUED`／`SENT`／`FAILED`）**：`SCS-016 §3` 原載設計意圖
> `{PENDING, SENT, FAILED, RETRYING}`，與契約 `NotificationView.status` **脫鉤**
> （`PENDING` ≠ `QUEUED`；`RETRYING` 契約未定義）——已依「**契約優先**」更正 `SCS-016 §3`。
> **重試不以獨立狀態表示**：失敗時 `retry_count + 1` 且**狀態維持 `QUEUED`**，
> 達上限方轉 `FAILED`（見 `SCS-016 §7`）。此設計使「重試」**可達且可測**，
> 且不需引入契約未定義之狀態（避免無對應語意之死狀態＝假綠）。
>
> **⚠️ 本期語意（誠實揭露）**：
> ①**實際發送未接**——`NotificationChannelPort` 之唯一實作為**本地 Log 通道**
> （`LoggingNotificationChannel`，stand-in），**不呼叫任何簡訊／電郵／推播閘道**；
> ②**收件人聯絡方式未解析**——契約 `NotificationRequest` **無收件人欄位**，
> 且 `party-service` 契約**無聯絡資訊端點**（實測 4 端點皆非），故 `party_id` 僅為
> **參照**，實際手機／電郵解析待契約與通道就緒；
> ③**無 PII 欄位**——因②，本 schema **刻意不含加密聯絡欄**（避免無來源之欄位＝假綠）；
> ④**多語**——模板以 `(template_code, channel, language)` 為鍵，惟契約**無語言欄位**，
> 本期固定 `zh-TW`，多語選定待契約擴充。

---

## 4. 資料字典（關鍵欄位）

| 服務 | 表 | 欄位 | 型別 | 必填 | 說明 | PII |
|---|---|---|---|:--:|---|:--:|
| party | accounts | account_no | varchar(20) | ✅ | 歸戶編號 | |
| party | accounts | block_code | varchar(16) | ✅ | 管制碼 | |
| party | parties | id_no_enc | bytea | | 身分證字號（加密） | ✅ |
| party | parties | id_no_masked | varchar(20) | ✅ | 遮罩身分證字號 | |
| card | cards | card_no_enc | bytea | ✅ | 卡號（加密） | ✅ |
| card | cards | card_no_hash | char(64) | ✅ | 卡號雜湊（比對用） | ✅ |
| card | cards | expiry_date | date | ✅ | 到期日 | |
| lim | credit_limits | available_amount | bigint | ✅ | 可用額度（分） | |
| lim | credit_limits | used_amount | bigint | ✅ | 已用額度（分） | |
| lim | limit_holds | hold_amount | bigint | ✅ | 佔用金額（分） | |
| auth | authorizations | amount | bigint | ✅ | 交易金額（分） | |
| auth | authorizations | resp_code | varchar(8) | ✅ | 回應碼 | |
| auth | authorizations | standin_flag | boolean | ✅ | 是否 Stand-in | |
| posting | transactions | idempotency_key | varchar(120) | ✅ | 冪等鍵 | |
| posting | transactions | reversal_of | uuid | | 沖正之原交易 | |
| posting | balances | aging_bucket | smallint | ✅ | 帳齡分類 | |
| payment | payments | unmatched_flag | boolean | ✅ | 三日未銷帳標記 | |
| rw | points_accounts | pending_balance | bigint | ✅ | 待確認點數（消費週期後轉 confirmed） | |
| rw | points_ledger | entry_type | varchar(16) | ✅ | EARN/CONFIRM/FORFEIT/REDEEM/EXPIRE | |
| rw | redemptions | cash_value | bigint | | 兌換現金等值（分），沖抵本金 | |
| inst | installment_plans | principal_outstanding | bigint | ✅ | 分期剩餘本金，與 balances 勾稽 | |
| inst | installment_periods | remaining_principal | bigint | | 每期剩餘本金 | |
| stmt | statements | new_balance | bigint | ✅ | 本期應付總額 | |
| stmt | statements | minimum_due | bigint | ✅ | 本期最低應繳 | |
| stmt | statement_lines | amount | bigint | ✅ | 凍結明細金額（防沖正漂移） | |
| prc | pricing_rules | calc_method | varchar(16) | ✅ | ADB/PREVIOUS_BALANCE/TWO_CYCLE/FLAT | |
| clr | clearing_batches | file_type | varchar(16) | ✅ | VCF/EMS/SETTLEMENT/CHARGEBACK/FEES | |
| clr | clearing_items | matched_tx_id | uuid | | 比對回原交易（防重複請款） | |
| dis | dispute_cases | stage | varchar(24) | ✅ | Inquiry→Presentment→Arb 階段機制 | |
| dis | dispute_cases | response_deadline | timestamptz | ✅ | 卡組織時限（逾期即敗訴） | |
| mch | merchants | risk_level | varchar(8) | ✅ | LOW/MEDIUM/HIGH 風險分級 | |
| mch | merchant_settlements | net_amount | bigint | ✅ | 淨額＝總額－費用－稅 | |

---

## 5. 資料完整性約束

| 約束 | 實作 |
|---|---|
| 金額非負（除沖正） | `CHECK (amount >= 0)`；沖正以 `tx_type='REVERSAL'` 允許負值 |
| 可用額度不得為負 | `CHECK (available_amount >= 0)` + 條件更新 |
| 冪等鍵唯一 | `UNIQUE (idempotency_key)` |
| 卡號唯一 | `UNIQUE (card_no_hash)` |
| 參數唯一 | `UNIQUE (namespace, param_key, effective_from)` |
| 交易不得刪除 | 資料庫權限僅授予 `INSERT`／`SELECT`；無 `DELETE` |
| 稽核不得竄改 | `audit_trail` 僅 `INSERT`；`row_hash` 鏈結前一筆 |
| 外鍵 | 同 schema 內使用外鍵；跨服務**不使用外鍵**（以應用層保證） |

---

## 6. 索引策略

| 表 | 索引 | 用途 |
|---|---|---|
| `auth.authorizations` | `(card_id, tx_time DESC)` | 卡片授權查詢 |
| `auth.authorizations` | `(account_id, tx_time DESC)` | 歸戶授權查詢 |
| `auth.authorizations` | `(stan)` | 交易序號查詢 |
| `posting.transactions` | `(account_id, posting_date DESC)` | 帳務明細 |
| `posting.transactions` | `(authorization_id)` | 授權請款比對 |
| `posting.transactions` | `UNIQUE (idempotency_key)` | 重複入帳防範 |
| `lim.limit_holds` | `(account_id, status)` | 佔用查詢 |
| `lim.limit_holds` | `(authorization_id)` | 佔用釋放 |
| `card.cards` | `(account_id)`、`(party_id)` | 卡片查詢 |
| `payment.payments` | `(account_id, payment_date DESC)` | 繳款查詢 |

---

## 7. 分區與歸檔

| 表 | 分區鍵 | 保留 | 歸檔 |
|---|---|---|---|
| `auth.authorizations` | `tx_time`（月） | 24 個月 | 歸檔庫 |
| `posting.transactions` | `posting_date`（月） | 依會計要求 | 歸檔庫 |
| `posting.balance_history` | `account_id`（雜湊） | 永久 | — |
| `audit.audit_trails` | `created_at`（月） | 依資安政策 | 歸檔庫 |
| `*.outbox` | — | 發布後 90 天 | 刪除（**唯一允許刪除之表**） |

> `outbox` 為**唯一**允許刪除資料之表（發布成功且超過保留期）；
> 其餘交易類表一律不得刪除（`BRD-001 C-02`）。

**Oracle 實作對照**（`v1.1`）

| 情境 | PostgreSQL | Oracle |
|---|---|---|
| 月分區 | `PARTITION BY RANGE (tx_time)` + 每月一個 partition | `PARTITION BY RANGE (tx_time)` + `INTERVAL (NUMTOYMINTERVAL(1,'MONTH'))` 自動延伸。**⚠️ 必須附帶至少一個初始分區**，否則 `ORA-00906` |
| 雜湊分區 | `PARTITION BY HASH (account_id)` | `PARTITION BY HASH (account_id) PARTITIONS 16`（**分區數須為 2 的次方**以達均勻） |
| 歸檔 | 卸載 partition → 外部儲存 | `ALTER TABLE ... EXCHANGE PARTITION` 換出至歸檔表，或 `MOVE PARTITION` 至低成本表空間 |
| 分區索引 | 預設 local | 須明寫 `LOCAL`；金額與時間查詢路徑一律用 local index 避免跨分區掃描 |
| Outbox 清理 | `DELETE ... WHERE published_at < ...` | 同語法；但**建議以 partition drop 取代 delete**，避免 UNDO 膨脹與高水位不降 |

#### 7.1 Oracle 分區實測限制（2026-09-18）

於 Oracle 26ai Free（23.26.0）實測，以下兩點直接影響本節之設計：

| # | 限制 | 實測結果 | 影響 |
|---|---|---|---|
| PT-1 | **`INTERVAL` 分區必須提供至少一個初始分區** | 無初始分區 → `ORA-00906: missing left parenthesis`；加上 `(PARTITION p0 VALUES LESS THAN (...))` 後通過 | 產生器已自動附加初始分區 |
| PT-2 | **`TIMESTAMP WITH TIME ZONE` 不可作為分區鍵** | → `ORA-03001: unimplemented feature`。`DATE` 與 `TIMESTAMP`（無時區）均可 | 見下方待決事項 |

> **⚠️ 待決事項（影響 §7 之 `auth.authorizations`）**
> 本節原訂 `auth.authorizations` 以 `tx_time`（`timestamptz`）作月分區，
> 但依 PT-2，**Oracle 無法以含時區之時間欄位分區**，且該表**無 DATE 欄位可替代**。
> 目前 Flyway 基線（`V1__baseline.sql`）**未對該表套用分區**，並於檔內留註待決。
>
> 候選方案（須經 SA 核定後修訂本節）：
> 1. **新增 `tx_date DATE NOT NULL` 業務日期欄位**，以之為分區鍵（維持月分區能力；需應用層於寫入時帶入）
> 2. **放棄分區**，改以 `(card_id, tx_time DESC)` 等索引支撐查詢
> 3. 將 `tx_time` 改存為不含時區之 UTC `TIMESTAMP`（**與 DP-07 衝突**，不建議）
>
> `posting.transactions`（分區鍵 `posting_date` 為 `date`）**不受影響**，已成功建立月分區。

---

## 8. 需求 ↔ Schema 追溯（回答「欄位如何支撐業務」）

本節以 BRD-004 之 17 項 POC 為主軸，逐項列出**支撐該場景的資料表與關鍵欄位**，
證明本 Schema 之欄位粒度足以支撐全部業務功能，Coder 不需自行腦補欄位。

| POC | 場景 | 主要資料表 | 關鍵欄位（支撐點） |
|---|---|---|---|
| POC-01 | 授權主流程（本行卡） | `auth.authorizations`, `lim.credit_limits`, `lim.limit_holds` | `decision`, `resp_code`, `hold_amount`, `standin_flag`, `available_amount`（條件更新） |
| POC-02 | 授權分流（他行/國外卡） | `auth.authorizations`, `card.cards` | `channel`, `card_no_hash`, `bin`, `tx_type` |
| POC-03 | Stand-in 代授權 | `auth.authorizations` | `standin_flag`, `entry_mode`, `auth_check_results.result` |
| POC-04 | 請款入帳與差異比對 | `posting.transactions`, `posting.sub_account_entries`, `posting.balances` | `authorization_id`, `idempotency_key`, `before_amount/after_amount`, `total_amount` |
| POC-05 | 餘額即時更新（CQRS） | `posting.balances`, `posting.balance_history` | `principal_amount/interest_amount/fee_amount`, `aging_bucket`, `as_of_date` 快照 |
| POC-06 | 帳單產生與繳款沖抵 | `stmt.statements`, `stmt.statement_lines`, `payment.payments` | `new_balance/minimum_due`, `prev_balance/payments_credits`, `allocation_order`, `limit_restored` |
| POC-07 | 分期交易 | `inst.installment_plans`, `inst.installment_periods`, `inst.installment_products` | `principal_outstanding`, `per_period_amount`, `remaining_periods`, `annual_rate` |
| POC-08 | 點數累積與兌換 | `rw.points_accounts`, `rw.points_ledger`, `rw.redemptions` | `pending/confirmed_balance`, `entry_type`, `cash_value`, `next_expiry_points` |
| POC-09 | 爭議帳款處理 | `dis.dispute_cases`, `dis.dispute_timelines` | `stage`, `response_deadline`, `representment_count`, `liability`, `evidence_*` |
| POC-10 | 清算結算 | `clr.clearing_batches`, `clr.clearing_items`, `clr.settlements` | `file_type`, `matched_tx_id`, `interchange_fee/network_fee`, `net_amount` |
| POC-11 | 卡片生命週期 | `card.cards`, `card.card_applications`, `card.tokens` | `block_code`, `status`, `expiry_date`, `token_no`, `instant_issued` |
| POC-12 | 額度管理與調整 | `lim.credit_limits`, `lim.special_limits`, `lim.limit_adjustments` | `permanent/temporary_amount`, `shadow_amount`, `purpose`, `before/after_amount` |
| POC-13 | 產品參數化（P0–P3） | `product.products`, `product.parameters`, `product.rule_sets` | `param_value(jsonb)`, `rule_type`, `condition/action`, `effective_from/to` |
| POC-14 | 多層次帳戶結構 | `party.accounts`, `party.parties` | `account_no/cif_no`, `party_type`, `relation_type`, `flags(jsonb)` |
| POC-15 | 遷移波次與回退 | `auth.authorizations`, `posting.transactions`（版本欄位） | `version`（樂觀鎖）、`idempotency_key`、`created_at` 時序 |
| POC-16 | 安全／個資保護 | 全表 `id_no_enc/id_no_masked`, `card_no_enc/card_no_hash`, `audit_trail` | 加密欄 + 遮罩欄 + `before/after_image` + `row_hash` 鏈 |
| POC-17 | 監控與可觀測 | `:schema.outbox`, `:schema.idempotency_records`, `audit_trail` | `trace_id`, `published_at`, `response_status`, `aggregate_type/id` |

> **結論**：全 17 項 POC 與 68 項 FR 皆能被本 Schema 之表與欄位直接支撐；
> 欄位設計已含「加密/遮罩雙軌」「樂觀鎖 version」「冪等鍵」「JSON 彈性參數」等
> 支撐關鍵非功能需求（NFR-SEC / NFR-REL）的結構，Coder 實作時**不得**自行新增表或欄位，
> 若確有缺口須提变更申請（Change Request）經 SA 核定後由本文件統一納管。

---

## 9. 總表與統計

| 項目 | 數量 |
|---|---|
| Bounded Context（schema） | **17**（party/card/product/lim/auth/posting/payment/rw/inst/stmt/prc/clr/dis/mch/platform ＋ **rpt**／**mig**／**notif**） |
| 實體表（含 DDL） | **55** |
| 通用平台表（每服務各一份） | outbox / idempotency_records / audit_trail |
| 跨服務外鍵 | 0（以應用層保證，避免分散式交易） |
| 金額欄位統一單位 | 最小單位（分）：Oracle `NUMBER(19,0)`／PostgreSQL `bigint` |

> **⚠️ 統計方法與前版更正（2026-09-22）**：上列數字為**逐項清點**之結果——
> §3.1–§3.15 之 `CREATE TABLE` 區塊共 **45**（含 §3.15 平台表 3），
> ＋`CR-016` 以文字表格描述之 `CLEARING_RUNS`／`CLEARING_RECONCILIATIONS` **2**，
> ＋`CR-017` 裁定 D 之 `REPORT_DEFINITIONS`／`REPORT_JOBS`／`rpt.daily_auth_summary` **3**，
> ＋裁定 E 之 `MIGRATION_WAVES`／`MIGRATION_JOBS`／`LEGACY_ACCOUNT_MAPPINGS` **3**，
> ＋裁定 F 之 `NOTIFICATION_TEMPLATES`／`NOTIFICATIONS` **2** ＝ **55**。
> **前版（1.12）之「14」與「47」已過時**——`47` 係 `1.9` 版（含 `CR-016` 之 2 表）之數字，
> 其後 §3.16／§3.17 新增 6 表卻未同步更新，本次一併更正。

> **schema 命名對照**：本表所列 schema 為邏輯名稱（小寫）。於 **Oracle** 中，
> 未加引號之識別碼將轉為大寫（`auth` → `AUTH`），詳見 `§1.3` 陷阱 T-01；
> `platform` schema 於 Oracle 對應 `CC_PLT`（Saga 狀態、Outbox、稽核軌跡）。

---

## 10. 修訂記錄

| 版本 | 日期 | 修訂者 | 說明 |
|---|---|---|---|
| 1.16 | 2026-10-02 | WorkBuddy AI | **`CR-035 §2.1／§2.2`（`#A-154`）：§3.4 補「專用額度池」之欄位**——新增「變更交付說明（`special_limits` 之『池』與用途範圍）」，載明 **`V7__special_limit_pool.sql`** 之四項：`special_limits.scope_ref`（**MCC 用途範圍**，逗號分隔；`NULL` ＝ 不限定）、`limit_adjustments.scope_ref`（稽核軌跡同步）、`limit_holds.special_limit_id`（**池歸屬**）、索引。**併載語意**：專用池之佔用**完全不動 `account_limits`** → `BR-04-01` 為**結構性成立**。**既有表結構未變**（`V1` 仍凍結） |
| 1.15 | 2026-10-02 | WorkBuddy AI | **`CR-014 v1.1`（`#A-153`）：§3.4 `special_limits` 補 `currency` 與唯一性約束**——①**ER 圖**補上漏列之 `char currency`；②新增「變更交付說明（`special_limits`）」區塊，載明 **`V6__special_limits_currency_and_index.sql`** 之三項內容（`ADD currency` ＋ 回填 ＋ `MODIFY NOT NULL`；`idx_special_limits_account`；**`uk_special_limits_active`**）與理由；③★ **明示本節 SQL 區塊不得改動**——該區塊即 `V1__baseline.sql` 之產生器來源，一改將造成 **checksum 失配、服務起不來**。**既有表結構未變**（`V1` 仍凍結）。⚠️ **併補 1.14 之漏列**（見下） |
| 1.14 | 2026-09-22 | WorkBuddy AI | **`CR-020` F1：`card.card_applications` 新增 `card_id`**（`V7__card_application_card_id.sql`）——發卡後回填，使卡片可追溯其來源申請案件；開卡（`ACTIVATE`）時以此反查核准序號驗證三要素。⚠️ **本列為 2026-10-02 補記**：前版**已於檔頭升為 1.14 並載明此變更，惟修訂記錄漏列**（檔頭與表格不一致） |
| 1.13 | 2026-09-22 | WorkBuddy AI | **`CR-017` 裁定 F／G（已執行）：新增 §3.18 `notification` schema**——`NOTIFICATION_TEMPLATES`（`(template_code, channel, language)` 唯一鍵；通道 5 值）＋`NOTIFICATIONS`（`status` 3 值、`retry_count`、`fail_reason`）。併載明**四項誠實揭露**：①實際發送未接（唯一實作為本地 Log 通道 stand-in）；②收件人聯絡方式未解析（契約無收件人欄、`party-service` 無聯絡端點）→③故**刻意不含加密聯絡欄**（避免無來源之欄位＝假綠）；④多語以 `(code, channel, language)` 為鍵但契約無語言欄位，本期固定 `zh-TW`。**併更正 §9 統計**（14／47 → **17／55**；前版未隨 §3.16／§3.17 更新） |
| 1.12 | 2026-09-22 | WorkBuddy AI | **`CR-017` 裁定 E（已執行）：新增 §3.17 `migration` schema**——**兩層結構**：`MIGRATION_WAVES`（波次 `W0`–`W4`，狀態 6 值）＋`MIGRATION_JOBS`（型別 5 值／狀態 3 值，皆對齊契約）＋`LEGACY_ACCOUNT_MAPPINGS`（舊帳對映，含 `SNAPSHOT` JSON）。併載明**命名更正**（`SQL-04` 複數）與**本期語意**（資料源未接入 → 不搬移帳務資料） |
| 1.11 | 2026-09-22 | WorkBuddy AI | **`CR-017` 裁定 D（已執行）：新增 §3.16 `report` schema**——①Oracle `CC_RPT`：`REPORT_DEFINITIONS`／`REPORT_JOBS`（V2）；②**PostgreSQL `rpt.daily_auth_summary`**（讀模型，CQRS 讀側**首例**，獨立 Flyway `db/migration-postgres`）；③載明**讀模型表刻意不含 `id`／`version`** 之理由與**讀側接線慣例**（雙資料源須明確標 `@Primary`、Flyway 不註冊 bean、需資料庫專屬模組）。⚠️ 本節為**新 schema**，非既有表之補充 |
| 1.10 | 2026-09-22 | WorkBuddy AI | **`CR-017`（已核定／已執行）：`dis` schema 爭議雙軸拆分**——§3.13 補充區新增：①**`DISPUTE_CASES.DISPUTE_REASON`**（爭議原因 4 值）與 `DISPUTE_TYPE`（**程序類型**）為**正交兩軸**（原契約 `disputeType` 實指原因，而 DDL 之 `dispute_type` 混用兩者，交集僅 `DUPLICATE`）；②**`WORKFLOW_STAGE`**（本行作業階段 7 值）與 `STAGE`（**卡組織程序階段**，`SDD-005 §7.1`）為**正交兩軸**（交集僅 `ARBITRATION`／`CLOSED`）；③`CHARGEBACK_TYPE`／`CHARGEBACK_REASON`；④`dispute_type` 之 CHECK **具名化**（值域不變）。ER 圖未動（循 V2 補充區之同一慣例） |
| 1.9 | 2026-09-22 | WorkBuddy AI | **`CR-016`（已核定／已執行）：`clr` schema 補齊契約所需資源**——§3.12 補充區新增：①**`CLEARING_RUNS`**（清算執行；一次執行涵蓋多批次檔，與 `CLEARING_BATCHES` 為一對多）與 **`CLEARING_RECONCILIATIONS`**（對帳結果，依執行 × 組織，`difference_type` 3 值）——**此二者為契約之核心資源而 V1 完全不存在**；②`CLEARING_BATCHES.ORG` 值域擴充為 8 值（增 `DISCOVER`／`DINERS`，**保留 `DFS`** 供相容）並重建為具名約束；③`SETTLEMENTS.DIRECTION`（契約必要欄位）。ER 圖未動（循 V2 補充區之同一慣例） |
| 1.8 | 2026-09-22 | WorkBuddy AI | **`CR-015`（已核定／已執行）：`mch` schema 雙軸拆分與補欄**——§3.14 補充區新增：①**`MERCHANTS.MERCHANT_FORM`**（商店型態 `SINGLE`／`FRANCHISE`／`CHAIN`／`GROUP`）與 `MERCHANT_TYPE`（**業別**）為**正交兩軸**（契約原以單一 `merchantType` 承載，且與 DDL 業別值域**無交集**）；②`PARENT_MERCHANT_ID`（集團店上層，自參考）、`ACCEPTED_CARD_TYPES`（JSON）、`MERCHANT_SETTLEMENTS.BATCH_NO`／`EXCEPTION_REASON`；③兩狀態值域擴充並**重建為具名約束**（`CK_MERCHANTS_STATUS` 補 `APPLIED`／`CLOSED`；`CK_MERCHANT_SETTLEMENTS_STATUS` 併入契約之 `SUBMITTED`／`VERIFIED`／`EXCEPTION`／`PAID`）。ER 圖未動（循 V2 補充區之同一慣例） |
| 1.7 | 2026-09-22 | WorkBuddy AI | **C-31：`PRICING_RULES.tx_type`（交易型別，`V2__pricing_rules_tx_type.sql`）**——§3.11 補充區新增：`tx_type IN ('GENERAL','CASH_ADVANCE')`、**可為 null**（僅 `INTEREST` 規則具值，費用類規則不硬塞值）；既有 `INTEREST` 列依 `TIER` 回填。**定性**：原「以 `TIER`（客層級距）暫代計息子型」屬本地慣例，本欄位增設後 `TIER` 回歸本義（`SCS-006 §7.5`、`CR-005 §8 #1` 結案）。真機 e2e：清空 `TX_TYPE` → `CC-PRC-1001`、還原 → 20% 恢復（證明讀取權威來源且非快取） |
| 1.6 | 2026-09-22 | WorkBuddy AI | **`CR-012`（已核定／已執行）：`REDEMPTIONS` 雙欄位**——§3.8 補充區新增 `settlement_method`（折抵方式，`V4__redemptions_settlement_method.sql`；`CHECK IN ('POINTS_ONLY','POINTS_PLUS_CASH','ONLINE_REDEEM','MERGE','EXCHANGE_CENTER')`、`DEFAULT 'POINTS_ONLY' NOT NULL`）。**定性**：`redemption_type`（標的類別）與 `settlement_method`（折抵方式）為**正交兩維度**，「點數＋現金換哩程」兩者皆需，不可合併為一欄。ER 圖未動（循 V2 補充區之同一慣例） |
| 1.5 | 2026-09-21 | WorkBuddy AI | **`HANDOVER #C-18`：V2+ 遷移落差回寫（跨服務，共 8 項）**——以腳本客觀比對「V2/V3 遷移實際新增之表／欄」與「本檔是否已記載」，補上：①**§3.1 `pty_idempotency`**（`V2`；服務內部狀態，不入 ER 圖，為 §3.15 `IDEMPOTENCY_RECORD` 於 `party` schema 之實作）；②**§3.2 `card_idempotency`**（`V2`，同上）；③**§3.4 `account_limits`**（`V2`，帳戶 × 幣別之信用額度主檔，`CR-001`）與 **`limit_adjustments`**（`V3`，含 `adjust_type`／`status` 二組 `CHECK` 值域）；④**§3.5 `card_status_caches`／`account_limit_caches`**（`V2` 讀模型，權威仍在 `card`／`limit`，不入 ER 圖）與 **`authorizations.hold_id`**（`V3`，關聯 `lim.limit_holds` 使授權與佔額可對照）；⑤**§3.7 `payments.reversal_reason`**（`V3`，沖正原因之稽核留痕），**ER 圖與 DDL 皆已同步**。⚠️ **更正 `CR-003 §6 #3` 之不實記載**：該處稱「本次僅加註 §3.7／§3.8／§3.10 三處」，實測 `reversal_reason` 於本檔全文出現 **0 次**——§3.7 **並未**回寫（§3.8／§3.10 確已完成），已於 `CR-003` 修訂記錄更正。⚠️ **盤點方法**：勿以「章節是否含該名稱」判缺——`lim.limit_holds` 記載於 §3.4（正確位置），按 §3.5 查會誤判為缺。`validate_mermaid.py docs` 仍為 **115 區塊 0 錯誤** |
| 1.4 | 2026-09-20 | WorkBuddy AI | §3.2 `CARD` 實體新增 `ticket_type`（票證種類：`EASYCARD`／`IPASS`／`ICASH`，可為 NULL）——契約 `CreateCardRequest.ticketType` 原有此欄位而本模型漏列，致 `cards` 表**無處可存**（`SCS-003 §10 #14`）。依 `SAD-003 §9` 不改 V1 基線，另以 `V3__card_ticket_type.sql` 追加 |
| 1.0 | 2026-09-17 | SD / DA | 初版 |
| 1.1 | 2026-09-18 | SD / DA | 依客戶指定「Oracle 為主」補入實作方言對照，**DDL 內容與 ER 圖未變**：①§1 增列方言聲明，明訂 DDL 為邏輯模型記法、Flyway 遷移腳本須以 Oracle 方言撰寫；②新增 **§1.3 資料型別方言對照**（`uuid`→`RAW(16)`、`timestamptz`→`TIMESTAMP(6) WITH TIME ZONE`、`bigint`→`NUMBER(19,0)`、`jsonb`→`JSON` 等）與常見語法對照；③新增 **Oracle 特有陷阱 T-01～T-08**（識別碼大小寫、空字串等於 NULL、`DATE` 含時間、DDL 隱式提交、隔離等級、隱式型別轉換、**`RESOURCE` 保留字**、JSON/RAW(16) 可用性）；④§7 補 Oracle 分區／歸檔實作對照；⑤§8 用語由 `jsonb` 改為方言中立之「JSON」；⑥§9 補 schema 命名大小寫對照與 `platform` → `CC_PLT` 之映射 |
| 1.2 | 2026-09-18 | SD / DA | **解決 §1.1「所有表皆有通用欄位」與本文件自身 DDL 之矛盾**（以 `validate_sql.py` 掃描本文件抽出之 DDL，命中 13 處）：①§1.1 標題由「所有表皆有」改為「原則上所有表皆有」，新增 **§1.1.1 例外條款與豁免機制**，明訂 append-only 表、自然主鍵註冊表、技術慣用單數名三類例外，並要求**逐表明示豁免且理由具體**；②於 7 張表之 DDL 前加註 `-- lint:skip=...` 豁免指令與理由：`party.block_code_history`、`card.card_no_pool`、`card.issued_card_numbers`、`posting.balance_history`、`rw.points_ledger`、`:schema.outbox`、`:schema.idempotency_records`；③**補齊 §3.15 之 `audit_trail` DDL**——該表原僅見於 ER 圖，DDL 僅列 outbox 與 idempotency_records 兩張（文字卻稱「三張表」），屬文件缺口，已依 ER 圖還原欄位。**既有 DDL 結構與 ER 圖未變** |
| 1.3 | 2026-09-19 | SD / DA | 依 **`W-1`（`KeyProvider` 推廣）** 補齊 **§3.7 `payment` 之 `AUTO_DEBIT_ENROLLMENTS`**：①**ER 圖**補上漏列之 `debit_account_masked` 與 `idempotency_key`；②**DDL 區塊補入本表**——原 §3.7 之 DDL **完全未列本表**（僅 ER 圖有），而 ER 圖卻列了實作尚未建立之 `debit_account_enc`，屬**文件與實物雙向不一致**；③新增「變更交付說明」，載明本表係由 **`V2`**（建表）與 **`V5`**（新增 `debit_account_enc`）兩支遷移交付，並說明**兩欄並存之理由**（遮蔽值供顯示、密文供扣款）。**既有表結構未變**；`V1__baseline.sql` 仍凍結 |
