CR #201 » SCS-011_帳單服務程式設計規格.md
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.yamlaggregates) - 值物件:
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_ATTIMESTAMP(6) WITH TIME ZONE、VERSION樂觀鎖。CARD_NO_MASKED落實 PII 遮蔽(common-securityPiiMasker)。
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 |
行為要求:
-
唯讀——僅查詢,不觸發任何批次(觸發屬
POST /statement-cycles/{cycleCode}/runs)。 -
依
startedAt遞減(最近執行者在前)——與SCS-020 §9.9.4 #3一致。 -
無資料回
200+ 空陣列——非404(「尚無批次執行」是合法狀態,不是錯誤)。 -
不快取(
SCS-020 §9.9.4 #4)。 - 回應沿用既有
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_balance57,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 §0P2,
命令不得走事件,須走 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),
未納入StatementRunCheckpoint 之明細(見 §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/本地 Garagev2.4.1/正式環境客戶企業物件儲存)+ 產生器
(Apache PDFBox 3.0.8,Apache 2.0) 皆已就位並真機驗證(§8.2.4)。
⚠️ 仍待者為電子帳單寄送通道**(§3.D D-3真阻塞——供應商未定)。
實作後補記(2026-09-19,誠實揭露):
-
契約與 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[]陣列語法以支援清單端點。
-
-
✅
交易未依期間過濾 → 每期重複計收(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 月)為準,勿以舊帳單判斷修復成效。
-
原缺陷實測(2026-09-20,真實 Oracle,帳戶
-
新增
STATEMENT_RUNS與STATEMENT_CYCLE_ACCOUNTS兩表(V2 遷移):
V1 基線無處存放批次進度與「哪些帳戶屬於哪個週期」,
若僅存記憶體則重啟即失去續跑能力,故補建。 -
批次刻意不包單一交易:若整批包在同一交易,Checkpoint 會因回滾而無法留存,
喪失續跑意義。改採「每帳戶一筆交易」+逐步推進 Checkpoint。 -
最低應繳採簡化公式(新餘額 10%、下限 1,000 分):實務尚涉循環息、
逾期加成與法規上限,待pricing-service費率引擎就緒後參數化。 -
CARD_NO_MASKED本期為 null:posting-service未回傳卡號,
故明細無卡號可遮蔽;待其提供後由PiiMasker遮蔽寫入(欄位可為 null,不違約)。 -
parallelism(多工並行度)參數已收未實作:本期為循序處理。 -
結帳分類:
REVERSAL反向交易計入貸方沖抵(payments),
因其TxType.sign() = -1;若誤列借方將使沖正金額反向加成欠款(已修正並實測驗證)。
⚠️ 須與CR-009一併理解:貸方照列的前題是借方也照列——
只列貸方而排除REVERSED之借方,會使同一筆交易減免兩次(見 §7)。 -
狀態機已建模(
CR-003解除):原「STATEMENTS.status轉換未建模為SDD-005正式狀態機」
之限制已解除——SDD-005 §9已拆分為兩個狀態機:小節 狀態機 對應欄位 值域(實測 CHECK) §9.1 帳單本體 STATEMENTS.statusGENERATED/SENT/PAID/PARTIALLY_PAID/OVERDUE/DISPUTED/CLOSED§9.2 批次執行 STATEMENT_RUNS.statusGENERATING/GENERATED/FAILED二者皆有
GENERATED但語意不同(前者「帳單已產製」、後者「批次跑完」),
不得混用同一狀態對照表(SDD-007 §5.4.5已同步明訂)。
原 §9 將兩實體繪於單一圖,且缺DISPUTED/CLOSED,已一併更正。 -
DISPUTED狀態之產生者尚未實作:§9.1轉換 6/10/11/12 涉及與
dispute-service之連動(BRD-001BF-09),該服務目前僅有骨架,故無程式路徑可達。
同理CLOSED(轉換 12~14)之結案/歸檔排程亦未實作。 -
平台層請求層例外之狀態碼缺口:缺必要標頭、非法 JSON、不支援之方法等
會回 500CC-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)——未使用上一期實際結帳日;個別帳戶計息失敗僅記錄於日誌,未納入
StatementRunCheckpoint 明細。 -
最低應繳維持簡化(
CR-006 #6):computeMinimumDue本期不納入逾期加成
——實務最低應繳尚涉及循環息、逾期加成與法規上限,本期維持既有簡化公式,
並於此明列以供查核。
CR-009(2026-09-20 核定採 A)後補記:
- ✅
排除→ 已修復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(PURCHASE12,345,狀態REVERSED)被跳過,
其沖正分錄TXN2026091900003(REVERSAL12,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亦錯)→ 既有帳單資料須重建。
-
原缺陷實測(2026-09-20,真實 Oracle):10 月帳單
-
明細出現「一借一貸」兩行係正確行為:同期沖正時,帳單明細會同時列出
原始消費與其沖正分錄。此為稽核留痕所需,非缺陷。已於 §7 明列,
避免日後為「讓明細變乾淨」而改成成對排除(反而會在跨期時漏掉貸方)。
C-32(2026-09-22)後補記:
- ✅
計息幣別硬編→ 已修復(見 §8.1)TWD-
原實作:
interestClient.accrueInterest(..., "TWD")寫死,而帳單幣別取自
balance.currency()→ 非 TWD 帳戶將「利息以 TWD 入帳、帳單標示他幣」。 -
修法:
runCycle先取 balance,以其幣別觸發計息,並將同一快照傳入
generateOne(計息幣別與帳單幣別同源,且每帳戶僅查一次餘額)。 -
真機實證:帳戶幣別
USD→ 入帳TXN20270215000016與帳單STMT2027020012
同步為USD(1066);還原TWD後恢復。
-
原實作:
-
與
pricing之四捨五入一致性——已驗證(C-32):
取整僅發生於pricing(HALF_UP至分),本服務以入帳交易加總、不重算,
故三方逐分相同。真機實測(帳戶…0010,本金 67,845 分 × 18.5%):
pricing試算 1066 =posting交易TXN202701150000151066
= 本服務STMT2027010011finance_charges1066;
帳單明細並直接引用該入帳交易編號(可逐筆追溯)。
⚠️ 本服務之「最低應繳」仍為整數截斷(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 僅查一次) |