From e08c4a8e71e257c670af4f8171ba4ce2131a7866 Mon Sep 17 00:00:00 2001 From: tangweijie <877588133@qq.com> Date: Wed, 15 Jul 2026 09:54:43 +0800 Subject: [PATCH] docs: add REV-003 charging P0 implementation plan --- ...26-07-15-rev003-charging-p0-remediation.md | 986 ++++++++++++++++++ 1 file changed, 986 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-rev003-charging-p0-remediation.md diff --git a/docs/superpowers/plans/2026-07-15-rev003-charging-p0-remediation.md b/docs/superpowers/plans/2026-07-15-rev003-charging-p0-remediation.md new file mode 100644 index 0000000..60508ad --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-rev003-charging-p0-remediation.md @@ -0,0 +1,986 @@ +# REV-003 Charging P0 Remediation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the unsafe per-bill generic update flow with an atomic, idempotent counter-charge command and make amounts, settlement, top-up reversal, account concurrency, bank reversal, and callback replay financially consistent. + +**Architecture:** Keep the existing one-`PaymentRecord`-per-charge compatibility model, but group all records from one counter operation with `requestId` and `paymentBatchNo`. A new `CounterChargeApplicationService` owns amount calculation, row locking, account mutations, payment capture, and charge projection inside one transaction. Settlement accepts explicit payment record IDs, and all balance/reversal paths use locked account rows plus append-only payment/account logs. + +**Tech Stack:** Java 17, Spring Boot, MyBatis-Plus, PostgreSQL 16, JUnit 5, Mockito, Vue 3, TypeScript, Element Plus, Node.js `node:test`. + +--- + +## File map + +### Backend files to create + +- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeAmountCalculator.java` — pure amount allocation rules. +- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeApplicationService.java` — command boundary. +- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeApplicationServiceImpl.java` — transaction orchestration. +- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterChargeSubmitReqVO.java` — dedicated command request. +- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterChargeSubmitRespVO.java` — payment batch result. +- `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeAmountCalculatorTest.java`. +- `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeApplicationServiceImplTest.java`. +- `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/account/AccountServiceImplConcurrencyTest.java`. +- `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/payment-record-p0-schema.contract.test.mjs`. +- `sql/rev003/REV003_counter_charge_p0_ddl.sql` — additive nullable columns and unique indexes. + +### Backend files to modify + +- `PaymentRecordDO.java`, `PaymentRecordMapper.java`, `PaymentRecordService.java`, `PaymentRecordServiceImpl.java`. +- `AccountMapper.java`, `AccountServiceImpl.java`. +- `ChargeMapper.java`, `ChargeController.java`, `ChargeServiceImpl.java`, `ChargeServiceCounterPaymentTest.java`. +- `CounterTopupReqVO.java`, `CounterTopupRespVO.java`. +- `CounterSettleConfirmReqVO.java`, `CounterSettleApplicationServiceImpl.java`, `CounterSettleApplicationServiceImplTest.java`. +- `CounterUnsettledPageRespVO.java`, `CounterSettleDetailRespVO.java`, `PaymentQueryServiceImpl.java`, `PaymentQueryServiceTest.java`. +- `PrestorageBpmCallbackService.java`, `PrestorageFormalizationService.java`, `PrestorageBpmCallbackServiceTest.java`. +- `sw-business-bank/.../PayInvalidServiceImpl.java` plus a new focused test class. + +### Frontend files to create + +- `src/views/operatingCharges/counterCharging/counterChargeMath.mjs` — finite-number and receivable helpers used by the page and directly tested by Node. +- `types/counter-charge-math.d.ts` — TypeScript declarations for the `.mjs` helper. +- `tests/operatingCharges/counterChargingP0Flow.test.mjs` — API and page wiring contract. +- `tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs` — exact-settlement contract. + +### Frontend files to modify + +- `src/api/operatingCharges/counterCharging/index.ts`. +- `src/views/operatingCharges/counterCharging/index.vue`. +- `src/api/business/charge/counterSettle.ts`. +- `src/views/operatingCharges/counterCheckout/components/CounterUnsettledPanel.vue`. +- `src/views/operatingCharges/counterCheckout/components/CounterSettleConfirmDialog.vue`. +- `src/views/operatingCharges/counterCheckout/components/CounterSettledDetailDialog.vue`. + +### Documentation files to update + +- `docs/evidence/rev003-charging/2026-07-15-p0-audit.md`. +- `docs/evidence/rev003-charging/2026-07-15-p0-verification.md`. +- `docs/design/02_Detailed_Design/12_REV_Detailed.md`. +- `docs/design/03_Technical_Design/03_Interface_Design.md`. + +--- + +### Task 1: Add explicit payment amount and idempotency fields + +**Files:** +- Create: `water-backend/sql/rev003/REV003_counter_charge_p0_ddl.sql` +- Create: `water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/payment-record-p0-schema.contract.test.mjs` +- Modify: `water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/paymentrecord/PaymentRecordDO.java` +- Modify: `water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord/PaymentRecordMapper.java` + +- [ ] **Step 1: Write the failing schema contract test** + +```javascript +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const paymentRecord = readFileSync( + 'sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/paymentrecord/PaymentRecordDO.java', + 'utf8' +) +const ddl = readFileSync('sql/rev003/REV003_counter_charge_p0_ddl.sql', 'utf8') + +test('payment record exposes P0 amount split and idempotency fields', () => { + for (const field of ['requestId', 'paymentBatchNo', 'channelAmount', 'prepayAmount', 'overpayAmount']) { + assert.match(paymentRecord, new RegExp(`private .* ${field};`)) + } +}) + +test('P0 DDL adds additive fields and unique idempotency indexes', () => { + assert.match(ddl, /ADD COLUMN IF NOT EXISTS request_id/) + assert.match(ddl, /ADD COLUMN IF NOT EXISTS channel_amount/) + assert.match(ddl, /uk_biz_payment_record_counter_request/) + assert.match(ddl, /uk_biz_payment_record_reverse_relation/) +}) +``` + +- [ ] **Step 2: Run the contract and verify RED** + +Run: + +```bash +node --test sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/payment-record-p0-schema.contract.test.mjs +``` + +Expected: FAIL because the DDL file and fields do not exist. + +- [ ] **Step 3: Add the DO fields** + +```java +private String requestId; +private String paymentBatchNo; +private BigDecimal channelAmount; +private BigDecimal prepayAmount; +private BigDecimal overpayAmount; +``` + +- [ ] **Step 4: Add additive PostgreSQL DDL** + +```sql +ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS request_id VARCHAR(64); +ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS payment_batch_no VARCHAR(64); +ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS channel_amount NUMERIC(18, 2) NOT NULL DEFAULT 0; +ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS prepay_amount NUMERIC(18, 2) NOT NULL DEFAULT 0; +ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS overpay_amount NUMERIC(18, 2) NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_biz_payment_record_batch + ON biz_payment_record (tenant_id, payment_batch_no, deleted); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_biz_payment_record_counter_request + ON biz_payment_record (tenant_id, request_id, biz_scene, source_ref_id) + WHERE deleted = 0 AND request_id IS NOT NULL AND biz_scene = 'CHARGE_PAYMENT'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_biz_payment_record_topup_request + ON biz_payment_record (tenant_id, request_id, biz_scene) + WHERE deleted = 0 AND request_id IS NOT NULL AND biz_scene = 'DEPOSIT_TOPUP'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_biz_payment_record_reverse_relation + ON biz_payment_record (tenant_id, related_payment_record_id, biz_scene) + WHERE deleted = 0 AND related_payment_record_id IS NOT NULL; +``` + +- [ ] **Step 5: Add mapper queries used by idempotent commands** + +```java +default List selectByRequestId(String requestId, String bizScene) { + if (requestId == null || requestId.isBlank()) { + return List.of(); + } + return selectList(new LambdaQueryWrapperX() + .eq(PaymentRecordDO::getRequestId, requestId) + .eqIfPresent(PaymentRecordDO::getBizScene, bizScene) + .orderByAsc(PaymentRecordDO::getId)); +} + +default List selectCounterUnsettledRecordsByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return selectList(new LambdaQueryWrapperX() + .in(PaymentRecordDO::getId, ids) + .eq(PaymentRecordDO::getSourceType, PaymentSourceTypeEnum.COUNTER_CHARGE.getValue()) + .in(PaymentRecordDO::getBizScene, List.of("CHARGE_PAYMENT", "DEPOSIT_TOPUP")) + .eq(PaymentRecordDO::getPayInOut, "IN") + .eq(PaymentRecordDO::getSettleStatus, PaymentSettleStatusEnum.UNSETTLED.getCode()) + .isNull(PaymentRecordDO::getSettleId) + .orderByAsc(PaymentRecordDO::getId)); +} +``` + +- [ ] **Step 6: Verify GREEN** + +Run the Step 2 command. Expected: 2 tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add sql/rev003 sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/payment-record-p0-schema.contract.test.mjs +git commit -m "feat: add counter payment idempotency fields" +``` + +### Task 2: Correct principal and late-fee semantics + +**Files:** +- Modify: `water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/PaymentRecordServiceImplTest.java` +- Modify: `water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord/PaymentRecordServiceImpl.java` +- Modify: `water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceCounterPaymentTest.java` +- Modify: `water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java` + +- [ ] **Step 1: Add a failing payment allocation test** + +```java +@Test +void captureCounterPayment_shouldKeepPrincipalAndAddLateFeeToReceivable() { + ChargeDO charge = buildCharge(1001L, 9001L, new BigDecimal("100.00"), new BigDecimal("10.00")); + + PaymentRecordDO result = paymentRecordService.captureChargePaymentFromCounter( + charge, LocalDateTime.of(2026, 7, 15, 10, 0), 1, 1, "1001", BigDecimal.ZERO); + + ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(PaymentRecordDO.class); + verify(paymentRecordMapper).insert(recordCaptor.capture()); + assertEquals(new BigDecimal("110.00"), recordCaptor.getValue().getPaymentAmount()); + assertEquals(new BigDecimal("100.00"), recordCaptor.getValue().getBillAmount()); + assertEquals(new BigDecimal("10.00"), recordCaptor.getValue().getLateFeeAmount()); + + ArgumentCaptor detailCaptor = ArgumentCaptor.forClass(PaymentRecordDetailDO.class); + verify(paymentRecordDetailMapper, times(2)).insert(detailCaptor.capture()); + assertEquals(new BigDecimal("100.00"), detailCaptor.getAllValues().get(0).getPrincipalAmount()); + assertEquals(new BigDecimal("10.00"), detailCaptor.getAllValues().get(1).getLateFeeAmount()); +} +``` + +- [ ] **Step 2: Run the test and verify RED** + +```bash +mvn -pl sw-business/sw-business-server -Dtest=PaymentRecordServiceImplTest#captureCounterPayment_shouldKeepPrincipalAndAddLateFeeToReceivable -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: FAIL because payment amount is `100.00` and principal is `90.00`. + +- [ ] **Step 3: Implement canonical helpers** + +```java +private BigDecimal principalAmount(ChargeDO charge) { + return nonNegative(charge == null ? null : charge.getExtendedAmount()); +} + +private BigDecimal lateFeeAmount(ChargeDO charge) { + return nonNegative(charge == null ? null : charge.getLateFee()); +} + +private BigDecimal receivableAmount(ChargeDO charge) { + return principalAmount(charge).add(lateFeeAmount(charge)); +} + +private BigDecimal nonNegative(BigDecimal amount) { + return amount == null || amount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : amount; +} +``` + +Use `receivableAmount(charge)` for `paymentAmount` and `allocatedAmount`; use `principalAmount(charge)` for `billAmount` and the principal detail. Never subtract late fee from `extendedAmount`. + +- [ ] **Step 4: Add a failing counter-preview test** + +For an overdue charge with principal `8.00`, late fee `3.00`, and a current principal `12.00`, assert: + +```java +assertEquals(new BigDecimal("8.00"), result.getOverduePrincipal()); +assertEquals(new BigDecimal("3.00"), result.getOverdueLateFee()); +assertEquals(new BigDecimal("23.00"), result.getTotalReceivable()); +assertEquals(new BigDecimal("13.00"), result.getRemainingPayable()); +``` + +- [ ] **Step 5: Run the preview test and verify RED** + +```bash +mvn -pl sw-business/sw-business-server -Dtest=ChargeServiceCounterPaymentTest#getCounterPreview_shouldApplyPrepaidInFixedPriorityOrder -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: FAIL because overdue principal is currently calculated as `extendedAmount - lateFee`. + +- [ ] **Step 6: Correct preview calculation** + +Use `extendedAmount` as principal and add `lateFee` to the total. Preserve priority: overdue late fee, overdue principal, current principal. + +- [ ] **Step 7: Run both focused test classes** + +```bash +mvn -pl sw-business/sw-business-server -Dtest=PaymentRecordServiceImplTest,ChargeServiceCounterPaymentTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord/PaymentRecordServiceImpl.java sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/PaymentRecordServiceImplTest.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceCounterPaymentTest.java +git commit -m "fix: align counter charge principal and late fee amounts" +``` + +### Task 3: Lock accounts and make top-up idempotent + +**Files:** +- Create: `water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/account/AccountServiceImplConcurrencyTest.java` +- Modify: `AccountMapper.java`, `AccountServiceImpl.java` +- Modify: `CounterTopupReqVO.java`, `ChargeServiceCounterPaymentTest.java`, `ChargeServiceImpl.java` +- Modify: `PaymentRecordService.java`, `PaymentRecordServiceImpl.java`, `PaymentCommandApplicationService.java`, `PaymentCommandApplicationServiceImpl.java` + +- [ ] **Step 1: Write failing account-lock tests** + +```java +@Test +void increaseDeposit_shouldReadAccountForUpdateBeforeWriting() { + AccountDO account = AccountDO.builder().id(1L).custId(66L).deposit(new BigDecimal("5.00")).build(); + when(accountMapper.selectByCustIdForUpdate(66L)).thenReturn(account); + + AccountDO result = service.increaseDeposit(66L, new BigDecimal("2.00"), logContext()); + + assertEquals(new BigDecimal("7.00"), result.getDeposit()); + verify(accountMapper).selectByCustIdForUpdate(66L); + verify(accountMapper).updateById(account); +} + +@Test +void decreaseDeposit_shouldRejectInsufficientLockedBalanceWithoutUpdate() { + AccountDO account = AccountDO.builder().id(1L).custId(66L).deposit(new BigDecimal("5.00")).build(); + when(accountMapper.selectByCustIdForUpdate(66L)).thenReturn(account); + + assertThrows(ServiceException.class, + () -> service.decreaseDeposit(66L, new BigDecimal("6.00"), logContext())); + verify(accountMapper, never()).updateById(any()); +} +``` + +- [ ] **Step 2: Verify RED** + +Run the new test class. Expected: compilation failure because `selectByCustIdForUpdate` does not exist. + +- [ ] **Step 3: Add the locking mapper method** + +```java +default AccountDO selectByCustIdForUpdate(Long custId) { + if (custId == null) { + return null; + } + return selectOne(new LambdaQueryWrapperX() + .eq(AccountDO::getCustId, custId) + .last("FOR UPDATE")); +} +``` + +Change all deposit increase/decrease implementations to use this method. + +- [ ] **Step 4: Add a failing top-up replay test** + +```java +@Test +void counterTopup_shouldReturnExistingPaymentForRepeatedRequestId() { + CounterTopupReqVO req = buildTopup("REQ-1"); + PaymentRecordDO existing = PaymentRecordDO.builder() + .id(99L).paymentNo("TOP-99").requestId("REQ-1") + .paymentAmount(new BigDecimal("50.00")).build(); + when(paymentRecordService.getByRequestId("REQ-1", "DEPOSIT_TOPUP")).thenReturn(List.of(existing)); + when(accountService.getAccountByCustId(66L)).thenReturn(AccountDO.builder().deposit(new BigDecimal("80.00")).build()); + + CounterTopupRespVO result = chargeService.counterTopup(req); + + assertEquals(99L, result.getPaymentRecordId()); + verify(accountService, never()).increaseDeposit(anyLong(), any(), any()); +} +``` + +- [ ] **Step 5: Add request ID and server-owned actor/time** + +`CounterTopupReqVO`: + +```java +@NotBlank(message = "请求号不能为空") +private String requestId; +``` + +Keep legacy `cashierId` and `payTime` nullable for deserialization compatibility, but resolve production values as: + +```java +String cashierId = Optional.ofNullable(SecurityFrameworkUtils.getLoginUserId()) + .map(String::valueOf) + .orElse(reqVO.getCashierId()); +LocalDateTime payTime = LocalDateTime.now(); +``` + +Extend payment capture with `requestId`, `paymentBatchNo`, `channelAmount`, and initialize `prepayAmount/overpayAmount` to zero. Before mutating balance, return an existing completed payment for the same request. + +- [ ] **Step 6: Run focused tests** + +```bash +mvn -pl sw-business/sw-business-server -Dtest=AccountServiceImplConcurrencyTest,ChargeServiceCounterPaymentTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/account sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/account sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterTopupReqVO.java sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentapp sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/account sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceCounterPaymentTest.java +git commit -m "fix: lock deposit updates and deduplicate counter topups" +``` + +### Task 4: Implement the atomic multi-bill counter-charge command + +**Files:** +- Create the counter-charge service, calculator, DTOs, and tests listed in the file map. +- Modify: `ChargeController.java`, `ChargeMapper.java`, payment service/application signatures. + +- [ ] **Step 1: Write calculator tests first** + +```java +@Test +void calculate_shouldAddLateFeeAndAllocatePrepayOldestFirst() { + List bills = List.of( + new BillInput(1L, 66L, 202605, bd("100.00"), bd("10.00")), + new BillInput(2L, 66L, 202606, bd("50.00"), bd("0.00"))); + + BatchAmounts result = calculator.calculate(bills, Map.of(66L, bd("120.00")), true, bd("40.00")); + + assertEquals(bd("160.00"), result.totalReceivable()); + assertEquals(bd("120.00"), result.totalPrepay()); + assertEquals(bd("40.00"), result.channelAmount()); + assertEquals(bd("0.00"), result.overpay()); + assertEquals(bd("110.00"), result.items().get(0).prepayAmount()); + assertEquals(bd("10.00"), result.items().get(1).prepayAmount()); +} + +@Test +void calculate_shouldRejectShortPayment() { + assertThrows(ServiceException.class, () -> calculator.calculate( + List.of(new BillInput(1L, 66L, 202605, bd("100.00"), bd("10.00"))), + Map.of(), false, bd("109.99"))); +} + +@Test +void calculate_shouldRejectOverpayAcrossMultipleMainAccounts() { + assertThrows(ServiceException.class, () -> calculator.calculate( + List.of( + new BillInput(1L, 66L, 202605, bd("10.00"), bd("0.00")), + new BillInput(2L, 77L, 202605, bd("10.00"), bd("0.00"))), + Map.of(), false, bd("21.00"))); +} +``` + +- [ ] **Step 2: Verify calculator RED** + +Run `CounterChargeAmountCalculatorTest`. Expected: compilation failure because the calculator does not exist. + +- [ ] **Step 3: Implement the pure calculator** + +Create records `BillInput`, `ItemAmounts`, and `BatchAmounts`. Sort by bill month then charge ID. Clamp only null values to zero; reject negative principal, late fee, channel amount, or balance. Allocate prepay per main account and enforce the batch equation exactly at scale 2. + +- [ ] **Step 4: Write application-service tests** + +Cover: + +```java +@Test +void submit_shouldCaptureTwoBillsAndProjectBothInsideOneBatch() { /* assert same requestId/batchNo */ } + +@Test +void submit_shouldReturnExistingBatchWithoutMutatingForReplay() { /* existing request records */ } + +@Test +void submit_shouldRollbackContractWhenOneChargeIsNoLongerUnpaid() { /* conditional update count */ } + +@Test +void submit_shouldCreateTopupInSameTransactionForSingleAccountOverpay() { /* overpay record + account log */ } + +@Test +void submit_shouldRejectExpectedAmountMismatch() { /* stale page amount */ } +``` + +- [ ] **Step 5: Define the request and response DTOs** + +```java +@Data +public class CounterChargeSubmitReqVO { + @NotBlank private String requestId; + @NotEmpty private List<@NotNull Long> chargeIds; + @NotNull @DecimalMin("0.01") private BigDecimal expectedReceivableAmount; + @NotNull @DecimalMin("0.00") private BigDecimal actualPayAmount; + @NotNull private Boolean usePrepay; + @NotNull private Integer chargeWay; + private String remark; +} +``` + +```java +@Data @Builder +public class CounterChargeSubmitRespVO { + private String requestId; + private String paymentBatchNo; + private List paymentRecordIds; + private BigDecimal totalReceivableAmount; + private BigDecimal channelAmount; + private BigDecimal prepayAmount; + private BigDecimal overpayTopupAmount; + private BigDecimal balanceAfter; +} +``` + +- [ ] **Step 6: Implement locking and conditional projection** + +`ChargeMapper` must provide: + +```java +default List selectByIdsForUpdate(List ids) { + return selectList(new LambdaQueryWrapperX() + .in(ChargeDO::getId, ids) + .orderByAsc(ChargeDO::getId) + .last("FOR UPDATE")); +} + +default int markCounterPaid(Long id, LocalDateTime payTime, Integer chargeMethod, + Integer chargeWay, String cashierId) { + return update(null, new LambdaUpdateWrapper() + .set(ChargeDO::getPayState, PayStateEnum.PAID.getValue()) + .set(ChargeDO::getPayDate, payTime) + .set(ChargeDO::getChargeMethod, chargeMethod) + .set(ChargeDO::getChargeWay, chargeWay) + .set(ChargeDO::getCashierId, cashierId) + .eq(ChargeDO::getId, id) + .eq(ChargeDO::getPayState, PayStateEnum.UNPAID.getValue())); +} +``` + +- [ ] **Step 7: Implement the transaction service** + +The `@Transactional` method must: + +1. Normalize and validate `requestId` and IDs. +2. Return an existing batch when request ID and charge set match; reject reuse with different IDs. +3. Allow only `chargeWay=1` until a confirmed channel adapter exists. +4. Lock charges and main accounts in ascending ID order. +5. Calculate amounts and compare server total with expected total. +6. Create per-charge PaymentRecords with shared batch/request IDs and explicit channel/prepay values. +7. Write prepay deductions using the payment record ID in `AccountLogContext`. +8. Conditionally mark every charge paid; any update count other than one throws and rolls back. +9. Create overpay top-up and balance increase in the same transaction. +10. Return the batch response. + +- [ ] **Step 8: Add the controller endpoint** + +```java +@PostMapping("/counter-charge/submit") +@PreAuthorize("@ss.hasPermission('business:charge:update')") +public CommonResult submitCounterCharge( + @Valid @RequestBody CounterChargeSubmitReqVO reqVO) { + return success(counterChargeApplicationService.submit(reqVO)); +} +``` + +- [ ] **Step 9: Run focused tests** + +```bash +mvn -pl sw-business/sw-business-server -Dtest=CounterChargeAmountCalculatorTest,CounterChargeApplicationServiceImplTest,ChargeControllerTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: PASS. + +- [ ] **Step 10: Commit** + +```bash +git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countercharge sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/charge sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentapp sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countercharge sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/controller/admin/charge +git commit -m "feat: add atomic multi-bill counter charge command" +``` + +### Task 5: Close generic charge and account financial bypasses + +**Files:** +- Modify: `ChargeServiceCounterPaymentTest.java`, `ChargeServiceImpl.java`. +- Modify: `AccountServiceImplConcurrencyTest.java`, `AccountServiceImpl.java`. + +- [ ] **Step 1: Replace old generic-payment tests with rejection tests** + +```java +@Test +void updateCharge_shouldRejectCounterPaymentTransition() { + ChargeDO before = buildCharge(1001L, PayStateEnum.UNPAID.getValue()); + when(chargeMapper.selectById(1001L)).thenReturn(before); + ChargeSaveReqVO req = paidCounterUpdate(1001L); + + ServiceException error = assertThrows(ServiceException.class, () -> chargeService.updateCharge(req)); + + assertEquals("柜台收费请使用专用收费接口", error.getMessage()); + verify(chargeMapper, never()).updateById(any()); +} + +@Test +void deleteCharge_shouldRejectPaidOrSettledBill() { /* payState != UNPAID */ } +``` + +- [ ] **Step 2: Verify RED** + +Run `ChargeServiceCounterPaymentTest`. Expected: old path still succeeds. + +- [ ] **Step 3: Implement guards before generic updates/deletes** + +Reject any `UNPAID -> PAID/SETTLED` transition in `updateCharge`; reject delete when `payState != UNPAID` or an active PaymentRecord exists. + +- [ ] **Step 4: Guard generic account balance changes** + +Add tests that `updateAccount` rejects a request whose deposit differs from the persisted account and `deleteAccount` rejects non-zero balances. Preserve non-financial account metadata updates. + +- [ ] **Step 5: Run focused tests and commit** + +```bash +mvn -pl sw-business/sw-business-server -Dtest=ChargeServiceCounterPaymentTest,AccountServiceImplConcurrencyTest -Dsurefire.failIfNoSpecifiedTests=false test +git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/account/AccountServiceImpl.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceCounterPaymentTest.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/account/AccountServiceImplConcurrencyTest.java +git commit -m "fix: block generic financial state mutations" +``` + +### Task 6: Settle an explicit payment-record set and use channel amounts + +**Files:** +- Modify settlement VO/service/tests, PaymentRecord service/mapper, query VO/service/tests. + +- [ ] **Step 1: Write failing exact-scope tests** + +```java +@Test +void confirm_shouldSettleOnlyRequestedPaymentRecords() { + CounterSettleConfirmReqVO req = new CounterSettleConfirmReqVO(); + req.setPaymentRecordIds(List.of(101L, 102L)); + req.setSettleTime(SETTLE_TIME); + when(paymentRecordService.getCounterUnsettledRecordsByIds(List.of(101L, 102L))) + .thenReturn(List.of(record(101L, "1001", "20.00", "5.00"), + record(102L, "1001", "16.00", "0.00"))); + + CounterSettleRespVO result = service.confirm(req); + + verify(paymentRecordService).markSettled(List.of(101L, 102L), result.getSettleId(), SETTLE_TIME); + assertEquals(new BigDecimal("36.00"), result.getTotalAmount()); +} + +@Test +void confirm_shouldRejectRequestedRecordOwnedByAnotherCashier() { /* login 1001, row 2002 */ } + +@Test +void confirm_shouldRejectWhenAnyRequestedIdIsMissingOrAlreadySettled() { /* size mismatch */ } +``` + +- [ ] **Step 2: Verify RED** + +Run `CounterSettleApplicationServiceImplTest`. Expected: compile failure because request IDs and service method do not exist. + +- [ ] **Step 3: Change the request contract** + +```java +@NotEmpty(message = "请选择待结账记录") +private List<@NotNull Long> paymentRecordIds; + +// cashierId remains read-compatible but is ignored when an authenticated user exists +private String cashierId; +``` + +- [ ] **Step 4: Implement exact-scope settlement** + +- Normalize/deduplicate request IDs. +- Load eligible rows only by IDs. +- Require loaded count to equal requested count. +- Resolve cashier from authenticated user and require every row to match. +- Calculate `settleAmount` from `channelAmount`, falling back to legacy `paymentAmount` only when the new field is null. +- Mark exactly those records settled and exactly their charge IDs projected as settled. + +- [ ] **Step 5: Expose amount split in query responses** + +Add `channelAmount` and `prepayAmount` to unsettled/detail response VOs. Update payment summary to aggregate channel money separately from prepay. + +- [ ] **Step 6: Run focused tests and commit** + +```bash +mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest,PaymentQueryServiceTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test +git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentquery sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentquery +git commit -m "fix: settle explicit counter payment records" +``` + +### Task 7: Repair top-up reversal and enforce reverse uniqueness + +**Files:** +- Modify: `CounterSettleApplicationServiceImplTest.java`, `CounterSettleApplicationServiceImpl.java`, `PaymentRecordMapper.java`, `PaymentRecordServiceImplTest.java`, `PaymentRecordServiceImpl.java`. + +- [ ] **Step 1: Correct the mock expectation first** + +Change the settled top-up test to expect: + +```java +when(paymentRecordMapper.markCounterSettleTopupReversed(501L, 701L)).thenReturn(1); +verify(paymentRecordMapper).markCounterSettleTopupReversed(501L, 701L); +verify(paymentRecordMapper, never()).markCounterSettleReversed(anyLong(), anyLong()); +``` + +Add an unsettled top-up test expecting `markCounterUnsettledTopupReversed`. + +- [ ] **Step 2: Verify RED** + +Run the two top-up red-flush tests. Expected: FAIL because production calls the charge-payment mapper method or rejects the unsettled top-up. + +- [ ] **Step 3: Route each state to the correct mapper method** + +Use `markCounterSettleTopupReversed` for settled top-ups and `markCounterUnsettledTopupReversed` for unsettled top-ups. Do not require a settle detail for an unsettled top-up. + +- [ ] **Step 4: Handle concurrent duplicate reverse insertion** + +Keep the existing read-before-insert behavior for friendly replay, rely on the new unique index for races, and translate duplicate-key exceptions by reloading the existing reverse record. + +- [ ] **Step 5: Run tests and commit** + +```bash +mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test +git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord +git commit -m "fix: reverse counter topups through correct state path" +``` + +### Task 8: Harden bank invalidation and prestorage callback replay + +**Files:** +- Create: `water-backend/sw-business-bank/sw-business-bank-server/src/test/java/cn/com/emsoft/sw/bankbusiness/service/payinvalid/PayInvalidServiceImplTest.java` +- Modify: `PayInvalidServiceImpl.java`, `ChargeServiceImpl.java`. +- Modify: `PrestorageBpmCallbackServiceTest.java`, `PrestorageBpmCallbackService.java`, `PrestorageFormalizationService.java` and its mapper. + +- [ ] **Step 1: Add a failing invalid-charge state test** + +```java +@Test +void invalidCharge_shouldRejectWhenBusinessReverseDidNotRun() { + ChargeDO charge = charge(1L, PayStateEnum.SETTLED.getValue()); + when(chargeMapper.selectById(1L)).thenReturn(charge); + + ServiceException error = assertThrows(ServiceException.class, () -> service.invalidCharge(1L)); + + assertEquals("当前账单状态不允许银行冲正", error.getMessage()); + verify(paymentCommandApplicationService, never()).reverseChargePayment(any()); +} +``` + +- [ ] **Step 2: Implement explicit invalidation result** + +`ChargeServiceImpl.invalidCharge` returns only after `reverseChargePayment` succeeds. Null charge returns null; every non-`PAID` state throws. `PayInvalidServiceImpl` treats any exception or null as failure and must not mark the original bank transaction reversed. + +- [ ] **Step 3: Add failing callback replay tests** + +```java +@Test +void handleApproved_shouldReturnWithoutMutationWhenAlreadyCompleted() { + PrestorageAdjustDO main = adjustment("COMPLETED"); + when(prestorageFormalizationService.getViewForUpdate("REV004-PRF-1")) + .thenReturn(view(main)); + + service.handleApproved("REV004-PRF-1", "PROC-1"); + + verifyNoInteractions(accountService, paymentCommandApplicationService); +} +``` + +Add a transfer test that verifies both accounts are locked in ascending account ID order and changed through `AccountService` with AccountLog contexts. + +- [ ] **Step 4: Implement locked callback execution** + +- Add `getViewForUpdate(adjustmentNo)` using a mapper `FOR UPDATE` query. +- Return immediately when `businessStatus=COMPLETED`. +- Only `APPROVED_PENDING_EXECUTION`, or the initial approved callback before binding, may execute. +- Replace direct `AccountMapper.updateById` balance writes with locked `AccountService.decreaseDeposit/increaseDeposit` calls carrying adjustment source information. + +- [ ] **Step 5: Run focused tests and commit** + +```bash +mvn -pl sw-business/sw-business-server -Dtest=PrestorageBpmCallbackServiceTest -Dsurefire.failIfNoSpecifiedTests=false test +mvn -pl sw-business-bank/sw-business-bank-server -Dtest=PayInvalidServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test +git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/accountingadjust/prestorage sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/accountingadjust/prestorage sw-business-bank/sw-business-bank-server/src/main/java/cn/com/emsoft/sw/bankbusiness/service/payinvalid sw-business-bank/sw-business-bank-server/src/test/java/cn/com/emsoft/sw/bankbusiness/service/payinvalid sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java +git commit -m "fix: prevent false bank reversal and callback replay" +``` + +### Task 9: Switch counter charging UI to the atomic command + +**Files:** +- Create: `counterChargeMath.mjs`, declaration file, `counterChargingP0Flow.test.mjs`. +- Modify: counter-charging API and page. + +- [ ] **Step 1: Write failing real math-helper tests** + +```javascript +import test from 'node:test' +import assert from 'node:assert/strict' +import { billReceivable, parseMoney } from '../../src/views/operatingCharges/counterCharging/counterChargeMath.mjs' + +test('bill receivable adds principal and late fee', () => { + assert.equal(billReceivable({ extendedAmount: 100, lateFee: 10 }), 110) +}) + +test('parseMoney rejects non finite values', () => { + assert.equal(parseMoney('abc'), null) + assert.equal(parseMoney('Infinity'), null) + assert.equal(parseMoney('10.20'), 10.2) +}) +``` + +- [ ] **Step 2: Verify RED** + +Run `node --test tests/operatingCharges/counterChargingP0Flow.test.mjs`. Expected: module-not-found. + +- [ ] **Step 3: Implement the helper** + +```javascript +export const toCents = (value) => Math.round(Number(value || 0) * 100) +export const fromCents = (value) => Number((value / 100).toFixed(2)) +export const parseMoney = (value) => { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= 0 ? fromCents(toCents(parsed)) : null +} +export const billReceivable = (bill) => fromCents( + toCents(bill?.extendedAmount ?? bill?.billAmount ?? 0) + toCents(bill?.lateFee ?? 0) +) +``` + +- [ ] **Step 4: Add the atomic API contract** + +```ts +export interface CounterChargeSubmitPayload { + requestId: string + chargeIds: number[] + expectedReceivableAmount: number + actualPayAmount: number + usePrepay: boolean + chargeWay: number + remark?: string +} + +export const submitCounterCharge = (data: CounterChargeSubmitPayload) => + request.post({ + url: '/business/charge/counter-charge/submit', + data + }) +``` + +Update top-up payload to require `requestId`; stop sending client-owned cashier/time for the new path. + +- [ ] **Step 5: Replace page-side allocation and sequential submission** + +- Calculate display receivable with `billReceivable`. +- Validate `parseMoney(actualAmount)` before opening and before confirming. +- Keep a `pendingRequestId` across retry; clear it only when selection/amount changes or the request succeeds. +- Call `submitCounterCharge` exactly once with all selected IDs. +- Remove the `for ... submitCashCharge` loop and separate overpay request. +- Update success rendering from the batch response and then refresh customer, bills, balance, and payment summary. +- Only cash remains enabled for direct submission; configuration failure also falls back to cash only. +- Rename “删除” to “本次不收”. + +- [ ] **Step 6: Expand confirmation details** + +Display principal, late fee, total receivable, estimated prepay, channel actual, and overpay. Mark the prepay split as “以服务端提交结果为准”. + +- [ ] **Step 7: Run focused frontend tests** + +```bash +node --test tests/operatingCharges/counterChargingP0Flow.test.mjs src/views/operatingCharges/counterCharging/counterTopup.contract.test.mjs tests/operatingCharges/counterChargingZeroAmount.contract.test.mjs tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs +``` + +Expected: new tests PASS; update obsolete regex assertions in existing files to the new accepted behavior, without weakening amount or atomicity assertions. + +- [ ] **Step 8: Commit** + +```bash +git add src/api/operatingCharges/counterCharging src/views/operatingCharges/counterCharging types/counter-charge-math.d.ts tests/operatingCharges/counterChargingP0Flow.test.mjs tests/operatingCharges/counterChargingZeroAmount.contract.test.mjs tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs +git commit -m "fix: submit counter charges as one atomic batch" +``` + +### Task 10: Make counter settlement selection real + +**Files:** +- Create: `tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs`. +- Modify: counter-settle API and three counter-checkout components. + +- [ ] **Step 1: Write the failing page contract** + +```javascript +test('counter checkout submits selected payment record ids', () => { + assert.match(panel, /@selection-change="\(rows.*handleGroupSelection/) + assert.match(panel, /selectedPaymentRecordIds/) + assert.match(dialog, /paymentRecordIds: summary\.value\.paymentRecordIds/) + assert.match(api, /paymentRecordIds: number\[\]/) +}) +``` + +- [ ] **Step 2: Verify RED** + +Run the new test. Expected: FAIL because selection is currently decorative and the request only contains cashier ID. + +- [ ] **Step 3: Implement group selection state** + +Maintain `Map`, flatten unique selected rows, and derive amount/count from those rows only. The settle button is disabled until at least one eligible row is selected. Clear selections after data reload or successful settlement. + +- [ ] **Step 4: Submit exact IDs** + +Change `CounterSettleConfirmReqVO` to: + +```ts +export interface CounterSettleConfirmReqVO { + paymentRecordIds: number[] + settleTime: string + remark?: string +} +``` + +Pass IDs through the dialog. Do not send `cashierId` as authority. + +- [ ] **Step 5: Make reversal reason required** + +```ts +export interface CounterRedFlushReqVO { + paymentRecordIds: number[] + reason: string +} +``` + +Use an input validator that rejects blank reasons in both unsettled and settled dialogs. + +- [ ] **Step 6: Run focused tests and commit** + +```bash +node --test tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs tests/rev006/counterCheckoutOldPageInventory.test.mjs tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs src/views/operatingCharges/counterCheckout/redFlushReason.contract.test.mjs +git add src/api/business/charge/counterSettle.ts src/views/operatingCharges/counterCheckout tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs tests/rev006/counterCheckoutOldPageInventory.test.mjs tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs +git commit -m "fix: settle only explicitly selected payment records" +``` + +### Task 11: Verification, formal documentation, and evidence + +**Files:** +- Create: `water-docs/docs/evidence/rev003-charging/2026-07-15-p0-verification.md`. +- Modify formal detailed/interface design and the audit record. + +- [ ] **Step 1: Run backend unit tests by focused class** + +```bash +mvn -pl sw-business/sw-business-server -Dtest=CounterChargeAmountCalculatorTest,CounterChargeApplicationServiceImplTest,PaymentRecordServiceImplTest,ChargeServiceCounterPaymentTest,AccountServiceImplConcurrencyTest,CounterSettleApplicationServiceImplTest,PaymentQueryServiceTest,PrestorageBpmCallbackServiceTest -Dsurefire.failIfNoSpecifiedTests=false test +mvn -pl sw-business-bank/sw-business-bank-server -Dtest=PayInvalidServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +- [ ] **Step 2: Run backend compile** + +```bash +mvn -pl sw-business/sw-business-server,sw-business-bank/sw-business-bank-server -am -DskipTests compile +``` + +- [ ] **Step 3: Run frontend deterministic tests** + +```bash +node --test \ + tests/operatingCharges/counterChargingP0Flow.test.mjs \ + tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs \ + src/views/operatingCharges/counterCharging/counterTopup.contract.test.mjs \ + src/views/operatingCharges/counterCheckout/redFlushReason.contract.test.mjs \ + tests/operatingCharges/counterChargingZeroAmount.contract.test.mjs \ + tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs \ + tests/rev006/counterCheckoutOldPageInventory.test.mjs +``` + +- [ ] **Step 4: Run frontend build without `vue-tsc`** + +```bash +pnpm build:dev +``` + +Do not run `pnpm ts:check` or any direct `vue-tsc` command. + +- [ ] **Step 5: Run database integration tests when configured** + +```bash +if [ -n "$REV004_IT_DB_URL" ]; then + mvn -pl sw-business/sw-business-server \ + -Dtest=CounterChargeFullChainIntegrationTest,CounterSettleIntegrationTest \ + -Dsurefire.failIfNoSpecifiedTests=false test +else + echo "BLOCKED: REV004_IT_DB_URL is not configured" +fi +``` + +- [ ] **Step 6: Update formal design** + +Document the actual `/business/charge/counter-charge/submit` contract, request idempotency, explicit settlement IDs, amount formulas, channel-confirmation boundary, and additive database fields. Mark the old generic update payment path as compatibility-only and prohibited for new counter payments. + +- [ ] **Step 7: Record verification evidence** + +The evidence file must contain exact commands, exit codes, test counts, build result, integration-test `PASS/FAIL/BLOCKED`, backend/frontend commit SHAs, remaining risks, and an explicit statement that `vue-tsc` was not run. + +- [ ] **Step 8: Commit documentation** + +```bash +git add docs/evidence/rev003-charging docs/design/02_Detailed_Design/12_REV_Detailed.md docs/design/03_Technical_Design/03_Interface_Design.md docs/superpowers/plans/2026-07-15-rev003-charging-p0-remediation.md +git commit -m "docs: record REV-003 charging P0 implementation evidence" +``` + +## Plan self-review + +- Spec coverage: all P0 findings in the approved design map to Tasks 1–10; bank outbox is explicitly excluded from the first delivery but false-success handling is included. +- Placeholder scan: no placeholder markers or unspecified test steps remain. +- Type consistency: request fields use `requestId`, `chargeIds`, `expectedReceivableAmount`, `actualPayAmount`, `usePrepay`, `chargeWay`; settlement consistently uses `paymentRecordIds`. +- Safety: all production changes have a failing test first; all financial mutations are transactional and use locked rows or conditional updates. +- User constraint: no plan step invokes `vue-tsc`.