From cb28638b892fca056d636cf9aa30d1affa2bbde0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Chang?= Date: Fri, 1 May 2026 12:05:21 +0000 Subject: [PATCH 1/3] feat(delacc): translate DELACC to Java --- .../augment/cbsa/domain/DelaccRequest.java | 4 + .../com/augment/cbsa/domain/DelaccResult.java | 41 +++++ .../cbsa/repository/DelaccRepository.java | 95 +++++++++++ .../augment/cbsa/service/DelaccService.java | 131 +++++++++++++++ .../cbsa/web/delacc/DelaccController.java | 114 +++++++++++++ .../delacc/dto/DelaccCommareaRequestDto.java | 105 ++++++++++++ .../delacc/dto/DelaccCommareaResponseDto.java | 67 ++++++++ .../cbsa/web/delacc/dto/DelaccRequestDto.java | 18 ++ .../web/delacc/dto/DelaccResponseDto.java | 14 ++ .../augment/cbsa/CbsaApplicationTests.java | 8 + .../service/DelaccServiceIntegrationTest.java | 89 ++++++++++ .../cbsa/service/DelaccServiceUnitTest.java | 159 ++++++++++++++++++ .../delacc/DelaccControllerWebMvcTest.java | 131 +++++++++++++++ 13 files changed, 976 insertions(+) create mode 100644 src/main/java/com/augment/cbsa/domain/DelaccRequest.java create mode 100644 src/main/java/com/augment/cbsa/domain/DelaccResult.java create mode 100644 src/main/java/com/augment/cbsa/repository/DelaccRepository.java create mode 100644 src/main/java/com/augment/cbsa/service/DelaccService.java create mode 100644 src/main/java/com/augment/cbsa/web/delacc/DelaccController.java create mode 100644 src/main/java/com/augment/cbsa/web/delacc/dto/DelaccCommareaRequestDto.java create mode 100644 src/main/java/com/augment/cbsa/web/delacc/dto/DelaccCommareaResponseDto.java create mode 100644 src/main/java/com/augment/cbsa/web/delacc/dto/DelaccRequestDto.java create mode 100644 src/main/java/com/augment/cbsa/web/delacc/dto/DelaccResponseDto.java create mode 100644 src/test/java/com/augment/cbsa/service/DelaccServiceIntegrationTest.java create mode 100644 src/test/java/com/augment/cbsa/service/DelaccServiceUnitTest.java create mode 100644 src/test/java/com/augment/cbsa/web/delacc/DelaccControllerWebMvcTest.java diff --git a/src/main/java/com/augment/cbsa/domain/DelaccRequest.java b/src/main/java/com/augment/cbsa/domain/DelaccRequest.java new file mode 100644 index 0000000..7ff484f --- /dev/null +++ b/src/main/java/com/augment/cbsa/domain/DelaccRequest.java @@ -0,0 +1,4 @@ +package com.augment.cbsa.domain; + +public record DelaccRequest(long accountNumber) { +} diff --git a/src/main/java/com/augment/cbsa/domain/DelaccResult.java b/src/main/java/com/augment/cbsa/domain/DelaccResult.java new file mode 100644 index 0000000..d9d803f --- /dev/null +++ b/src/main/java/com/augment/cbsa/domain/DelaccResult.java @@ -0,0 +1,41 @@ +package com.augment.cbsa.domain; + +import java.util.Objects; + +public record DelaccResult( + boolean deleteSuccess, + String failCode, + long accountNumber, + AccountDetails account, + String message +) { + + public DelaccResult { + Objects.requireNonNull(failCode, "failCode must not be null"); + + if (deleteSuccess && account == null) { + throw new IllegalArgumentException("Successful results must include an account"); + } + if (!deleteSuccess && account != null) { + throw new IllegalArgumentException("Failure results must not include an account"); + } + if (!deleteSuccess && (message == null || message.isBlank())) { + throw new IllegalArgumentException("Failure results must include a non-blank message"); + } + } + + public static DelaccResult success(AccountDetails account) { + Objects.requireNonNull(account, "account must not be null"); + return new DelaccResult(true, " ", account.accountNumber(), account, null); + } + + public static DelaccResult failure(String failCode, long accountNumber, String message) { + Objects.requireNonNull(failCode, "failCode must not be null"); + Objects.requireNonNull(message, "message must not be null"); + return new DelaccResult(false, failCode, accountNumber, null, message); + } + + public boolean isNotFoundFailure() { + return !deleteSuccess && "1".equals(failCode); + } +} diff --git a/src/main/java/com/augment/cbsa/repository/DelaccRepository.java b/src/main/java/com/augment/cbsa/repository/DelaccRepository.java new file mode 100644 index 0000000..9a0ed1f --- /dev/null +++ b/src/main/java/com/augment/cbsa/repository/DelaccRepository.java @@ -0,0 +1,95 @@ +package com.augment.cbsa.repository; + +import com.augment.cbsa.domain.AccountDetails; +import com.augment.cbsa.jooq.tables.records.AccountRecord; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import org.jooq.DSLContext; +import org.springframework.stereotype.Repository; + +import static com.augment.cbsa.jooq.Tables.ACCOUNT; +import static com.augment.cbsa.jooq.Tables.PROCTRAN; + +@Repository +public class DelaccRepository { + + private static final DateTimeFormatter ACCOUNT_AUDIT_DATE_FORMATTER = DateTimeFormatter.ofPattern("ddMMyyyy", Locale.ROOT); + + private final DSLContext dsl; + + public DelaccRepository(DSLContext dsl) { + this.dsl = Objects.requireNonNull(dsl, "dsl must not be null"); + } + + public Optional findBySortcodeAndAccountNumberForUpdate(String sortcode, long accountNumber) { + return dsl.selectFrom(ACCOUNT) + .where(ACCOUNT.SORTCODE.eq(sortcode)) + .and(ACCOUNT.ACCOUNT_NUMBER.eq(accountNumber)) + .forUpdate() + .fetchOptional(this::toDomain); + } + + public int deleteAccount(String sortcode, long accountNumber) { + return dsl.deleteFrom(ACCOUNT) + .where(ACCOUNT.SORTCODE.eq(sortcode)) + .and(ACCOUNT.ACCOUNT_NUMBER.eq(accountNumber)) + .execute(); + } + + public void insertAccountDeletionAudit( + AccountDetails account, + long transactionReference, + LocalDate transactionDate, + LocalTime transactionTime + ) { + Objects.requireNonNull(account, "account must not be null"); + Objects.requireNonNull(transactionDate, "transactionDate must not be null"); + Objects.requireNonNull(transactionTime, "transactionTime must not be null"); + + dsl.insertInto(PROCTRAN) + .set(PROCTRAN.SORTCODE, account.sortcode()) + .set(PROCTRAN.LOGICAL_DELETE, false) + .set(PROCTRAN.TRAN_DATE, transactionDate) + .set(PROCTRAN.TRAN_TIME, transactionTime) + .set(PROCTRAN.TRAN_REF, transactionReference) + .set(PROCTRAN.TRAN_TYPE, "ODA") + .set(PROCTRAN.DESCRIPTION, toAccountDeletionDescription(account)) + .set(PROCTRAN.AMOUNT, account.actualBalance()) + .execute(); + } + + private AccountDetails toDomain(AccountRecord record) { + return new AccountDetails( + record.getSortcode(), + record.getCustomerNumber(), + record.getAccountNumber(), + record.getAccountType(), + record.getInterestRate(), + record.getOpened(), + record.getOverdraftLimit(), + record.getLastStmtDate(), + record.getNextStmtDate(), + record.getAvailableBalance(), + record.getActualBalance() + ); + } + + private String toAccountDeletionDescription(AccountDetails account) { + return String.format( + Locale.ROOT, + "%010d%-8.8s%s%sDELETE", + account.customerNumber(), + account.accountType(), + toCobolDate(account.lastStatementDate()), + toCobolDate(account.nextStatementDate()) + ); + } + + private String toCobolDate(LocalDate date) { + return date == null ? "00000000" : date.format(ACCOUNT_AUDIT_DATE_FORMATTER); + } +} diff --git a/src/main/java/com/augment/cbsa/service/DelaccService.java b/src/main/java/com/augment/cbsa/service/DelaccService.java new file mode 100644 index 0000000..ebfd94b --- /dev/null +++ b/src/main/java/com/augment/cbsa/service/DelaccService.java @@ -0,0 +1,131 @@ +package com.augment.cbsa.service; + +import com.augment.cbsa.domain.AccountDetails; +import com.augment.cbsa.domain.DelaccRequest; +import com.augment.cbsa.domain.DelaccResult; +import com.augment.cbsa.error.CbsaAbendException; +import com.augment.cbsa.repository.CrdbRetry; +import com.augment.cbsa.repository.DelaccRepository; +import java.sql.SQLException; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.Optional; +import org.jooq.DSLContext; +import org.jooq.exception.DataAccessException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +@Service +public class DelaccService { + + private static final String NOT_FOUND_CODE = "1"; + private static final String DELETE_FAILURE_CODE = "3"; + private static final String READ_ABEND_CODE = "HRAC"; + private static final String PROCTRAN_ABEND_CODE = "HWPT"; + + private final DelaccRepository delaccRepository; + private final DSLContext dsl; + private final TransactionTemplate transactionTemplate; + private final String sortcode; + private final Clock clock; + + public DelaccService( + DelaccRepository delaccRepository, + DSLContext dsl, + TransactionTemplate transactionTemplate, + @Value("${cbsa.sortcode}") String sortcode, + Clock clock + ) { + this.delaccRepository = Objects.requireNonNull(delaccRepository, "delaccRepository must not be null"); + this.dsl = Objects.requireNonNull(dsl, "dsl must not be null"); + this.transactionTemplate = Objects.requireNonNull(transactionTemplate, "transactionTemplate must not be null"); + this.sortcode = Objects.requireNonNull(sortcode, "sortcode must not be null"); + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + } + + public DelaccResult delete(DelaccRequest request) { + Objects.requireNonNull(request, "request must not be null"); + + return Objects.requireNonNull( + CrdbRetry.run(dsl, () -> transactionTemplate.execute(status -> deleteWithinTransaction(request))), + "transactionTemplate returned null result" + ); + } + + private DelaccResult deleteWithinTransaction(DelaccRequest request) { + Optional account; + try { + account = delaccRepository.findBySortcodeAndAccountNumberForUpdate(sortcode, request.accountNumber()); + } catch (DataAccessException exception) { + if (isSerializationFailure(exception)) { + throw exception; + } + throw new CbsaAbendException(READ_ABEND_CODE, "DELACC failed to read the account data.", exception); + } + + if (account.isEmpty()) { + return DelaccResult.failure( + NOT_FOUND_CODE, + request.accountNumber(), + "Account number %d was not found.".formatted(request.accountNumber()) + ); + } + + AccountDetails accountDetails = account.get(); + + try { + int deletedRows = delaccRepository.deleteAccount(sortcode, request.accountNumber()); + if (deletedRows != 1) { + return deleteFailure(request.accountNumber()); + } + } catch (DataAccessException exception) { + if (isSerializationFailure(exception)) { + throw exception; + } + return deleteFailure(request.accountNumber()); + } + + Instant now = Instant.now(clock); + + try { + delaccRepository.insertAccountDeletionAudit( + accountDetails, + Math.max(0L, now.toEpochMilli()), + LocalDate.ofInstant(now, ZoneOffset.UTC), + LocalTime.ofInstant(now, ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS) + ); + } catch (DataAccessException exception) { + if (isSerializationFailure(exception)) { + throw exception; + } + throw new CbsaAbendException(PROCTRAN_ABEND_CODE, "DELACC failed to write the account deletion audit trail.", exception); + } + + return DelaccResult.success(accountDetails); + } + + private DelaccResult deleteFailure(long accountNumber) { + return DelaccResult.failure( + DELETE_FAILURE_CODE, + accountNumber, + "Account number %d could not be deleted.".formatted(accountNumber) + ); + } + + private static boolean isSerializationFailure(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof SQLException sqlException && "40001".equals(sqlException.getSQLState())) { + return true; + } + current = current.getCause(); + } + return false; + } +} diff --git a/src/main/java/com/augment/cbsa/web/delacc/DelaccController.java b/src/main/java/com/augment/cbsa/web/delacc/DelaccController.java new file mode 100644 index 0000000..0c19a7f --- /dev/null +++ b/src/main/java/com/augment/cbsa/web/delacc/DelaccController.java @@ -0,0 +1,114 @@ +package com.augment.cbsa.web.delacc; + +import com.augment.cbsa.domain.AccountDetails; +import com.augment.cbsa.domain.DelaccRequest; +import com.augment.cbsa.domain.DelaccResult; +import com.augment.cbsa.service.DelaccService; +import com.augment.cbsa.web.delacc.dto.DelaccCommareaResponseDto; +import com.augment.cbsa.web.delacc.dto.DelaccRequestDto; +import com.augment.cbsa.web.delacc.dto.DelaccResponseDto; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.PositiveOrZero; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.Objects; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Validated +@RequestMapping("/api/v1/delacc") +public class DelaccController { + + private static final String EYE_CATCHER = "ACCT"; + private static final DateTimeFormatter COBOL_DATE_FORMATTER = DateTimeFormatter.ofPattern("ddMMyyyy", Locale.ROOT); + + private final DelaccService delaccService; + + public DelaccController(DelaccService delaccService) { + this.delaccService = Objects.requireNonNull(delaccService, "delaccService must not be null"); + } + + @DeleteMapping("/remove/{accno}") + public ResponseEntity delete( + @PathVariable("accno") + @PositiveOrZero + @Max(99_999_999L) + long accountNumber, + @Valid @RequestBody DelaccRequestDto requestDto + ) { + Objects.requireNonNull(requestDto, "requestDto must not be null"); + + DelaccResult result = delaccService.delete(new DelaccRequest(accountNumber)); + if (!result.deleteSuccess()) { + return ResponseEntity.status(failureStatus(result)).body(failureBody(result)); + } + + return ResponseEntity.ok(toResponse(result)); + } + + private HttpStatus failureStatus(DelaccResult result) { + if (result.isNotFoundFailure()) { + return HttpStatus.NOT_FOUND; + } + return HttpStatus.INTERNAL_SERVER_ERROR; + } + + private ProblemDetail failureBody(DelaccResult result) { + HttpStatus status = failureStatus(result); + ProblemDetail problemDetail = ProblemDetail.forStatus(status); + problemDetail.setTitle(failureTitle(result)); + problemDetail.setDetail(result.message()); + problemDetail.setProperty("failCode", result.failCode()); + return problemDetail; + } + + private String failureTitle(DelaccResult result) { + if (result.isNotFoundFailure()) { + return "Account not found"; + } + return "Account deletion failed"; + } + + private DelaccResponseDto toResponse(DelaccResult result) { + AccountDetails account = Objects.requireNonNull(result.account(), "Successful response requires an account"); + return new DelaccResponseDto(new DelaccCommareaResponseDto( + EYE_CATCHER, + String.format(Locale.ROOT, "%010d", account.customerNumber()), + account.sortcode(), + account.accountNumber(), + account.accountType(), + account.interestRate(), + toCobolDate(account.opened()), + account.overdraftLimit().longValueExact(), + toCobolDate(account.lastStatementDate()), + toCobolDate(account.nextStatementDate()), + account.availableBalance(), + account.actualBalance(), + "", + "", + "Y", + "", + "", + "", + "", + "" + )); + } + + private int toCobolDate(LocalDate date) { + if (date == null) { + return 0; + } + return Integer.parseInt(date.format(COBOL_DATE_FORMATTER)); + } +} diff --git a/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccCommareaRequestDto.java b/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccCommareaRequestDto.java new file mode 100644 index 0000000..85a3f51 --- /dev/null +++ b/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccCommareaRequestDto.java @@ -0,0 +1,105 @@ +package com.augment.cbsa.web.delacc.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import java.math.BigDecimal; + +public record DelaccCommareaRequestDto( + @JsonProperty("DelAccEye") + @Size(max = 4) + String delAccEye, + + @JsonProperty("DelAccCustno") + @Pattern(regexp = "[0-9]{0,10}") + String delAccCustno, + + @JsonProperty("DelAccScode") + @Pattern(regexp = "[0-9]{0,6}") + String delAccScode, + + @JsonProperty("DelAccAccno") + @PositiveOrZero + @Max(99_999_999L) + Long delAccAccno, + + @JsonProperty("DelAccAccType") + @Size(max = 8) + String delAccAccType, + + @JsonProperty("DelAccIntRate") + @Digits(integer = 4, fraction = 2) + @DecimalMin("0.00") + @DecimalMax("9999.99") + BigDecimal delAccIntRate, + + @JsonProperty("DelAccOpened") + @PositiveOrZero + @Max(99_999_999L) + Integer delAccOpened, + + @JsonProperty("DelAccOverdraft") + @PositiveOrZero + @Max(99_999_999L) + Long delAccOverdraft, + + @JsonProperty("DelAccLastStmtDt") + @PositiveOrZero + @Max(99_999_999L) + Integer delAccLastStmtDt, + + @JsonProperty("DelAccNextStmtDt") + @PositiveOrZero + @Max(99_999_999L) + Integer delAccNextStmtDt, + + @JsonProperty("DelAccAvailBal") + @Digits(integer = 10, fraction = 2) + @DecimalMin("-9999999999.99") + @DecimalMax("9999999999.99") + BigDecimal delAccAvailBal, + + @JsonProperty("DelAccActualBal") + @Digits(integer = 10, fraction = 2) + @DecimalMin("-9999999999.99") + @DecimalMax("9999999999.99") + BigDecimal delAccActualBal, + + @JsonProperty("DelAccSuccess") + @Size(max = 1) + String delAccSuccess, + + @JsonProperty("DelAccFailCd") + @Size(max = 1) + String delAccFailCd, + + @JsonProperty("DelAccDelSuccess") + @Size(max = 1) + String delAccDelSuccess, + + @JsonProperty("DelAccDelFailCd") + @Size(max = 1) + String delAccDelFailCd, + + @JsonProperty("DelAccDelApplid") + @Size(max = 8) + String delAccDelApplid, + + @JsonProperty("DelAccDelPcb1") + @Size(max = 4) + String delAccDelPcb1, + + @JsonProperty("DelAccDelPcb2") + @Size(max = 4) + String delAccDelPcb2, + + @JsonProperty("DelAccDelPcb3") + @Size(max = 4) + String delAccDelPcb3 +) { +} diff --git a/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccCommareaResponseDto.java b/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccCommareaResponseDto.java new file mode 100644 index 0000000..44f85d8 --- /dev/null +++ b/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccCommareaResponseDto.java @@ -0,0 +1,67 @@ +package com.augment.cbsa.web.delacc.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.math.BigDecimal; + +public record DelaccCommareaResponseDto( + @JsonProperty("DelAccEye") + String delAccEye, + + @JsonProperty("DelAccCustno") + String delAccCustno, + + @JsonProperty("DelAccScode") + String delAccScode, + + @JsonProperty("DelAccAccno") + long delAccAccno, + + @JsonProperty("DelAccAccType") + String delAccAccType, + + @JsonProperty("DelAccIntRate") + BigDecimal delAccIntRate, + + @JsonProperty("DelAccOpened") + int delAccOpened, + + @JsonProperty("DelAccOverdraft") + long delAccOverdraft, + + @JsonProperty("DelAccLastStmtDt") + int delAccLastStmtDt, + + @JsonProperty("DelAccNextStmtDt") + int delAccNextStmtDt, + + @JsonProperty("DelAccAvailBal") + BigDecimal delAccAvailBal, + + @JsonProperty("DelAccActualBal") + BigDecimal delAccActualBal, + + @JsonProperty("DelAccSuccess") + String delAccSuccess, + + @JsonProperty("DelAccFailCd") + String delAccFailCd, + + @JsonProperty("DelAccDelSuccess") + String delAccDelSuccess, + + @JsonProperty("DelAccDelFailCd") + String delAccDelFailCd, + + @JsonProperty("DelAccDelApplid") + String delAccDelApplid, + + @JsonProperty("DelAccDelPcb1") + String delAccDelPcb1, + + @JsonProperty("DelAccDelPcb2") + String delAccDelPcb2, + + @JsonProperty("DelAccDelPcb3") + String delAccDelPcb3 +) { +} diff --git a/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccRequestDto.java b/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccRequestDto.java new file mode 100644 index 0000000..417e3b4 --- /dev/null +++ b/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccRequestDto.java @@ -0,0 +1,18 @@ +package com.augment.cbsa.web.delacc.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.Objects; + +public record DelaccRequestDto( + @JsonProperty("DelAcc") + @Valid + @NotNull + DelaccCommareaRequestDto delAcc +) { + + public DelaccRequestDto { + Objects.requireNonNull(delAcc, "delAcc must not be null"); + } +} diff --git a/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccResponseDto.java b/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccResponseDto.java new file mode 100644 index 0000000..862ee34 --- /dev/null +++ b/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccResponseDto.java @@ -0,0 +1,14 @@ +package com.augment.cbsa.web.delacc.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Objects; + +public record DelaccResponseDto( + @JsonProperty("DelAcc") + DelaccCommareaResponseDto delAcc +) { + + public DelaccResponseDto { + Objects.requireNonNull(delAcc, "delAcc must not be null"); + } +} diff --git a/src/test/java/com/augment/cbsa/CbsaApplicationTests.java b/src/test/java/com/augment/cbsa/CbsaApplicationTests.java index 3892b62..d1fb014 100644 --- a/src/test/java/com/augment/cbsa/CbsaApplicationTests.java +++ b/src/test/java/com/augment/cbsa/CbsaApplicationTests.java @@ -4,10 +4,12 @@ import com.augment.cbsa.repository.CreaccRepository; import com.augment.cbsa.repository.CrecustRepository; import com.augment.cbsa.repository.DbcrfunRepository; +import com.augment.cbsa.repository.DelaccRepository; import com.augment.cbsa.repository.DelcusRepository; import com.augment.cbsa.repository.CustomerRepository; import com.augment.cbsa.repository.UpdaccRepository; import com.augment.cbsa.repository.UpdcustRepository; +import com.augment.cbsa.service.DelaccService; import com.augment.cbsa.service.DbcrfunService; import com.augment.cbsa.service.DelcusService; import org.junit.jupiter.api.Test; @@ -51,12 +53,18 @@ class CbsaApplicationTests { @MockitoBean private DelcusRepository delcusRepository; + @MockitoBean + private DelaccRepository delaccRepository; + @MockitoBean private DbcrfunRepository dbcrfunRepository; @MockitoBean private DelcusService delcusService; + @MockitoBean + private DelaccService delaccService; + @MockitoBean private DbcrfunService dbcrfunService; diff --git a/src/test/java/com/augment/cbsa/service/DelaccServiceIntegrationTest.java b/src/test/java/com/augment/cbsa/service/DelaccServiceIntegrationTest.java new file mode 100644 index 0000000..a26c25a --- /dev/null +++ b/src/test/java/com/augment/cbsa/service/DelaccServiceIntegrationTest.java @@ -0,0 +1,89 @@ +package com.augment.cbsa.service; + +import com.augment.cbsa.domain.DelaccRequest; +import com.augment.cbsa.domain.DelaccResult; +import com.augment.cbsa.support.AbstractCockroachIntegrationTest; +import java.math.BigDecimal; +import java.time.LocalDate; +import org.jooq.DSLContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static com.augment.cbsa.jooq.Tables.ACCOUNT; +import static com.augment.cbsa.jooq.Tables.CUSTOMER; +import static com.augment.cbsa.jooq.Tables.PROCTRAN; +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class DelaccServiceIntegrationTest extends AbstractCockroachIntegrationTest { + + @Autowired + private DSLContext dsl; + + @Autowired + private DelaccService delaccService; + + @BeforeEach + void cleanDatabase() { + dsl.deleteFrom(PROCTRAN).execute(); + dsl.deleteFrom(ACCOUNT).execute(); + dsl.deleteFrom(CUSTOMER).execute(); + } + + @Test + void deletesAccountAndWritesDeletionAudit() { + insertCustomer(10L); + insertAccount(10L, 12345678L, new BigDecimal("1499.75")); + + DelaccResult result = delaccService.delete(new DelaccRequest(12345678L)); + + assertThat(result.deleteSuccess()).isTrue(); + assertThat(result.account()).isNotNull(); + assertThat(dsl.fetchCount(ACCOUNT)).isZero(); + assertThat(dsl.fetchCount(PROCTRAN)).isEqualTo(1); + assertThat(dsl.select(PROCTRAN.TRAN_TYPE, PROCTRAN.DESCRIPTION, PROCTRAN.AMOUNT) + .from(PROCTRAN) + .fetchOne()) + .extracting(record -> record.get(PROCTRAN.TRAN_TYPE), record -> record.get(PROCTRAN.DESCRIPTION), record -> record.get(PROCTRAN.AMOUNT)) + .containsExactly("ODA", "0000000010ISA 0302202404032024DELETE", new BigDecimal("1499.75")); + } + + @Test + void returnsNotFoundWithoutWritingAuditRows() { + DelaccResult result = delaccService.delete(new DelaccRequest(12345678L)); + + assertThat(result.deleteSuccess()).isFalse(); + assertThat(result.failCode()).isEqualTo("1"); + assertThat(dsl.fetchCount(PROCTRAN)).isZero(); + } + + private void insertCustomer(long customerNumber) { + dsl.insertInto(CUSTOMER) + .set(CUSTOMER.SORTCODE, "987654") + .set(CUSTOMER.CUSTOMER_NUMBER, customerNumber) + .set(CUSTOMER.NAME, "Example Customer %d".formatted(customerNumber)) + .set(CUSTOMER.ADDRESS, "%d Example Road".formatted(customerNumber)) + .set(CUSTOMER.DATE_OF_BIRTH, LocalDate.of(1990, 1, 1)) + .set(CUSTOMER.CREDIT_SCORE, (short) 500) + .set(CUSTOMER.CS_REVIEW_DATE, LocalDate.of(2025, 1, 1)) + .execute(); + } + + private void insertAccount(long customerNumber, long accountNumber, BigDecimal actualBalance) { + dsl.insertInto(ACCOUNT) + .set(ACCOUNT.SORTCODE, "987654") + .set(ACCOUNT.ACCOUNT_NUMBER, accountNumber) + .set(ACCOUNT.CUSTOMER_NUMBER, customerNumber) + .set(ACCOUNT.ACCOUNT_TYPE, "ISA") + .set(ACCOUNT.INTEREST_RATE, new BigDecimal("1.50")) + .set(ACCOUNT.OPENED, LocalDate.of(2024, 1, 2)) + .set(ACCOUNT.OVERDRAFT_LIMIT, new BigDecimal("250.00")) + .set(ACCOUNT.LAST_STMT_DATE, LocalDate.of(2024, 2, 3)) + .set(ACCOUNT.NEXT_STMT_DATE, LocalDate.of(2024, 3, 4)) + .set(ACCOUNT.AVAILABLE_BALANCE, new BigDecimal("1500.25")) + .set(ACCOUNT.ACTUAL_BALANCE, actualBalance) + .execute(); + } +} diff --git a/src/test/java/com/augment/cbsa/service/DelaccServiceUnitTest.java b/src/test/java/com/augment/cbsa/service/DelaccServiceUnitTest.java new file mode 100644 index 0000000..2165c91 --- /dev/null +++ b/src/test/java/com/augment/cbsa/service/DelaccServiceUnitTest.java @@ -0,0 +1,159 @@ +package com.augment.cbsa.service; + +import com.augment.cbsa.domain.AccountDetails; +import com.augment.cbsa.domain.DelaccRequest; +import com.augment.cbsa.domain.DelaccResult; +import com.augment.cbsa.error.CbsaAbendException; +import com.augment.cbsa.repository.DelaccRepository; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.util.Optional; +import org.jooq.DSLContext; +import org.jooq.exception.DataAccessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class DelaccServiceUnitTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-05-01T10:15:30Z"), ZoneOffset.UTC); + + private DelaccRepository delaccRepository; + private DelaccService delaccService; + + @BeforeEach + void setUp() { + delaccRepository = mock(DelaccRepository.class); + DSLContext dsl = mock(DSLContext.class); + TransactionTemplate transactionTemplate = mock(TransactionTemplate.class); + when(transactionTemplate.execute(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(new SimpleTransactionStatus()); + }); + + delaccService = new DelaccService(delaccRepository, dsl, transactionTemplate, "987654", FIXED_CLOCK); + } + + @Test + void rejectsNullRequestWithClearMessage() { + assertThatThrownBy(() -> delaccService.delete(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("request must not be null"); + verifyNoInteractions(delaccRepository); + } + + @Test + void returnsNotFoundWhenAccountDoesNotExist() { + when(delaccRepository.findBySortcodeAndAccountNumberForUpdate("987654", 12345678L)).thenReturn(Optional.empty()); + + DelaccResult result = delaccService.delete(new DelaccRequest(12345678L)); + + assertThat(result.deleteSuccess()).isFalse(); + assertThat(result.failCode()).isEqualTo("1"); + assertThat(result.message()).isEqualTo("Account number 12345678 was not found."); + } + + @Test + void returnsDeleteFailureWhenDeleteAffectsNoRows() { + AccountDetails account = account(); + when(delaccRepository.findBySortcodeAndAccountNumberForUpdate("987654", 12345678L)).thenReturn(Optional.of(account)); + when(delaccRepository.deleteAccount("987654", 12345678L)).thenReturn(0); + + DelaccResult result = delaccService.delete(new DelaccRequest(12345678L)); + + assertThat(result.deleteSuccess()).isFalse(); + assertThat(result.failCode()).isEqualTo("3"); + verify(delaccRepository).deleteAccount("987654", 12345678L); + } + + @Test + void returnsDeleteFailureWhenDeleteRaisesNonRetryableDataAccessException() { + AccountDetails account = account(); + when(delaccRepository.findBySortcodeAndAccountNumberForUpdate("987654", 12345678L)).thenReturn(Optional.of(account)); + when(delaccRepository.deleteAccount("987654", 12345678L)).thenThrow(new DataAccessException("delete failed") { + }); + + DelaccResult result = delaccService.delete(new DelaccRequest(12345678L)); + + assertThat(result.deleteSuccess()).isFalse(); + assertThat(result.failCode()).isEqualTo("3"); + assertThat(result.message()).isEqualTo("Account number 12345678 could not be deleted."); + } + + @Test + void deletesAccountAndWritesAuditTrail() { + AccountDetails account = account(); + when(delaccRepository.findBySortcodeAndAccountNumberForUpdate("987654", 12345678L)).thenReturn(Optional.of(account)); + when(delaccRepository.deleteAccount("987654", 12345678L)).thenReturn(1); + + DelaccResult result = delaccService.delete(new DelaccRequest(12345678L)); + + assertThat(result.deleteSuccess()).isTrue(); + assertThat(result.account()).isEqualTo(account); + verify(delaccRepository).insertAccountDeletionAudit( + eq(account), + eq(1_777_630_530_000L), + eq(LocalDate.of(2026, 5, 1)), + eq(LocalTime.of(10, 15, 30)) + ); + } + + @Test + void wrapsReadFailuresAsProgramAbends() { + when(delaccRepository.findBySortcodeAndAccountNumberForUpdate("987654", 12345678L)) + .thenThrow(new DataAccessException("read failed") { + }); + + assertThatThrownBy(() -> delaccService.delete(new DelaccRequest(12345678L))) + .isInstanceOf(CbsaAbendException.class) + .extracting(exception -> ((CbsaAbendException) exception).getAbendCode()) + .isEqualTo("HRAC"); + } + + @Test + void wrapsAuditFailuresAsProgramAbends() { + AccountDetails account = account(); + when(delaccRepository.findBySortcodeAndAccountNumberForUpdate("987654", 12345678L)).thenReturn(Optional.of(account)); + when(delaccRepository.deleteAccount("987654", 12345678L)).thenReturn(1); + org.mockito.Mockito.doThrow(new DataAccessException("audit failed") { + }).when(delaccRepository).insertAccountDeletionAudit(eq(account), anyLong(), any(LocalDate.class), any(LocalTime.class)); + + assertThatThrownBy(() -> delaccService.delete(new DelaccRequest(12345678L))) + .isInstanceOf(CbsaAbendException.class) + .extracting(exception -> ((CbsaAbendException) exception).getAbendCode()) + .isEqualTo("HWPT"); + } + + private AccountDetails account() { + return new AccountDetails( + "987654", + 10L, + 12345678L, + "ISA", + new BigDecimal("1.50"), + LocalDate.of(2024, 1, 2), + new BigDecimal("250.00"), + LocalDate.of(2024, 2, 3), + LocalDate.of(2024, 3, 4), + new BigDecimal("1500.25"), + new BigDecimal("1499.75") + ); + } +} diff --git a/src/test/java/com/augment/cbsa/web/delacc/DelaccControllerWebMvcTest.java b/src/test/java/com/augment/cbsa/web/delacc/DelaccControllerWebMvcTest.java new file mode 100644 index 0000000..a5ed105 --- /dev/null +++ b/src/test/java/com/augment/cbsa/web/delacc/DelaccControllerWebMvcTest.java @@ -0,0 +1,131 @@ +package com.augment.cbsa.web.delacc; + +import com.augment.cbsa.domain.AccountDetails; +import com.augment.cbsa.domain.DelaccRequest; +import com.augment.cbsa.domain.DelaccResult; +import com.augment.cbsa.error.CbsaAbendException; +import com.augment.cbsa.error.CbsaExceptionHandler; +import com.augment.cbsa.service.DelaccService; +import java.math.BigDecimal; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(DelaccController.class) +@Import(CbsaExceptionHandler.class) +class DelaccControllerWebMvcTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private DelaccService delaccService; + + @Test + void returnsSuccessfulResponseForHappyPath() throws Exception { + when(delaccService.delete(new DelaccRequest(12345678L))).thenReturn(DelaccResult.success(new AccountDetails( + "987654", + 10L, + 12345678L, + "ISA", + new BigDecimal("1.50"), + LocalDate.of(2024, 1, 2), + new BigDecimal("250.00"), + LocalDate.of(2024, 2, 3), + LocalDate.of(2024, 3, 4), + new BigDecimal("1500.25"), + new BigDecimal("1499.75") + ))); + + mockMvc.perform(delete("/api/v1/delacc/remove/12345678").contentType(APPLICATION_JSON).content(requestJson())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.DelAcc.DelAccEye").value("ACCT")) + .andExpect(jsonPath("$.DelAcc.DelAccCustno").value("0000000010")) + .andExpect(jsonPath("$.DelAcc.DelAccScode").value("987654")) + .andExpect(jsonPath("$.DelAcc.DelAccAccno").value(12345678)) + .andExpect(jsonPath("$.DelAcc.DelAccDelSuccess").value("Y")); + } + + @Test + void returnsProblemDetailForNotFoundFailures() throws Exception { + when(delaccService.delete(new DelaccRequest(12345678L))) + .thenReturn(DelaccResult.failure("1", 12345678L, "Account number 12345678 was not found.")); + + mockMvc.perform(delete("/api/v1/delacc/remove/12345678").contentType(APPLICATION_JSON).content(requestJson())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.title").value("Account not found")) + .andExpect(jsonPath("$.failCode").value("1")); + } + + @Test + void returnsProblemDetailForDeleteFailures() throws Exception { + when(delaccService.delete(new DelaccRequest(12345678L))) + .thenReturn(DelaccResult.failure("3", 12345678L, "Account number 12345678 could not be deleted.")); + + mockMvc.perform(delete("/api/v1/delacc/remove/12345678").contentType(APPLICATION_JSON).content(requestJson())) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.title").value("Account deletion failed")) + .andExpect(jsonPath("$.failCode").value("3")); + } + + @Test + void requestValidationFailuresRemainProblemDetails() throws Exception { + mockMvc.perform(delete("/api/v1/delacc/remove/100000000").contentType(APPLICATION_JSON).content(requestJson())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.title").value("Validation failed")); + } + + @Test + void redactsAbendExceptionMessageFromResponseBody() throws Exception { + when(delaccService.delete(new DelaccRequest(12345678L))) + .thenThrow(new CbsaAbendException("HWPT", "sensitive audit failure")); + + mockMvc.perform(delete("/api/v1/delacc/remove/12345678").contentType(APPLICATION_JSON).content(requestJson())) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.detail").value("Service abend")) + .andExpect(jsonPath("$.abendCode").value("HWPT")) + .andExpect(content().string(not(containsString("sensitive audit failure")))); + } + + private String requestJson() { + return """ + { + "DelAcc": { + "DelAccEye": "ACCT", + "DelAccCustno": "", + "DelAccScode": "987654", + "DelAccAccno": 12345678, + "DelAccAccType": "", + "DelAccIntRate": 0.00, + "DelAccOpened": 0, + "DelAccOverdraft": 0, + "DelAccLastStmtDt": 0, + "DelAccNextStmtDt": 0, + "DelAccAvailBal": 0.00, + "DelAccActualBal": 0.00, + "DelAccSuccess": " ", + "DelAccFailCd": " ", + "DelAccDelSuccess": " ", + "DelAccDelFailCd": " ", + "DelAccDelApplid": "", + "DelAccDelPcb1": "", + "DelAccDelPcb2": "", + "DelAccDelPcb3": "" + } + } + """; + } +} \ No newline at end of file From 4fe5fcaf69c9af86aec0f8a82803b9d3bbb45ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Chang?= Date: Fri, 1 May 2026 12:16:10 +0000 Subject: [PATCH 2/3] fix(delacc): drop compact-constructor null check so @NotNull yields 400 instead of 500 --- .../com/augment/cbsa/web/delacc/dto/DelaccRequestDto.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccRequestDto.java b/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccRequestDto.java index 417e3b4..ae02193 100644 --- a/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccRequestDto.java +++ b/src/main/java/com/augment/cbsa/web/delacc/dto/DelaccRequestDto.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; -import java.util.Objects; public record DelaccRequestDto( @JsonProperty("DelAcc") @@ -11,8 +10,4 @@ public record DelaccRequestDto( @NotNull DelaccCommareaRequestDto delAcc ) { - - public DelaccRequestDto { - Objects.requireNonNull(delAcc, "delAcc must not be null"); - } } From dd2e5b9e0f0e192c7ff6e371c276c6abaa121b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Chang?= Date: Fri, 1 May 2026 12:27:20 +0000 Subject: [PATCH 3/3] fix(delacc): abend on over-delete; populate program-level success/failCd on success --- src/main/java/com/augment/cbsa/service/DelaccService.java | 8 +++++++- .../com/augment/cbsa/web/delacc/DelaccController.java | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/augment/cbsa/service/DelaccService.java b/src/main/java/com/augment/cbsa/service/DelaccService.java index ebfd94b..1ef4a89 100644 --- a/src/main/java/com/augment/cbsa/service/DelaccService.java +++ b/src/main/java/com/augment/cbsa/service/DelaccService.java @@ -81,9 +81,15 @@ private DelaccResult deleteWithinTransaction(DelaccRequest request) { try { int deletedRows = delaccRepository.deleteAccount(sortcode, request.accountNumber()); - if (deletedRows != 1) { + if (deletedRows == 0) { return deleteFailure(request.accountNumber()); } + if (deletedRows > 1) { + // Unique (sortcode, account_number) makes >1 impossible; throw to roll back + // any unexpected over-delete and fail loudly instead of committing silently. + throw new CbsaAbendException(PROCTRAN_ABEND_CODE, + "DELACC unexpectedly deleted %d rows for account %d.".formatted(deletedRows, request.accountNumber())); + } } catch (DataAccessException exception) { if (isSerializationFailure(exception)) { throw exception; diff --git a/src/main/java/com/augment/cbsa/web/delacc/DelaccController.java b/src/main/java/com/augment/cbsa/web/delacc/DelaccController.java index 0c19a7f..77ce1ff 100644 --- a/src/main/java/com/augment/cbsa/web/delacc/DelaccController.java +++ b/src/main/java/com/augment/cbsa/web/delacc/DelaccController.java @@ -94,8 +94,8 @@ private DelaccResponseDto toResponse(DelaccResult result) { toCobolDate(account.nextStatementDate()), account.availableBalance(), account.actualBalance(), - "", - "", + "Y", + "0", "Y", "", "",