Project

General

Profile

CR #201 » SCS-011_帳單服務程式設計規格.md

內容更新:內部稱謂去識別化(2026-10-18) - Redmine Admin, 10/18/2026 05:32 AM

 

SCS-011 帳單服務(statement-service)程式設計規格

項目 內容
文件編號 SCS-011
版本 1.11(CR-033:更正 §7.2 與 §10 之消費者——原載「payment」,經查現行 SAD-004 §5.2 未列 payment(CR-030 §2 #5 重寫後已移除);「出帳後觸發自扣」屬未實作功能(SCS-008 §10)而非事件消費,且自扣之執行為寫入,依 CR-030 §0 P2 命令不得走事件 → payment 非本事件之消費者。前版 1.10(CR-029 批次 C-2:§7.2 payload 補可選 currency——供 notification-service 以既有種子模板 STATEMENT_ISSUED 建立帳單通知;依 SAD-004 §5.3 不升版)。前版 1.9(CR-027 批次 A-4:新增 §7.1 事件輸出——statement.statement.issued.v1)。(GOV-007 §5:新增 §8.2 帳單 PDF 產生與物件儲存——BEAM PMO 2026-10-01 裁定「必做」;函式庫經 version-compat-verification 實查採 Apache PDFBox 3.0.8(Apache 2.0,避 iText AGPL);產生 → 上傳物件儲存 → 回寫 PDF_REF;中文須內嵌字型。§10 之「PDF 產生器仍待」解除。前版 1.7(HANDOVER #A-106:§10 帳單 PDF 之儲存媒體已定——S3 API/本地 Garage,見 SAD-006 §3.2;PDF 產生器與寄送通道仍待)
狀態 已核定(規格先行)→ 已實作並實測通過(2026-09-19);1.1 依 CR-003 補狀態機對照
對應需求 ISS-2.8(信用卡帳單管理)
服務埠號 8092(application.yml 實測)
套件根 com.beam.cardcore.stmt
權威 schema CC_STMT
關聯文件 SDD-001 §5.6、SDD-002 §3.5、BRD-002、GOV-005

1. 對應需求

FR 編號 需求名稱 來源
ISS-2.8 信用卡帳單管理 BRD-002

1.1 上層設計依據(本規格不重複這些圖,僅引用)

圖種 文件 數量(實測) 本服務相關
ER-Diagram SDD-001_領域模型與ER-Diagram 16 個 erDiagram STATEMENTS/STATEMENT_LINES 實體(SDD-001 §5.6)
DFD SDD-002_DFD與流程分解 10 個 flowchart 帳單產生流(SDD-002 §3.5)
Sequence SDD-003_Sequence-Diagrams 15 個 sequenceDiagram 帳單產生時序
狀態機 SDD-005_狀態機設計 10 個 stateDiagram ⚠️ STATEMENTS.status(GENERATED→SENT→PAID…)以 CHECK 控管,未定義正式狀態機
API Contract SDD-006 + contracts/openapi/statement-service.yaml 17 份 本服務 3 支端點

2. 服務職責與邊界

承作:

  • 帳單週期執行(statement-cycles/{cycleCode}/runs)
  • 帳單產生(STATEMENTS:前期餘額、本期消費/繳款/費用/利息、新餘額、最低應繳、點數)
  • 帳單明細(STATEMENT_LINES:逐筆交易,含遮蔽卡號 CARD_NO_MASKED)
  • 帳單寄送(SEND_TYPE、E_STATEMENT_FLAG、PDF_REF)

不做(屬他服務):

  • 餘額來源 → posting-service
  • 實際交易 → authorization-service/posting-service
  • 點數 → rewards-service

3. 領域模型

  • 聚合:Statement(帳單)、StatementItem(帳單明細,依 services.yaml aggregates)
  • 值物件:CycleCode、StatementStatus、SendType、Money、Currency
  • 狀態:STATEMENTS.status ∈ {GENERATED, SENT, PAID, PARTIALLY_PAID, OVERDUE, DISPUTED, CLOSED}(CHECK,預設 GENERATED)

4. 程式結構

套件/類別 職責 對應需求
domain/model/Statement StatementItem 聚合與值物件 ISS-2.8
domain/port/StatementRepository 持久化埠 —
application/StatementGenerationService 週期執行/產生 ISS-2.8
infrastructure/jdbc/StatementRepositoryImpl JDBC 實作 —
interfaces/rest/StatementController REST 端點 ISS-2.8

5. 資料模型(約束為 Oracle 實測值)

表 關鍵欄位 約束(實測)
STATEMENTS STATEMENT_NO VARCHAR2(24) UNIQUE;ACCOUNT_ID;STATEMENT_MONTH VARCHAR2(8);CYCLE_CODE;STATEMENT_DATE/PAYMENT_DUE_DATE DATE;GRACE_PERIOD_DAYS NUMBER(5);PREV_BALANCE/PAYMENTS_CREDITS/PURCHASES_DEBITS/FINANCE_CHARGES/FEES/ADJUSTMENTS/NEW_BALANCE/MINIMUM_DUE NUMBER(19);REWARD_POINTS_EARNED/BALANCE NUMBER(19);CURRENCY CHAR(3) DEFAULT 'TWD';SEND_TYPE DEFAULT 'ELECTRONIC';E_STATEMENT_FLAG NUMBER(1) DEFAULT 1;STATUS DEFAULT 'GENERATED';GENERATED_AT;SENT_AT send_type IN ('PAPER','ELECTRONIC','NONE');status IN ('GENERATED','SENT','PAID','PARTIALLY_PAID','OVERDUE','DISPUTED','CLOSED');UQ_STMT_MONTH UNIQUE(ACCOUNT_ID,STATEMENT_MONTH)
STATEMENT_LINES STATEMENT_ID(FK→STATEMENTS.ID);SEQ NUMBER(10);POSTING_DATE;TX_NO;DESCRIPTION VARCHAR2(120);MCC;TX_TYPE;CARD_NO_MASKED VARCHAR2(20);AMOUNT;CURRENCY FK(STATEMENT_ID) REFERENCES STATEMENTS(ID)

含 CREATED_AT/UPDATED_AT TIMESTAMP(6) WITH TIME ZONE、VERSION 樂觀鎖。CARD_NO_MASKED 落實 PII 遮蔽(common-security PiiMasker)。


6. API 設計(來自 contracts/openapi/statement-service.yaml)

端點 方法 說明 對應 FR
/api/v1/statement-cycles/{cycleCode}/runs POST 帳單週期執行 ISS-2.8
/api/v1/statement-runs/{runId} GET 執行結果查詢 ISS-2.8
/api/v1/accounts/{accountId}/statements GET 帳單查詢(分頁,data 為陣列) ISS-2.8
/api/v1/statements/{statementId} GET 單筆帳單查詢(含明細,CR 2026-09-19 新增) ISS-2.8
/api/v1/statement-runs GET 批次執行清單(最近 N 筆;2026-09-30 補建,見 §6.1) ISS-2.8

明細載入策略:分頁查詢之 statementLines 為空陣列(避免逐筆載入造成 N+1);
明細由單筆查詢端點取得,供前端點選後展開。

錯誤碼:404 找不到帳單、409 週期重複執行、400 參數錯誤。

6.1 批次執行清單端點(2026-09-30 補建,HANDOVER #A-104)

緣由:SDD-007 §5.4.26 之桶 C「批次作業監控」(/system/batch)——
bff-web 依 SCS-020 §9.9 聚合四服務之批次執行紀錄,需各服務提供「列出批次」端點。
⚠️ 實測揭露:本服務原僅有「觸發」(POST /statement-cycles/{cycleCode}/runs)與
「單筆查詢」(GET /statement-runs/{runId})——無「列出」端點
(GET /statement-cycles/{code}/runs 實測回 405)。
SCS-020 §9.9.1 原載「四個既有端點,皆已實作並真機驗證」與事實不符,已一併更正。

GET /api/v1/statement-runs?limit=N(回應 CycleRunView[],200)

參數 型別 說明
limit integer(可選) 回傳筆數上限。預設 20,上限 100

行為要求:

  1. 唯讀——僅查詢,不觸發任何批次(觸發屬 POST /statement-cycles/{cycleCode}/runs)。
  2. 依 startedAt 遞減(最近執行者在前)——與 SCS-020 §9.9.4 #3 一致。
  3. 無資料回 200 + 空陣列——非 404(「尚無批次執行」是合法狀態,不是錯誤)。
  4. 不快取(SCS-020 §9.9.4 #4)。
  5. 回應沿用既有 CycleRunView(已含 skippedCount/checkpoint/時間欄位)——不另造 DTO,
    避免同一資源出現兩套形狀。

錯誤碼:400 參數錯誤(limit 超出 1..100)。無 404 情境。

未竟:

  • 不支援分頁游標(僅取最近 N 筆)——作業台為即時總覽;長期歷史查詢屬觀測平台(SCS-020 §9.8.4 同一原則)。

7. 關鍵邏輯

  • 帳單產生:由 posting-service 取當期交易與餘額,計算新餘額/最低應繳(參 pricing-service 利息),寫入 STATEMENTS+STATEMENT_LINES。
    • 「當期」之意即 SCS-007 §7.1 之半開區間 (上期結帳日, 本期結帳日];
      PostingQueryPort.getTransactions 以期間為必要參數(型別即為約束,遺漏無法編譯)。

      此為 CR-007(P0:每期重複計收)之根治。核定前之缺陷實測見 §10 #2。

    • 上期結帳日之取得:StatementRepository.findByAccountId(accountId, 1, 0) 取上一期帳單之
      statementDate;無前期帳戶則以 businessDate.minusMonths(1) 為 from
      (與 §8.1 計息區間起點一致,屬簡化,見 §10)。

    • 分頁採 Fail-Closed:逐頁取至「不足一頁」為止(size=200);
      頁數上限 20 頁(4,000 筆/期),超限即丟錯(CC-STM-5001)。
      僅取第一頁等同靜默截斷=帳單短收,故明令禁止(見 SCS-007 §7.1)。

    • ⚠️ 不得依 STATUS 過濾交易(含 REVERSED)(CR-009,P0):
      分錄簿為 append-only——沖正是「新增一筆反向分錄」,不是「把原交易刪掉」。
      故結帳時原始借方與沖正貸方皆須照列,由金額相抵:

      情境 處理 結果
      同期(原始消費與沖正同落本期) 借方照列 + 貸方照列 本期淨 0(該交易實質未成立)
      跨期(消費落前期、沖正落本期) 前期借、本期貸 跨期淨 0(符合會計期間歸屬)
      跨期(消費落本期、沖正落下期) 本期照列借方 下期才抵

      為何明列:原實作以「已沖正之交易不列入帳單」排除 REVERSED 之借方,
      卻仍將其 REVERSAL 分錄計入貸方 → 同一筆交易被減免兩次,
      實測 10 月帳單短收 12,345 分(new_balance 57,898 應為 70,243)。
      該寫法研判為 CR-007 期間過濾前「交易全量取回」時代之防護,
      期間過濾上路後已失去理由且轉為有害。

      配套:明細將出現「一借一貸」兩行——此為正確行為
      (稽核須看見原始消費與其沖正),非缺陷,不得「優化」掉。

  • 週期冪等:UQ_STMT_MONTH 防止同帳戶同月份重複產生。
  • PII 遮蔽:明細卡號一律 CARD_NO_MASKED(遮蔽後),不得存明文。

7.1 事件輸出(Transactional Outbox;CR-027 批次 A-4)

掛點:StatementGenerationService.generateOne 於帳單落庫處、同一交易內 append。

⚠️ 為何需要一個專屬的交易單元(本節之關鍵設計)

runCycle 刻意不標 @Transactional(§7 開頭:大量批次包在單一交易會使 Checkpoint 因回滾而失效)。
原架構之交易邊界落在 JdbcStatementRepository.save(@Transactional,每帳戶一筆交易)。

因此不可在 generateOne 內「先呼叫 save()、再呼叫 outbox.append()」——
前者已自成一個交易並提交,後者將落在另一個交易 → 違反「與權威寫入同交易」
(斷電時會出現「帳單已出、事件未發」)。

故新增 application/StatementIssueService(@Component,非 private 方法):
@Transactional public void issue(Statement, String traceId) —— 內部依序
statementRepository.save(statement)(REQUIRED → 併入同一交易)與 outbox.append(...)。
generateOne 經由注入之 bean 呼叫它,確保 Spring 代理生效。

⚠️ 反例(不得採用):把 generateOne 改標 @Transactional——它由同類別之 runCycle
自我呼叫,Spring 代理不會介入,交易靜默不生效(「看起來有標註、實際沒有」)。

事件:statement.statement.issued.v1(SAD-002 §3.11、SDD-006 §4.2)

項目 值 依據
Kafka 主題 cc.statement.statement.issued(=cc.<aggregateType>.<eventType 去版本>) OutboxPublisher.topicFor
分區鍵 aggregateId = statementId SAD-004 §5.4(同聚合有序)
aggregate_type statement —
event_type(outbox 欄) statement.issued(不含版本) SDD-001 §3.15
event_version 1 —
payload statementId/accountId/statementMonth/totalAmount/minimumDue/paymentDueDate/currency(可選,§7.2) SDD-006 §4.2 + §7.2

payload 欄位之來源對映(不得自行推導):

payload 欄 來源 說明
statementId statement.id() 聚合主鍵
accountId statement.accountId() —
statementMonth statement.statementMonth() 帳單月份(YYYY-MM)
totalAmount statement.newBalance() 本期應繳總額。⚠️ 對映依據=契約 StatementView.totalAmount 即 newBalance()(StatementViewAssembler 實作),非 purchasesDebits/adjustments 等單項
minimumDue statement.minimumDue() 最低應繳
paymentDueDate statement.paymentDueDate() 繳款截止日(YYYY-MM-DD)

⚠️ 金額一律最小單位(分)(SDD-006 §2.5)——禁止浮點。

發射條件:逐帳戶——generateOne 成功產生並落庫一筆帳單即發射恰一則事件。
不發射之情形(皆早於掛點):

情形 原因
同帳戶同月份已存在帳單(UQ_STMT_MONTH,runCycle 之 findByAccountAndMonth 早退) 冪等——不重複產生,故不重複發射
該帳戶計息失敗 → 跳過不產生帳單(§8.1) 無帳單即無事件
零產出(該期無交易) 仍產生帳單並發射——totalAmount 如實為 0(與 SCS-013 §7.1 同立場:不虛構語意)

消費者:notification(STATEMENT_ISSUED)——✅ 已實作(#A-141,CR-029 §5 C-2)。

★ 更正(CR-033,2026-10-02):本節原載消費者含 payment——不成立:
現行權威清單 SAD-004 §5.2 未列 payment(CR-030 §2 #5 重寫後已移除)。
「出帳後觸發自扣」屬未實作功能(SCS-008 §10 自載「自扣執行流程尚未實作」),
非事件消費缺口;且自扣之執行是一筆真實付款(寫入)——依 CR-030 §0 P2,
命令不得走事件,須走 API。→ payment 非本事件之消費者。

⚠️ outbox event_type 之推導慣例(本節為明確化,非新規則):
事件全名去 .vN 後,再移除一層與 aggregate_type 相同之首段——
例:card.card.issued.v1 → card.issued(主題 cc.card.card.issued,見 SCS-003 §7.11);
limit.limit.adjusted.v1 → limit.adjusted(cc.limit.limit.adjusted,見 SCS-002 §7.5)。
故本事件 statement.statement.issued.v1 → statement.issued。

⚠️ 已知不一致(CR-027 §5 待裁定):product/clearing/dispute/posting
目前寫入完整名(如 dispute.case.opened),主題因而多一層重複
(cc.dispute.case.opened)。本服務依既有文件慣例實作,
不一致之對齊方向待 BEAM PMO 裁定(現階段無消費者,變更成本低)。

7.2 payload 補可選 currency——2026-10-01 新增(CR-029 批次 C-2)

緣由:notification-service 訂閱本事件並以既有種子模板 STATEMENT_ISSUED 建立帳單通知
(SAD-004 §5.2 載消費者含 notification)。惟該模板需要 {statementMonth}/{totalAmount}/
{currency}/{paymentDueDate},而 §7.1 之 payload 無 currency →
變數不足即無法建立通知(CC-NOT-1003)。與 SCS-008 §7.2(payment.posted.v1)同族。

處置:依 SAD-004 §5.3「新增可選欄位 → 不升版」,於 v1 payload 增列 currency(可選)。

欄位 來源 必填 說明
currency statements.currency 否(可選) ISO 4217 三碼。本即存在於 statements 表(非新增資料);補入僅為使下游(通知)得渲染

⚠️ currency 為「本期應繳金額」之幣別(與 totalAmount 同源,皆取自同一 Statement 聚合)
——不得改取帳戶之其他幣別。

相容性:不升版(SAD-004 §5.3)。

  • 舊事件(無 currency)之消費者須容忍缺欄——notification-service 之處理見 SCS-016 §7.12:
    缺 currency 時不建立通知並記 WARN(不臆造幣別)。
  • 本服務之事件目前無其他消費者。★ 更正(CR-033):原載「payment 之消費端尚未實作,屬 CR-029 §4 #13 待裁定」——已裁定:payment 非消費者(CR-030 §2 #5+CR-033 §2.1;「自扣」屬未實作功能,非事件流)。

先例:CR-027 §5 #11(#A-125)/#14(#A-128)/SCS-008 §7.2(#A-141)——
皆為「依建議執行 + 登記待裁定」(見 CR-029 §8.4)。


8. 外部依賴與降級

依賴 失敗處理(Stand-in)
posting-service(餘額/交易)、pricing-service(利息)、rewards-service(點數) 經查詢/事件取得;若來源暫不可用,帳單產生排程延後重試(冪等)
計息觸發 posting-service(同步,結帳前) 見 §8.1——該帳戶計息失敗即跳過、不產生帳單,批次繼續

8.1 結帳前觸發計息(CR-006/HANDOVER #C-38)

時序為本設計之核心:利息必須先入帳,結帳時才彙總得到。
若於結帳後才計息,本期帳單將缺少循環息(金額少於應收)。

呼叫點:StatementGenerationService.runCycle → 逐帳戶於 generateOne 之前
呼叫 POST /api/v1/posting-interest/recalculate(SCS-007 §8.1)。

幣別來源(C-32,2026-09-22)

  • 計息之 currency 取自該帳戶之 balance 快照,且與帳單幣別共用同一次查詢
    (runCycle 先 getBalance,再以其幣別觸發計息,並將同一快照傳入 generateOne)。
  • ⛔ 不得硬編 TWD:原實作寫死 "TWD",非 TWD 帳戶將出現
    「利息以 TWD 入帳、帳單標示他幣」之跨服務不一致(本輪修正)。
  • 真機實證:帳戶幣別改為 USD → 入帳交易 TXN20270215000016 與帳單 STMT2027020012
    同步為 USD(1066 分);還原 TWD 後恢復。
  • ⚠️ 連帶效果:getBalance 失敗改於計息前發生(原為計息後),
    故不再留下「已入息、未產帳單」之中間態;失敗處理同下表(外層標記批次 FAILED)。

計息區間之推算

項目 規則
periodEnd 本期結帳日 businessDate(不含——pricing 採半開區間)
periodStart businessDate.minusMonths(1)
區間長度 約 30 日(與 pricing 實測之 [08-16, 09-15) 一致)

⚠️ 本期未使用「上一期實際結帳日」作為起點(須逐帳戶多一次查詢),
一律以「往前推一個月」推算。此簡化已列 §10。

⚠️ 計息區間與帳單區間錯開一日:計息為 [B-1月, B),帳單為 (B-1月, B]。
故結帳日 B 當日發生之消費會進入本期帳單,但下期才開始計息
(銀行實務之「結帳日交易次期起息」)。此為刻意設計,非缺陷。

失敗處理(本期定奪)

情境 處理
計息成功 繼續產生帳單
計息失敗(CC-PLT-5005 等) 跳過該帳戶、不產生帳單,計入 skipped,批次繼續

為何是「跳過」而非「中止批次」:
產生一張缺少利息的帳單等於短收,屬帳務錯誤,代價高於延後;
反之若因單一帳戶失敗就中止整批,將使大量帳戶無法結帳。
故採銀行批次慣例——錯誤帳戶跳過、其餘繼續。

⚠️ 限制:個別帳戶之失敗原因本期僅記錄於日誌(WARN),
未納入 StatementRun Checkpoint 之明細(見 §10)。

8.2 帳單 PDF 產生與物件儲存(GOV-007 §5;2026-10-01 BEAM PMO 裁定「必做」)

緣起:BEAM PMO 2026-10-01 裁定——「現有系統有帳單 PDF,微服務不能少」(GOV-007 §5)。
原 §10 載「儲存媒體已定(S3 API/本地 Garage),PDF 產生器本身仍待」→ 本節補之。

規格先行:本節 → 依賴查證 → 加依賴 → 實作 → 物件儲存上傳 → 真機驗證。

8.2.1 函式庫選型(依 version-compat-verification,Maven Central 實查)

候選 最新穩定 授權 判定
Apache PDFBox 3.0.8 Apache 2.0 ✅ 採用——授權寬鬆(無 AGPL 風險)、純 Java、父 POM source/target 1.8 → Java 21 相容
OpenPDF 3.0.5 LGPL/MPL 🟡 可用但依賴較重(icu4j/fop/brotli4j)
iText 7 — AGPL/商業 ❌ 不採用(銀行案授權風險)

⚠️ 傳遞依賴揭露:PDFBox 3.0.8 會帶入 commons-logging(Spring Boot 以 spring-jcl 橋接)與
bouncycastle(bcprov-jdk18on,用於 PDF 加密;本服務不使用加密,惟仍為傳遞依賴)。

8.2.2 產生流程與降級

步驟 說明
1. 產生 StatementGenerationService.generateOne 於帳單落庫後,以 StatementPdfPort(port)產生 PDF 位元組
2. 上傳 以 ObjectStoragePort.put(key, bytes, "application/pdf") 上傳;鍵=statements/{statementNo}.pdf
3. 回寫 上傳成功後,將物件鍵寫入 STATEMENTS.PDF_REF

降級政策(PDF 為衍生物——fail-soft 但不靜默):

  • ⚠️ 帳單為帳務權威、PDF 為衍生物——PDF 產生/上傳失敗時,帳單仍落庫(PDF_REF 留空)並記 WARN;
    不因單一 PDF 失敗而擋掉整批結帳(物件儲存短暫中斷不應使帳務停擺)。
  • ⚠️ 先上傳成功、才寫 PDF_REF——順序顛倒會留下「DB 有鍵、儲存無檔」之死連結(假成功)。
  • ⚠️ 不退回本地檔案——不以本地檔案冒充成功;PDF_REF 留空即為可見之失敗(不靜默)。
  • ⚠️ 憑證 fail-closed:缺 cardcore.storage.* 即啟動期失敗,不靜默略過。

8.2.3 PDF 版型(本期)

區塊 內容
標頭 「信用卡帳單」(中文)+ 帳單編號
客戶與帳期 帳戶、帳單月份、結帳日、繳款截止日
應繳摘要 前期餘額/本期繳款/本期消費/循環息/費用/調整 → 新餘額/最低應繳
明細 逐筆消費(日期/商店/金額)
頁尾 產生時間、幣別

⚠️ 中文字型須內嵌——PDF 標準字型不含 CJK,未內嵌將顯示為方框。
本期自系統字型載入(本地 macOS:/System/Library/Fonts/...)或專案內附之開源 CJK 字型(如 Noto Sans CJK,SIL OFL)。
⚠️ 正式環境應改用經授權之商業字型或客戶指定字型(不得散布未授權字型)。

8.2.4 真機驗證表

驗證 結果
產生 PDF(含中文) ✅ 真實 PDFBox 產生 35,471 bytes;抽出文字含「信用卡帳單」與帳單編號(CJK 已內嵌)
上傳物件儲存 ✅ 上傳真實 Garage(鍵 statements/STMT-RM-*.pdf)
自儲存取回且為合法 PDF ✅ 取回逐位元組一致;Loader.loadPDF 可解析、1 頁
單元測試 ✅ PdfBoxStatementPdfAdapterTest(PDF 合法+中文可抽出);既有批次測試 3 檔補替身後全綠
驗證方式 StatementPdfRealMachineTest——以 CARDCORE_REAL_STORAGE=1 守門,打真實 Garage;未設即略過(不污染 CI)

⚠️ 本節僅涵蓋「產生 + 入庫」——寄送通道(簡訊/Email/推播)仍為真阻塞(供應商未定,§10/GOV-006 §3.D D-3)。


9. 測試案例(對應 TRP-001)

  • UT-STM-001:帳單管理(ISS-2.8)——週期執行、產生、查詢、PII 遮蔽
  • 週期冪等、最低應繳計算、跨幣別

單測現況(2026-09-20 實測):statement-service 共 22 例、0 失敗
(StatementTest 6/LayeringTest 4/StatementGenerationInterestTriggerTest 6/
StatementReversalNettingTest 3/StatementZeroOutputTest 3)。

測試類 案例數 涵蓋
StatementGenerationInterestTriggerTest 6 CR-006 結帳前計息(時序、半開區間、失敗跳過)、CR-007 期間過濾(3 例)
StatementReversalNettingTest(CR-009) 3 ①同期沖正相抵 ②跨期(消費前期/沖正本期)僅列貸方 ③跨期(消費本期/沖正下期)仍列借方
StatementZeroOutputTest(#C-42) 3 ①帳戶清單為空 → 記 WARN 且狀態 GENERATED ②有處理卻零產出 → 記 ERROR ③skippedCount = processedCount − statementCount 正確導出

9.1 CR-009 之判定力(負樣本實測)

將 REVERSED 排除之缺陷寫法加回後,案例 ①由 80,190 掉回 67,845、
案例 ③由 12,345 掉回 0 → 二者皆轉紅;
案例 ②不受影響(跨期情境下被排除者本就不在本期)——與理論一致,非假綠。

9.2 #C-42 之判定力(負樣本實測)

移除「零產出」之 ERROR 日誌後,案例 ②精準轉紅(其餘不受影響)→
證明該防呆真被測到,不是「寫了但沒生效」的靜默不做。

9.3 CycleRunView 加值欄位 skippedCount

欄位 型別 來源 用途
skippedCount long processedCount − statementCount(非領域模型推導,屬組裝層加值) 使「成功產生 0 張」與「全部跳過」在 API 回應上可區分

純加值(additive)、非 breaking;已同步 tools/gen_openapi.py 並重產契約
(72 端點 0 錯誤,變動僅落在 statement-service.yaml)。


10. 已知限制(誠實揭露)

  • ✅ 帳單 PDF 產生(PDF_REF)已實作(2026-10-01;GOV-007 §5/§8.2**)——儲存媒體
    (契約 S3 API/本地 Garage v2.4.1/正式環境客戶企業物件儲存)+ 產生器
    (Apache PDFBox 3.0.8,Apache 2.0)
    皆已就位並真機驗證(§8.2.4)。
    ⚠️ 仍待者為
    電子帳單寄送通道**(§3.D D-3 真阻塞——供應商未定)。

實作後補記(2026-09-19,誠實揭露):

  1. 契約與 DDL 不一致(須提 CR) → ✅ 已於 CR(2026-09-19)修約完成:

    • StatementView.sendType 枚舉已改為 PAPER / ELECTRONIC / NONE,
      與 STATEMENTS.send_type 之 CHECK 約束一致,檢視層不再需要映射。
    • 帳單查詢之 data 已修約為 StatementView[](陣列),與分頁語意一致。
    • StatementView 已補 statementLines 明細欄位,並新增
      GET /api/v1/statements/{statementId} 單筆查詢端點(含明細)。
    • 修約方式是改 tools/gen_openapi.py(契約之單一真實來源)後重生;
      該產生器並已新增 SchemaName[] 陣列語法以支援清單端點。
  2. ✅ 交易未依期間過濾 → 每期重複計收(P0) → 已於 CR-007 核定並修復

    • 原缺陷實測(2026-09-20,真實 Oracle,帳戶 018f2a3b-…-0010):
      9 月帳單 pay=12,345/fee=300;10 月帳單 pay=12,345/fee=300 完全相同
      (明細 3 行 → 6 行)→ 同一筆交易被兩期帳單計收;
      且 finance_charges=2064 = 9 月息 1032 + 10 月息 1032(逐期等差累加)。
    • 根因:PostingQueryPort.getTransactions 無期間參數,契約端亦僅 page/size。
    • 修法(CR-007 方案 A,BEAM PMO 2026-09-20 核定):posting 交易查詢新增
      from(不含)/to(含)參數,statement 以 (上期結帳日, 本期結帳日] 查詢
      (SCS-007 §7.1/SCS-011 §7)。
    • ⚠️ 歷史髒資料仍在:上述 9/10 月兩筆 INTEREST 交易與帳單係缺陷修復之前產生,
      其金額仍為錯的;修復僅保證往後之週期不再重複計收。
      驗證一律以新月份(11 月)為準,勿以舊帳單判斷修復成效。
  3. 新增 STATEMENT_RUNS 與 STATEMENT_CYCLE_ACCOUNTS 兩表(V2 遷移):
    V1 基線無處存放批次進度與「哪些帳戶屬於哪個週期」,
    若僅存記憶體則重啟即失去續跑能力,故補建。

  4. 批次刻意不包單一交易:若整批包在同一交易,Checkpoint 會因回滾而無法留存,
    喪失續跑意義。改採「每帳戶一筆交易」+逐步推進 Checkpoint。

  5. 最低應繳採簡化公式(新餘額 10%、下限 1,000 分):實務尚涉循環息、
    逾期加成與法規上限,待 pricing-service 費率引擎就緒後參數化。

  6. CARD_NO_MASKED 本期為 null:posting-service 未回傳卡號,
    故明細無卡號可遮蔽;待其提供後由 PiiMasker 遮蔽寫入(欄位可為 null,不違約)。

  7. parallelism(多工並行度)參數已收未實作:本期為循序處理。

  8. 結帳分類:REVERSAL 反向交易計入貸方沖抵(payments),
    因其 TxType.sign() = -1;若誤列借方將使沖正金額反向加成欠款(已修正並實測驗證)。
    ⚠️ 須與 CR-009 一併理解:貸方照列的前題是借方也照列——
    只列貸方而排除 REVERSED 之借方,會使同一筆交易減免兩次(見 §7)。

  9. 狀態機已建模(CR-003 解除):原「STATEMENTS.status 轉換未建模為 SDD-005 正式狀態機」
    之限制已解除——SDD-005 §9 已拆分為兩個狀態機:

    小節 狀態機 對應欄位 值域(實測 CHECK)
    §9.1 帳單本體 STATEMENTS.status GENERATED/SENT/PAID/PARTIALLY_PAID/OVERDUE/DISPUTED/CLOSED
    §9.2 批次執行 STATEMENT_RUNS.status GENERATING/GENERATED/FAILED

    二者皆有 GENERATED 但語意不同(前者「帳單已產製」、後者「批次跑完」),
    不得混用同一狀態對照表(SDD-007 §5.4.5 已同步明訂)。
    原 §9 將兩實體繪於單一圖,且缺 DISPUTED/CLOSED,已一併更正。

  10. DISPUTED 狀態之產生者尚未實作:§9.1 轉換 6/10/11/12 涉及與
    dispute-service 之連動(BRD-001 BF-09),該服務目前僅有骨架,故無程式路徑可達。
    同理 CLOSED(轉換 12~14)之結案/歸檔排程亦未實作。

  11. 平台層請求層例外之狀態碼缺口:缺必要標頭、非法 JSON、不支援之方法等
    會回 500 CC-PLT-5000(契約要求 400/405)。屬全平台問題,
    已立 CR-004(待核定)。

CR-006(2026-09-20 核定)後補記:

  • 循環利息之來源已打通:posting-service 新增計息服務
    (POST /api/v1/posting-interest/recalculate,見 SCS-007 §8.1),
    由結帳/批次流程於結帳前觸發,利息以 TxType.INTEREST 入帳後,
    本服務彙總時即可讀得(此前無人產生 INTEREST 交易)。
  • 計息區間以「往前推一個月」推算(§8.1)——未使用上一期實際結帳日;個別帳戶計息失敗僅記錄於日誌,未納入 StatementRun Checkpoint 明細。
  • 最低應繳維持簡化(CR-006 #6):computeMinimumDue 本期不納入逾期加成
    ——實務最低應繳尚涉及循環息、逾期加成與法規上限,本期維持既有簡化公式,
    並於此明列以供查核。

CR-009(2026-09-20 核定採 A)後補記:

  1. ✅ 排除 REVERSED 交易 → 與沖正分錄重複減免、帳單短收(P0) → 已修復
    • 原缺陷實測(2026-09-20,真實 Oracle):10 月帳單
      purchases_debits=67,845、payments_credits=12,345、new_balance=57,898;
      正確應為 80,190/12,345/70,243 → 短收 12,345 分。
    • 根因:TXN2026091900001(PURCHASE 12,345,狀態 REVERSED)被跳過,
      其沖正分錄 TXN2026091900003(REVERSAL 12,345)仍計入貸方 → 減免兩次。
    • 修法:移除狀態過濾,借貸照列相抵(見 §7 三情境對照表)。
    • 重跑實測(2026-09-20):清帳單三表與 INTEREST 交易後以每月 15 日重跑三期 →
      10 月 purchases_debits=80,190/new_balance=70,243(與上列正確值完全一致)、
      11 月 prev_balance=70,243/new_balance=71,309;明細 9 月 1/10 月 6(含一借一貸)/11 月 1,各期互斥。
    • ⚠️ 修復前產生之帳單金額皆為短收,且 prev_balance 會逐期傳遞錯誤
      (11 月 prev_balance=57,898 亦錯)→ 既有帳單資料須重建。
  2. 明細出現「一借一貸」兩行係正確行為:同期沖正時,帳單明細會同時列出
    原始消費與其沖正分錄。此為稽核留痕所需,非缺陷。已於 §7 明列,
    避免日後為「讓明細變乾淨」而改成成對排除(反而會在跨期時漏掉貸方)。

C-32(2026-09-22)後補記:

  1. ✅ 計息幣別硬編 TWD → 已修復(見 §8.1)
    • 原實作:interestClient.accrueInterest(..., "TWD") 寫死,而帳單幣別取自
      balance.currency() → 非 TWD 帳戶將「利息以 TWD 入帳、帳單標示他幣」。
    • 修法:runCycle 先取 balance,以其幣別觸發計息,並將同一快照傳入
      generateOne(計息幣別與帳單幣別同源,且每帳戶僅查一次餘額)。
    • 真機實證:帳戶幣別 USD → 入帳 TXN20270215000016 與帳單 STMT2027020012
      同步為 USD(1066);還原 TWD 後恢復。
  2. 與 pricing 之四捨五入一致性——已驗證(C-32):
    取整僅發生於 pricing(HALF_UP 至分),本服務以入帳交易加總、不重算,
    故三方逐分相同。真機實測(帳戶 …0010,本金 67,845 分 × 18.5%):
    pricing 試算 1066 = posting 交易 TXN20270115000015 1066
    = 本服務 STMT2027010011 finance_charges 1066;
    帳單明細並直接引用該入帳交易編號(可逐筆追溯)。
    ⚠️ 本服務之「最低應繳」仍為整數截斷(newBalance × 10 ÷ 100),
    與 pricing 之 HALF_UP 不同調——惟該欄位非 pricing 產出、
    且屬簡化公式(§10 #5),故不構成跨服務不一致;俟參數化時一併統一取整方向。

11. 修訂記錄

版本 日期 修訂者 說明
1.11 2026-10-02 WorkBuddy AI CR-033:更正事件消費者之記載——①**§7.2** 原載「消費者:payment」→ 更正為僅 notification(已實作,#A-141);「出帳後觸發自扣」屬未實作功能(SCS-008 §10)而非事件消費,且自扣之執行為寫入,依 CR-030 §0 P2 命令不得走事件 → payment 非本事件之消費者;②**§10** 之「payment 之消費端尚未實作,屬 CR-029 §4 #13 待裁定」同步更正為「已裁定:非消費者(CR-030 §2 #5+CR-033 §2.1)」。★ 零程式變更(純文件更正)
1.10 2026-10-01 WorkBuddy AI CR-029 批次 C-2:§7.2 payload 補【可選】currency——notification-service 訂閱本事件並以既有種子模板 STATEMENT_ISSUED(EMAIL,需 {statementMonth}/{totalAmount}/{currency}/{paymentDueDate})建立帳單通知,惟 §7.1 之 payload 無 currency → 變數不足即無法建立通知(CC-NOT-1003;與 SCS-008 §7.2 同族)。依 SAD-004 §5.3「新增可選欄位 → 不升版」補入;statements.currency 本即存在(非新增資料);★ 明訂 currency 為「本期應繳金額之幣別」(與 totalAmount 同源),不得改取帳戶之其他幣別。相容性:舊事件之消費者須容忍缺欄(SCS-016 §7.12:缺則不建立並記 WARN,不臆造幣別)。先例:CR-027 §5 #11(#A-125)/#14(#A-128)/SCS-008 §7.2——皆「依建議執行 + 登記待裁定」
1.9 2026-10-01 WorkBuddy AI CR-027 批次 A-4(#A-135):新增 §7.1「事件輸出」——statement.statement.issued.v1:主題(cc.statement.statement.issued)/分區鍵(statementId)/payload 逐欄來源對映(★ totalAmount = newBalance(),對映依據為契約 StatementView.totalAmount,不得誤取 purchasesDebits 等單項)/發射與否(冪等重跑不發射、零產出仍發射)。★ 併載本節之關鍵設計:runCycle 刻意不用外層交易(批次需 Checkpoint),原交易邊界在 JdbcStatementRepository.save(每帳戶一筆)→ 不可「先 save() 再 append()」(會各自提交成兩個交易);故抽出獨立 bean StatementIssueService(@Transactional issue(...) 內完成 save + append)。⚠️ 併載反例:把 generateOne 標 @Transactional 無效——同類別自我呼叫,Spring 代理不介入,交易靜默不生效。⚠️ 本列為回填:#A-135 當時漏升版次與補列(由 #A-141 自查發現並補正)
1.8 2026-10-01 WorkBuddy AI 新增 §8.2「帳單 PDF 產生與物件儲存」(GOV-007 §5;BEAM PMO 裁定「必做」)——規格先行 → 依賴查證(version-compat-verification;Maven Central 實查:Apache PDFBox 3.0.8 採 Apache 2.0、父 POM source/target 1.8 → Java 21 相容;OpenPDF 依賴較重;iText 之 AGPL 不採)→ 加依賴 → 實作 → 上傳物件儲存 → 真機驗證。設計要點:StatementPdfPort(產生)+ ObjectStoragePort(上傳,複用 SCS-017 §8.1 之埠模式)→ 先上傳成功才寫 PDF_REF(避死連結);不退回本地檔案(fail-loud);憑證 fail-closed。⚠️ 中文字型須內嵌(PDF 標準字型無 CJK)。§10 之「PDF 產生器仍待」解除;寄送通道仍為真阻塞
1.7 2026-10-01 WorkBuddy AI §10 帳單 PDF 儲存媒體更正(HANDOVER #A-106):原載「儲存媒體(物件儲存)待基礎設施設計凍結」→ 更正為「已定」——契約為 S3 API,本地替代方案為 Garage v2.4.1(已建置並實測,見 SAD-006 §3.2),正式環境為客戶之企業物件儲存。仍待:PDF 產生器本身(需額外函式庫)與電子帳單寄送通道(§3.D D-3 真阻塞)。★ 併載:本地物件儲存原規劃為 MinIO,因 MinIO 開源版已封存不再維護(官方下載站 410 Gone)而改採 Garage
1.6 2026-09-30 WorkBuddy AI 新增 §6.1「批次執行清單端點」(GET /api/v1/statement-runs?limit=N)——解鎖 SDD-007 §5.4.26 桶 C「批次作業監控」(SCS-020 §9.9)。⚠️ 併載實測揭露:本服務原僅有觸發與單筆查詢、無「列出」端點(GET /statement-cycles/{code}/runs 實測 405)——SCS-020 §9.9.1 原載「四個既有端點」與事實不符,已更正。回應沿用既有 CycleRunView(不另造 DTO);limit 預設 20/上限 100;依 startedAt 遞減;無資料回空陣列非 404
1.0 2026-09-19 開發團隊 初版(規格先行),並完成實作與實測
1.1 2026-09-19 開發團隊 依 CR-003 更新:①§10 移除「STATEMENTS.status 未建模為正式狀態機」之限制(SDD-005 §9 已拆分為二);②新增 #8(兩狀態機與值域對照、GENERATED 語意區分)、#9(DISPUTED/CLOSED 之產生者未實作)、#10(CR-004 平台缺口);③新增本修訂記錄區(原文件欠缺)
1.2 2026-09-20 WorkBuddy AI ①新增 §8.1「結帳前觸發計息」(CR-006/HANDOVER #C-38):於 generateOne 之前逐帳戶呼叫 POST /api/v1/posting-interest/recalculate,區間 [businessDate-1個月, businessDate);計息失敗→跳過該帳戶、不產生帳單,批次繼續(實測:利息 1,032 分入 INTEREST 交易、帳單 finance_charges 讀得)。
1.3 2026-09-20 WorkBuddy AI 依 CR-007(BEAM PMO 核定採方案 A)修復 P0「每期重複計收」:①**§7** 「取當期交易」改為明訂 (上期結帳日, 本期結帳日] 半開區間,PostingQueryPort 以期間為必要參數;載明無前期帳戶時之 from 推算與分頁 Fail-Closed;②**§8.1** 補「計息區間與帳單區間錯開一日」之說明;③**§10 #2** 由「待核定」改列為已修復,並揭露歷史髒資料仍在
1.4 2026-09-20 WorkBuddy AI 依 CR-009(BEAM PMO 核定採方案 A:不排除已沖正交易)修復 P0「帳單短收」:①**§7** 新增「不得依 STATUS 過濾交易」及其三情境對照表(同期淨 0/跨期前期借本期貸/跨期本期借下期貸),並載明明細「一借一貸」兩行為正確之稽核留痕;②**§9** 補單測現況(19 例 0 失敗)與 StatementReversalNettingTest 3 例之負樣本判定力實測;③**§10** 新增 #12(缺陷已修復,含實測短收 12,345 與「錯誤會逐期傳遞、須重建」)、#13(一借一貸非缺陷),並於 #8 加註須與 CR-009 一併理解
1.5 2026-09-22 WorkBuddy AI C-32:計息幣別一致性——①**§8.1 新增「幣別來源」:計息幣別取自帳戶 balance 快照且與帳單幣別同源**(原硬編 TWD,非 TWD 帳戶將「以 TWD 入息、帳單標他幣」);②**§10 新增 #14**(缺陷已修復,含 USD 真機實證)與 #15(與 pricing 之四捨五入一致性已驗證:三方逐分相同、明細回引入帳交易編號;並揭露「最低應繳整數截斷」與 pricing HALF_UP 不同調但非跨服務不一致);③單測 29 例 0 失敗(新增 2 例:計息幣別取帳戶幣別、balance 僅查一次)
(44-44/67)