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
57 changes: 38 additions & 19 deletions .github/workflows/verify-liquibase-backward-compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,20 @@ jobs:
TZ: Asia/Kolkata

steps:
- name: Checkout the base branch (`develop`)
- name: Checkout base commit
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.ref }}
ref: ${{ github.event.pull_request.base.sha }}
fetch-depth: 0
path: baseline

- name: Checkout PR merge commit
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.sha }}
fetch-depth: 0
path: current

- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
Expand All @@ -52,11 +60,29 @@ jobs:
done

- name: Init base schema
working-directory: baseline
run: |
./gradlew --no-daemon createPGDB -PdbName=fineract_tenants
./gradlew --no-daemon createPGDB -PdbName=$DB_NAME

- name: Start backend on base branch
- name: Print checked out revisions
run: |
echo "Base branch ref: ${{ github.event.pull_request.base.ref }}"
echo "Base branch event SHA:"
echo "${{ github.event.pull_request.base.sha }}"
echo "PR head SHA:"
echo "${{ github.event.pull_request.head.sha }}"
echo "GitHub merge SHA:"
echo "${{ github.sha }}"
echo "Baseline revision:"
git -C baseline rev-parse HEAD
git -C baseline log -1 --oneline
echo "Merged PR revision:"
git -C current rev-parse HEAD
git -C current log -1 --oneline

- name: Start backend on base commit
working-directory: baseline
run: |
./gradlew :fineract-provider:devRun --args="\
--spring.datasource.hikari.driverClassName=org.postgresql.Driver \
Expand All @@ -79,19 +105,16 @@ jobs:

start_ts=$(date +%s)
while true; do
# If the process died, fail fast
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
echo "Backend process exited before Actuator became available."
exit 1
fi

# Check endpoint
if curl -kfsS "$ACTUATOR_URL" >/dev/null 2>&1; then
echo "Actuator is up."
break
fi

# Timeout
now_ts=$(date +%s)
if [ $((now_ts - start_ts)) -ge "$TIMEOUT_SECONDS" ]; then
echo "Timed out waiting for Actuator."
Expand All @@ -101,19 +124,15 @@ jobs:
sleep "$INTERVAL_SECONDS"
done

- name: Stop backend
- name: Stop baseline backend
if: always()
working-directory: baseline
run: |
kill $(cat backend.pid)
sleep 10

- name: Checkout the PR branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0

- name: Start backend on PR branch
- name: Start backend on merged PR commit
working-directory: current
run: |
./gradlew :fineract-provider:devRun --args="\
--spring.datasource.hikari.driverClassName=org.postgresql.Driver \
Expand All @@ -136,19 +155,16 @@ jobs:

start_ts=$(date +%s)
while true; do
# If the process died, fail fast
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
echo "Backend process exited before Actuator became available."
exit 1
fi

# Check endpoint
if curl -kfsS "$ACTUATOR_URL" >/dev/null 2>&1; then
echo "Actuator is up."
break
fi

# Timeout
now_ts=$(date +%s)
if [ $((now_ts - start_ts)) -ge "$TIMEOUT_SECONDS" ]; then
echo "Timed out waiting for Actuator."
Expand All @@ -157,7 +173,10 @@ jobs:

sleep "$INTERVAL_SECONDS"
done
- name: Stop backend

- name: Stop PR backend
if: always()
working-directory: current
run: |
kill $(cat backend.pid)
sleep 10
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,6 @@ private void validateEventConfigurationForIndividualTenant(FineractPlatformTenan
log.debug("Missing from eventConfigurations: {}", CollectionUtils.subtract(eventConfigurations, eventClasses));
}

if (eventClasses.size() != eventConfigurations.size()) {
throw new ExternalEventConfigurationNotFoundException();
}

