Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/main/java/com/augment/cbsa/domain/DelaccRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.augment.cbsa.domain;

public record DelaccRequest(long accountNumber) {
}
41 changes: 41 additions & 0 deletions src/main/java/com/augment/cbsa/domain/DelaccResult.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
95 changes: 95 additions & 0 deletions src/main/java/com/augment/cbsa/repository/DelaccRepository.java
Original file line number Diff line number Diff line change
@@ -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<AccountDetails> 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);
}
}
137 changes: 137 additions & 0 deletions src/main/java/com/augment/cbsa/service/DelaccService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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<AccountDetails> 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 == 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;
}
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;
}
}
114 changes: 114 additions & 0 deletions src/main/java/com/augment/cbsa/web/delacc/DelaccController.java
Original file line number Diff line number Diff line change
@@ -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));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DelaccRequestDto is validated but its fields aren’t used (the delete is driven solely by the {accno} path variable), so a mismatched body (e.g., different DelAccAccno/sortcode) would still delete the path account. Consider rejecting inconsistent requests (or documenting that the body is ignored) to reduce the risk of accidental deletions.

Severity: medium

🤖 Was this useful? React with 👍 or 👎

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",
"0",
"Y",
"",
"",
"",
"",
""
));
}

private int toCobolDate(LocalDate date) {
if (date == null) {
return 0;
}
return Integer.parseInt(date.format(COBOL_DATE_FORMATTER));
}
}
Loading
Loading