Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PAGOPA-2089] feat: Add Recovery API #88

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package it.gov.pagopa.wispconverter.controller;

import com.azure.core.annotation.QueryParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import it.gov.pagopa.wispconverter.controller.model.RecoveryReceiptResponse;
import it.gov.pagopa.wispconverter.exception.AppErrorCodeMessageEnum;
import it.gov.pagopa.wispconverter.exception.AppException;
import it.gov.pagopa.wispconverter.service.RecoveryService;
import it.gov.pagopa.wispconverter.util.Constants;
import it.gov.pagopa.wispconverter.util.ErrorUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/recovery")
@Validated
@RequiredArgsConstructor
@Tag(name = "Recovery", description = "Recovery and reconciliation APIs")
@Slf4j
public class RecoveryController {

private final RecoveryService recoveryService;

private final ErrorUtil errorUtil;

@Operation(summary = "", description = "", security = {@SecurityRequirement(name = "ApiKey")}, tags = {"Receipt"})
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Started reconciling IUVs with explicit RT send")
})
@PostMapping(value = "/{creditor_institution}/receipt-ko")
public ResponseEntity<RecoveryReceiptResponse> recoverReceiptKOForCreditorInstitution(@PathVariable("creditor_institution") String ci, @QueryParam("date_from") String dateFrom, @QueryParam("date_to") String dateTo) {
try {
log.info("Invoking API operation recoverReceiptKOForCreditorInstitution - args: {} {} {}", ci, dateFrom, dateTo);
Dismissed Show dismissed Hide dismissed
RecoveryReceiptResponse response = recoveryService.recoverReceiptKOForCreditorInstitution(ci, dateFrom, dateTo);
return ResponseEntity.ok(response);
} catch (Exception ex) {
String operationId = MDC.get(Constants.MDC_OPERATION_ID);
log.error(String.format("GenericException: operation-id=[%s]", operationId != null ? operationId : "n/a"), ex);
AppException appException = new AppException(ex, AppErrorCodeMessageEnum.ERROR, ex.getMessage());
ErrorResponse errorResponse = errorUtil.forAppException(appException);
log.error("Failed API operation recoverReceiptKOForCreditorInstitution - error: {}", errorResponse);
throw ex;
} finally {
log.info("Successful API operation recoverReceiptKOForCreditorInstitution");
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package it.gov.pagopa.wispconverter.controller.model;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.*;

@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
@ToString
@JsonIgnoreProperties(ignoreUnknown = true)
public class RecoveryReceiptPaymentResponse {

private String iuv;
private String ccp;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package it.gov.pagopa.wispconverter.controller.model;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.*;

import java.util.List;

@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
@ToString
@JsonIgnoreProperties(ignoreUnknown = true)
public class RecoveryReceiptResponse {

private List<RecoveryReceiptPaymentResponse> payments;
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
package it.gov.pagopa.wispconverter.repository;

import com.azure.spring.data.cosmos.repository.CosmosRepository;
import com.azure.spring.data.cosmos.repository.Query;
import it.gov.pagopa.wispconverter.repository.model.RTEntity;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface RTRepository extends CosmosRepository<RTEntity, String> {


@Query("SELECT * FROM c WHERE IS_NULL(c.rt) AND c.idDominio = @organizationId AND c._ts >= DateTimeToTimestamp(@dateFrom) / 1000 and c._ts <= DateTimeToTimestamp(@dateTo) / 1000")
List<RTEntity> findByOrganizationId(@Param("organizationId") String organizationId,
@Param("dateFrom") String dateFrom,
@Param("dateTo") String dateTo);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,35 @@
package it.gov.pagopa.wispconverter.repository;

import com.azure.spring.data.cosmos.repository.CosmosRepository;
import com.azure.spring.data.cosmos.repository.Query;
import it.gov.pagopa.wispconverter.repository.model.ReEventEntity;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface ReEventRepository extends CosmosRepository<ReEventEntity, String> {

@Query("SELECT * FROM c " +
"WHERE (c.partitionKey >= @dateFrom AND c.partitionKey <= @dateTo) " +
"AND c.iuv = @iuv " +
"AND c.domainId = @organizationId")
List<ReEventEntity> findByIuvAndOrganizationId(@Param("dateFrom") String dateFrom,
@Param("dateTo") String dateTo,
@Param("iuv") String iuv,
@Param("organizationId") String organizationId);


@Query("SELECT * FROM c " +
"WHERE (c.partitionKey >= @dateFrom AND c.partitionKey <= @dateTo) " +
"AND c.sessionId = @sessionId " +
"AND c.status = @status" +
"AND (c.businessProcess = @businessProcess1 OR c.businessProcess = @businessProcess2)")
List<ReEventEntity> findBySessionIdAndStatusAndBusinessProcess(@Param("dateFrom") String dateFrom,
@Param("dateTo") String dateTo,
@Param("sessionId") String sessionId,
@Param("status") String status,
@Param("businessProcess1") String businessProcess1,
@Param("businessProcess2") String businessProcess2);
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public enum InternalStepStatus {
RT_SEND_RESCHEDULING_FAILURE,
RT_SEND_RESCHEDULING_REACHED_MAX_RETRIES,
RT_SEND_RESCHEDULING_SUCCESS,
RT_START_RECONCILIATION_PROCESS,
RT_END_RECONCILIATION_PROCESS,
RECEIPT_TIMER_GENERATION_CREATED_SCHEDULED_SEND,
RECEIPT_TIMER_GENERATION_CACHED_SEQUENCE_NUMBER,
RECEIPT_TIMER_GENERATION_DELETED_SCHEDULED_SEND,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
@RequiredArgsConstructor
public class DecouplerService {

private static final String CACHING_KEY_TEMPLATE = "wisp_%s_%s";
public static final String CACHING_KEY_TEMPLATE = "wisp_%s_%s";

private static final String MAP_CACHING_KEY_TEMPLATE = "wisp_nav2iuv_%s_%s";
public static final String MAP_CACHING_KEY_TEMPLATE = "wisp_nav2iuv_%s_%s";

private final ReService reService;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ public void sendKoPaaInviaRtToCreditorInstitution(String payload) {
// generate and send a KO RT for each receipt received in the payload
for (ReceiptDto receipt : receipts) {

MDCUtil.setReceiptTimerInfoInMDC(receipt.getFiscalCode(), receipt.getNoticeNumber(), null);

// retrieve the NAV-to-IUV mapping key from Redis, then use the result for retrieve the session data
String noticeNumber = receipt.getNoticeNumber();
CachedKeysMapping cachedMapping = decouplerService.getCachedMappingFromNavToIuv(receipt.getFiscalCode(), noticeNumber);
Expand Down Expand Up @@ -303,7 +305,8 @@ From station identifier (the common one defined, not the payment reference), ret

// generate a new event in RE for store the successful sending of the receipt
generateREForSentRT(sessionData, iuv, noticeNumber);
idempotencyStatus = IdempotencyStatusEnum.SUCCESS;
--
idempotencyStatus = IdempotencyStatusEnum.SUCCESS;
isSuccessful = true;

} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package it.gov.pagopa.wispconverter.service;

import it.gov.pagopa.wispconverter.controller.ReceiptController;
import it.gov.pagopa.wispconverter.controller.model.RecoveryReceiptPaymentResponse;
import it.gov.pagopa.wispconverter.controller.model.RecoveryReceiptResponse;
import it.gov.pagopa.wispconverter.exception.AppErrorCodeMessageEnum;
import it.gov.pagopa.wispconverter.exception.AppException;
import it.gov.pagopa.wispconverter.repository.CacheRepository;
import it.gov.pagopa.wispconverter.repository.RTRepository;
import it.gov.pagopa.wispconverter.repository.ReEventRepository;
import it.gov.pagopa.wispconverter.repository.model.RTEntity;
import it.gov.pagopa.wispconverter.repository.model.ReEventEntity;
import it.gov.pagopa.wispconverter.repository.model.enumz.InternalStepStatus;
import it.gov.pagopa.wispconverter.service.model.ReceiptDto;
import it.gov.pagopa.wispconverter.service.model.re.ReEventDto;
import it.gov.pagopa.wispconverter.util.Constants;
import it.gov.pagopa.wispconverter.util.ReUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CompletableFuture;

@Service
@Slf4j
@RequiredArgsConstructor
public class RecoveryService {

private static final String EVENT_TYPE_FOR_RECEIPTKO_SEARCH = "GENERATED_CACHE_ABOUT_RPT_FOR_RT_GENERATION";

private static final String STATUS_RT_SEND_SUCCESS = "RT_SEND_SUCCESS";

private static final String BUSINESS_RECEIPT_OK = "receipt-ok";

private static final String BUSINESS_RECEIPT_KO = "receipt-ko";

private final ReceiptController receiptController;

private final RTRepository rtRepository;

private final ReEventRepository reEventRepository;

private final CacheRepository cacheRepository;

private final ReService reService;

@Value("${wisp-converter.cached-requestid-mapping.ttl.minutes}")
private Long requestIDMappingTTL;

@Value("${wisp-converter.recovery.receipt-generation.wait-time.minutes:60}")
private Long receiptGenerationWaitTime;

public RecoveryReceiptResponse recoverReceiptKOForCreditorInstitution(String creditorInstitution, String dateFrom, String dateTo) {

String startDate = "2024-09-03";
LocalDate lowerLimit = LocalDate.parse(startDate, DateTimeFormatter.ISO_LOCAL_DATE);
if (LocalDate.parse(dateFrom, DateTimeFormatter.ISO_LOCAL_DATE).isBefore(lowerLimit)) {
throw new AppException(AppErrorCodeMessageEnum.ERROR, String.format("The lower bound cannot be lower than [%s]", startDate));
}

LocalDate now = LocalDate.now();
LocalDate parse = LocalDate.parse(dateTo, DateTimeFormatter.ISO_LOCAL_DATE);
if (parse.isAfter(now)) {
throw new AppException(AppErrorCodeMessageEnum.ERROR, String.format("The upper bound cannot be higher than [%s]", now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))));
}

String dateToRefactored = dateTo;
if (now.isEqual(parse)) {
ZonedDateTime nowMinus1h = ZonedDateTime.now(ZoneOffset.UTC).minusMinutes(receiptGenerationWaitTime);
dateToRefactored = nowMinus1h.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
log.info("Upper bound forced to {}", dateToRefactored);
}


List<RTEntity> receiptRTs = rtRepository.findByOrganizationId(creditorInstitution, dateFrom, dateToRefactored);
List<RecoveryReceiptPaymentResponse> paymentsToReconcile = receiptRTs.stream().map(entity -> RecoveryReceiptPaymentResponse.builder()
.iuv(entity.getIuv())
.ccp(entity.getCcp())
.build())
.toList();

CompletableFuture<Boolean> executeRecovery = recoverReceiptKOAsync(dateFrom, dateTo, creditorInstitution, paymentsToReconcile);
executeRecovery
.thenAccept(value -> log.info("Reconciliation for creditor institution [{}] in date range [{}-{}] completed!", creditorInstitution, dateFrom, dateTo))
Dismissed Show dismissed Hide dismissed
.exceptionally(e -> {
log.error("Reconciliation for creditor institution [{}] in date range [{}-{}] ended unsuccessfully!", creditorInstitution, dateFrom, dateTo, e);
Dismissed Show dismissed Hide dismissed
throw new AppException(e, AppErrorCodeMessageEnum.ERROR, e.getMessage());
});

return RecoveryReceiptResponse.builder()
.payments(paymentsToReconcile)
.build();
}

private CompletableFuture<Boolean> recoverReceiptKOAsync(String dateFrom, String dateTo, String creditorInstitution, List<RecoveryReceiptPaymentResponse> paymentsToReconcile) {

return CompletableFuture.supplyAsync(() -> {

for (RecoveryReceiptPaymentResponse payment : paymentsToReconcile) {

String iuv = payment.getIuv();
String ccp = payment.getCcp();

try {
List<ReEventEntity> reEvents = reEventRepository.findByIuvAndOrganizationId(dateFrom, dateTo, iuv, creditorInstitution);

List<ReEventEntity> filteredEvents = reEvents.stream()
.filter(event -> EVENT_TYPE_FOR_RECEIPTKO_SEARCH.equals(event.getStatus()))
.filter(event -> ccp.equals(event.getCcp()))
.sorted(Comparator.comparing(ReEventEntity::getInsertedTimestamp))
.toList();

int numberOfEvents = filteredEvents.size();
if (numberOfEvents > 0) {

ReEventEntity event = filteredEvents.get(numberOfEvents - 1);
String noticeNumber = event.getNoticeNumber();
String sessionId = event.getSessionId();

// search by sessionId, then filter by status=RT_SEND_SUCCESS and businessProcess=receipt-ko|ok. If there is zero, then proceed
List<ReEventEntity> reEventsRT = reEventRepository.findBySessionIdAndStatusAndBusinessProcess(
dateFrom, dateTo, sessionId, STATUS_RT_SEND_SUCCESS, BUSINESS_RECEIPT_OK, BUSINESS_RECEIPT_KO
);

if(reEventsRT.isEmpty()) {
String navToIuvMapping = String.format(DecouplerService.MAP_CACHING_KEY_TEMPLATE, creditorInstitution, noticeNumber);
String iuvToSessionIdMapping = String.format(DecouplerService.CACHING_KEY_TEMPLATE, creditorInstitution, iuv);
this.cacheRepository.insert(navToIuvMapping, iuvToSessionIdMapping, this.requestIDMappingTTL);
this.cacheRepository.insert(iuvToSessionIdMapping, sessionId, this.requestIDMappingTTL);

MDC.put(Constants.MDC_BUSINESS_PROCESS, "receipt-ko");
generateRE(Constants.PAA_INVIA_RT, null, InternalStepStatus.RT_START_RECONCILIATION_PROCESS, creditorInstitution, iuv, noticeNumber, ccp, sessionId);
String receiptKoRequest = ReceiptDto.builder()
.fiscalCode(creditorInstitution)
.noticeNumber(noticeNumber)
.build()
.toString();
this.receiptController.receiptKo(receiptKoRequest);
generateRE(Constants.PAA_INVIA_RT, "Success", InternalStepStatus.RT_END_RECONCILIATION_PROCESS, creditorInstitution, iuv, noticeNumber, ccp, sessionId);
MDC.remove(Constants.MDC_BUSINESS_PROCESS);
}
}

} catch (Exception e) {
generateRE(Constants.PAA_INVIA_RT, "Failure", InternalStepStatus.RT_END_RECONCILIATION_PROCESS, creditorInstitution, iuv, null, ccp, null);
throw new AppException(e, AppErrorCodeMessageEnum.ERROR, e.getMessage());
}
}

return true;
});
}

private void generateRE(String primitive, String operationStatus, InternalStepStatus status, String domainId, String iuv, String noticeNumber, String ccp, String sessionId) {

// setting data in MDC for next use
ReEventDto reEvent = ReUtil.getREBuilder()
.primitive(primitive)
.operationStatus(operationStatus)
.status(status)
.sessionId(sessionId)
.domainId(domainId)
.iuv(iuv)
.ccp(ccp)
.noticeNumber(noticeNumber)
.build();
reService.addRe(reEvent);
}
}
3 changes: 3 additions & 0 deletions src/main/resources/application-local.properties
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ logging.level.it.gov.pagopa.wispconverter.util.client.checkout.CheckoutClientLog
logging.level.it.gov.pagopa.wispconverter.util.client.decouplercaching.DecouplerCachingClientLoggingInterceptor=${APP_LOGGING_LEVEL:DEBUG}
logging.level.it.gov.pagopa.wispconverter.util.client.gpd.GpdClientLoggingInterceptor=${APP_LOGGING_LEVEL:DEBUG}
logging.level.it.gov.pagopa.wispconverter.util.client.iuvgenerator.IuvGeneratorClientLoggingInterceptor=${APP_LOGGING_LEVEL:DEBUG}


wisp-converter.recovery.receipt-generation.wait-time.minutes=5
1 change: 1 addition & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ wisp-converter.idempotency.lock-validity-in-minutes=${IDEMPOTENCY_LOCK_VALIDITY_
wisp-converter.refresh.cache.cron=${CACHE_REFRESH_CRON:-}
wisp-converter.rtMapper.ctRicevutaTelematica.versioneOggetto=6.2.0
wisp-converter.forwarder.api-key=${FORWARDER_SUBKEY:none}
wisp-converter.recovery.receipt-generation.wait-time.minutes=60


## Exclude url from filter
Expand Down
Loading