fujian_water_biz_doc/docs/superpowers/plans/2026-07-15-rev003-charging-p0-remediation.md

45 KiB
Raw Blame History

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

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:

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
private String requestId;
private String paymentBatchNo;
private BigDecimal channelAmount;
private BigDecimal prepayAmount;
private BigDecimal overpayAmount;
  • Step 4: Add additive PostgreSQL DDL
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
default List<PaymentRecordDO> selectByRequestId(String requestId, String bizScene) {
    if (requestId == null || requestId.isBlank()) {
        return List.of();
    }
    return selectList(new LambdaQueryWrapperX<PaymentRecordDO>()
            .eq(PaymentRecordDO::getRequestId, requestId)
            .eqIfPresent(PaymentRecordDO::getBizScene, bizScene)
            .orderByAsc(PaymentRecordDO::getId));
}

default List<PaymentRecordDO> selectCounterUnsettledRecordsByIds(List<Long> ids) {
    if (ids == null || ids.isEmpty()) {
        return List.of();
    }
    return selectList(new LambdaQueryWrapperX<PaymentRecordDO>()
            .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
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

@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<PaymentRecordDO> 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<PaymentRecordDetailDO> 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
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
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:

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
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
mvn -pl sw-business/sw-business-server -Dtest=PaymentRecordServiceImplTest,ChargeServiceCounterPaymentTest -Dsurefire.failIfNoSpecifiedTests=false test

Expected: PASS.

  • Step 8: Commit
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

@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
default AccountDO selectByCustIdForUpdate(Long custId) {
    if (custId == null) {
        return null;
    }
    return selectOne(new LambdaQueryWrapperX<AccountDO>()
            .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
@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:

@NotBlank(message = "请求号不能为空")
private String requestId;

Keep legacy cashierId and payTime nullable for deserialization compatibility, but resolve production values as:

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
mvn -pl sw-business/sw-business-server -Dtest=AccountServiceImplConcurrencyTest,ChargeServiceCounterPaymentTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test

Expected: PASS.

  • Step 7: Commit
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

@Test
void calculate_shouldAddLateFeeAndAllocatePrepayOldestFirst() {
    List<BillInput> 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:

@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
@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;
}
@Data @Builder
public class CounterChargeSubmitRespVO {
    private String requestId;
    private String paymentBatchNo;
    private List<Long> 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:

default List<ChargeDO> selectByIdsForUpdate(List<Long> ids) {
    return selectList(new LambdaQueryWrapperX<ChargeDO>()
            .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<ChargeDO>()
            .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
@PostMapping("/counter-charge/submit")
@PreAuthorize("@ss.hasPermission('business:charge:update')")
public CommonResult<CounterChargeSubmitRespVO> submitCounterCharge(
        @Valid @RequestBody CounterChargeSubmitReqVO reqVO) {
    return success(counterChargeApplicationService.submit(reqVO));
}
  • Step 9: Run focused tests
mvn -pl sw-business/sw-business-server -Dtest=CounterChargeAmountCalculatorTest,CounterChargeApplicationServiceImplTest,ChargeControllerTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test

Expected: PASS.

  • Step 10: Commit
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

@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
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

@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
@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
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:

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
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

@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
@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

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

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
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
export interface CounterChargeSubmitPayload {
  requestId: string
  chargeIds: number[]
  expectedReceivableAmount: number
  actualPayAmount: number
  usePrepay: boolean
  chargeWay: number
  remark?: string
}

export const submitCounterCharge = (data: CounterChargeSubmitPayload) =>
  request.post<CounterChargeSubmitRespVO>({
    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
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
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

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<string, CounterUnsettledRowVO[]>, 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:

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
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
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

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
mvn -pl sw-business/sw-business-server,sw-business-bank/sw-business-bank-server -am -DskipTests compile
  • Step 3: Run frontend deterministic tests
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
pnpm build:dev

Do not run pnpm ts:check or any direct vue-tsc command.

  • Step 5: Run database integration tests when configured
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
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 110; 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.