for (String eventTypeClass : eventClasses) {
if (!eventConfigurations.contains(eventTypeClass)) {
throw new ExternalEventConfigurationNotFoundException(eventTypeClass);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.avro.loan.v1.LoanTransactionAdjustmentDataV1;
import org.apache.fineract.avro.loan.v1.LoanTransactionDataV1;
import org.apache.fineract.avro.loan.v1.OriginatorDetailsV1;
import org.apache.fineract.client.feign.FineractFeignClient;
import org.apache.fineract.client.feign.util.CallFailedRuntimeException;
Expand All @@ -41,11 +44,14 @@
import org.apache.fineract.client.models.GetLoanOriginatorsResponse;
import org.apache.fineract.client.models.GetLoansLoanIdOriginatorData;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.GetLoansLoanIdTransactions;
import org.apache.fineract.client.models.PostClientsResponse;
import org.apache.fineract.client.models.PostLoanOriginatorsRequest;
import org.apache.fineract.client.models.PostLoanOriginatorsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdRequest;
import org.apache.fineract.client.models.PostLoansLoanIdResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsTransactionIdRequest;
import org.apache.fineract.client.models.PostLoansOriginatorData;
import org.apache.fineract.client.models.PostLoansRequest;
import org.apache.fineract.client.models.PostLoansResponse;
Expand All @@ -56,6 +62,8 @@
import org.apache.fineract.test.helper.ErrorMessageHelper;
import org.apache.fineract.test.messaging.EventAssertion;
import org.apache.fineract.test.messaging.event.loan.LoanApprovedEvent;
import org.apache.fineract.test.messaging.event.loan.transaction.LoanAccrualTransactionCreatedBusinessEvent;
import org.apache.fineract.test.messaging.event.loan.transaction.LoanAdjustTransactionBusinessEvent;
import org.apache.fineract.test.messaging.store.EventStore;
import org.apache.fineract.test.stepdef.AbstractStepDef;
import org.apache.fineract.test.support.TestContextKey;
Expand All @@ -65,6 +73,10 @@
public class LoanOriginationStepDef extends AbstractStepDef {

private static final long NON_EXISTENT_ID = Long.MAX_VALUE;
private static final String DATE_FORMAT = "dd MMMM yyyy";
private static final String DEFAULT_LOCALE = "en";
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT);
private static final String ADJUSTED_TRANSACTION_ID = "adjustedTransactionId";

@Autowired
private FineractFeignClient fineractClient;
Expand Down Expand Up @@ -603,6 +615,135 @@ public void deleteOriginatorShouldFailWithStatus(int expectedStatus) {
log.info("Deleting originator {} failed with expected status {}", originatorId, expectedStatus);
}

@When("Customer makes a repayment undo on {string} without event check")
public void makeLoanRepaymentUndoWithoutEventCheck(String transactionDate) {
eventStore.reset();
PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
long loanId = loanResponse.getLoanId();
PostLoansLoanIdTransactionsResponse repaymentResponse = testContext().get(TestContextKey.LOAN_REPAYMENT_RESPONSE);
Long originalTransactionId = repaymentResponse.getResourceId();

PostLoansLoanIdTransactionsTransactionIdRequest repaymentUndoRequest = LoanRequestFactory.defaultRepaymentUndoRequest()
.transactionDate(transactionDate).dateFormat(DATE_FORMAT).locale(DEFAULT_LOCALE);

ok(() -> fineractClient.loanTransactions().adjustLoanTransaction(loanId, originalTransactionId, repaymentUndoRequest,
Map.<String, Object>of()));
testContext().set(ADJUSTED_TRANSACTION_ID, originalTransactionId);
log.info("Repayment {} undo on loan {} (event check skipped for separate originator verification)", originalTransactionId, loanId);
}

@When("Customer adjusts the repayment on {string} to {double} EUR without event check")
public void adjustLoanRepaymentWithoutEventCheck(String transactionDate, double transactionAmount) {
eventStore.reset();
PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
long loanId = loanResponse.getLoanId();
PostLoansLoanIdTransactionsResponse repaymentResponse = testContext().get(TestContextKey.LOAN_REPAYMENT_RESPONSE);
Long originalTransactionId = repaymentResponse.getResourceId();

PostLoansLoanIdTransactionsTransactionIdRequest repaymentAdjustRequest = LoanRequestFactory
.defaultRepaymentAdjustRequest(transactionAmount).transactionDate(transactionDate).dateFormat(DATE_FORMAT)
.locale(DEFAULT_LOCALE);

PostLoansLoanIdTransactionsResponse repaymentAdjustmentResponse = ok(() -> fineractClient.loanTransactions()
.adjustLoanTransaction(loanId, originalTransactionId, repaymentAdjustRequest, Map.<String, Object>of()));
testContext().set(TestContextKey.LOAN_REPAYMENT_UNDO_RESPONSE, repaymentAdjustmentResponse);
testContext().set(ADJUSTED_TRANSACTION_ID, originalTransactionId);
log.info("Repayment {} adjusted to {} on loan {} (event check skipped for separate originator verification)", originalTransactionId,
transactionAmount, loanId);
}

@When("Customer reverses the waiver transaction on {string}")
public void reverseWaiverTransaction(String transactionDate) {
eventStore.reset();
PostLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
long loanId = loanResponse.getLoanId();

GetLoansLoanIdResponse loanDetails = ok(
() -> fineractClient.loans().retrieveLoan(loanId, Map.<String, Object>of("associations", "transactions")));
Long waiveTransactionId = loanDetails.getTransactions().stream()
.filter(t -> "loanTransactionType.waiveCharges".equals(t.getType().getCode())).map(GetLoansLoanIdTransactions::getId)
.findFirst().orElseThrow(() -> new IllegalStateException("Waiver transaction not found on loan " + loanId));

PostLoansLoanIdTransactionsTransactionIdRequest undoRequest = LoanRequestFactory.defaultRepaymentUndoRequest()
.transactionDate(transactionDate).dateFormat(DATE_FORMAT).locale(DEFAULT_LOCALE);

ok(() -> fineractClient.loanTransactions().adjustLoanTransaction(loanId, waiveTransactionId, undoRequest,
Map.<String, Object>of()));
testContext().set(ADJUSTED_TRANSACTION_ID, waiveTransactionId);
log.info("Waiver transaction {} reversed on loan {} (for originator event verification)", waiveTransactionId, loanId);
}

// --- Originator event verification steps ---

@Then("LoanAdjustTransactionBusinessEvent is created with originator details in {string}")
public void verifyOriginatorInAdjustEvent(String nestedField) {
long loanId = getLoanId();
Long adjustedTransactionId = testContext().get(ADJUSTED_TRANSACTION_ID);
String expectedExternalId = testContext().get(TestContextKey.ORIGINATOR_EXTERNAL_ID);

eventAssertion.assertEvent(LoanAdjustTransactionBusinessEvent.class, adjustedTransactionId).extractingData(adjustmentData -> {
LoanTransactionDataV1 nested = resolveAdjustmentField(adjustmentData, nestedField);
assertThat(nested).as("Field '%s' in LoanAdjustTransactionBusinessEvent", nestedField).isNotNull();

List<OriginatorDetailsV1> originators = nested.getOriginators();
assertThat(originators).as("Originators in %s should not be null or empty", nestedField).isNotNull().isNotEmpty();
assertThat(originators.get(0).getExternalId()).as("Originator externalId in %s", nestedField).isEqualTo(expectedExternalId);
assertThat(originators.get(0).getStatus()).as("Originator status in %s", nestedField).isEqualTo("ACTIVE");
return adjustmentData.getTransactionToAdjust().getId();
}).isEqualTo(adjustedTransactionId);
log.info("Verified originator {} in LoanAdjustTransactionBusinessEvent.{} for loan {}", expectedExternalId, nestedField, loanId);
}

@Then("LoanAdjustTransactionBusinessEvent is created without originator details in {string}")
public void verifyNoOriginatorInAdjustEvent(String nestedField) {
Long adjustedTransactionId = testContext().get(ADJUSTED_TRANSACTION_ID);

eventAssertion.assertEvent(LoanAdjustTransactionBusinessEvent.class, adjustedTransactionId).extractingData(adjustmentData -> {
LoanTransactionDataV1 nested = resolveAdjustmentField(adjustmentData, nestedField);
assertThat(nested).as("Field '%s' in LoanAdjustTransactionBusinessEvent", nestedField).isNotNull();

List<OriginatorDetailsV1> originators = nested.getOriginators();
assertThat(originators).as("Originators in %s should be null or empty", nestedField).isNullOrEmpty();
return adjustmentData.getTransactionToAdjust().getId();
}).isEqualTo(adjustedTransactionId);
log.info("Verified no originators in LoanAdjustTransactionBusinessEvent.{}", nestedField);
}

@Then("LoanAccrualTransactionCreatedBusinessEvent is created with originator details on {string}")
public void verifyOriginatorInAccrualEvent(String date) {
long loanId = getLoanId();
String expectedExternalId = testContext().get(TestContextKey.ORIGINATOR_EXTERNAL_ID);

GetLoansLoanIdResponse loanDetails = ok(() -> fineractClient.loans().retrieveLoan(loanId,
Map.of("staffInSelectedOfficeOnly", "false", "associations", "transactions")));
GetLoansLoanIdTransactions accrualTransaction = loanDetails.getTransactions().stream()
.filter(t -> date.equals(FORMATTER.format(t.getDate())) && "Accrual".equals(t.getType().getValue()))
.reduce((first, second) -> second)
.orElseThrow(() -> new IllegalStateException(String.format("No Accrual transaction found on %s", date)));

eventAssertion.assertEvent(LoanAccrualTransactionCreatedBusinessEvent.class, accrualTransaction.getId())
.extractingData(loanTransactionDataV1 -> {
List<OriginatorDetailsV1> originators = loanTransactionDataV1.getOriginators();
assertThat(originators).as("Originators in LoanAccrualTransactionCreatedBusinessEvent should not be null or empty")
.isNotNull().isNotEmpty();
assertThat(originators.get(0).getExternalId()).as("Originator externalId in LoanAccrualTransactionCreatedBusinessEvent")
.isEqualTo(expectedExternalId);
assertThat(originators.get(0).getStatus()).as("Originator status in LoanAccrualTransactionCreatedBusinessEvent")
.isEqualTo("ACTIVE");
return loanTransactionDataV1.getId();
}).isEqualTo(accrualTransaction.getId());
log.info("Verified originator {} in LoanAccrualTransactionCreatedBusinessEvent on {} for loan {}", expectedExternalId, date,
loanId);
}

private LoanTransactionDataV1 resolveAdjustmentField(LoanTransactionAdjustmentDataV1 data, String field) {
return switch (field) {
case "transactionToAdjust" -> data.getTransactionToAdjust();
case "newTransactionDetail" -> data.getNewTransactionDetail();
default -> throw new IllegalArgumentException("Unknown adjustment field: " + field);
};
}

// --- Helper methods ---

private long getLoanId() {
Expand Down
Loading
Loading