From 21cf9046660dc1b7b412eabe28c849ae3efc4b2a Mon Sep 17 00:00:00 2001 From: Oleksii Novikov Date: Wed, 15 Jul 2026 15:24:45 +0300 Subject: [PATCH 1/3] FINERACT-2455: Add WC loan details business events --- .../v1/WorkingCapitalBreachDataV1.avsc | 79 +++ .../v1/WorkingCapitalLoanAccountDataV1.avsc | 411 +++++++++++++ .../v1/WorkingCapitalLoanChargeDataV1.avsc | 179 ++++++ .../WorkingCapitalLoanCollectionDataV1.avsc | 98 +++ .../WorkingCapitalLoanDelinquencyDataV1.avsc | 24 + ...alLoanDelinquencySchedulePeriodDataV1.avsc | 79 +++ ...ngCapitalLoanDisbursementDetailDataV1.avsc | 48 ++ .../v1/WorkingCapitalLoanSummaryDataV1.avsc | 322 ++++++++++ .../v1/WorkingCapitalNearBreachDataV1.avsc | 47 ++ .../messaging/event/EventCheckHelper.java | 139 +++++ .../loan/AbstractWorkingCapitalLoanEvent.java | 36 ++ ...ngCapitalLoanApplicationModifiedEvent.java | 27 + .../loan/WorkingCapitalLoanApprovedEvent.java | 27 + ...WorkingCapitalLoanBalanceChangedEvent.java | 27 + .../loan/WorkingCapitalLoanCreatedEvent.java | 27 + ...apitalLoanDelinquencyRangeChangeEvent.java | 27 + .../WorkingCapitalLoanDisbursalEvent.java | 27 + .../loan/WorkingCapitalLoanRejectedEvent.java | 27 + .../WorkingCapitalLoanStatusChangedEvent.java | 27 + .../WorkingCapitalLoanUndoApprovalEvent.java | 27 + .../WorkingCapitalLoanUndoDisbursalEvent.java | 27 + .../WorkingCapitalLoanAccountStepDef.java | 55 ++ ...ngCapitalLoanAccountBusinessEvents.feature | 159 +++++ ...entConfigurationValidationServiceTest.java | 13 +- ...lLoanApplicationModifiedBusinessEvent.java | 35 ++ ...rkingCapitalLoanApprovedBusinessEvent.java | 35 ++ ...apitalLoanBalanceChangedBusinessEvent.java | 35 ++ .../loan/WorkingCapitalLoanBusinessEvent.java | 41 ++ ...orkingCapitalLoanCreatedBusinessEvent.java | 35 ++ ...anDelinquencyRangeChangeBusinessEvent.java | 35 ++ ...kingCapitalLoanDisbursalBusinessEvent.java | 35 ++ ...rkingCapitalLoanRejectedBusinessEvent.java | 35 ++ ...CapitalLoanStatusChangedBusinessEvent.java | 35 ++ ...gCapitalLoanUndoApprovalBusinessEvent.java | 35 ++ ...CapitalLoanUndoDisbursalBusinessEvent.java | 35 ++ .../data/ChargeIdAndAmountHolder.java | 24 + .../data/TransactionTypeTotalHolder.java | 25 + ...italLoanTransactionRelationRepository.java | 15 + ...apitalLoanDelinquencyActionRepository.java | 3 + ...rkingCapitalLoanTransactionRepository.java | 21 + .../WorkingCapitalLoanAccountDataMapper.java | 122 ++++ ...ingCapitalLoanBusinessEventSerializer.java | 192 ++++++ ...argeExternalEventCustomDataSerializer.java | 29 + ...anApplicationWritePlatformServiceImpl.java | 12 +- ...talLoanChargeWritePlatformServiceImpl.java | 20 + ...lLoanDelinquencyClassificationService.java | 2 +- ...nDelinquencyClassificationServiceImpl.java | 19 +- ...oanDelinquencyReadPlatformServiceImpl.java | 24 + ...ngCapitalLoanWritePlatformServiceImpl.java | 63 +- .../module-changelog-master.xml | 1 + .../0062_wc_loan_details_external_events.xml | 126 ++++ ...rkingCapitalLoanAccountDataMapperTest.java | 573 ++++++++++++++++++ .../ExternalEventConfigurationHelper.java | 50 ++ 53 files changed, 3660 insertions(+), 11 deletions(-) create mode 100644 fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalBreachDataV1.avsc create mode 100644 fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanAccountDataV1.avsc create mode 100644 fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanChargeDataV1.avsc create mode 100644 fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanCollectionDataV1.avsc create mode 100644 fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencyDataV1.avsc create mode 100644 fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencySchedulePeriodDataV1.avsc create mode 100644 fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDisbursementDetailDataV1.avsc create mode 100644 fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanSummaryDataV1.avsc create mode 100644 fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalNearBreachDataV1.avsc create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/AbstractWorkingCapitalLoanEvent.java create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanApplicationModifiedEvent.java create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanApprovedEvent.java create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanBalanceChangedEvent.java create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanCreatedEvent.java create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanDelinquencyRangeChangeEvent.java create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanDisbursalEvent.java create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanRejectedEvent.java create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanStatusChangedEvent.java create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanUndoApprovalEvent.java create mode 100644 fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanUndoDisbursalEvent.java create mode 100644 fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccountBusinessEvents.feature create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanApplicationModifiedBusinessEvent.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanApprovedBusinessEvent.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanBalanceChangedBusinessEvent.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanBusinessEvent.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanCreatedBusinessEvent.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanDisbursalBusinessEvent.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanRejectedBusinessEvent.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanStatusChangedBusinessEvent.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanUndoApprovalBusinessEvent.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanUndoDisbursalBusinessEvent.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/ChargeIdAndAmountHolder.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/TransactionTypeTotalHolder.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapper.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/serializer/WorkingCapitalLoanBusinessEventSerializer.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/serializer/WorkingCapitalLoanChargeExternalEventCustomDataSerializer.java create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0062_wc_loan_details_external_events.xml create mode 100644 fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapperTest.java diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalBreachDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalBreachDataV1.avsc new file mode 100644 index 00000000000..9096e788453 --- /dev/null +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalBreachDataV1.avsc @@ -0,0 +1,79 @@ +{ + "name": "WorkingCapitalBreachDataV1", + "namespace": "org.apache.fineract.avro.workingcapitalloan.v1", + "type": "record", + "fields": [ + { + "default": null, + "name": "id", + "type": [ + "null", + "long" + ] + }, + { + "default": null, + "name": "name", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "breachFrequency", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "breachFrequencyType", + "type": [ + "null", + "org.apache.fineract.avro.generic.v1.StringEnumOptionDataV1" + ] + }, + { + "default": null, + "name": "breachAmountCalculationType", + "type": [ + "null", + "org.apache.fineract.avro.generic.v1.StringEnumOptionDataV1" + ] + }, + { + "default": null, + "name": "breachAmount", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "breachGraceDays", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "breachStartDate", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "nearBreach", + "type": [ + "null", + "org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalNearBreachDataV1" + ] + } + ] +} diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanAccountDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanAccountDataV1.avsc new file mode 100644 index 00000000000..916e692c3e0 --- /dev/null +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanAccountDataV1.avsc @@ -0,0 +1,411 @@ +{ + "name": "WorkingCapitalLoanAccountDataV1", + "namespace": "org.apache.fineract.avro.workingcapitalloan.v1", + "type": "record", + "fields": [ + { + "default": null, + "name": "id", + "type": [ + "null", + "long" + ] + }, + { + "default": null, + "name": "accountNo", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "externalId", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "status", + "type": [ + "null", + "org.apache.fineract.avro.loan.v1.LoanStatusEnumDataV1" + ] + }, + { + "default": null, + "name": "clientId", + "type": [ + "null", + "long" + ] + }, + { + "default": null, + "name": "clientAccountNo", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "clientName", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "clientOfficeId", + "type": [ + "null", + "long" + ] + }, + { + "default": null, + "name": "clientExternalId", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "loanProductId", + "type": [ + "null", + "long" + ] + }, + { + "default": null, + "name": "loanProductName", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "loanProductDescription", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "fundId", + "type": [ + "null", + "long" + ] + }, + { + "default": null, + "name": "fundName", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "principal", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "approvedPrincipal", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "proposedPrincipal", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "netDisbursalAmount", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "numberOfRepayments", + "doc": "Loan term in days, not a count of repayments. Null until the amortization schedule is generated.", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "repaymentEvery", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "repaymentFrequencyType", + "type": [ + "null", + "org.apache.fineract.avro.generic.v1.StringEnumOptionDataV1" + ] + }, + { + "default": null, + "name": "amortizationType", + "type": [ + "null", + "org.apache.fineract.avro.generic.v1.StringEnumOptionDataV1" + ] + }, + { + "default": null, + "name": "graceOnArrearsAgeing", + "doc": "Grace days before a period counts as delinquent.", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "timeline", + "type": [ + "null", + "org.apache.fineract.avro.loan.v1.LoanApplicationTimelineDataV1" + ] + }, + { + "default": null, + "name": "summary", + "type": [ + "null", + "org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanSummaryDataV1" + ] + }, + { + "default": null, + "name": "charges", + "type": [ + "null", + { + "type": "array", + "items": "org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanChargeDataV1" + } + ] + }, + { + "default": null, + "name": "disbursementDetails", + "type": [ + "null", + { + "type": "array", + "items": "org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDisbursementDetailDataV1" + } + ] + }, + { + "default": null, + "name": "totalOverpaid", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "loanProductCounter", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "delinquent", + "type": [ + "null", + "org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanCollectionDataV1" + ] + }, + { + "default": null, + "name": "lastClosedBusinessDate", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "chargedOff", + "doc": "Always null: working capital does not yet implement charge-off.", + "type": [ + "null", + "boolean" + ] + }, + { + "default": null, + "name": "enableInstallmentLevelDelinquency", + "doc": "True when the loan has a delinquency bucket configured. Working capital classifies delinquency per period, not per installment.", + "type": [ + "null", + "boolean" + ] + }, + { + "default": null, + "name": "originators", + "type": [ + "null", + { + "type": "array", + "items": "org.apache.fineract.avro.loan.v1.OriginatorDetailsV1" + } + ] + }, + { + "default": null, + "name": "breach", + "type": [ + "null", + "org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalBreachDataV1" + ] + }, + { + "default": null, + "name": "paymentRate", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "discountFee", + "doc": "Effective discount fee, set at disbursement.", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "proposedDiscountFee", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "approvedDiscountFee", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "periodPaymentAmount", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "dailyEir", + "doc": "Daily effective interest rate derived from discount-fee pricing, not a lending interest rate.", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "calculatedAnnualEir", + "doc": "Annualised dailyEir.", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "npvDayCount", + "doc": "Day count used by the amortization schedule.", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "totalPaymentVolume", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "delinquency", + "type": [ + "null", + "org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencyDataV1" + ] + }, + { + "default": null, + "name": "overpaidOnDate", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "actualNoTerm", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "customData", + "type": [ + "null", + { + "values": "bytes", + "type": "map" + } + ] + } + ] +} diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanChargeDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanChargeDataV1.avsc new file mode 100644 index 00000000000..15439afe6fc --- /dev/null +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanChargeDataV1.avsc @@ -0,0 +1,179 @@ +{ + "name": "WorkingCapitalLoanChargeDataV1", + "namespace": "org.apache.fineract.avro.workingcapitalloan.v1", + "type": "record", + "fields": [ + { + "default": null, + "name": "id", + "type": [ + "null", + "long" + ] + }, + { + "default": null, + "name": "chargeId", + "type": [ + "null", + "long" + ] + }, + { + "default": null, + "name": "name", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "chargeTimeType", + "type": [ + "null", + "org.apache.fineract.avro.generic.v1.EnumOptionDataV1" + ] + }, + { + "default": null, + "name": "dueDate", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "chargeCalculationType", + "type": [ + "null", + "org.apache.fineract.avro.generic.v1.EnumOptionDataV1" + ] + }, + { + "default": null, + "name": "currency", + "type": [ + "null", + "org.apache.fineract.avro.generic.v1.CurrencyDataV1" + ] + }, + { + "default": null, + "name": "amount", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "amountPaid", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "amountWrittenOff", + "doc": "Always null: working capital does not yet implement write-off.", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "amountOutstanding", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "amountAccrued", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "amountUnrecognized", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "penalty", + "type": [ + "null", + "boolean" + ] + }, + { + "default": null, + "name": "chargePaymentMode", + "type": [ + "null", + "org.apache.fineract.avro.generic.v1.EnumOptionDataV1" + ] + }, + { + "default": null, + "name": "paid", + "type": [ + "null", + "boolean" + ] + }, + { + "default": null, + "name": "loanId", + "type": [ + "null", + "long" + ] + }, + { + "default": null, + "name": "externalId", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "externalLoanId", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "submittedOnDate", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "customData", + "type": [ + "null", + { + "values": "bytes", + "type": "map" + } + ] + } + ] +} diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanCollectionDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanCollectionDataV1.avsc new file mode 100644 index 00000000000..9fd134a7f12 --- /dev/null +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanCollectionDataV1.avsc @@ -0,0 +1,98 @@ +{ + "name": "WorkingCapitalLoanCollectionDataV1", + "namespace": "org.apache.fineract.avro.workingcapitalloan.v1", + "type": "record", + "fields": [ + { + "default": null, + "name": "delinquentDays", + "doc": "Days since the oldest active delinquency tag.", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "delinquentDate", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "delinquentAmount", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "lastPaymentDate", + "doc": "Date of the last non-reversed repayment-like transaction (repayment, payout refund, goodwill credit, charge adjustment).", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "lastPaymentAmount", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "lastRepaymentDate", + "doc": "Date of the last non-reversed repayment transaction.", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "lastRepaymentAmount", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "delinquencyPausePeriods", + "type": [ + "null", + { + "type": "array", + "items": "org.apache.fineract.avro.loan.v1.DelinquencyPausePeriodV1" + } + ] + }, + { + "default": null, + "name": "delinquencySchedule", + "doc": "Current delinquency schedule periods.", + "type": [ + "null", + { + "type": "array", + "items": "org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencySchedulePeriodDataV1" + } + ] + }, + { + "default": null, + "name": "totalDelinquentAmount", + "doc": "Aggregated outstanding of the delinquency schedule periods; always equal to delinquentAmount.", + "type": [ + "null", + "bigdecimal" + ] + } + ] +} diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencyDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencyDataV1.avsc new file mode 100644 index 00000000000..2bcb20622d0 --- /dev/null +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencyDataV1.avsc @@ -0,0 +1,24 @@ +{ + "name": "WorkingCapitalLoanDelinquencyDataV1", + "namespace": "org.apache.fineract.avro.workingcapitalloan.v1", + "type": "record", + "fields": [ + { + "default": null, + "name": "delinquencyStartType", + "doc": "What starts the delinquency clock.", + "type": [ + "null", + "org.apache.fineract.avro.generic.v1.StringEnumOptionDataV1" + ] + }, + { + "default": null, + "name": "delinquencyStartDate", + "type": [ + "null", + "string" + ] + } + ] +} diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencySchedulePeriodDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencySchedulePeriodDataV1.avsc new file mode 100644 index 00000000000..9ec0007da07 --- /dev/null +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencySchedulePeriodDataV1.avsc @@ -0,0 +1,79 @@ +{ + "name": "WorkingCapitalLoanDelinquencySchedulePeriodDataV1", + "namespace": "org.apache.fineract.avro.workingcapitalloan.v1", + "type": "record", + "fields": [ + { + "default": null, + "name": "periodNumber", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "fromDate", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "toDate", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "expectedAmount", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "paidAmount", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "outstandingAmount", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "minPaymentCriteriaMet", + "type": [ + "null", + "boolean" + ] + }, + { + "default": null, + "name": "delinquentDays", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "delinquentAmount", + "type": [ + "null", + "bigdecimal" + ] + } + ] +} diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDisbursementDetailDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDisbursementDetailDataV1.avsc new file mode 100644 index 00000000000..cec630873f8 --- /dev/null +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDisbursementDetailDataV1.avsc @@ -0,0 +1,48 @@ +{ + "name": "WorkingCapitalLoanDisbursementDetailDataV1", + "namespace": "org.apache.fineract.avro.workingcapitalloan.v1", + "type": "record", + "fields": [ + { + "default": null, + "name": "id", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "expectedDisbursementDate", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "actualDisbursementDate", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "principal", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "netDisbursalAmount", + "doc": "Net amount disbursed to the client after up-front deductions (from the amortization model).", + "type": [ + "null", + "bigdecimal" + ] + } + ] +} diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanSummaryDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanSummaryDataV1.avsc new file mode 100644 index 00000000000..3ffae5d0e92 --- /dev/null +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanSummaryDataV1.avsc @@ -0,0 +1,322 @@ +{ + "name": "WorkingCapitalLoanSummaryDataV1", + "namespace": "org.apache.fineract.avro.workingcapitalloan.v1", + "type": "record", + "fields": [ + { + "default": null, + "name": "currency", + "type": [ + "null", + "org.apache.fineract.avro.generic.v1.CurrencyDataV1" + ] + }, + { + "default": null, + "name": "principalDisbursed", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "principalAdjustments", + "doc": "Always null: working capital has no chargeback, the only source of principal adjustments.", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "principalPaid", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "principalWrittenOff", + "doc": "Always null: working capital does not yet implement write-off.", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "principalOutstanding", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "feeChargesCharged", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "feeChargesPaid", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "feeChargesWrittenOff", + "doc": "Always null: working capital does not yet implement write-off.", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "feeChargesOutstanding", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "penaltyChargesCharged", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "penaltyChargesPaid", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "penaltyChargesWrittenOff", + "doc": "Always null: working capital does not yet implement write-off.", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "penaltyChargesOutstanding", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalExpectedRepayment", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalRepayment", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalOutstanding", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalWrittenOff", + "doc": "Always null: working capital does not yet implement write-off.", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalRecovered", + "doc": "Always null: working capital does not yet implement recovery.", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "writeoffReasonId", + "doc": "Always null: working capital does not yet implement write-off.", + "type": [ + "null", + "long" + ] + }, + { + "default": null, + "name": "writeoffReason", + "doc": "Always null: working capital does not yet implement write-off.", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "chargeOffReasonId", + "doc": "Always null: working capital does not yet implement charge-off.", + "type": [ + "null", + "long" + ] + }, + { + "default": null, + "name": "chargeOffReason", + "doc": "Always null: working capital does not yet implement charge-off.", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "totalPayoutRefund", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalPayoutRefundReversed", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalGoodwillCredit", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalGoodwillCreditReversed", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalChargeAdjustment", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalChargeAdjustmentReversed", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalCreditBalanceRefund", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalCreditBalanceRefundReversed", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalRepaymentTransaction", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalRepaymentTransactionReversed", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalPrincipal", + "doc": "Disbursed amount plus the discount fee.", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "realizedIncomeFromDiscountFee", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "unrealizedIncomeFromDiscountFee", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalDiscountFee", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "totalDiscountFeeAdjustment", + "type": [ + "null", + "bigdecimal" + ] + } + ] +} diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalNearBreachDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalNearBreachDataV1.avsc new file mode 100644 index 00000000000..01aaf251677 --- /dev/null +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalNearBreachDataV1.avsc @@ -0,0 +1,47 @@ +{ + "name": "WorkingCapitalNearBreachDataV1", + "namespace": "org.apache.fineract.avro.workingcapitalloan.v1", + "type": "record", + "fields": [ + { + "default": null, + "name": "id", + "type": [ + "null", + "long" + ] + }, + { + "default": null, + "name": "name", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "frequency", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "frequencyType", + "type": [ + "null", + "org.apache.fineract.avro.generic.v1.StringEnumOptionDataV1" + ] + }, + { + "default": null, + "name": "threshold", + "type": [ + "null", + "bigdecimal" + ] + } + ] +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java index 90cf8dd9ffa..c237c6b0e66 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java @@ -27,6 +27,7 @@ import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.avro.client.v1.ClientDataV1; @@ -37,6 +38,10 @@ import org.apache.fineract.avro.loan.v1.LoanOwnershipTransferDataV1; import org.apache.fineract.avro.loan.v1.LoanTransactionAdjustmentDataV1; import org.apache.fineract.avro.loan.v1.LoanTransactionDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanAccountDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanChargeDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanCollectionDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanSummaryDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanTransactionDataV1; import org.apache.fineract.client.feign.FineractFeignClient; import org.apache.fineract.client.models.ExternalTransferData; @@ -44,14 +49,17 @@ import org.apache.fineract.client.models.GetLoansLoanIdDelinquencyPausePeriod; import org.apache.fineract.client.models.GetLoansLoanIdResponse; import org.apache.fineract.client.models.GetLoansLoanIdTransactions; +import org.apache.fineract.client.models.GetWorkingCapitalLoanSummary; import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionsResponse; +import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; import org.apache.fineract.client.models.GlobalConfigurationPropertyData; import org.apache.fineract.client.models.PageExternalTransferData; import org.apache.fineract.client.models.PostClientsResponse; import org.apache.fineract.client.models.PostLoansLoanIdResponse; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse; import org.apache.fineract.client.models.PostLoansResponse; +import org.apache.fineract.client.models.WorkingCapitalCollection; import org.apache.fineract.test.data.AssetExternalizationTransferStatus; import org.apache.fineract.test.data.AssetExternalizationTransferStatusReason; import org.apache.fineract.test.data.TransactionType; @@ -83,11 +91,23 @@ import org.apache.fineract.test.messaging.event.loan.transaction.LoanTransactionMerchantIssuedRefundPostEvent; import org.apache.fineract.test.messaging.event.loan.transaction.LoanTransactionPayoutRefundPostEvent; import org.apache.fineract.test.messaging.event.loan.transaction.LoanUndoContractTerminationBusinessEvent; +import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.AbstractWorkingCapitalLoanEvent; +import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.WorkingCapitalLoanApplicationModifiedEvent; +import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.WorkingCapitalLoanApprovedEvent; +import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.WorkingCapitalLoanBalanceChangedEvent; +import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.WorkingCapitalLoanCreatedEvent; +import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.WorkingCapitalLoanDelinquencyRangeChangeEvent; +import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.WorkingCapitalLoanDisbursalEvent; +import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.WorkingCapitalLoanRejectedEvent; +import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.WorkingCapitalLoanStatusChangedEvent; +import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.WorkingCapitalLoanUndoApprovalEvent; +import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.WorkingCapitalLoanUndoDisbursalEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanCreditBalanceRefundTransactionBusinessEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanDisbursalTransactionBusinessEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanDiscountFeeAdjustmentTransactionBusinessEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanDiscountFeeTransactionBusinessEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanUndoDisbursalTransactionBusinessEvent; +import org.junit.jupiter.api.Assertions; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -736,4 +756,123 @@ public void approveLoanEventCheck(PostLoansLoanIdResponse loanApproveResponse) { .isEqualTo(body.getCurrency().getCode());// } + public void workingCapitalLoanCreatedEventCheck(final Long loanId) { + workingCapitalLoanAccountDataV1Check(WorkingCapitalLoanCreatedEvent.class, loanId); + } + + public void workingCapitalLoanApplicationModifiedEventCheck(final Long loanId) { + workingCapitalLoanAccountDataV1Check(WorkingCapitalLoanApplicationModifiedEvent.class, loanId); + } + + public void workingCapitalLoanApprovedEventCheck(final Long loanId) { + workingCapitalLoanAccountDataV1Check(WorkingCapitalLoanApprovedEvent.class, loanId); + } + + public void workingCapitalLoanUndoApprovalEventCheck(final Long loanId) { + workingCapitalLoanAccountDataV1Check(WorkingCapitalLoanUndoApprovalEvent.class, loanId); + } + + public void workingCapitalLoanRejectedEventCheck(final Long loanId) { + workingCapitalLoanAccountDataV1Check(WorkingCapitalLoanRejectedEvent.class, loanId); + } + + public void workingCapitalLoanDisbursalEventCheck(final Long loanId) { + workingCapitalLoanAccountDataV1Check(WorkingCapitalLoanDisbursalEvent.class, loanId); + } + + public void workingCapitalLoanUndoDisbursalEventCheck(final Long loanId) { + workingCapitalLoanAccountDataV1Check(WorkingCapitalLoanUndoDisbursalEvent.class, loanId); + } + + public void workingCapitalLoanStatusChangedEventCheck(final Long loanId) { + workingCapitalLoanAccountDataV1Check(WorkingCapitalLoanStatusChangedEvent.class, loanId); + } + + public void workingCapitalLoanBalanceChangedEventCheck(final Long loanId) { + waitForTransactionCommit(); + final GetWorkingCapitalLoansLoanIdResponse body = ok( + () -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId)); + + eventAssertion.assertEvent(WorkingCapitalLoanBalanceChangedEvent.class, loanId).extractingData(event -> { + assertWorkingCapitalLoanAccountData(event, body); + + final WorkingCapitalLoanSummaryDataV1 eventSummary = event.getSummary(); + final GetWorkingCapitalLoanSummary bodySummary = body.getSummary(); + assertThat(eventSummary).isNotNull(); + assertThat(bodySummary).isNotNull(); + assertThat(areBigDecimalValuesEqual(eventSummary.getPrincipalPaid(), bodySummary.getPrincipalPaid())).isTrue(); + assertThat(areBigDecimalValuesEqual(eventSummary.getPrincipalOutstanding(), bodySummary.getPrincipalOutstanding())).isTrue(); + assertThat(areBigDecimalValuesEqual(eventSummary.getTotalOutstanding(), bodySummary.getTotalOutstanding())).isTrue(); + assertThat(areBigDecimalValuesEqual(event.getTotalOverpaid(), bodySummary.getOverpayment())).isTrue(); + return null; + }); + } + + public void workingCapitalLoanBalanceChangedEventChargesCheck(final Long loanId, final List> expectedCharges) { + waitForTransactionCommit(); + eventAssertion.assertEvent(WorkingCapitalLoanBalanceChangedEvent.class, loanId).extractingData(event -> { + final List eventCharges = event.getCharges(); + assertThat(eventCharges).isNotNull().hasSize(expectedCharges.size()); + IntStream.range(0, expectedCharges.size()).forEach(i -> { + final Map expected = expectedCharges.get(i); + final WorkingCapitalLoanChargeDataV1 actual = eventCharges.get(i); + assertThat(actual.getAmount()).isNotNull(); + assertThat(actual.getAmountAccrued()).isNotNull(); + assertThat(actual.getAmountUnrecognized()).isNotNull(); + assertThat(areBigDecimalValuesEqual(actual.getAmount(), new BigDecimal(expected.get("amount")))).isTrue(); + assertThat(areBigDecimalValuesEqual(actual.getAmountAccrued(), new BigDecimal(expected.get("amountAccrued")))).isTrue(); + assertThat(areBigDecimalValuesEqual(actual.getAmountUnrecognized(), new BigDecimal(expected.get("amountUnrecognized")))) + .isTrue(); + }); + return null; + }); + } + + public void workingCapitalLoanDelinquencyRangeChangeEventCheck(final Long loanId) { + waitForTransactionCommit(); + final GetWorkingCapitalLoansLoanIdResponse body = ok( + () -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId)); + + eventAssertion.assertEvent(WorkingCapitalLoanDelinquencyRangeChangeEvent.class, loanId).extractingData(event -> { + assertWorkingCapitalLoanAccountData(event, body); + + final WorkingCapitalLoanCollectionDataV1 eventDelinquent = event.getDelinquent(); + final WorkingCapitalCollection bodyDelinquent = body.getDelinquent(); + assertThat(eventDelinquent).isNotNull(); + assertThat(bodyDelinquent).isNotNull(); + assertThat(areBigDecimalValuesEqual(eventDelinquent.getDelinquentAmount(), bodyDelinquent.getDelinquentAmount())).isTrue(); + assertThat(areBigDecimalValuesEqual(eventDelinquent.getTotalDelinquentAmount(), bodyDelinquent.getDelinquentPrincipal())) + .isTrue(); + assertThat(eventDelinquent.getDelinquencySchedule()).isNotEmpty(); + return null; + }); + } + + private void workingCapitalLoanAccountDataV1Check(final Class eventClazz, + final Long loanId) { + waitForTransactionCommit(); + final GetWorkingCapitalLoansLoanIdResponse body = ok( + () -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId)); + + eventAssertion.assertEvent(eventClazz, loanId).extractingData(event -> { + assertWorkingCapitalLoanAccountData(event, body); + return null; + }); + } + + private void assertWorkingCapitalLoanAccountData(final WorkingCapitalLoanAccountDataV1 event, + final GetWorkingCapitalLoansLoanIdResponse body) { + Assertions.assertNotNull(body.getStatus()); + assertThat(event.getStatus().getId().longValue()).isEqualTo(body.getStatus().getId()); + + assertThat(event.getDelinquent()).isNotNull(); + assertThat(event.getDelinquent().getDelinquencySchedule()).isNotNull(); + + if (event.getDisbursementDetails() != null && !event.getDisbursementDetails().isEmpty() && body.getNetDisbursalAmount() != null) { + event.getDisbursementDetails() + .forEach(tranche -> assertThat(areBigDecimalValuesEqual(tranche.getNetDisbursalAmount(), body.getNetDisbursalAmount())) + .isTrue()); + } + } + } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/AbstractWorkingCapitalLoanEvent.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/AbstractWorkingCapitalLoanEvent.java new file mode 100644 index 00000000000..d4d9d48d041 --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/AbstractWorkingCapitalLoanEvent.java @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.test.messaging.event.workingcapitalloan.loan; + +import java.util.function.Function; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanAccountDataV1; +import org.apache.fineract.test.messaging.event.Event; + +public abstract class AbstractWorkingCapitalLoanEvent implements Event { + + @Override + public Class getDataClass() { + return WorkingCapitalLoanAccountDataV1.class; + } + + @Override + public Function getIdExtractor() { + return WorkingCapitalLoanAccountDataV1::getId; + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanApplicationModifiedEvent.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanApplicationModifiedEvent.java new file mode 100644 index 00000000000..914baf500bb --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanApplicationModifiedEvent.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.test.messaging.event.workingcapitalloan.loan; + +public class WorkingCapitalLoanApplicationModifiedEvent extends AbstractWorkingCapitalLoanEvent { + + @Override + public String getEventName() { + return "WorkingCapitalLoanApplicationModifiedBusinessEvent"; + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanApprovedEvent.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanApprovedEvent.java new file mode 100644 index 00000000000..289ce9dccf1 --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanApprovedEvent.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.test.messaging.event.workingcapitalloan.loan; + +public class WorkingCapitalLoanApprovedEvent extends AbstractWorkingCapitalLoanEvent { + + @Override + public String getEventName() { + return "WorkingCapitalLoanApprovedBusinessEvent"; + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanBalanceChangedEvent.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanBalanceChangedEvent.java new file mode 100644 index 00000000000..1005fbd82fb --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanBalanceChangedEvent.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.test.messaging.event.workingcapitalloan.loan; + +public class WorkingCapitalLoanBalanceChangedEvent extends AbstractWorkingCapitalLoanEvent { + + @Override + public String getEventName() { + return "WorkingCapitalLoanBalanceChangedBusinessEvent"; + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanCreatedEvent.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanCreatedEvent.java new file mode 100644 index 00000000000..6b3817dc820 --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanCreatedEvent.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.test.messaging.event.workingcapitalloan.loan; + +public class WorkingCapitalLoanCreatedEvent extends AbstractWorkingCapitalLoanEvent { + + @Override + public String getEventName() { + return "WorkingCapitalLoanCreatedBusinessEvent"; + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanDelinquencyRangeChangeEvent.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanDelinquencyRangeChangeEvent.java new file mode 100644 index 00000000000..219ef798af6 --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanDelinquencyRangeChangeEvent.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.test.messaging.event.workingcapitalloan.loan; + +public class WorkingCapitalLoanDelinquencyRangeChangeEvent extends AbstractWorkingCapitalLoanEvent { + + @Override + public String getEventName() { + return "WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent"; + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanDisbursalEvent.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanDisbursalEvent.java new file mode 100644 index 00000000000..99ed3ad59a0 --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanDisbursalEvent.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.test.messaging.event.workingcapitalloan.loan; + +public class WorkingCapitalLoanDisbursalEvent extends AbstractWorkingCapitalLoanEvent { + + @Override + public String getEventName() { + return "WorkingCapitalLoanDisbursalBusinessEvent"; + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanRejectedEvent.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanRejectedEvent.java new file mode 100644 index 00000000000..f64208516c9 --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanRejectedEvent.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.test.messaging.event.workingcapitalloan.loan; + +public class WorkingCapitalLoanRejectedEvent extends AbstractWorkingCapitalLoanEvent { + + @Override + public String getEventName() { + return "WorkingCapitalLoanRejectedBusinessEvent"; + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanStatusChangedEvent.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanStatusChangedEvent.java new file mode 100644 index 00000000000..bcf4a4baecd --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanStatusChangedEvent.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.test.messaging.event.workingcapitalloan.loan; + +public class WorkingCapitalLoanStatusChangedEvent extends AbstractWorkingCapitalLoanEvent { + + @Override + public String getEventName() { + return "WorkingCapitalLoanStatusChangedBusinessEvent"; + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanUndoApprovalEvent.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanUndoApprovalEvent.java new file mode 100644 index 00000000000..f008572405c --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanUndoApprovalEvent.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.test.messaging.event.workingcapitalloan.loan; + +public class WorkingCapitalLoanUndoApprovalEvent extends AbstractWorkingCapitalLoanEvent { + + @Override + public String getEventName() { + return "WorkingCapitalLoanUndoApprovalBusinessEvent"; + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanUndoDisbursalEvent.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanUndoDisbursalEvent.java new file mode 100644 index 00000000000..ff2b0802441 --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/workingcapitalloan/loan/WorkingCapitalLoanUndoDisbursalEvent.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.test.messaging.event.workingcapitalloan.loan; + +public class WorkingCapitalLoanUndoDisbursalEvent extends AbstractWorkingCapitalLoanEvent { + + @Override + public String getEventName() { + return "WorkingCapitalLoanUndoDisbursalBusinessEvent"; + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java index bb07efd8b7f..ceefd3d73e7 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java @@ -1446,6 +1446,61 @@ public void checkWorkingCapitalDiscountFeeAdjustmentBusinessEventIsRaised(final new BigDecimal(amount), transactionDate); } + @Then("a Working Capital Loan Created business event is raised") + public void aWorkingCapitalLoanCreatedBusinessEventIsRaised() { + eventCheckHelper.workingCapitalLoanCreatedEventCheck(getCreatedLoanId()); + } + + @Then("a Working Capital Loan Application Modified business event is raised") + public void aWorkingCapitalLoanApplicationModifiedBusinessEventIsRaised() { + eventCheckHelper.workingCapitalLoanApplicationModifiedEventCheck(getCreatedLoanId()); + } + + @Then("a Working Capital Loan Approved business event is raised") + public void aWorkingCapitalLoanApprovedBusinessEventIsRaised() { + eventCheckHelper.workingCapitalLoanApprovedEventCheck(getCreatedLoanId()); + } + + @Then("a Working Capital Loan Undo Approval business event is raised") + public void aWorkingCapitalLoanUndoApprovalBusinessEventIsRaised() { + eventCheckHelper.workingCapitalLoanUndoApprovalEventCheck(getCreatedLoanId()); + } + + @Then("a Working Capital Loan Rejected business event is raised") + public void aWorkingCapitalLoanRejectedBusinessEventIsRaised() { + eventCheckHelper.workingCapitalLoanRejectedEventCheck(getCreatedLoanId()); + } + + @Then("a Working Capital Loan Disbursal business event is raised") + public void aWorkingCapitalLoanDisbursalBusinessEventIsRaised() { + eventCheckHelper.workingCapitalLoanDisbursalEventCheck(getCreatedLoanId()); + } + + @Then("a Working Capital Loan Undo Disbursal business event is raised") + public void aWorkingCapitalLoanUndoDisbursalBusinessEventIsRaised() { + eventCheckHelper.workingCapitalLoanUndoDisbursalEventCheck(getCreatedLoanId()); + } + + @Then("a Working Capital Loan Status Changed business event is raised") + public void aWorkingCapitalLoanStatusChangedBusinessEventIsRaised() { + eventCheckHelper.workingCapitalLoanStatusChangedEventCheck(getCreatedLoanId()); + } + + @Then("a Working Capital Loan Balance Changed business event is raised") + public void aWorkingCapitalLoanBalanceChangedBusinessEventIsRaised() { + eventCheckHelper.workingCapitalLoanBalanceChangedEventCheck(getCreatedLoanId()); + } + + @Then("a Working Capital Loan Balance Changed business event is raised with charges:") + public void aWorkingCapitalLoanBalanceChangedBusinessEventIsRaisedWithCharges(final DataTable table) { + eventCheckHelper.workingCapitalLoanBalanceChangedEventChargesCheck(getCreatedLoanId(), table.asMaps()); + } + + @Then("a Working Capital Loan Delinquency Range Change business event is raised") + public void aWorkingCapitalLoanDelinquencyRangeChangeBusinessEventIsRaised() { + eventCheckHelper.workingCapitalLoanDelinquencyRangeChangeEventCheck(getCreatedLoanId()); + } + @Then("Admin fails to disburse the Working Capital loan on {string} with {string} EUR transaction amount because of not approved") public void disburseWCLoanFailureWithNotApproved(String actualDisbursementDate, String transactionAmount) { final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccountBusinessEvents.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccountBusinessEvents.feature new file mode 100644 index 00000000000..f5396866f43 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccountBusinessEvents.feature @@ -0,0 +1,159 @@ +@WorkingCapital +@WorkingCapitalLoanAccountBusinessEventsFeature +Feature: Working Capital Loan Account Business Events + + Scenario: Working Capital loan raises Created, Application Modified, Approved, Undo Approval, Disbursal and Undo Disbursal business events across its lifecycle + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100.0 | 100.0 | 1.0 | 0.0 | + Then Working capital loan creation was successful + And a Working Capital Loan Created business event is raised + When Admin modifies the working capital loan with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | | | 80.0 | | | | + Then a Working Capital Loan Application Modified business event is raised + When Admin successfully approves the working capital loan on "01 January 2026" with "80" amount and expected disbursement date on "01 January 2026" + Then Working capital loan approval was successful + And a Working Capital Loan Approved business event is raised + And a Working Capital Loan Status Changed business event is raised + When Admin makes undo approval on the working capital loan + Then Working capital loan undo approval was successful + And a Working Capital Loan Undo Approval business event is raised + And a Working Capital Loan Status Changed business event is raised + When Admin successfully approves the working capital loan on "01 January 2026" with "80" amount and expected disbursement date on "01 January 2026" + Then Working capital loan approval was successful + And a Working Capital Loan Approved business event is raised + And a Working Capital Loan Status Changed business event is raised + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "80" EUR transaction amount + Then a Working Capital Loan Disbursal business event is raised + And a Working Capital Loan Status Changed business event is raised + When Admin successfully undo Working Capital disbursal + Then a Working Capital Loan Undo Disbursal business event is raised + And a Working Capital Loan Status Changed business event is raised + + Scenario: Rejected Working Capital loan raises Rejected and Status Changed business events + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100.0 | 100.0 | 1.0 | 0.0 | + Then Working capital loan creation was successful + When Admin rejects the working capital loan on "01 January 2026" + Then Working capital loan rejection was successful + And a Working Capital Loan Rejected business event is raised + And a Working Capital Loan Status Changed business event is raised + + Scenario: Working Capital loan raises Balance Changed on partial repayment and Status Changed on full repayment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + Then Working capital loan creation was successful + When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then a Working Capital Loan Status Changed business event is raised + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes repayment on "10 January 2026" with 4000.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Balance Changed business event is raised + When Admin sets the business date to "20 January 2026" + And Customer makes repayment on "20 January 2026" with 5000.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And a Working Capital Loan Status Changed business event is raised + + Scenario: Overpaid Working Capital loan raises Balance Changed and Status Changed business events through credit balance refund + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + Then Working capital loan creation was successful + When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then a Working Capital Loan Status Changed business event is raised + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + When Admin sets the business date to "20 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes repayment on "20 January 2026" with 9200.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "OVERPAID" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + When Customer makes credit balance refund on "20 January 2026" with 200.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + + Scenario: Delinquent Working Capital loan raises a Delinquency Range Change business event + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | 0 | + Then Working capital loan creation was successful + When Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then a Working Capital Loan Status Changed business event is raised + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Status Changed business event is raised + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin sets the business date to "15 February 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then a Working Capital Loan Delinquency Range Change business event is raised + When Admin closes the Working Capital loan with a full repayment on "15 February 2026" + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And a Working Capital Loan Status Changed business event is raised + + Scenario: Working Capital loan raises Balance Changed on discount fee, discount fee adjustment and undo of the adjustment + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | | + Then Working capital loan creation was successful + When Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then a Working Capital Loan Status Changed business event is raised + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + When Admin adds Discount fee with "12" amount on Working Capital loan account for last disbursement + Then a Working Capital Loan Balance Changed business event is raised + When Admin adds Discount fee adjustment with "5" amount on Working Capital loan account for last discount + Then a Working Capital Loan Balance Changed business event is raised + When Admin undo the last Discount fee adjustment on Working Capital loan account + Then a Working Capital Loan Balance Changed business event is raised + When Admin closes the Working Capital loan with a full repayment on "01 January 2026" + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And a Working Capital Loan Status Changed business event is raised + + @SerialChargeAccrualConfig + Scenario: Working Capital loan Balance Changed event exposes charge amountAccrued and amountUnrecognized before and after accrual + Given Admin sets the business date to "01 January 2028" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP_ACC_DEF_REV_AM | 01 January 2028 | 01 January 2028 | 9000 | 100000 | 18 | 0 | + And a Working Capital Loan Balance Changed business event is raised + When Global config "charge-accrual-date" value set to "due-date" + And Admin sets the business date to "05 January 2028" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "15 January 2028" due date and 50.0 transaction amount + And Admin sets the business date to "10 January 2028" + And Customer makes repayment on "10 January 2028" with 100.0 transaction amount on Working Capital loan + Then a Working Capital Loan Balance Changed business event is raised with charges: + | amount | amountAccrued | amountUnrecognized | + | 50.0 | 0.0 | 50.0 | + When Admin sets the business date to "16 January 2028" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Admin closes the Working Capital loan with a full repayment on "16 January 2028" + And a Working Capital Loan Balance Changed business event is raised with charges: + | amount | amountAccrued | amountUnrecognized | + | 50.0 | 50.0 | 0.0 | diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java index 78673e840f1..4e6d1679f5b 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java @@ -117,16 +117,21 @@ public void givenAllConfigurationWhenValidatedThenValidationSuccessful() throws "WorkingCapitalLoanDisbursalTransactionBusinessEvent", "WorkingCapitalLoanUndoDisbursalTransactionBusinessEvent", "WorkingCapitalLoanRepaymentTransactionBusinessEvent", "WorkingCapitalLoanDiscountFeeTransactionBusinessEvent", "WorkingCapitalLoanDiscountFeeAdjustmentTransactionBusinessEvent", "WorkingCapitalLoanChargeAdjustmentPreBusinessEvent", - "WorkingCapitalLoanChargeAdjustmentPostBusinessEvent", "WorkingCapitalLoanCreditBalanceRefundTransactionBusinessEvent"); + "WorkingCapitalLoanChargeAdjustmentPostBusinessEvent", "WorkingCapitalLoanCreditBalanceRefundTransactionBusinessEvent", + "WorkingCapitalLoanCreatedBusinessEvent", "WorkingCapitalLoanApplicationModifiedBusinessEvent", + "WorkingCapitalLoanApprovedBusinessEvent", "WorkingCapitalLoanUndoApprovalBusinessEvent", + "WorkingCapitalLoanRejectedBusinessEvent", "WorkingCapitalLoanDisbursalBusinessEvent", + "WorkingCapitalLoanUndoDisbursalBusinessEvent", "WorkingCapitalLoanStatusChangedBusinessEvent", + "WorkingCapitalLoanBalanceChangedBusinessEvent", "WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent"); - List tenants = Arrays - .asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null)); + List tenants = List + .of(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null)); JdbcTemplate jdbcTemplate = Mockito.mock(JdbcTemplate.class); when(tenantDetailsService.findAllTenants()).thenReturn(tenants); when(jdbcTemplateFactory.create(any())).thenReturn(jdbcTemplate); when(jdbcTemplate.queryForList(anyString(), eq(String.class))).thenReturn(configurations); - List sourcePackage = Arrays.asList("org.apache.fineract"); + List sourcePackage = List.of("org.apache.fineract"); when(externalEventSourceService.getSourcePackages()).thenReturn(sourcePackage); // when underTest.afterPropertiesSet(); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanApplicationModifiedBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanApplicationModifiedBusinessEvent.java new file mode 100644 index 00000000000..137338c1823 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanApplicationModifiedBusinessEvent.java @@ -0,0 +1,35 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; + +public class WorkingCapitalLoanApplicationModifiedBusinessEvent extends WorkingCapitalLoanBusinessEvent { + + private static final String TYPE = "WorkingCapitalLoanApplicationModifiedBusinessEvent"; + + public WorkingCapitalLoanApplicationModifiedBusinessEvent(final WorkingCapitalLoan value) { + super(value); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanApprovedBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanApprovedBusinessEvent.java new file mode 100644 index 00000000000..09dda04b015 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanApprovedBusinessEvent.java @@ -0,0 +1,35 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; + +public class WorkingCapitalLoanApprovedBusinessEvent extends WorkingCapitalLoanBusinessEvent { + + private static final String TYPE = "WorkingCapitalLoanApprovedBusinessEvent"; + + public WorkingCapitalLoanApprovedBusinessEvent(final WorkingCapitalLoan value) { + super(value); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanBalanceChangedBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanBalanceChangedBusinessEvent.java new file mode 100644 index 00000000000..3042f352d9a --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanBalanceChangedBusinessEvent.java @@ -0,0 +1,35 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; + +public class WorkingCapitalLoanBalanceChangedBusinessEvent extends WorkingCapitalLoanBusinessEvent { + + private static final String TYPE = "WorkingCapitalLoanBalanceChangedBusinessEvent"; + + public WorkingCapitalLoanBalanceChangedBusinessEvent(final WorkingCapitalLoan value) { + super(value); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanBusinessEvent.java new file mode 100644 index 00000000000..cb43af49c2d --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanBusinessEvent.java @@ -0,0 +1,41 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan; + +import org.apache.fineract.infrastructure.event.business.domain.AbstractBusinessEvent; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; + +public abstract class WorkingCapitalLoanBusinessEvent extends AbstractBusinessEvent { + + private static final String CATEGORY = "WorkingCapitalLoan"; + + public WorkingCapitalLoanBusinessEvent(final WorkingCapitalLoan value) { + super(value); + } + + @Override + public String getCategory() { + return CATEGORY; + } + + @Override + public Long getAggregateRootId() { + return get().getId(); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanCreatedBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanCreatedBusinessEvent.java new file mode 100644 index 00000000000..ec3b61b8d8c --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanCreatedBusinessEvent.java @@ -0,0 +1,35 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; + +public class WorkingCapitalLoanCreatedBusinessEvent extends WorkingCapitalLoanBusinessEvent { + + private static final String TYPE = "WorkingCapitalLoanCreatedBusinessEvent"; + + public WorkingCapitalLoanCreatedBusinessEvent(final WorkingCapitalLoan value) { + super(value); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent.java new file mode 100644 index 00000000000..823a960f076 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent.java @@ -0,0 +1,35 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; + +public class WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent extends WorkingCapitalLoanBusinessEvent { + + private static final String TYPE = "WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent"; + + public WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent(final WorkingCapitalLoan value) { + super(value); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanDisbursalBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanDisbursalBusinessEvent.java new file mode 100644 index 00000000000..ddbcbe70ccd --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanDisbursalBusinessEvent.java @@ -0,0 +1,35 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; + +public class WorkingCapitalLoanDisbursalBusinessEvent extends WorkingCapitalLoanBusinessEvent { + + private static final String TYPE = "WorkingCapitalLoanDisbursalBusinessEvent"; + + public WorkingCapitalLoanDisbursalBusinessEvent(final WorkingCapitalLoan value) { + super(value); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanRejectedBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanRejectedBusinessEvent.java new file mode 100644 index 00000000000..6699dabeb2f --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanRejectedBusinessEvent.java @@ -0,0 +1,35 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; + +public class WorkingCapitalLoanRejectedBusinessEvent extends WorkingCapitalLoanBusinessEvent { + + private static final String TYPE = "WorkingCapitalLoanRejectedBusinessEvent"; + + public WorkingCapitalLoanRejectedBusinessEvent(final WorkingCapitalLoan value) { + super(value); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanStatusChangedBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanStatusChangedBusinessEvent.java new file mode 100644 index 00000000000..9a6119b0fed --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanStatusChangedBusinessEvent.java @@ -0,0 +1,35 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; + +public class WorkingCapitalLoanStatusChangedBusinessEvent extends WorkingCapitalLoanBusinessEvent { + + private static final String TYPE = "WorkingCapitalLoanStatusChangedBusinessEvent"; + + public WorkingCapitalLoanStatusChangedBusinessEvent(final WorkingCapitalLoan value) { + super(value); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanUndoApprovalBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanUndoApprovalBusinessEvent.java new file mode 100644 index 00000000000..73a6626b1a6 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanUndoApprovalBusinessEvent.java @@ -0,0 +1,35 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; + +public class WorkingCapitalLoanUndoApprovalBusinessEvent extends WorkingCapitalLoanBusinessEvent { + + private static final String TYPE = "WorkingCapitalLoanUndoApprovalBusinessEvent"; + + public WorkingCapitalLoanUndoApprovalBusinessEvent(final WorkingCapitalLoan value) { + super(value); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanUndoDisbursalBusinessEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanUndoDisbursalBusinessEvent.java new file mode 100644 index 00000000000..f19eedda7cd --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/workingcapitalloan/loan/WorkingCapitalLoanUndoDisbursalBusinessEvent.java @@ -0,0 +1,35 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; + +public class WorkingCapitalLoanUndoDisbursalBusinessEvent extends WorkingCapitalLoanBusinessEvent { + + private static final String TYPE = "WorkingCapitalLoanUndoDisbursalBusinessEvent"; + + public WorkingCapitalLoanUndoDisbursalBusinessEvent(final WorkingCapitalLoan value) { + super(value); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/ChargeIdAndAmountHolder.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/ChargeIdAndAmountHolder.java new file mode 100644 index 00000000000..45e32505b6b --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/ChargeIdAndAmountHolder.java @@ -0,0 +1,24 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.data; + +import java.math.BigDecimal; + +public record ChargeIdAndAmountHolder(Long chargeId, BigDecimal amount) { +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/TransactionTypeTotalHolder.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/TransactionTypeTotalHolder.java new file mode 100644 index 00000000000..cd3521b7196 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/TransactionTypeTotalHolder.java @@ -0,0 +1,25 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.data; + +import java.math.BigDecimal; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; + +public record TransactionTypeTotalHolder(LoanTransactionType transactionType, boolean reversed, BigDecimal total) { +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionRelationRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionRelationRepository.java index 32929b76b36..4b5b8009f44 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionRelationRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionRelationRepository.java @@ -22,8 +22,11 @@ import java.util.List; import java.util.Optional; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.workingcapitalloan.data.ChargeIdAndAmountHolder; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository @@ -38,4 +41,16 @@ List findAllByToTransactionAndFromTransac List findAllByToChargeAndFromTransactionReversedAndFromTransactionTransactionType( WorkingCapitalLoanCharge toCharge, boolean reversed, LoanTransactionType transactionType); + + @Query(""" + SELECT r.toCharge.id, SUM(r.fromTransaction.transactionAmount) + FROM WorkingCapitalLoanTransactionRelation r + WHERE r.fromTransaction.wcLoan.id = :wcLoanId + AND r.fromTransaction.reversed = FALSE + AND r.fromTransaction.transactionType = :transactionType + AND r.toCharge IS NOT NULL + GROUP BY r.toCharge.id + """) + List fetchTransactionAmountPerCharge(@Param("wcLoanId") Long wcLoanId, + @Param("transactionType") LoanTransactionType transactionType); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java index 5d5a416469f..b6dc3fc7c46 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanDelinquencyActionRepository.java @@ -35,6 +35,9 @@ public interface WorkingCapitalLoanDelinquencyActionRepository extends JpaReposi List findByWorkingCapitalLoanIdAndActionOrderByIdDesc(Long workingCapitalLoanId, DelinquencyAction action); + List findByWorkingCapitalLoanIdAndActionOrderByStartDateAsc(Long workingCapitalLoanId, + DelinquencyAction action); + @Query(""" select case when count(action) > 0 then true else false end from WorkingCapitalLoanDelinquencyAction action where action.action = org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction.DISABLE diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java index 9128760fc4c..a73f66ec97c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java @@ -26,6 +26,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.workingcapitalloan.data.TransactionDateAndAmountHolder; +import org.apache.fineract.portfolio.workingcapitalloan.data.TransactionTypeTotalHolder; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -105,4 +106,24 @@ SELECT CASE WHEN COUNT(transaction) > 0 THEN TRUE ELSE FALSE END """) boolean existsLaterTransaction(@Param("loanId") Long loanId, @Param("transactionDate") LocalDate transactionDate, @Param("createdDateTime") OffsetDateTime createdDateTime); + + @Query(""" + SELECT t.transactionType, t.reversed, SUM(t.transactionAmount) + FROM WorkingCapitalLoanTransaction t + WHERE t.wcLoan.id = :wcLoanId + AND t.transactionType in :transactionTypes + GROUP BY t.transactionType, t.reversed + """) + List fetchTotalsPerTypeAndReversed(@Param("wcLoanId") Long wcLoanId, + @Param("transactionTypes") List transactionTypes); + + @Query(""" + SELECT t FROM WorkingCapitalLoanTransaction t + WHERE t.wcLoan.id = :wcLoanId + AND t.reversed = FALSE + AND t.transactionType in :transactionTypes + ORDER BY t.transactionDate DESC, t.id DESC + """) + List findActiveByTypesOrderByDateDesc(@Param("wcLoanId") Long wcLoanId, + @Param("transactionTypes") List transactionTypes); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapper.java new file mode 100644 index 00000000000..f00232e6d4a --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapper.java @@ -0,0 +1,122 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.serialization.mapper; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanAccountDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanChargeDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanCollectionDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencySchedulePeriodDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDisbursementDetailDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanSummaryDataV1; +import org.apache.fineract.infrastructure.event.external.service.serialization.mapper.support.AvroMapperConfig; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanChargeData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanCollectionData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDelinquencyRangeScheduleData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDisbursementDetailData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanSummaryData; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; + +@Mapper(config = AvroMapperConfig.class) +public interface WorkingCapitalLoanAccountDataMapper { + + int AVRO_DECIMAL_SCALE = 8; + + @Mapping(source = "delinquencyGraceDays", target = "graceOnArrearsAgeing") + @Mapping(source = "summary.overpayment", target = "totalOverpaid") + @Mapping(source = "dailyEir", target = "dailyEir", qualifiedByName = "toAvroDecimalScale") + @Mapping(source = "calculatedAnnualEir", target = "calculatedAnnualEir", qualifiedByName = "toAvroDecimalScale") + @Mapping(source = "numberOfRepayments", target = "actualNoTerm") + @Mapping(source = "delinquencyStartType", target = "delinquency.delinquencyStartType") + @Mapping(source = "delinquencyStartDate", target = "delinquency.delinquencyStartDate") + @Mapping(source = "breach.id", target = "breach.id") + @Mapping(source = "breach.name", target = "breach.name") + @Mapping(source = "breach.breachFrequency", target = "breach.breachFrequency") + @Mapping(source = "breach.breachFrequencyType", target = "breach.breachFrequencyType") + @Mapping(source = "breach.breachAmountCalculationType", target = "breach.breachAmountCalculationType") + @Mapping(source = "breach.breachAmount", target = "breach.breachAmount") + @Mapping(source = "breachGraceDays", target = "breach.breachGraceDays") + @Mapping(source = "breachStartDate", target = "breach.breachStartDate") + @Mapping(source = "nearBreach", target = "breach.nearBreach") + @Mapping(target = "overpaidOnDate", ignore = true) + @Mapping(target = "customData", ignore = true) + WorkingCapitalLoanAccountDataV1 map(WorkingCapitalLoanData source); + + @Mapping(source = "principal", target = "totalPrincipal") + @Mapping(source = "totalDisbursement", target = "principalDisbursed") + @Mapping(source = "fee", target = "feeChargesCharged") + @Mapping(source = "feePaid", target = "feeChargesPaid") + @Mapping(source = "feeOutstanding", target = "feeChargesOutstanding") + @Mapping(source = "penalty", target = "penaltyChargesCharged") + @Mapping(source = "penaltyPaid", target = "penaltyChargesPaid") + @Mapping(source = "penaltyOutstanding", target = "penaltyChargesOutstanding") + @Mapping(target = "principalAdjustments", ignore = true) + @Mapping(target = "principalWrittenOff", ignore = true) + @Mapping(target = "feeChargesWrittenOff", ignore = true) + @Mapping(target = "penaltyChargesWrittenOff", ignore = true) + @Mapping(target = "totalWrittenOff", ignore = true) + @Mapping(target = "totalRecovered", ignore = true) + @Mapping(target = "writeoffReasonId", ignore = true) + @Mapping(target = "writeoffReason", ignore = true) + @Mapping(target = "chargeOffReasonId", ignore = true) + @Mapping(target = "chargeOffReason", ignore = true) + @Mapping(target = "totalPayoutRefund", ignore = true) + @Mapping(target = "totalPayoutRefundReversed", ignore = true) + @Mapping(target = "totalGoodwillCredit", ignore = true) + @Mapping(target = "totalGoodwillCreditReversed", ignore = true) + @Mapping(target = "totalChargeAdjustment", ignore = true) + @Mapping(target = "totalChargeAdjustmentReversed", ignore = true) + @Mapping(target = "totalCreditBalanceRefund", ignore = true) + @Mapping(target = "totalCreditBalanceRefundReversed", ignore = true) + @Mapping(target = "totalRepaymentTransaction", ignore = true) + @Mapping(target = "totalRepaymentTransactionReversed", ignore = true) + WorkingCapitalLoanSummaryDataV1 map(WorkingCapitalLoanSummaryData source); + + @Mapping(source = "delinquentPrincipal", target = "totalDelinquentAmount") + @Mapping(target = "lastPaymentDate", ignore = true) + @Mapping(target = "lastPaymentAmount", ignore = true) + @Mapping(target = "lastRepaymentDate", ignore = true) + @Mapping(target = "lastRepaymentAmount", ignore = true) + @Mapping(target = "delinquencySchedule", ignore = true) + WorkingCapitalLoanCollectionDataV1 map(WorkingCapitalLoanCollectionData source); + + @Mapping(target = "amountAccrued", ignore = true) + @Mapping(target = "amountUnrecognized", ignore = true) + @Mapping(target = "amountWrittenOff", ignore = true) + @Mapping(target = "customData", ignore = true) + WorkingCapitalLoanChargeDataV1 map(WorkingCapitalLoanChargeData source); + + @Mapping(target = "netDisbursalAmount", ignore = true) + WorkingCapitalLoanDisbursementDetailDataV1 map(WorkingCapitalLoanDisbursementDetailData source); + + WorkingCapitalLoanDelinquencySchedulePeriodDataV1 map(WorkingCapitalLoanDelinquencyRangeScheduleData source); + + List mapDelinquencySchedule( + List source); + + @Named("toAvroDecimalScale") + default BigDecimal toAvroDecimalScale(final BigDecimal value) { + return value == null ? null : value.setScale(AVRO_DECIMAL_SCALE, RoundingMode.HALF_UP); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/serializer/WorkingCapitalLoanBusinessEventSerializer.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/serializer/WorkingCapitalLoanBusinessEventSerializer.java new file mode 100644 index 00000000000..19cb10f1701 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/serializer/WorkingCapitalLoanBusinessEventSerializer.java @@ -0,0 +1,192 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.serialization.serializer; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.avro.generic.GenericContainer; +import org.apache.fineract.avro.generator.ByteBufferSerializable; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanAccountDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanCollectionDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanSummaryDataV1; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.infrastructure.event.business.domain.BusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanBusinessEvent; +import org.apache.fineract.infrastructure.event.external.service.serialization.mapper.support.AvroDateTimeMapper; +import org.apache.fineract.infrastructure.event.external.service.serialization.serializer.AbstractBusinessEventWithCustomDataSerializer; +import org.apache.fineract.infrastructure.event.external.service.serialization.serializer.BusinessEventSerializer; +import org.apache.fineract.infrastructure.event.external.service.serialization.serializer.ExternalEventCustomDataSerializer; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.workingcapitalloan.data.ChargeIdAndAmountHolder; +import org.apache.fineract.portfolio.workingcapitalloan.data.TransactionTypeTotalHolder; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanData; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelationRepository; +import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanDelinquencyRangeScheduleMapper; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyRangeScheduleRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; +import org.apache.fineract.portfolio.workingcapitalloan.serialization.mapper.WorkingCapitalLoanAccountDataMapper; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanApplicationReadPlatformService; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class WorkingCapitalLoanBusinessEventSerializer + extends AbstractBusinessEventWithCustomDataSerializer implements BusinessEventSerializer { + + private static final List SUMMARY_TOTAL_TRANSACTION_TYPES = List.of(LoanTransactionType.PAYOUT_REFUND, + LoanTransactionType.GOODWILL_CREDIT, LoanTransactionType.CHARGE_ADJUSTMENT, LoanTransactionType.CREDIT_BALANCE_REFUND, + LoanTransactionType.REPAYMENT); + + private final WorkingCapitalLoanApplicationReadPlatformService readPlatformService; + private final WorkingCapitalLoanAccountDataMapper mapper; + private final WorkingCapitalLoanTransactionRelationRepository transactionRelationRepository; + private final WorkingCapitalLoanTransactionRepository transactionRepository; + private final WorkingCapitalLoanDelinquencyRangeScheduleRepository rangeScheduleRepository; + private final WorkingCapitalLoanDelinquencyRangeScheduleMapper rangeScheduleMapper; + private final AvroDateTimeMapper avroDateTimeMapper; + private final List> externalEventCustomDataSerializers; + private final List chargeExternalEventCustomDataSerializers; + + @Override + public boolean canSerialize(BusinessEvent event) { + return event instanceof WorkingCapitalLoanBusinessEvent; + } + + @Override + public ByteBufferSerializable toAvroDTO(BusinessEvent rawEvent) { + final WorkingCapitalLoanBusinessEvent event = (WorkingCapitalLoanBusinessEvent) rawEvent; + final WorkingCapitalLoan loan = event.get(); + final WorkingCapitalLoanData data = readPlatformService.retrieveOne(loan.getId()); + final WorkingCapitalLoanAccountDataV1 result = mapper.map(data); + if (result.getDisbursementDetails() != null) { + result.getDisbursementDetails().forEach(detail -> detail.setNetDisbursalAmount(result.getNetDisbursalAmount())); + } + populateChargeAccruals(loan, result); + populateChargeCustomData(event, result); + populateSummaryTransactionTypeTotals(loan, result); + populateLastTransactions(loan, result); + populateDelinquencySchedule(loan, result); + result.setCustomData(collectCustomData(event)); + return result; + } + + private void populateChargeAccruals(final WorkingCapitalLoan loan, final WorkingCapitalLoanAccountDataV1 result) { + if (result.getCharges() == null || result.getCharges().isEmpty() || loan.getLoanProduct() == null + || !loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) { + return; + } + final Map accruedAmountsByChargeId = transactionRelationRepository + .fetchTransactionAmountPerCharge(loan.getId(), LoanTransactionType.ACCRUAL).stream() + .collect(Collectors.toMap(ChargeIdAndAmountHolder::chargeId, ChargeIdAndAmountHolder::amount)); + result.getCharges().forEach(charge -> { + final BigDecimal amountAccrued = accruedAmountsByChargeId.getOrDefault(charge.getId(), BigDecimal.ZERO); + charge.setAmountAccrued(amountAccrued); + charge.setAmountUnrecognized(MathUtil.subtractToZero(charge.getAmount(), amountAccrued)); + }); + } + + private void populateChargeCustomData(final WorkingCapitalLoanBusinessEvent event, final WorkingCapitalLoanAccountDataV1 result) { + if (result.getCharges() == null) { + return; + } + result.getCharges().forEach(charge -> charge.setCustomData(collectChargeCustomData(event, charge.getId()))); + } + + private Map collectChargeCustomData(final WorkingCapitalLoanBusinessEvent event, final Long chargeId) { + return chargeExternalEventCustomDataSerializers.stream().collect(HashMap::new, (map, serializer) -> { + final ByteBuffer buffer = serializer.serialize(event, chargeId); + if (buffer != null) { + map.put(serializer.key(), buffer); + } + }, HashMap::putAll); + } + + private void populateSummaryTransactionTypeTotals(final WorkingCapitalLoan loan, final WorkingCapitalLoanAccountDataV1 result) { + final WorkingCapitalLoanSummaryDataV1 summary = result.getSummary(); + if (summary == null) { + return; + } + final Map> totals = transactionRepository + .fetchTotalsPerTypeAndReversed(loan.getId(), SUMMARY_TOTAL_TRANSACTION_TYPES).stream() + .collect(Collectors.groupingBy(TransactionTypeTotalHolder::transactionType, + Collectors.toMap(TransactionTypeTotalHolder::reversed, TransactionTypeTotalHolder::total))); + summary.setTotalPayoutRefund(totalOf(totals, LoanTransactionType.PAYOUT_REFUND, false)); + summary.setTotalPayoutRefundReversed(totalOf(totals, LoanTransactionType.PAYOUT_REFUND, true)); + summary.setTotalGoodwillCredit(totalOf(totals, LoanTransactionType.GOODWILL_CREDIT, false)); + summary.setTotalGoodwillCreditReversed(totalOf(totals, LoanTransactionType.GOODWILL_CREDIT, true)); + summary.setTotalChargeAdjustment(totalOf(totals, LoanTransactionType.CHARGE_ADJUSTMENT, false)); + summary.setTotalChargeAdjustmentReversed(totalOf(totals, LoanTransactionType.CHARGE_ADJUSTMENT, true)); + summary.setTotalCreditBalanceRefund(totalOf(totals, LoanTransactionType.CREDIT_BALANCE_REFUND, false)); + summary.setTotalCreditBalanceRefundReversed(totalOf(totals, LoanTransactionType.CREDIT_BALANCE_REFUND, true)); + summary.setTotalRepaymentTransaction(totalOf(totals, LoanTransactionType.REPAYMENT, false)); + summary.setTotalRepaymentTransactionReversed(totalOf(totals, LoanTransactionType.REPAYMENT, true)); + } + + private BigDecimal totalOf(final Map> totals, final LoanTransactionType transactionType, + final boolean reversed) { + return totals.getOrDefault(transactionType, Map.of()).getOrDefault(reversed, BigDecimal.ZERO); + } + + private void populateLastTransactions(final WorkingCapitalLoan loan, final WorkingCapitalLoanAccountDataV1 result) { + final WorkingCapitalLoanCollectionDataV1 delinquent = result.getDelinquent(); + if (delinquent == null) { + return; + } + findLastActiveTransaction(loan, LoanTransactionType.getRepaymentLikeTransactionTypes()).ifPresent(transaction -> { + delinquent.setLastPaymentDate(avroDateTimeMapper.mapLocalDate(transaction.getTransactionDate())); + delinquent.setLastPaymentAmount(transaction.getTransactionAmount()); + }); + findLastActiveTransaction(loan, List.of(LoanTransactionType.REPAYMENT)).ifPresent(transaction -> { + delinquent.setLastRepaymentDate(avroDateTimeMapper.mapLocalDate(transaction.getTransactionDate())); + delinquent.setLastRepaymentAmount(transaction.getTransactionAmount()); + }); + } + + private Optional findLastActiveTransaction(final WorkingCapitalLoan loan, + final List transactionTypes) { + return transactionRepository.findActiveByTypesOrderByDateDesc(loan.getId(), transactionTypes).stream().findFirst(); + } + + private void populateDelinquencySchedule(final WorkingCapitalLoan loan, final WorkingCapitalLoanAccountDataV1 result) { + final WorkingCapitalLoanCollectionDataV1 delinquent = result.getDelinquent(); + if (delinquent == null) { + return; + } + delinquent.setDelinquencySchedule(mapper.mapDelinquencySchedule( + rangeScheduleMapper.toDataList(rangeScheduleRepository.findByLoanIdOrderByPeriodNumberAsc(loan.getId())))); + } + + @Override + protected List> getExternalEventCustomDataSerializers() { + return externalEventCustomDataSerializers; + } + + @Override + public Class getSupportedSchema() { + return WorkingCapitalLoanAccountDataV1.class; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/serializer/WorkingCapitalLoanChargeExternalEventCustomDataSerializer.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/serializer/WorkingCapitalLoanChargeExternalEventCustomDataSerializer.java new file mode 100644 index 00000000000..79251aff7de --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/serializer/WorkingCapitalLoanChargeExternalEventCustomDataSerializer.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.serialization.serializer; + +import java.nio.ByteBuffer; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanBusinessEvent; + +public interface WorkingCapitalLoanChargeExternalEventCustomDataSerializer { + + ByteBuffer serialize(WorkingCapitalLoanBusinessEvent event, Long chargeId); + + String key(); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationWritePlatformServiceImpl.java index 191d025b6b2..5e567229def 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationWritePlatformServiceImpl.java @@ -28,6 +28,9 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanApplicationModifiedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanCreatedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; import org.apache.fineract.portfolio.loanaccount.service.LoanOriginatorLinkingService; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; @@ -54,17 +57,20 @@ public class WorkingCapitalLoanApplicationWritePlatformServiceImpl implements Wo private final WorkingCapitalLoanNoteRepository noteRepository; private final ProjectedAmortizationLoanModelRepository projectedAmortizationLoanModelRepository; private final Optional loanOriginatorLinkingService; + private final BusinessEventNotifierService businessEventNotifierService; public WorkingCapitalLoanApplicationWritePlatformServiceImpl(WorkingCapitalLoanApplicationDataValidator validator, WorkingCapitalLoanRepository repository, WorkingCapitalLoanAssembler assembler, WorkingCapitalLoanNoteRepository noteRepository, ProjectedAmortizationLoanModelRepository projectedAmortizationLoanModelRepository, - @Qualifier("workingCapitalLoanOriginatorLinkingServiceImpl") Optional loanOriginatorLinkingService) { + @Qualifier("workingCapitalLoanOriginatorLinkingServiceImpl") Optional loanOriginatorLinkingService, + BusinessEventNotifierService businessEventNotifierService) { this.validator = validator; this.repository = repository; this.assembler = assembler; this.noteRepository = noteRepository; this.projectedAmortizationLoanModelRepository = projectedAmortizationLoanModelRepository; this.loanOriginatorLinkingService = loanOriginatorLinkingService; + this.businessEventNotifierService = businessEventNotifierService; } @Transactional @@ -80,6 +86,8 @@ public CommandProcessingResult submitApplication(final JsonCommand command) { createNote(submittedOnNote, saved); attachOriginatorsIfProvided(command, saved); + this.businessEventNotifierService.notifyPostBusinessEvent(new WorkingCapitalLoanCreatedBusinessEvent(saved)); + return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // .withEntityId(saved.getId()) // @@ -113,6 +121,8 @@ public CommandProcessingResult modifyApplication(final Long loanId, final JsonCo final String submittedOnNote = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.submittedOnNoteParameterName); createNote(submittedOnNote, saved); + this.businessEventNotifierService.notifyPostBusinessEvent(new WorkingCapitalLoanApplicationModifiedBusinessEvent(saved)); + return new CommandProcessingResultBuilder() // .withEntityId(loanId) // .withEntityExternalId(saved.getExternalId()) // diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java index 5ae60f84db8..c45551a5694 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanChargeWritePlatformServiceImpl.java @@ -37,6 +37,8 @@ import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanBalanceChangedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanStatusChangedBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanChargeAdjustmentPostBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanChargeAdjustmentPreBusinessEvent; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; @@ -143,6 +145,9 @@ public CommandProcessingResult createLoanCharge(Long loanId, JsonCommand command chargeAccrualService.processOnChargeAdded(loan, loanCharge); + notifyBalanceChanged(loan); + notifyStatusChanged(loan, statusBeforeCharge); + return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // .withEntityId(loanCharge.getId()) // @@ -274,6 +279,7 @@ public CommandProcessingResult adjustmentForLoanCharge(final Long loanId, final adjustmentTx.getLoanTransactionRelations().add(relation); transactionRepository.saveAndFlush(adjustmentTx); + final LoanStatus oldStatus = loan.getLoanStatus(); final WorkingCapitalLoanTransactionAllocation allocation = transactionProcessor.processRepaymentLikeTransaction(loan, adjustmentTx, LoanTransactionType.CHARGE_ADJUSTMENT, transactionDate, amount); @@ -290,6 +296,10 @@ public CommandProcessingResult adjustmentForLoanCharge(final Long loanId, final businessEventNotifierService .notifyPostBusinessEvent(new WorkingCapitalLoanChargeAdjustmentPostBusinessEvent(adjustmentTx, loan.getId())); + workingCapitalLoanRepository.saveAndFlush(loan); + notifyBalanceChanged(loan); + notifyStatusChanged(loan, oldStatus); + return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // .withEntityId(wcLoanChargeId) // @@ -376,4 +386,14 @@ private void addChargeToBalance(final WorkingCapitalLoanBalance balance, final W balance.setFee(balance.getFee().add(loanCharge.getAmount())); } } + + private void notifyStatusChanged(final WorkingCapitalLoan loan, final LoanStatus oldStatus) { + if (oldStatus != loan.getLoanStatus()) { + businessEventNotifierService.notifyPostBusinessEvent(new WorkingCapitalLoanStatusChangedBusinessEvent(loan)); + } + } + + private void notifyBalanceChanged(final WorkingCapitalLoan loan) { + businessEventNotifierService.notifyPostBusinessEvent(new WorkingCapitalLoanBalanceChangedBusinessEvent(loan)); + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyClassificationService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyClassificationService.java index 518294ca764..70bf17cf009 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyClassificationService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyClassificationService.java @@ -30,7 +30,7 @@ public interface WorkingCapitalLoanDelinquencyClassificationService { void classifyDelinquency(WorkingCapitalLoan loan, LocalDate businessDate); - void applyDelinquencyTagForRange(WorkingCapitalLoan loan, WorkingCapitalLoanDelinquencyRangeSchedule range, + boolean applyDelinquencyTagForRange(WorkingCapitalLoan loan, WorkingCapitalLoanDelinquencyRangeSchedule range, DelinquencyRange currentRange, LocalDate businessDate); boolean isDelinquencyDisabled(WorkingCapitalLoan loan, LocalDate date); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyClassificationServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyClassificationServiceImpl.java index c61ec769c22..245e6b6923c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyClassificationServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyClassificationServiceImpl.java @@ -31,6 +31,8 @@ import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.configuration.domain.GlobalConfigurationRepositoryWrapper; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucket; import org.apache.fineract.portfolio.delinquency.domain.DelinquencyRange; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; @@ -50,6 +52,7 @@ public class WorkingCapitalLoanDelinquencyClassificationServiceImpl implements W private final WorkingCapitalLoanDelinquencyRangeScheduleTagHistoryRepository delinquencyRangeScheduleTagHistoryRepository; private final WorkingCapitalLoanDelinquencyActionRepository delinquencyActionRepository; private final GlobalConfigurationRepositoryWrapper globalConfigurationRepository; + private final BusinessEventNotifierService businessEventNotifierService; /** * If ENABLE_INSTANT_DELINQUENCY_CALCULATION is set to true in global config, classifies the delinquency of a loan @@ -90,6 +93,7 @@ public void classifyDelinquency(WorkingCapitalLoan loan, LocalDate businessDate) List delinquencyRangeScheduleList = delinquencyRangeScheduleRepository .findByLoanIdOrderByPeriodNumberAsc(loan.getId()); + boolean delinquencyRangeChanged = false; for (WorkingCapitalLoanDelinquencyRangeSchedule range : delinquencyRangeScheduleList) { if (!Objects.equals(range.getReset(), true) && range.getToDate().isBefore(businessDate)) { long rangeDelinquentDays = range.getOutstandingAmount().compareTo(BigDecimal.ZERO) > 0 @@ -102,14 +106,19 @@ public void classifyDelinquency(WorkingCapitalLoan loan, LocalDate businessDate) range.setDelinquentDays(rangeDelinquentDays); Optional delinquencyRangeByDays = findDelinquencyRangeByDays( loan.getLoanProductRelatedDetails().getDelinquencyBucket(), (int) rangeDelinquentDays); - applyDelinquencyTagForRange(loan, range, delinquencyRangeByDays.orElse(null), businessDate); + delinquencyRangeChanged |= applyDelinquencyTagForRange(loan, range, delinquencyRangeByDays.orElse(null), businessDate); } else { range.setDelinquentAmount(BigDecimal.ZERO); range.setDelinquentDays(0L); - applyDelinquencyTagForRange(loan, range, null, businessDate); + delinquencyRangeChanged |= applyDelinquencyTagForRange(loan, range, null, businessDate); } } } + + if (delinquencyRangeChanged) { + delinquencyRangeScheduleTagHistoryRepository.flush(); + businessEventNotifierService.notifyPostBusinessEvent(new WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent(loan)); + } } /** @@ -169,9 +178,10 @@ private Optional findDelinquencyRangeByDays(final DelinquencyB * the current delinquency range to be applied; can be null to lift all previous tags * @param businessDate * the date on which the tagging operation is performed + * @return true if the tag history was changed (a tag added or lifted), false if nothing changed */ @Override - public void applyDelinquencyTagForRange(final WorkingCapitalLoan loan, final WorkingCapitalLoanDelinquencyRangeSchedule range, + public boolean applyDelinquencyTagForRange(final WorkingCapitalLoan loan, final WorkingCapitalLoanDelinquencyRangeSchedule range, final DelinquencyRange currentRange, final LocalDate businessDate) { List updatedList = new ArrayList<>(); List rangeScheduleTagHistoryList = delinquencyRangeScheduleTagHistoryRepository @@ -183,7 +193,7 @@ public void applyDelinquencyTagForRange(final WorkingCapitalLoan loan, final Wor // do nothing if currentRange is in rangeScheduleTagHistoryList or last and currentRange are null if ((last == null && currentRange == null) || (last != null && currentRange != null && rangeScheduleTagHistoryList.stream() .anyMatch(tag -> Objects.equals(tag.getDelinquencyRange().getId(), currentRange.getId())))) { - return; + return false; } if (currentRange == null) { @@ -202,6 +212,7 @@ public void applyDelinquencyTagForRange(final WorkingCapitalLoan loan, final Wor } delinquencyRangeScheduleTagHistoryRepository.saveAll(updatedList); + return !updatedList.isEmpty(); } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyReadPlatformServiceImpl.java index b54f4e66280..d646604fab8 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyReadPlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanDelinquencyReadPlatformServiceImpl.java @@ -19,6 +19,9 @@ package org.apache.fineract.portfolio.workingcapitalloan.service; +import static org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyPauseUtils.isPauseActiveOnDate; +import static org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyPauseUtils.resolveEffectivePauseEnd; + import java.math.BigDecimal; import java.time.LocalDate; import java.util.Comparator; @@ -27,12 +30,16 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.delinquency.domain.DelinquencyAction; +import org.apache.fineract.portfolio.loanaccount.data.DelinquencyPausePeriod; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanCollectionData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDelinquencyTagHistoryData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanRangeScheduleDelinquencyData; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyAction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyRangeSchedule; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDelinquencyRangeScheduleTagHistory; import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanDelinquencyRangeScheduleTagHistoryMapper; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyActionRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyRangeScheduleRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyRangeScheduleTagHistoryRepository; import org.springframework.stereotype.Service; @@ -47,6 +54,7 @@ public class WorkingCapitalLoanDelinquencyReadPlatformServiceImpl implements Wor private final WorkingCapitalLoanDelinquencyRangeScheduleTagHistoryMapper delinquencyRangeScheduleTagHistoryMapper; private final WorkingCapitalLoanDelinquencyRangeScheduleTagHistoryRepository delinquencyRangeScheduleTagHistoryRepository; private final WorkingCapitalLoanDelinquencyRangeScheduleRepository delinquencyRangeScheduleRepository; + private final WorkingCapitalLoanDelinquencyActionRepository delinquencyActionRepository; @Override public WorkingCapitalLoanCollectionData getCollectionData(Long loanId, LocalDate businessDate) { @@ -73,10 +81,26 @@ public WorkingCapitalLoanCollectionData getCollectionData(Long loanId, LocalDate .map(WorkingCapitalLoanDelinquencyRangeSchedule::getDelinquentDays).ifPresent(template::setPastDueDays); template.setInstallmentLevelDelinquency(list); + template.setDelinquencyPausePeriods(retrieveDelinquencyPausePeriods(loanId, businessDate)); return template; } + private List retrieveDelinquencyPausePeriods(final Long loanId, final LocalDate businessDate) { + final List pauses = delinquencyActionRepository + .findByWorkingCapitalLoanIdAndActionOrderByStartDateAsc(loanId, DelinquencyAction.PAUSE); + final List resumes = delinquencyActionRepository + .findByWorkingCapitalLoanIdAndActionOrderByStartDateAsc(loanId, DelinquencyAction.RESUME); + return pauses.stream().map(pause -> toPausePeriod(pause, resumes, businessDate)).toList(); + } + + private DelinquencyPausePeriod toPausePeriod(final WorkingCapitalLoanDelinquencyAction pause, + final List resumes, final LocalDate businessDate) { + final LocalDate effectiveEnd = resolveEffectivePauseEnd(pause, resumes); + return new DelinquencyPausePeriod(isPauseActiveOnDate(pause.getStartDate(), effectiveEnd, businessDate), pause.getStartDate(), + effectiveEnd); + } + @Override public List retrieveDelinquencyRangeScheduleTagHistory(Long loanId) { List byLoanIdOrderByAddedOnDateDesc = delinquencyRangeScheduleTagHistoryRepository diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java index 60c714ea618..63d4abcd976 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java @@ -44,6 +44,13 @@ import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.infrastructure.event.business.domain.BusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanApprovedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanBalanceChangedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanDisbursalBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanRejectedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanStatusChangedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanUndoApprovalBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.loan.WorkingCapitalLoanUndoDisbursalBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanCreditBalanceRefundTransactionBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanDisbursalTransactionBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanDiscountFeeAdjustmentTransactionBusinessEvent; @@ -123,6 +130,7 @@ public CommandProcessingResult approveApplication(final Long loanId, final JsonC final AppUser currentUser = this.context.authenticatedUser(); + final LoanStatus oldStatus = loan.getLoanStatus(); this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_APPROVED, loan); // Approved date @@ -169,6 +177,9 @@ public CommandProcessingResult approveApplication(final Long loanId, final JsonC createNote(command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName), loan); + businessEventNotifierService.notifyPostBusinessEvent(new WorkingCapitalLoanApprovedBusinessEvent(loan)); + notifyStatusChanged(loan, oldStatus); + final Map changes = new LinkedHashMap<>(); changes.put(WorkingCapitalLoanConstants.approvedOnDateParamName, approvedOnDate); changes.put("status", loan.getLoanStatus()); @@ -193,6 +204,7 @@ public CommandProcessingResult undoApplicationApproval(final Long loanId, final this.validator.validateUndoApproval(command.json()); + final LoanStatus oldStatus = loan.getLoanStatus(); this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_APPROVAL_UNDO, loan); loan.setApprovedOnDate(null); @@ -209,6 +221,9 @@ public CommandProcessingResult undoApplicationApproval(final Long loanId, final createNote(command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName), loan); + businessEventNotifierService.notifyPostBusinessEvent(new WorkingCapitalLoanUndoApprovalBusinessEvent(loan)); + notifyStatusChanged(loan, oldStatus); + final Map changes = new LinkedHashMap<>(); changes.put("status", loan.getLoanStatus()); @@ -234,6 +249,7 @@ public CommandProcessingResult rejectApplication(final Long loanId, final JsonCo final AppUser currentUser = this.context.authenticatedUser(); + final LoanStatus oldStatus = loan.getLoanStatus(); this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_REJECTED, loan); final LocalDate rejectedOnDate = command.localDateValueOfParameterNamed(WorkingCapitalLoanConstants.rejectedOnDateParamName); @@ -244,6 +260,9 @@ public CommandProcessingResult rejectApplication(final Long loanId, final JsonCo createNote(command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName), loan); + businessEventNotifierService.notifyPostBusinessEvent(new WorkingCapitalLoanRejectedBusinessEvent(loan)); + notifyStatusChanged(loan, oldStatus); + final Map changes = new LinkedHashMap<>(); changes.put(WorkingCapitalLoanConstants.rejectedOnDateParamName, rejectedOnDate); changes.put("status", loan.getLoanStatus()); @@ -293,6 +312,7 @@ public CommandProcessingResult disburseLoan(final Long loanId, final JsonCommand changes.put(WorkingCapitalLoanConstants.classificationIdParamName, classificationId); final PaymentDetail paymentDetail = createAndPersistPaymentDetailFromCommand(command, changes); + final LoanStatus oldStatus = loan.getLoanStatus(); this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_DISBURSED, loan); if (!loan.getDisbursementDetails().isEmpty()) { @@ -347,6 +367,10 @@ public CommandProcessingResult disburseLoan(final Long loanId, final JsonCommand changes.put("status", loan.getLoanStatus()); handleNote(loan, command, changes); + businessEventNotifierService.notifyPostBusinessEvent(new WorkingCapitalLoanDisbursalBusinessEvent(loan)); + notifyBalanceChanged(loan); + notifyStatusChanged(loan, oldStatus); + log.debug("Working capital loan {} disbursed by user {}", loanId, currentUser != null ? currentUser.getId() : "system"); return new CommandProcessingResultBuilder() // @@ -375,6 +399,7 @@ public CommandProcessingResult undoDisbursal(final Long loanId, final JsonComman ensureUndoDisbursalAllowed(loan); + final LoanStatus oldStatus = loan.getLoanStatus(); this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_DISBURSAL_UNDO, loan); final WorkingCapitalLoanTransaction reversedTransaction = reverseDisbursementTransactionAndResetBalance(loan); @@ -401,6 +426,10 @@ public CommandProcessingResult undoDisbursal(final Long loanId, final JsonComman changes.put("actualAmount", null); handleNote(loan, command, changes); + businessEventNotifierService.notifyPostBusinessEvent(new WorkingCapitalLoanUndoDisbursalBusinessEvent(loan)); + notifyBalanceChanged(loan); + notifyStatusChanged(loan, oldStatus); + log.debug("Working capital loan {} disbursal undone", loanId); return new CommandProcessingResultBuilder() // @@ -515,6 +544,8 @@ public CommandProcessingResult makeDiscountFee(Long loanId, JsonCommand command) delinquencyRangeScheduleService.reprocessDelinquencySchedule(loan); loanRepository.saveAndFlush(loan); + notifyBalanceChanged(loan); + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(discountTransaction.getId()) .withEntityExternalId(discountTransaction.getExternalId()).withOfficeId(loan.getOfficeId()).withClientId(loan.getClientId()) .withLoanId(loanId).with(changes).build(); @@ -586,13 +617,15 @@ public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, fina // The principal change moves the remaining-balance cap, so the delinquency schedule must be re-derived. delinquencyRangeScheduleService.reprocessDelinquencySchedule(loan); + final LoanStatus oldStatus = loan.getLoanStatus(); + stateMachine.determineAndTransition(loan, transactionDate); transactionProcessor.triggerInlineAmortizationIfLoanClosed(loan, transactionDate); // A discount-fee adjustment can pay down principal and close the loan, so accrue any pending charge income. chargeAccrualService.accrueOnClosure(loan, transactionDate); changes.put("status", loan.getLoanStatus()); - loanRepository.save(loan); + loanRepository.saveAndFlush(loan); final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName); createNote(noteText, loan); @@ -606,6 +639,8 @@ public CommandProcessingResult makeDiscountFeeAdjustment(final Long loanId, fina } businessEventNotifierService .notifyPostBusinessEvent(new WorkingCapitalLoanDiscountFeeAdjustmentTransactionBusinessEvent(adjustmentTransaction)); + notifyBalanceChanged(loan); + notifyStatusChanged(loan, oldStatus); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(adjustmentTransaction.getId()) .withEntityExternalId(adjustmentTransaction.getExternalId()).withSubEntityId(relatedDiscountTransaction.getId()) .withSubEntityExternalId(relatedDiscountTransaction.getExternalId()).withOfficeId(loan.getOfficeId()) @@ -647,6 +682,8 @@ private CommandProcessingResult undoDiscountFeeAdjustment(final WorkingCapitalLo final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName); createNote(noteText, loan); + notifyBalanceChanged(loan); + final Map changes = new LinkedHashMap<>(); if (StringUtils.isNotBlank(noteText)) { changes.put(WorkingCapitalLoanConstants.noteParamName, noteText); @@ -667,6 +704,7 @@ private CommandProcessingResult makeRepaymentLikeTransaction(final Long loanId, .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); this.validator.validateRepayment(command.json(), loan, transactionType); + final LoanStatus oldStatus = loan.getLoanStatus(); final LocalDate transactionDate = command.localDateValueOfParameterNamed(WorkingCapitalLoanConstants.transactionDateParamName); final BigDecimal transactionAmount = this.fromApiJsonHelper .extractBigDecimalNamed(WorkingCapitalLoanConstants.transactionAmountParamName, command.parsedJson(), new HashSet<>()); @@ -700,6 +738,10 @@ private CommandProcessingResult makeRepaymentLikeTransaction(final Long loanId, notifyPostBusinessEvent(transactionType, transaction, loan); + this.loanRepository.saveAndFlush(loan); + notifyBalanceChanged(loan); + notifyStatusChanged(loan, oldStatus); + return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // .withEntityId(transaction.getId()) // @@ -722,6 +764,16 @@ private void notifyPostBusinessEvent(LoanTransactionType transactionType, Workin } } + private void notifyStatusChanged(final WorkingCapitalLoan loan, final LoanStatus oldStatus) { + if (oldStatus != loan.getLoanStatus()) { + businessEventNotifierService.notifyPostBusinessEvent(new WorkingCapitalLoanStatusChangedBusinessEvent(loan)); + } + } + + private void notifyBalanceChanged(final WorkingCapitalLoan loan) { + businessEventNotifierService.notifyPostBusinessEvent(new WorkingCapitalLoanBalanceChangedBusinessEvent(loan)); + } + private WorkingCapitalLoanTransaction resolveNewTransaction(final LoanTransactionType transactionType, WorkingCapitalLoan loan, BigDecimal transactionAmount, PaymentDetail paymentDetail, LocalDate transactionDate, CodeValue classification, ExternalId txnExternalId) { @@ -750,6 +802,7 @@ public CommandProcessingResult creditBalanceRefund(final Long loanId, final Json .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); this.validator.validateCreditBalanceRefund(command.json(), loan); + final LoanStatus oldStatus = loan.getLoanStatus(); if (loan.getLoanStatus() != LoanStatus.OVERPAID) { throw new PlatformApiDataValidationException("validation.msg.wc.loan.transition.not.allowed", "Credit balance refund is allowed only for overpaid loans", "loanStatus"); @@ -824,6 +877,9 @@ public CommandProcessingResult creditBalanceRefund(final Long loanId, final Json businessEventNotifierService.notifyPostBusinessEvent( new WorkingCapitalLoanCreditBalanceRefundTransactionBusinessEvent(creditBalanceRefundTransaction, loan.getId())); + notifyBalanceChanged(loan); + notifyStatusChanged(loan, oldStatus); + return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // .withEntityId(creditBalanceRefundTransaction.getId()) // @@ -887,6 +943,7 @@ public CommandProcessingResult undoTransaction(final WorkingCapitalLoan loan, fi validator.validateUndoTransaction(command, loan, transaction); + final LoanStatus oldStatus = loan.getLoanStatus(); Map changes = new HashMap<>(); changes.put("reversed", true); transaction.setReversed(true); @@ -932,6 +989,10 @@ public CommandProcessingResult undoTransaction(final WorkingCapitalLoan loan, fi handleNote(loan, command, changes); + this.loanRepository.saveAndFlush(loan); + notifyBalanceChanged(loan); + notifyStatusChanged(loan, oldStatus); + return new CommandProcessingResultBuilder().withLoanId(loan.getId()).withLoanExternalId(loan.getExternalId()) .withEntityId(transaction.getId()).withEntityExternalId(transaction.getExternalId()).with(changes).build(); } diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml index 349839a541c..e7203dbe33b 100644 --- a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml @@ -83,4 +83,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0062_wc_loan_details_external_events.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0062_wc_loan_details_external_events.xml new file mode 100644 index 00000000000..49c337462f9 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0062_wc_loan_details_external_events.xml @@ -0,0 +1,126 @@ + + + + + + + SELECT COUNT(*) FROM m_external_event_configuration WHERE type = 'WorkingCapitalLoanCreatedBusinessEvent' + + + + + + + + + + SELECT COUNT(*) FROM m_external_event_configuration WHERE type = 'WorkingCapitalLoanApplicationModifiedBusinessEvent' + + + + + + + + + + SELECT COUNT(*) FROM m_external_event_configuration WHERE type = 'WorkingCapitalLoanApprovedBusinessEvent' + + + + + + + + + + SELECT COUNT(*) FROM m_external_event_configuration WHERE type = 'WorkingCapitalLoanUndoApprovalBusinessEvent' + + + + + + + + + + SELECT COUNT(*) FROM m_external_event_configuration WHERE type = 'WorkingCapitalLoanRejectedBusinessEvent' + + + + + + + + + + SELECT COUNT(*) FROM m_external_event_configuration WHERE type = 'WorkingCapitalLoanDisbursalBusinessEvent' + + + + + + + + + + SELECT COUNT(*) FROM m_external_event_configuration WHERE type = 'WorkingCapitalLoanUndoDisbursalBusinessEvent' + + + + + + + + + + SELECT COUNT(*) FROM m_external_event_configuration WHERE type = 'WorkingCapitalLoanStatusChangedBusinessEvent' + + + + + + + + + + SELECT COUNT(*) FROM m_external_event_configuration WHERE type = 'WorkingCapitalLoanBalanceChangedBusinessEvent' + + + + + + + + + + SELECT COUNT(*) FROM m_external_event_configuration WHERE type = 'WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent' + + + + + + + + diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapperTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapperTest.java new file mode 100644 index 00000000000..332a7ea799c --- /dev/null +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapperTest.java @@ -0,0 +1,573 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloan.serialization.mapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import org.apache.fineract.avro.generic.v1.CurrencyDataV1; +import org.apache.fineract.avro.generic.v1.StringEnumOptionDataV1; +import org.apache.fineract.avro.loan.v1.LoanApplicationTimelineDataV1; +import org.apache.fineract.avro.loan.v1.LoanStatusEnumDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalBreachDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanAccountDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanChargeDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanCollectionDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencyDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencySchedulePeriodDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDisbursementDetailDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanSummaryDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalNearBreachDataV1; +import org.apache.fineract.infrastructure.codes.data.CodeValueData; +import org.apache.fineract.infrastructure.core.data.EnumOptionData; +import org.apache.fineract.infrastructure.core.data.StringEnumOptionData; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.event.external.service.serialization.mapper.support.AvroDateTimeMapper; +import org.apache.fineract.infrastructure.event.external.service.serialization.mapper.support.ExternalIdMapper; +import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.apache.fineract.portfolio.loanaccount.data.DelinquencyPausePeriod; +import org.apache.fineract.portfolio.loanaccount.data.LoanApplicationTimelineData; +import org.apache.fineract.portfolio.loanaccount.data.LoanStatusEnumData; +import org.apache.fineract.portfolio.loanorigination.data.LoanOriginatorData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanChargeData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanCollectionData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDelinquencyRangeScheduleData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDisbursementDetailData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanSummaryData; +import org.apache.fineract.portfolio.workingcapitalloanbreach.data.WorkingCapitalBreachData; +import org.apache.fineract.portfolio.workingcapitalloannearbreach.data.WorkingCapitalNearBreachData; +import org.junit.jupiter.api.Test; + +class WorkingCapitalLoanAccountDataMapperTest { + + private final WorkingCapitalLoanAccountDataMapper mapper = new WorkingCapitalLoanAccountDataMapperImpl(new AvroDateTimeMapper(), + new ExternalIdMapper()); + + @Test + void map_nullSource_returnsNull() { + assertNull(mapper.map((WorkingCapitalLoanData) null)); + } + + @Test + void map_loanData_coversAllTopLevelFields() { + final WorkingCapitalLoanData source = fullLoanData(); + + final WorkingCapitalLoanAccountDataV1 result = mapper.map(source); + + assertNotNull(result); + assertEquals(101L, result.getId()); + assertEquals("WC-000101", result.getAccountNo()); + assertEquals("ext-101", result.getExternalId()); + assertEquals(11L, result.getClientId()); + assertEquals("CL-11", result.getClientAccountNo()); + assertEquals("John Doe", result.getClientName()); + assertEquals(22L, result.getClientOfficeId()); + assertEquals("client-ext", result.getClientExternalId()); + assertEquals(33L, result.getLoanProductId()); + assertEquals("WC Product", result.getLoanProductName()); + assertEquals("WC product description", result.getLoanProductDescription()); + assertEquals(44L, result.getFundId()); + assertEquals("Main Fund", result.getFundName()); + assertEquals(new BigDecimal("1000.00"), result.getPrincipal()); + assertEquals(new BigDecimal("1100.00"), result.getApprovedPrincipal()); + assertEquals(new BigDecimal("1200.00"), result.getProposedPrincipal()); + assertEquals(new BigDecimal("980.00"), result.getNetDisbursalAmount()); + assertEquals(90, result.getNumberOfRepayments()); + assertEquals(30, result.getRepaymentEvery()); + assertEquals(7, result.getLoanProductCounter()); + assertEquals(360, result.getNpvDayCount()); + assertEquals(new BigDecimal("2.50"), result.getPaymentRate()); + assertEquals(new BigDecimal("12.00"), result.getDiscountFee()); + assertEquals(new BigDecimal("13.00"), result.getProposedDiscountFee()); + assertEquals(new BigDecimal("14.00"), result.getApprovedDiscountFee()); + assertEquals(new BigDecimal("120.00"), result.getPeriodPaymentAmount()); + assertEquals(new BigDecimal("5000.00"), result.getTotalPaymentVolume()); + assertEquals(Boolean.TRUE, result.getChargedOff()); + assertEquals(Boolean.TRUE, result.getEnableInstallmentLevelDelinquency()); + + // actualNoTerm duplicates numberOfRepayments + assertEquals(90, result.getActualNoTerm()); + + // delinquencyGraceDays is exposed under the legacy loan field name + assertEquals(7, result.getGraceOnArrearsAgeing()); + + // totalOverpaid is pulled from the nested summary.overpayment + assertEquals(new BigDecimal("9.99"), result.getTotalOverpaid()); + + // EIR values are re-scaled to the fixed avro decimal scale (8, HALF_UP) + assertEquals(new BigDecimal("0.05000000"), result.getDailyEir()); + assertEquals(8, result.getDailyEir().scale()); + assertEquals(new BigDecimal("18.25000000"), result.getCalculatedAnnualEir()); + assertEquals(8, result.getCalculatedAnnualEir().scale()); + assertEquals("2024-02-01", result.getLastClosedBusinessDate()); + assertStringEnum(source.getAmortizationType(), result.getAmortizationType()); + assertStringEnum(source.getRepaymentFrequencyType(), result.getRepaymentFrequencyType()); + assertNotNull(result.getStatus()); + assertNotNull(result.getTimeline()); + assertNotNull(result.getSummary()); + assertNotNull(result.getDelinquent()); + assertNotNull(result.getDelinquency()); + assertNotNull(result.getBreach()); + assertEquals(1, result.getCharges().size()); + assertEquals(1, result.getDisbursementDetails().size()); + assertEquals(1, result.getOriginators().size()); + + // serializer-only fields stay unmapped + assertNull(result.getOverpaidOnDate()); + assertNull(result.getCustomData()); + } + + @Test + void map_loanData_mapsStatusFully() { + final LoanStatusEnumDataV1 status = mapper.map(fullLoanData()).getStatus(); + + assertNotNull(status); + assertEquals(300, status.getId()); + assertEquals("loanStatusType.active", status.getCode()); + assertEquals("Active", status.getValue()); + assertEquals(Boolean.FALSE, status.getPendingApproval()); + assertEquals(Boolean.FALSE, status.getWaitingForDisbursal()); + assertEquals(Boolean.TRUE, status.getActive()); + assertEquals(Boolean.FALSE, status.getClosedObligationsMet()); + assertEquals(Boolean.FALSE, status.getClosedWrittenOff()); + assertEquals(Boolean.FALSE, status.getClosedRescheduled()); + assertEquals(Boolean.FALSE, status.getClosed()); + assertEquals(Boolean.FALSE, status.getOverpaid()); + } + + @Test + void map_loanData_mapsTimelineFully() { + final LoanApplicationTimelineDataV1 timeline = mapper.map(fullLoanData()).getTimeline(); + + assertNotNull(timeline); + assertEquals("2024-01-01", timeline.getSubmittedOnDate()); + assertEquals("sub-user", timeline.getSubmittedByUsername()); + assertEquals("sub-first", timeline.getSubmittedByFirstname()); + assertEquals("sub-last", timeline.getSubmittedByLastname()); + assertEquals("2024-01-02", timeline.getRejectedOnDate()); + assertEquals("rej-user", timeline.getRejectedByUsername()); + assertEquals("rej-first", timeline.getRejectedByFirstname()); + assertEquals("rej-last", timeline.getRejectedByLastname()); + assertEquals("2024-01-03", timeline.getWithdrawnOnDate()); + assertEquals("wd-user", timeline.getWithdrawnByUsername()); + assertEquals("wd-first", timeline.getWithdrawnByFirstname()); + assertEquals("wd-last", timeline.getWithdrawnByLastname()); + assertEquals("2024-01-04", timeline.getApprovedOnDate()); + assertEquals("app-user", timeline.getApprovedByUsername()); + assertEquals("app-first", timeline.getApprovedByFirstname()); + assertEquals("app-last", timeline.getApprovedByLastname()); + assertEquals("2024-01-05", timeline.getExpectedDisbursementDate()); + assertEquals("2024-01-06", timeline.getActualDisbursementDate()); + assertEquals("dis-user", timeline.getDisbursedByUsername()); + assertEquals("dis-first", timeline.getDisbursedByFirstname()); + assertEquals("dis-last", timeline.getDisbursedByLastname()); + assertEquals("2024-01-07", timeline.getClosedOnDate()); + assertEquals("cl-user", timeline.getClosedByUsername()); + assertEquals("cl-first", timeline.getClosedByFirstname()); + assertEquals("cl-last", timeline.getClosedByLastname()); + assertEquals("2024-01-08", timeline.getExpectedMaturityDate()); + assertEquals("2024-01-09", timeline.getWriteOffOnDate()); + assertEquals("wo-user", timeline.getWriteOffByUsername()); + assertEquals("wo-first", timeline.getWriteOffByFirstname()); + assertEquals("wo-last", timeline.getWriteOffByLastname()); + assertEquals("2024-01-10", timeline.getChargedOffOnDate()); + assertEquals("co-user", timeline.getChargedOffByUsername()); + assertEquals("co-first", timeline.getChargedOffByFirstname()); + assertEquals("co-last", timeline.getChargedOffByLastname()); + } + + @Test + void map_loanData_mapsOriginatorsFully() { + final var originator = mapper.map(fullLoanData()).getOriginators().getFirst(); + + assertNotNull(originator); + assertEquals(70L, originator.getId()); + assertEquals("orig-ext", originator.getExternalId()); + assertEquals("Origin Bank", originator.getName()); + assertEquals("ACTIVE", originator.getStatus()); + + assertNotNull(originator.getOriginatorType()); + assertEquals(71L, originator.getOriginatorType().getId()); + assertEquals("Broker", originator.getOriginatorType().getName()); + assertEquals(1, originator.getOriginatorType().getPosition()); + assertEquals("originator type", originator.getOriginatorType().getDescription()); + assertEquals(Boolean.TRUE, originator.getOriginatorType().getActive()); + assertEquals(Boolean.FALSE, originator.getOriginatorType().getMandatory()); + + assertNotNull(originator.getChannelType()); + assertEquals(72L, originator.getChannelType().getId()); + assertEquals("Online", originator.getChannelType().getName()); + } + + @Test + void map_loanData_mapsDelinquencyFully() { + final WorkingCapitalLoanData source = fullLoanData(); + + final WorkingCapitalLoanDelinquencyDataV1 delinquency = mapper.map(source).getDelinquency(); + + assertNotNull(delinquency); + assertStringEnum(source.getDelinquencyStartType(), delinquency.getDelinquencyStartType()); + assertEquals("2024-01-05", delinquency.getDelinquencyStartDate()); + } + + @Test + void map_loanData_mapsBreachWithNearBreachFully() { + final WorkingCapitalBreachDataV1 breach = mapper.map(fullLoanData()).getBreach(); + + assertNotNull(breach); + assertEquals(80L, breach.getId()); + assertEquals("Breach rule", breach.getName()); + assertEquals(2, breach.getBreachFrequency()); + assertStringEnum(fullBreach().getBreachFrequencyType(), breach.getBreachFrequencyType()); + assertStringEnum(fullBreach().getBreachAmountCalculationType(), breach.getBreachAmountCalculationType()); + assertEquals(new BigDecimal("500.00"), breach.getBreachAmount()); + + // loan-level fields lifted into the breach record + assertEquals(3, breach.getBreachGraceDays()); + assertEquals("2024-01-06", breach.getBreachStartDate()); + + final WorkingCapitalNearBreachDataV1 nearBreach = breach.getNearBreach(); + assertNotNull(nearBreach); + assertEquals(81L, nearBreach.getId()); + assertEquals("Near breach rule", nearBreach.getName()); + assertEquals(1, nearBreach.getFrequency()); + assertStringEnum(fullNearBreach().getFrequencyType(), nearBreach.getFrequencyType()); + assertEquals(new BigDecimal("450.00"), nearBreach.getThreshold()); + } + + @Test + void map_loanData_withoutBreachConfig_stillCreatesBreachRecordFromLoanFields() { + final WorkingCapitalLoanData source = fullLoanData(); + source.setBreach(null); + source.setNearBreach(null); + + final WorkingCapitalBreachDataV1 breach = mapper.map(source).getBreach(); + + assertNotNull(breach); + assertNull(breach.getId()); + assertNull(breach.getName()); + assertNull(breach.getNearBreach()); + assertEquals(3, breach.getBreachGraceDays()); + assertEquals("2024-01-06", breach.getBreachStartDate()); + } + + @Test + void map_summary_coversAllFields() { + final WorkingCapitalLoanSummaryData source = fullSummary(); + + final WorkingCapitalLoanSummaryDataV1 result = mapper.map(source); + + assertNotNull(result); + assertEquals(new BigDecimal("1000"), result.getTotalPrincipal()); + assertEquals(new BigDecimal("950"), result.getPrincipalDisbursed()); + assertEquals(new BigDecimal("50"), result.getFeeChargesCharged()); + assertEquals(new BigDecimal("20"), result.getFeeChargesPaid()); + assertEquals(new BigDecimal("30"), result.getFeeChargesOutstanding()); + assertEquals(new BigDecimal("10"), result.getPenaltyChargesCharged()); + assertEquals(new BigDecimal("4"), result.getPenaltyChargesPaid()); + assertEquals(new BigDecimal("6"), result.getPenaltyChargesOutstanding()); + assertEquals(new BigDecimal("100"), result.getPrincipalPaid()); + assertEquals(new BigDecimal("900"), result.getPrincipalOutstanding()); + assertEquals(new BigDecimal("1500"), result.getTotalExpectedRepayment()); + assertEquals(new BigDecimal("600"), result.getTotalRepayment()); + assertEquals(new BigDecimal("900"), result.getTotalOutstanding()); + assertEquals(new BigDecimal("15"), result.getRealizedIncomeFromDiscountFee()); + assertEquals(new BigDecimal("35"), result.getUnrealizedIncomeFromDiscountFee()); + assertEquals(new BigDecimal("50"), result.getTotalDiscountFee()); + assertEquals(new BigDecimal("5"), result.getTotalDiscountFeeAdjustment()); + assertCurrency(source.getCurrency(), result.getCurrency()); + } + + @Test + void map_summary_leavesStubAndSerializerFilledFieldsNull() { + final WorkingCapitalLoanSummaryDataV1 result = mapper.map(fullSummary()); + + // not-yet-implemented functionality stubs + assertNull(result.getPrincipalAdjustments()); + assertNull(result.getPrincipalWrittenOff()); + assertNull(result.getFeeChargesWrittenOff()); + assertNull(result.getPenaltyChargesWrittenOff()); + assertNull(result.getTotalWrittenOff()); + assertNull(result.getTotalRecovered()); + assertNull(result.getWriteoffReasonId()); + assertNull(result.getWriteoffReason()); + assertNull(result.getChargeOffReasonId()); + assertNull(result.getChargeOffReason()); + + // per-transaction-type totals are filled by the serializer, not the mapper + assertNull(result.getTotalPayoutRefund()); + assertNull(result.getTotalPayoutRefundReversed()); + assertNull(result.getTotalGoodwillCredit()); + assertNull(result.getTotalGoodwillCreditReversed()); + assertNull(result.getTotalChargeAdjustment()); + assertNull(result.getTotalChargeAdjustmentReversed()); + assertNull(result.getTotalCreditBalanceRefund()); + assertNull(result.getTotalCreditBalanceRefundReversed()); + assertNull(result.getTotalRepaymentTransaction()); + assertNull(result.getTotalRepaymentTransactionReversed()); + } + + @Test + void map_collection_coversAllFields() { + final WorkingCapitalLoanCollectionData source = fullCollection(); + + final WorkingCapitalLoanCollectionDataV1 result = mapper.map(source); + + assertNotNull(result); + assertEquals(3, result.getDelinquentDays()); + assertEquals("2024-02-01", result.getDelinquentDate()); + assertEquals(new BigDecimal("100"), result.getDelinquentAmount()); + + // delinquentPrincipal is exposed as totalDelinquentAmount + assertEquals(new BigDecimal("80"), result.getTotalDelinquentAmount()); + + assertNotNull(result.getDelinquencyPausePeriods()); + assertEquals(1, result.getDelinquencyPausePeriods().size()); + assertEquals(Boolean.TRUE, result.getDelinquencyPausePeriods().getFirst().getActive()); + assertEquals("2024-01-10", result.getDelinquencyPausePeriods().getFirst().getPausePeriodStart()); + assertEquals("2024-01-20", result.getDelinquencyPausePeriods().getFirst().getPausePeriodEnd()); + + // serializer-only fields stay unmapped + assertNull(result.getLastPaymentDate()); + assertNull(result.getLastPaymentAmount()); + assertNull(result.getLastRepaymentDate()); + assertNull(result.getLastRepaymentAmount()); + assertNull(result.getDelinquencySchedule()); + } + + @Test + void map_delinquencySchedulePeriod_coversAllFields() { + final WorkingCapitalLoanDelinquencyRangeScheduleData source = fullSchedulePeriod(); + + final WorkingCapitalLoanDelinquencySchedulePeriodDataV1 result = mapper.map(source); + + assertNotNull(result); + assertEquals(2, result.getPeriodNumber()); + assertEquals("2024-01-01", result.getFromDate()); + assertEquals("2024-01-30", result.getToDate()); + assertEquals(new BigDecimal("150.00"), result.getExpectedAmount()); + assertEquals(new BigDecimal("50.00"), result.getPaidAmount()); + assertEquals(new BigDecimal("100.00"), result.getOutstandingAmount()); + assertEquals(Boolean.FALSE, result.getMinPaymentCriteriaMet()); + assertEquals(12, result.getDelinquentDays()); + assertEquals(new BigDecimal("100.00"), result.getDelinquentAmount()); + } + + @Test + void mapDelinquencySchedule_mapsEveryPeriod() { + final List result = mapper + .mapDelinquencySchedule(List.of(fullSchedulePeriod(), fullSchedulePeriod())); + + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals(2, result.getFirst().getPeriodNumber()); + } + + @Test + void map_charge_coversAllFields() { + final WorkingCapitalLoanChargeData source = fullCharge(); + + final WorkingCapitalLoanChargeDataV1 result = mapper.map(source); + + assertNotNull(result); + assertEquals(7L, result.getId()); + assertEquals(11L, result.getChargeId()); + assertEquals("Processing fee", result.getName()); + assertEquals("2024-03-01", result.getSubmittedOnDate()); + assertEquals("2024-03-02", result.getDueDate()); + assertEquals(new BigDecimal("25.00"), result.getAmount()); + assertEquals(new BigDecimal("10.00"), result.getAmountPaid()); + assertEquals(new BigDecimal("15.00"), result.getAmountOutstanding()); + assertEquals(Boolean.FALSE, result.getPenalty()); + assertEquals(Boolean.FALSE, result.getPaid()); + assertEquals(99L, result.getLoanId()); + assertEquals("charge-ext", result.getExternalId()); + assertEquals("loan-ext", result.getExternalLoanId()); + assertEquals(1, result.getChargeTimeType().getId()); + assertEquals("chargeTimeType.disbursement", result.getChargeTimeType().getCode()); + assertEquals("Disbursement", result.getChargeTimeType().getValue()); + assertEquals(2, result.getChargeCalculationType().getId()); + assertEquals("chargeCalculationType.flat", result.getChargeCalculationType().getCode()); + assertEquals("Flat", result.getChargeCalculationType().getValue()); + assertEquals(3, result.getChargePaymentMode().getId()); + assertEquals("chargePaymentMode.regular", result.getChargePaymentMode().getCode()); + assertEquals("Regular", result.getChargePaymentMode().getValue()); + + assertCurrency(source.getCurrency(), result.getCurrency()); + + // accrual fields are intentionally not mapped for working capital charges + assertNull(result.getAmountAccrued()); + assertNull(result.getAmountUnrecognized()); + + // write-off is not implemented and custom data is never filled for the loan-details event + assertNull(result.getAmountWrittenOff()); + assertNull(result.getCustomData()); + } + + @Test + void map_disbursementDetail_coversAllFields() { + final WorkingCapitalLoanDisbursementDetailData source = fullDisbursement(); + + final WorkingCapitalLoanDisbursementDetailDataV1 result = mapper.map(source); + + assertNotNull(result); + assertEquals(42, result.getId()); + assertEquals(new BigDecimal("500.00"), result.getPrincipal()); + assertEquals("2024-01-01", result.getExpectedDisbursementDate()); + assertEquals("2024-01-02", result.getActualDisbursementDate()); + + // netDisbursalAmount is intentionally not mapped + assertNull(result.getNetDisbursalAmount()); + } + + @Test + void toAvroDecimalScale_nullReturnsNull_valueRescaledToEightHalfUp() { + assertNull(mapper.toAvroDecimalScale(null)); + + final BigDecimal scaled = mapper.toAvroDecimalScale(new BigDecimal("0.123456785")); + assertEquals(8, scaled.scale()); + assertEquals(new BigDecimal("0.12345679"), scaled); + } + + private static void assertStringEnum(final StringEnumOptionData source, final StringEnumOptionDataV1 result) { + assertNotNull(result); + assertEquals(source.getId(), result.getId()); + assertEquals(source.getCode(), result.getCode()); + assertEquals(source.getValue(), result.getValue()); + } + + private static void assertCurrency(final CurrencyData source, final CurrencyDataV1 result) { + assertNotNull(result); + assertEquals(source.getCode(), result.getCode()); + assertEquals(source.getName(), result.getName()); + assertEquals(source.getDecimalPlaces(), result.getDecimalPlaces()); + assertEquals(source.getInMultiplesOf(), result.getInMultiplesOf()); + assertEquals(source.getDisplaySymbol(), result.getDisplaySymbol()); + assertEquals(source.getNameCode(), result.getNameCode()); + assertEquals(source.getDisplayLabel(), result.getDisplayLabel()); + } + + private static CurrencyData currency() { + return new CurrencyData("USD", "US Dollar", 2, 1, "$", "currency.USD"); + } + + private static StringEnumOptionData stringEnum(final String id, final String code, final String value) { + return new StringEnumOptionData(id, code, value); + } + + private static WorkingCapitalLoanData fullLoanData() { + return WorkingCapitalLoanData.builder().id(101L).accountNo("WC-000101").externalId(new ExternalId("ext-101")).clientId(11L) + .clientAccountNo("CL-11").clientName("John Doe").clientExternalId(new ExternalId("client-ext")).clientOfficeId(22L) + .fundId(44L).fundName("Main Fund").loanProductId(33L).loanProductName("WC Product") + .loanProductDescription("WC product description").status(fullStatus()).proposedPrincipal(new BigDecimal("1200.00")) + .approvedPrincipal(new BigDecimal("1100.00")).principal(new BigDecimal("1000.00")) + .netDisbursalAmount(new BigDecimal("980.00")) + .amortizationType(stringEnum("1", "amortizationType.equal.installments", "Equal installments")).npvDayCount(360) + .loanProductCounter(7).paymentRate(new BigDecimal("2.50")).repaymentEvery(30) + .repaymentFrequencyType(stringEnum("0", "repaymentFrequency.days", "Days")).discountFee(new BigDecimal("12.00")) + .proposedDiscountFee(new BigDecimal("13.00")).approvedDiscountFee(new BigDecimal("14.00")).numberOfRepayments(90) + .periodPaymentAmount(new BigDecimal("120.00")).dailyEir(new BigDecimal("0.05")).calculatedAnnualEir(new BigDecimal("18.25")) + .totalPaymentVolume(new BigDecimal("5000.00")).breachGraceDays(3).delinquencyGraceDays(7) + .delinquencyStartType(stringEnum("1", "delinquencyStart.disbursement", "Disbursement")) + .delinquencyStartDate(LocalDate.of(2024, 1, 5)).breachStartDate(LocalDate.of(2024, 1, 6)) + .lastClosedBusinessDate(LocalDate.of(2024, 2, 1)).chargedOff(Boolean.TRUE).enableInstallmentLevelDelinquency(Boolean.TRUE) + .currency(currency()).timeline(fullTimeline()).summary(fullSummary()).delinquent(fullCollection()).breach(fullBreach()) + .nearBreach(fullNearBreach()).charges(List.of(fullCharge())).disbursementDetails(List.of(fullDisbursement())) + .originators(List.of(fullOriginator())).build(); + } + + private static LoanStatusEnumData fullStatus() { + return new LoanStatusEnumData(300L, "loanStatusType.active", "Active"); + } + + private static LoanApplicationTimelineData fullTimeline() { + return new LoanApplicationTimelineData().setSubmittedOnDate(LocalDate.of(2024, 1, 1)).setSubmittedByUsername("sub-user") + .setSubmittedByFirstname("sub-first").setSubmittedByLastname("sub-last").setRejectedOnDate(LocalDate.of(2024, 1, 2)) + .setRejectedByUsername("rej-user").setRejectedByFirstname("rej-first").setRejectedByLastname("rej-last") + .setWithdrawnOnDate(LocalDate.of(2024, 1, 3)).setWithdrawnByUsername("wd-user").setWithdrawnByFirstname("wd-first") + .setWithdrawnByLastname("wd-last").setApprovedOnDate(LocalDate.of(2024, 1, 4)).setApprovedByUsername("app-user") + .setApprovedByFirstname("app-first").setApprovedByLastname("app-last").setExpectedDisbursementDate(LocalDate.of(2024, 1, 5)) + .setActualDisbursementDate(LocalDate.of(2024, 1, 6)).setDisbursedByUsername("dis-user").setDisbursedByFirstname("dis-first") + .setDisbursedByLastname("dis-last").setClosedOnDate(LocalDate.of(2024, 1, 7)).setClosedByUsername("cl-user") + .setClosedByFirstname("cl-first").setClosedByLastname("cl-last").setExpectedMaturityDate(LocalDate.of(2024, 1, 8)) + .setWriteOffOnDate(LocalDate.of(2024, 1, 9)).setWriteOffByUsername("wo-user").setWriteOffByFirstname("wo-first") + .setWriteOffByLastname("wo-last").setChargedOffOnDate(LocalDate.of(2024, 1, 10)).setChargedOffByUsername("co-user") + .setChargedOffByFirstname("co-first").setChargedOffByLastname("co-last"); + } + + private static WorkingCapitalLoanSummaryData fullSummary() { + return WorkingCapitalLoanSummaryData.builder().currency(currency()).principal(new BigDecimal("1000")) + .principalPaid(new BigDecimal("100")).principalOutstanding(new BigDecimal("900")).fee(new BigDecimal("50")) + .feePaid(new BigDecimal("20")).feeOutstanding(new BigDecimal("30")).penalty(new BigDecimal("10")) + .penaltyPaid(new BigDecimal("4")).penaltyOutstanding(new BigDecimal("6")) + .realizedIncomeFromDiscountFee(new BigDecimal("15")).unrealizedIncomeFromDiscountFee(new BigDecimal("35")) + .overpayment(new BigDecimal("9.99")).totalDisbursement(new BigDecimal("950")).totalDiscountFee(new BigDecimal("50")) + .totalDiscountFeeAdjustment(new BigDecimal("5")).totalExpectedRepayment(new BigDecimal("1500")) + .totalRepayment(new BigDecimal("600")).totalOutstanding(new BigDecimal("900")).build(); + } + + private static WorkingCapitalLoanCollectionData fullCollection() { + final DelinquencyPausePeriod pause = new DelinquencyPausePeriod(true, LocalDate.of(2024, 1, 10), LocalDate.of(2024, 1, 20)); + return new WorkingCapitalLoanCollectionData(5L, 3L, LocalDate.of(2024, 2, 1), new BigDecimal("100"), List.of(pause), List.of(), + new BigDecimal("80")); + } + + private static WorkingCapitalLoanDelinquencyRangeScheduleData fullSchedulePeriod() { + return new WorkingCapitalLoanDelinquencyRangeScheduleData(9L, 101L, 2, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 30), + new BigDecimal("150.00"), new BigDecimal("50.00"), new BigDecimal("100.00"), Boolean.FALSE, 12L, new BigDecimal("100.00")); + } + + private static WorkingCapitalLoanChargeData fullCharge() { + return WorkingCapitalLoanChargeData.builder().id(7L).chargeId(11L).name("Processing fee") + .chargeTimeType(new EnumOptionData(1L, "chargeTimeType.disbursement", "Disbursement")) + .submittedOnDate(LocalDate.of(2024, 3, 1)).dueDate(LocalDate.of(2024, 3, 2)) + .chargeCalculationType(new EnumOptionData(2L, "chargeCalculationType.flat", "Flat")).currency(currency()) + .amount(new BigDecimal("25.00")).amountPaid(new BigDecimal("10.00")).amountOutstanding(new BigDecimal("15.00")) + .penalty(false).chargePaymentMode(new EnumOptionData(3L, "chargePaymentMode.regular", "Regular")).paid(false).loanId(99L) + .externalId(new ExternalId("charge-ext")).externalLoanId(new ExternalId("loan-ext")).build(); + } + + private static WorkingCapitalLoanDisbursementDetailData fullDisbursement() { + return WorkingCapitalLoanDisbursementDetailData.builder().id(42L).principal(new BigDecimal("500.00")) + .expectedDisbursementDate(LocalDate.of(2024, 1, 1)).actualDisbursementDate(LocalDate.of(2024, 1, 2)).build(); + } + + private static WorkingCapitalBreachData fullBreach() { + return WorkingCapitalBreachData.builder().id(80L).name("Breach rule").breachFrequency(2) + .breachFrequencyType(stringEnum("1", "breachFrequency.days", "Days")) + .breachAmountCalculationType(stringEnum("1", "breachAmount.fixed", "Fixed")).breachAmount(new BigDecimal("500.00")).build(); + } + + private static WorkingCapitalNearBreachData fullNearBreach() { + return WorkingCapitalNearBreachData.builder().id(81L).name("Near breach rule").frequency(1) + .frequencyType(stringEnum("1", "nearBreachFrequency.days", "Days")).threshold(new BigDecimal("450.00")).build(); + } + + private static LoanOriginatorData fullOriginator() { + final CodeValueData originatorType = CodeValueData.instance(71L, "Broker", "originator type", 1, true, false); + final CodeValueData channelType = CodeValueData.instance(72L, "Online", "channel type", 2, true, false); + return LoanOriginatorData.builder().id(70L).externalId("orig-ext").name("Origin Bank").status("ACTIVE") + .originatorType(originatorType).channelType(channelType).build(); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java index a97e9fc8184..303ac43ad45 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java @@ -715,6 +715,56 @@ public static ArrayList> getDefaultExternalEventConfiguratio workingCapitalLoanChargeAdjustmentPostBusinessEvent.put("enabled", false); defaults.add(workingCapitalLoanChargeAdjustmentPostBusinessEvent); + Map workingCapitalLoanCreatedBusinessEvent = new HashMap<>(); + workingCapitalLoanCreatedBusinessEvent.put("type", "WorkingCapitalLoanCreatedBusinessEvent"); + workingCapitalLoanCreatedBusinessEvent.put("enabled", false); + defaults.add(workingCapitalLoanCreatedBusinessEvent); + + Map workingCapitalLoanApplicationModifiedBusinessEvent = new HashMap<>(); + workingCapitalLoanApplicationModifiedBusinessEvent.put("type", "WorkingCapitalLoanApplicationModifiedBusinessEvent"); + workingCapitalLoanApplicationModifiedBusinessEvent.put("enabled", false); + defaults.add(workingCapitalLoanApplicationModifiedBusinessEvent); + + Map workingCapitalLoanApprovedBusinessEvent = new HashMap<>(); + workingCapitalLoanApprovedBusinessEvent.put("type", "WorkingCapitalLoanApprovedBusinessEvent"); + workingCapitalLoanApprovedBusinessEvent.put("enabled", false); + defaults.add(workingCapitalLoanApprovedBusinessEvent); + + Map workingCapitalLoanUndoApprovalBusinessEvent = new HashMap<>(); + workingCapitalLoanUndoApprovalBusinessEvent.put("type", "WorkingCapitalLoanUndoApprovalBusinessEvent"); + workingCapitalLoanUndoApprovalBusinessEvent.put("enabled", false); + defaults.add(workingCapitalLoanUndoApprovalBusinessEvent); + + Map workingCapitalLoanRejectedBusinessEvent = new HashMap<>(); + workingCapitalLoanRejectedBusinessEvent.put("type", "WorkingCapitalLoanRejectedBusinessEvent"); + workingCapitalLoanRejectedBusinessEvent.put("enabled", false); + defaults.add(workingCapitalLoanRejectedBusinessEvent); + + Map workingCapitalLoanDisbursalBusinessEvent = new HashMap<>(); + workingCapitalLoanDisbursalBusinessEvent.put("type", "WorkingCapitalLoanDisbursalBusinessEvent"); + workingCapitalLoanDisbursalBusinessEvent.put("enabled", false); + defaults.add(workingCapitalLoanDisbursalBusinessEvent); + + Map workingCapitalLoanUndoDisbursalBusinessEvent = new HashMap<>(); + workingCapitalLoanUndoDisbursalBusinessEvent.put("type", "WorkingCapitalLoanUndoDisbursalBusinessEvent"); + workingCapitalLoanUndoDisbursalBusinessEvent.put("enabled", false); + defaults.add(workingCapitalLoanUndoDisbursalBusinessEvent); + + Map workingCapitalLoanStatusChangedBusinessEvent = new HashMap<>(); + workingCapitalLoanStatusChangedBusinessEvent.put("type", "WorkingCapitalLoanStatusChangedBusinessEvent"); + workingCapitalLoanStatusChangedBusinessEvent.put("enabled", false); + defaults.add(workingCapitalLoanStatusChangedBusinessEvent); + + Map workingCapitalLoanBalanceChangedBusinessEvent = new HashMap<>(); + workingCapitalLoanBalanceChangedBusinessEvent.put("type", "WorkingCapitalLoanBalanceChangedBusinessEvent"); + workingCapitalLoanBalanceChangedBusinessEvent.put("enabled", false); + defaults.add(workingCapitalLoanBalanceChangedBusinessEvent); + + Map workingCapitalLoanDelinquencyRangeChangeBusinessEvent = new HashMap<>(); + workingCapitalLoanDelinquencyRangeChangeBusinessEvent.put("type", "WorkingCapitalLoanDelinquencyRangeChangeBusinessEvent"); + workingCapitalLoanDelinquencyRangeChangeBusinessEvent.put("enabled", false); + defaults.add(workingCapitalLoanDelinquencyRangeChangeBusinessEvent); + return defaults; } From 70fbcb00c22d50def19d4e03d82cdbb48e6f3309 Mon Sep 17 00:00:00 2001 From: Rustam Zeinalov Date: Mon, 27 Jul 2026 19:06:34 +0200 Subject: [PATCH 2/3] FINERACT-2455: Added e2e tests for validation of WC loan details business events --- .../messaging/event/EventCheckHelper.java | 584 ++++++++++-------- .../WorkingCapitalLoanAccountStepDef.java | 35 ++ ...ngCapitalLoanAccountBusinessEvents.feature | 227 ++++++- 3 files changed, 574 insertions(+), 272 deletions(-) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java index c237c6b0e66..8bb1fd5f2d9 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java @@ -24,9 +24,13 @@ import java.math.BigDecimal; import java.math.MathContext; +import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,17 +38,21 @@ import org.apache.fineract.avro.loan.v1.DelinquencyPausePeriodV1; import org.apache.fineract.avro.loan.v1.LoanAccountDataV1; import org.apache.fineract.avro.loan.v1.LoanAmountDataV1; +import org.apache.fineract.avro.loan.v1.LoanApplicationTimelineDataV1; import org.apache.fineract.avro.loan.v1.LoanInstallmentDelinquencyBucketDataV1; import org.apache.fineract.avro.loan.v1.LoanOwnershipTransferDataV1; import org.apache.fineract.avro.loan.v1.LoanTransactionAdjustmentDataV1; import org.apache.fineract.avro.loan.v1.LoanTransactionDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalBreachDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanAccountDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanChargeDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanCollectionDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencyDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanSummaryDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanTransactionDataV1; import org.apache.fineract.client.feign.FineractFeignClient; import org.apache.fineract.client.models.ExternalTransferData; +import org.apache.fineract.client.models.ExternalTransferDataDetails; import org.apache.fineract.client.models.GetClientsClientIdResponse; import org.apache.fineract.client.models.GetLoansLoanIdDelinquencyPausePeriod; import org.apache.fineract.client.models.GetLoansLoanIdResponse; @@ -53,6 +61,7 @@ import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionsResponse; import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdTimeline; import org.apache.fineract.client.models.GlobalConfigurationPropertyData; import org.apache.fineract.client.models.PageExternalTransferData; import org.apache.fineract.client.models.PostClientsResponse; @@ -60,6 +69,7 @@ import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse; import org.apache.fineract.client.models.PostLoansResponse; import org.apache.fineract.client.models.WorkingCapitalCollection; +import org.apache.fineract.client.models.WorkingCapitalCollectionDelinquencyPausePeriod; import org.apache.fineract.test.data.AssetExternalizationTransferStatus; import org.apache.fineract.test.data.AssetExternalizationTransferStatusReason; import org.apache.fineract.test.data.TransactionType; @@ -102,12 +112,12 @@ import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.WorkingCapitalLoanStatusChangedEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.WorkingCapitalLoanUndoApprovalEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.loan.WorkingCapitalLoanUndoDisbursalEvent; +import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.AbstractWorkingCapitalLoanTransactionEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanCreditBalanceRefundTransactionBusinessEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanDisbursalTransactionBusinessEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanDiscountFeeAdjustmentTransactionBusinessEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanDiscountFeeTransactionBusinessEvent; import org.apache.fineract.test.messaging.event.workingcapitalloan.transaction.WorkingCapitalLoanUndoDisbursalTransactionBusinessEvent; -import org.junit.jupiter.api.Assertions; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -130,9 +140,7 @@ public class EventCheckHelper { private org.apache.fineract.test.messaging.config.EventProperties eventProperties; public void waitForTransactionCommit() { - if (TRANSACTION_COMMIT_DELAY_MS > 0) { - sleepIfEventVerificationEnabled(TRANSACTION_COMMIT_DELAY_MS); - } + sleepIfEventVerificationEnabled(TRANSACTION_COMMIT_DELAY_MS); } public void sleepIfEventVerificationEnabled(long sleepInMs) { @@ -206,70 +214,43 @@ private void loanAccountDataV1Check(Class eventClaz eventAssertion.assertEvent(eventClazz, loanId)// .extractingData(loanAccountDataV1 -> { - Long idActual = loanAccountDataV1.getId(); - Long idExpected = body.getId(); - Long statusIdActual = loanAccountDataV1.getStatus().getId().longValue(); - Long statusIdExpected = body.getStatus().getId(); - String statusCodeActual = loanAccountDataV1.getStatus().getCode(); - String statusCodeExpected = body.getStatus().getCode(); - Long clientIdActual = loanAccountDataV1.getClientId(); - Long clientIdExpected = body.getClientId(); - BigDecimal principalDisbursedActual = loanAccountDataV1.getSummary().getPrincipalDisbursed(); - Double principalDisbursedExpectedDouble = body.getSummary().getPrincipalDisbursed().doubleValue(); - BigDecimal principalDisbursedExpected = BigDecimal.valueOf(principalDisbursedExpectedDouble); - String actualDisbursementDateActual = loanAccountDataV1.getTimeline().getActualDisbursementDate(); - String actualDisbursementDateExpected = FORMATTER_EVENTS.format(body.getTimeline().getActualDisbursementDate()); - String currencyCodeActual = loanAccountDataV1.getSummary().getCurrency().getCode(); - String currencyCodeExpected = body.getSummary().getCurrency().getCode(); - BigDecimal totalUnpaidPayableDueInterestActual = loanAccountDataV1.getSummary().getTotalUnpaidPayableDueInterest(); - BigDecimal totalUnpaidPayableDueInterestExpected = body.getSummary().getTotalUnpaidPayableDueInterest(); - BigDecimal totalUnpaidPayableNotDueInterestActual = loanAccountDataV1.getSummary() - .getTotalUnpaidPayableNotDueInterest(); - BigDecimal totalUnpaidPayableNotDueInterestExpected = body.getSummary().getTotalUnpaidPayableNotDueInterest(); - BigDecimal totalInterestPaymentWaiverActual = loanAccountDataV1.getSummary().getTotalInterestPaymentWaiver(); - Double totalInterestPaymentWaiverExpectedDouble = body.getSummary().getTotalInterestPaymentWaiver().doubleValue(); - BigDecimal totalInterestPaymentWaiverExpected = new BigDecimal(totalInterestPaymentWaiverExpectedDouble, - MathContext.DECIMAL64); - BigDecimal delinquentInterestActual = loanAccountDataV1.getDelinquent().getDelinquentInterest(); - BigDecimal delinquentInterestExpected = body.getDelinquent().getDelinquentInterest(); - BigDecimal delinquentFeeActual = loanAccountDataV1.getDelinquent().getDelinquentFee(); - BigDecimal delinquentFeeExpected = body.getDelinquent().getDelinquentFee(); - BigDecimal delinquentPenaltyActual = loanAccountDataV1.getDelinquent().getDelinquentPenalty(); - BigDecimal delinquentPenaltyExpected = body.getDelinquent().getDelinquentPenalty(); - Integer actualNoTermActual = loanAccountDataV1.getActualNoTerm(); - Integer actualNoTermExpected = body.getActualNoTerm(); - - assertThat(idActual).isEqualTo(idExpected); - assertThat(statusIdActual).isEqualTo(statusIdExpected); - assertThat(statusCodeActual).isEqualTo(statusCodeExpected); - assertThat(clientIdActual).isEqualTo(clientIdExpected); - assertThat(areBigDecimalValuesEqual(principalDisbursedActual, principalDisbursedExpected)).isTrue(); - assertThat(actualDisbursementDateActual).isEqualTo(actualDisbursementDateExpected); - assertThat(currencyCodeActual).isEqualTo(currencyCodeExpected); - assertThat(areBigDecimalValuesEqual(totalUnpaidPayableDueInterestActual, totalUnpaidPayableDueInterestExpected)) - .isTrue(); - assertThat(areBigDecimalValuesEqual(totalUnpaidPayableNotDueInterestActual, totalUnpaidPayableNotDueInterestExpected)) - .isTrue(); - assertThat(areBigDecimalValuesEqual(totalInterestPaymentWaiverActual, totalInterestPaymentWaiverExpected)).isTrue(); - assertThat(areBigDecimalValuesEqual(delinquentInterestActual, delinquentInterestExpected)).isTrue(); - assertThat(areBigDecimalValuesEqual(delinquentFeeActual, delinquentFeeExpected)).isTrue(); - assertThat(areBigDecimalValuesEqual(delinquentPenaltyActual, delinquentPenaltyExpected)).isTrue(); - assertThat(actualNoTermActual).isEqualTo(actualNoTermExpected); - + assertThat(loanAccountDataV1.getId()).as("id").isEqualTo(body.getId()); + assertThat(loanAccountDataV1.getStatus().getId().longValue()).as("status.id").isEqualTo(body.getStatus().getId()); + assertThat(loanAccountDataV1.getStatus().getCode()).as("status.code").isEqualTo(body.getStatus().getCode()); + assertThat(loanAccountDataV1.getClientId()).as("clientId").isEqualTo(body.getClientId()); + assertAmountEquals("summary.principalDisbursed", loanAccountDataV1.getSummary().getPrincipalDisbursed(), + BigDecimal.valueOf(body.getSummary().getPrincipalDisbursed().doubleValue())); + assertThat(loanAccountDataV1.getTimeline().getActualDisbursementDate()).as("timeline.actualDisbursementDate") + .isEqualTo(FORMATTER_EVENTS.format(body.getTimeline().getActualDisbursementDate())); + assertThat(loanAccountDataV1.getSummary().getCurrency().getCode()).as("summary.currency.code") + .isEqualTo(body.getSummary().getCurrency().getCode()); + assertAmountEquals("summary.totalUnpaidPayableDueInterest", + loanAccountDataV1.getSummary().getTotalUnpaidPayableDueInterest(), + body.getSummary().getTotalUnpaidPayableDueInterest()); + assertAmountEquals("summary.totalUnpaidPayableNotDueInterest", + loanAccountDataV1.getSummary().getTotalUnpaidPayableNotDueInterest(), + body.getSummary().getTotalUnpaidPayableNotDueInterest()); + assertAmountEquals("summary.totalInterestPaymentWaiver", loanAccountDataV1.getSummary().getTotalInterestPaymentWaiver(), + new BigDecimal(body.getSummary().getTotalInterestPaymentWaiver().doubleValue(), MathContext.DECIMAL64)); + assertAmountEquals("delinquent.delinquentInterest", loanAccountDataV1.getDelinquent().getDelinquentInterest(), + body.getDelinquent().getDelinquentInterest()); + assertAmountEquals("delinquent.delinquentFee", loanAccountDataV1.getDelinquent().getDelinquentFee(), + body.getDelinquent().getDelinquentFee()); + assertAmountEquals("delinquent.delinquentPenalty", loanAccountDataV1.getDelinquent().getDelinquentPenalty(), + body.getDelinquent().getDelinquentPenalty()); + assertThat(loanAccountDataV1.getActualNoTerm()).as("actualNoTerm").isEqualTo(body.getActualNoTerm()); return null; }); } public GetLoansLoanIdTransactions getNthTransactionType(String nthItemStr, String transactionType, String transactionDate, List transactions) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_FORMAT); int nthItem = Integer.parseInt(nthItemStr) - 1; - GetLoansLoanIdTransactions targetTransaction = transactions// + return transactions// .stream()// - .filter(t -> transactionDate.equals(formatter.format(t.getDate())) && transactionType.equals(t.getType().getValue()))// + .filter(t -> transactionDate.equals(DATE_FORMATTER.format(t.getDate())) && transactionType.equals(t.getType().getValue()))// .toList()// .get(nthItem);// - return targetTransaction; } public GetLoansLoanIdTransactions findNthTransaction(String nthItemStr, String transactionType, String transactionDate, long loanId) { @@ -298,11 +279,8 @@ public void loanUndoContractTerminationEventCheck(final GetLoansLoanIdTransactio eventAssertion.assertEventRaised(LoanUndoContractTerminationBusinessEvent.class, transaction.getId()); } - private boolean areBigDecimalValuesEqual(BigDecimal actual, BigDecimal expected) { - log.debug("--- Checking BigDecimal values.... ---"); - log.debug("Actual: {}", actual); - log.debug("Expected: {}", expected); - return actual.compareTo(expected) == 0; + private static void assertAmountEquals(final String description, final BigDecimal actual, final BigDecimal expected) { + assertThat(actual).as(description).isEqualByComparingTo(expected); } public void loanDisbursalTransactionEventCheck(PostLoansLoanIdResponse loanDisburseResponse) { @@ -330,43 +308,18 @@ public void workingCapitalLoanDisbursalTransactionEventCheck(final Long loanId) public void workingCapitalLoanDisbursalTransactionEventCheck(final Long loanId, final BigDecimal expectedAmount) { waitForTransactionCommit(); - final GetWorkingCapitalLoanTransactionsResponse body = ok( - () -> fineractClient.workingCapitalLoanTransactions().retrieveWorkingCapitalLoanTransactionsById(loanId)); - if (body.getContent() == null || body.getContent().isEmpty()) { - throw new IllegalStateException("No Working Capital Loan transactions found"); - } - - final GetWorkingCapitalLoanTransactionIdResponse disbursementTransaction = body.getContent().stream() - .filter(t -> t.getType() != null && "loanTransactionType.disbursement".equals(t.getType().getCode()) - && !Boolean.TRUE.equals(t.getReversed())) - .reduce((first, second) -> second).orElseThrow(() -> new IllegalStateException("Disbursement transaction not found")); - - eventAssertion.assertEvent(WorkingCapitalLoanDisbursalTransactionBusinessEvent.class, disbursementTransaction.getId())// - .extractingData(WorkingCapitalLoanTransactionDataV1::getWcLoanId).isEqualTo(loanId)// - .extractingBigDecimal(WorkingCapitalLoanTransactionDataV1::getTransactionAmount) - .isEqualTo(expectedAmount == null ? disbursementTransaction.getTransactionAmount() : expectedAmount)// - .extractingData(WorkingCapitalLoanTransactionDataV1::getReversed).isEqualTo(Boolean.FALSE); + final GetWorkingCapitalLoanTransactionIdResponse disbursementTransaction = findLastWorkingCapitalLoanTransaction(loanId, + "disbursement", false, "Disbursement transaction not found"); + workingCapitalLoanTransactionEventCheck(WorkingCapitalLoanDisbursalTransactionBusinessEvent.class, loanId, disbursementTransaction, + expectedAmount, false); } public void workingCapitalLoanCreditBalanceRefundTransactionEventCheck(final Long loanId, final BigDecimal expectedAmount) { waitForTransactionCommit(); - final GetWorkingCapitalLoanTransactionsResponse body = ok( - () -> fineractClient.workingCapitalLoanTransactions().retrieveWorkingCapitalLoanTransactionsById(loanId)); - if (body.getContent() == null || body.getContent().isEmpty()) { - throw new IllegalStateException("No Working Capital Loan transactions found"); - } - - final GetWorkingCapitalLoanTransactionIdResponse cbrTransaction = body.getContent().stream() - .filter(t -> t.getType() != null && "loanTransactionType.creditBalanceRefund".equals(t.getType().getCode()) - && !Boolean.TRUE.equals(t.getReversed())) - .reduce((first, second) -> second) - .orElseThrow(() -> new IllegalStateException("Credit balance refund transaction not found")); - - eventAssertion.assertEvent(WorkingCapitalLoanCreditBalanceRefundTransactionBusinessEvent.class, cbrTransaction.getId())// - .extractingData(WorkingCapitalLoanTransactionDataV1::getWcLoanId).isEqualTo(loanId)// - .extractingBigDecimal(WorkingCapitalLoanTransactionDataV1::getTransactionAmount) - .isEqualTo(expectedAmount == null ? cbrTransaction.getTransactionAmount() : expectedAmount)// - .extractingData(WorkingCapitalLoanTransactionDataV1::getReversed).isEqualTo(Boolean.FALSE); + final GetWorkingCapitalLoanTransactionIdResponse cbrTransaction = findLastWorkingCapitalLoanTransaction(loanId, + "creditBalanceRefund", false, "Credit balance refund transaction not found"); + workingCapitalLoanTransactionEventCheck(WorkingCapitalLoanCreditBalanceRefundTransactionBusinessEvent.class, loanId, cbrTransaction, + expectedAmount, false); } public void workingCapitalLoanUndoDisbursalTransactionEventCheck(final Long loanId) { @@ -375,47 +328,49 @@ public void workingCapitalLoanUndoDisbursalTransactionEventCheck(final Long loan public void workingCapitalLoanUndoDisbursalTransactionEventCheck(final Long loanId, final BigDecimal expectedAmount) { waitForTransactionCommit(); - final GetWorkingCapitalLoanTransactionsResponse body = ok( - () -> fineractClient.workingCapitalLoanTransactions().retrieveWorkingCapitalLoanTransactionsById(loanId)); - if (body.getContent() == null || body.getContent().isEmpty()) { - throw new IllegalStateException("No Working Capital Loan transactions found"); - } - - final GetWorkingCapitalLoanTransactionIdResponse reversedDisbursementTransaction = body.getContent().stream() - .filter(t -> t.getType() != null && "loanTransactionType.disbursement".equals(t.getType().getCode()) - && Boolean.TRUE.equals(t.getReversed())) - .reduce((first, second) -> second) - .orElseThrow(() -> new IllegalStateException("Reversed disbursement transaction not found")); - - eventAssertion.assertEvent(WorkingCapitalLoanUndoDisbursalTransactionBusinessEvent.class, reversedDisbursementTransaction.getId())// - .extractingData(WorkingCapitalLoanTransactionDataV1::getWcLoanId).isEqualTo(loanId)// - .extractingBigDecimal(WorkingCapitalLoanTransactionDataV1::getTransactionAmount) - .isEqualTo(expectedAmount == null ? reversedDisbursementTransaction.getTransactionAmount() : expectedAmount)// - .extractingData(WorkingCapitalLoanTransactionDataV1::getReversed).isEqualTo(Boolean.TRUE); + final GetWorkingCapitalLoanTransactionIdResponse reversedDisbursementTransaction = findLastWorkingCapitalLoanTransaction(loanId, + "disbursement", true, "Reversed disbursement transaction not found"); + workingCapitalLoanTransactionEventCheck(WorkingCapitalLoanUndoDisbursalTransactionBusinessEvent.class, loanId, + reversedDisbursementTransaction, expectedAmount, true); } public void workingCapitalLoanDiscountFeeTransactionEventCheck(final Long loanId, String transactionType, final BigDecimal expectedAmount, String transactionDate) { final GetWorkingCapitalLoanTransactionIdResponse discountFeeTransaction = workingCapitalLoanTransactionDetails(loanId, transactionType, transactionDate); - - eventAssertion.assertEvent(WorkingCapitalLoanDiscountFeeTransactionBusinessEvent.class, discountFeeTransaction.getId())// - .extractingData(WorkingCapitalLoanTransactionDataV1::getWcLoanId).isEqualTo(loanId)// - .extractingBigDecimal(WorkingCapitalLoanTransactionDataV1::getTransactionAmount) - .isEqualTo(expectedAmount == null ? discountFeeTransaction.getTransactionAmount() : expectedAmount)// - .extractingData(WorkingCapitalLoanTransactionDataV1::getReversed).isEqualTo(Boolean.FALSE); + workingCapitalLoanTransactionEventCheck(WorkingCapitalLoanDiscountFeeTransactionBusinessEvent.class, loanId, discountFeeTransaction, + expectedAmount, false); } public void workingCapitalLoanDiscountFeeAdjustmentTransactionEventCheck(final Long loanId, String transactionType, final BigDecimal expectedAmount, String transactionDate) { final GetWorkingCapitalLoanTransactionIdResponse discountFeeTransaction = workingCapitalLoanTransactionDetails(loanId, transactionType, transactionDate); + workingCapitalLoanTransactionEventCheck(WorkingCapitalLoanDiscountFeeAdjustmentTransactionBusinessEvent.class, loanId, + discountFeeTransaction, expectedAmount, false); + } + + private GetWorkingCapitalLoanTransactionIdResponse findLastWorkingCapitalLoanTransaction(final Long loanId, + final String transactionType, final boolean reversed, final String notFoundMessage) { + final GetWorkingCapitalLoanTransactionsResponse body = ok( + () -> fineractClient.workingCapitalLoanTransactions().retrieveWorkingCapitalLoanTransactionsById(loanId)); + if (body.getContent() == null || body.getContent().isEmpty()) { + throw new IllegalStateException("No Working Capital Loan transactions found"); + } + final String expectedCode = "loanTransactionType." + transactionType; + return body.getContent().stream().filter( + t -> t.getType() != null && expectedCode.equals(t.getType().getCode()) && reversed == Boolean.TRUE.equals(t.getReversed())) + .reduce((first, second) -> second).orElseThrow(() -> new IllegalStateException(notFoundMessage)); + } - eventAssertion.assertEvent(WorkingCapitalLoanDiscountFeeAdjustmentTransactionBusinessEvent.class, discountFeeTransaction.getId())// + private void workingCapitalLoanTransactionEventCheck(final Class eventClazz, + final Long loanId, final GetWorkingCapitalLoanTransactionIdResponse transaction, final BigDecimal expectedAmount, + final boolean expectedReversed) { + eventAssertion.assertEvent(eventClazz, transaction.getId())// .extractingData(WorkingCapitalLoanTransactionDataV1::getWcLoanId).isEqualTo(loanId)// .extractingBigDecimal(WorkingCapitalLoanTransactionDataV1::getTransactionAmount) - .isEqualTo(expectedAmount == null ? discountFeeTransaction.getTransactionAmount() : expectedAmount)// - .extractingData(WorkingCapitalLoanTransactionDataV1::getReversed).isEqualTo(Boolean.FALSE); + .isEqualTo(expectedAmount == null ? transaction.getTransactionAmount() : expectedAmount)// + .extractingData(WorkingCapitalLoanTransactionDataV1::getReversed).isEqualTo(expectedReversed); } public GetWorkingCapitalLoanTransactionIdResponse workingCapitalLoanTransactionDetails(final Long loanId, String transactionType, @@ -474,129 +429,74 @@ public EventAssertion.EventAssertionBuilder transactionEv public void loanOwnershipTransferBusinessEventCheck(Long loanId, Long transferId) { waitForTransactionCommit(); - PageExternalTransferData response = ok(() -> fineractClient.externalAssetOwners().getTransfers(Map.of("loanId", loanId))); - List content = response.getContent(); - - ExternalTransferData filtered = content.stream().filter(t -> transferId.equals(t.getTransferId())).reduce((first, second) -> second) - .orElseThrow(() -> new IllegalStateException("No element found")); - - BigDecimal totalOutstandingBalanceAmountExpected = zeroConversion(filtered.getDetails().getTotalOutstanding()); - BigDecimal outstandingPrincipalPortionExpected = zeroConversion(filtered.getDetails().getTotalPrincipalOutstanding()); - BigDecimal outstandingFeePortionExpected = zeroConversion(filtered.getDetails().getTotalFeeChargesOutstanding()); - BigDecimal outstandingPenaltyPortionExpected = zeroConversion(filtered.getDetails().getTotalPenaltyChargesOutstanding()); - BigDecimal outstandingInterestPortionExpected = zeroConversion(filtered.getDetails().getTotalInterestOutstanding()); - BigDecimal overPaymentPortionExpected = zeroConversion(filtered.getDetails().getTotalOverpaid()); - - eventAssertion.assertEvent(LoanOwnershipTransferEvent.class, loanId).extractingData(LoanOwnershipTransferDataV1::getLoanId) - .isEqualTo(loanId).extractingData(LoanOwnershipTransferDataV1::getAssetOwnerExternalId) - .isEqualTo(filtered.getOwner().getExternalId()).extractingData(LoanOwnershipTransferDataV1::getTransferExternalId) - .isEqualTo(filtered.getTransferExternalId()).extractingData(LoanOwnershipTransferDataV1::getSettlementDate) - .isEqualTo(FORMATTER_EVENTS.format(filtered.getSettlementDate())) - .extractingBigDecimal(LoanOwnershipTransferDataV1::getTotalOutstandingBalanceAmount) - .isEqualTo(totalOutstandingBalanceAmountExpected) - .extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingPrincipalPortion) - .isEqualTo(outstandingPrincipalPortionExpected).extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingFeePortion) - .isEqualTo(outstandingFeePortionExpected).extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingPenaltyPortion) - .isEqualTo(outstandingPenaltyPortionExpected) - .extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingInterestPortion) - .isEqualTo(outstandingInterestPortionExpected).extractingBigDecimal(LoanOwnershipTransferDataV1::getOverPaymentPortion) - .isEqualTo(overPaymentPortionExpected); + ExternalTransferData filtered = findLastTransfer(loanId, transferId); + loanOwnershipTransferEventBaseCheck(loanId, filtered, expectedAmountsOf(filtered.getDetails())); } public void loanOwnershipTransferBusinessEventWithStatusCheck(Long loanId, Long transferId, String transferStatus, String transferStatusReason) { - PageExternalTransferData response = ok(() -> fineractClient.externalAssetOwners().getTransfers(Map.of("loanId", loanId))); - List content = response.getContent(); - - ExternalTransferData filtered = content.stream().filter(t -> transferId.equals(t.getTransferId())).reduce((first, second) -> second) - .orElseThrow(() -> new IllegalStateException("No element found")); + ExternalTransferData filtered = findLastTransfer(loanId, transferId); + ExpectedTransferAmounts expected = filtered.getDetails() == null ? ExpectedTransferAmounts.EMPTY + : expectedAmountsOf(filtered.getDetails()); - BigDecimal totalOutstandingBalanceAmountExpected = filtered.getDetails() == null ? null - : zeroConversion(filtered.getDetails().getTotalOutstanding()); - BigDecimal outstandingPrincipalPortionExpected = filtered.getDetails() == null ? null - : zeroConversion(filtered.getDetails().getTotalPrincipalOutstanding()); - BigDecimal outstandingFeePortionExpected = filtered.getDetails() == null ? null - : zeroConversion(filtered.getDetails().getTotalFeeChargesOutstanding()); - BigDecimal outstandingPenaltyPortionExpected = filtered.getDetails() == null ? null - : zeroConversion(filtered.getDetails().getTotalPenaltyChargesOutstanding()); - BigDecimal outstandingInterestPortionExpected = filtered.getDetails() == null ? null - : zeroConversion(filtered.getDetails().getTotalInterestOutstanding()); - BigDecimal overPaymentPortionExpected = filtered.getDetails() == null ? null - : zeroConversion(filtered.getDetails().getTotalOverpaid()); - - AssetExternalizationTransferStatus transferStatusType = AssetExternalizationTransferStatus.valueOf(transferStatus); - String transferStatusExpected = transferStatusType.getValue(); - - AssetExternalizationTransferStatusReason transferStatusReasonType = AssetExternalizationTransferStatusReason - .valueOf(transferStatusReason); - String transferStatusReasonExpected = transferStatusReasonType.getValue(); - - eventAssertion.assertEvent(LoanOwnershipTransferEvent.class, loanId).extractingData(LoanOwnershipTransferDataV1::getLoanId) - .isEqualTo(loanId).extractingData(LoanOwnershipTransferDataV1::getAssetOwnerExternalId) - .isEqualTo(filtered.getOwner().getExternalId()).extractingData(LoanOwnershipTransferDataV1::getTransferExternalId) - .isEqualTo(filtered.getTransferExternalId()).extractingData(LoanOwnershipTransferDataV1::getSettlementDate) - .isEqualTo(FORMATTER_EVENTS.format(filtered.getSettlementDate())) - .extractingBigDecimal(LoanOwnershipTransferDataV1::getTotalOutstandingBalanceAmount) - .isEqualTo(totalOutstandingBalanceAmountExpected) - .extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingPrincipalPortion) - .isEqualTo(outstandingPrincipalPortionExpected).extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingFeePortion) - .isEqualTo(outstandingFeePortionExpected).extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingPenaltyPortion) - .isEqualTo(outstandingPenaltyPortionExpected) - .extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingInterestPortion) - .isEqualTo(outstandingInterestPortionExpected).extractingBigDecimal(LoanOwnershipTransferDataV1::getOverPaymentPortion) - .isEqualTo(overPaymentPortionExpected).extractingData(LoanOwnershipTransferDataV1::getTransferStatus) - .isEqualTo(transferStatusExpected).extractingData(LoanOwnershipTransferDataV1::getTransferStatusReason) - .isEqualTo(transferStatusReasonExpected); + loanOwnershipTransferEventBaseCheck(loanId, filtered, expected)// + .extractingData(LoanOwnershipTransferDataV1::getTransferStatus) + .isEqualTo(AssetExternalizationTransferStatus.valueOf(transferStatus).getValue())// + .extractingData(LoanOwnershipTransferDataV1::getTransferStatusReason) + .isEqualTo(AssetExternalizationTransferStatusReason.valueOf(transferStatusReason).getValue()); } public void loanOwnershipTransferBusinessEventWithTypeCheck(Long loanId, ExternalTransferData transferData, String transferType, String previousAssetOwner) { - PageExternalTransferData response = ok(() -> fineractClient.externalAssetOwners().getTransfers(Map.of("loanId", loanId))); - List content = response.getContent(); - Long transferId = transferData.getTransferId(); String assetOwner = transferData.getOwner() == null ? null : transferData.getOwner().getExternalId(); + ExternalTransferData filtered = findLastTransfer(loanId, transferData.getTransferId()); + ExpectedTransferAmounts expected = filtered.getDetails() == null ? ExpectedTransferAmounts.EMPTY + : expectedAmountsOf(filtered.getDetails()); - ExternalTransferData filtered = content.stream().filter(t -> transferId.equals(t.getTransferId())).reduce((first, second) -> second) + loanOwnershipTransferEventBaseCheck(loanId, filtered, expected)// + .extractingData(LoanOwnershipTransferDataV1::getType).isEqualTo(transferType)// + .extractingData(LoanOwnershipTransferDataV1::getAssetOwnerExternalId).isEqualTo(assetOwner)// + .extractingData(LoanOwnershipTransferDataV1::getPreviousOwnerExternalId).isEqualTo(previousAssetOwner); + } + + private ExternalTransferData findLastTransfer(final Long loanId, final Long transferId) { + final PageExternalTransferData response = ok(() -> fineractClient.externalAssetOwners().getTransfers(Map.of("loanId", loanId))); + return response.getContent().stream().filter(t -> transferId.equals(t.getTransferId())).reduce((first, second) -> second) .orElseThrow(() -> new IllegalStateException("No element found")); + } - BigDecimal totalOutstandingBalanceAmountExpected = filtered.getDetails() == null ? null - : zeroConversion(filtered.getDetails().getTotalOutstanding()); - BigDecimal outstandingPrincipalPortionExpected = filtered.getDetails() == null ? null - : zeroConversion(filtered.getDetails().getTotalPrincipalOutstanding()); - BigDecimal outstandingFeePortionExpected = filtered.getDetails() == null ? null - : zeroConversion(filtered.getDetails().getTotalFeeChargesOutstanding()); - BigDecimal outstandingPenaltyPortionExpected = filtered.getDetails() == null ? null - : zeroConversion(filtered.getDetails().getTotalPenaltyChargesOutstanding()); - BigDecimal outstandingInterestPortionExpected = filtered.getDetails() == null ? null - : zeroConversion(filtered.getDetails().getTotalInterestOutstanding()); - BigDecimal overPaymentPortionExpected = filtered.getDetails() == null ? null - : zeroConversion(filtered.getDetails().getTotalOverpaid()); - - eventAssertion.assertEvent(LoanOwnershipTransferEvent.class, loanId).extractingData(LoanOwnershipTransferDataV1::getLoanId) - .isEqualTo(loanId).extractingData(LoanOwnershipTransferDataV1::getAssetOwnerExternalId) - .isEqualTo(filtered.getOwner().getExternalId()).extractingData(LoanOwnershipTransferDataV1::getTransferExternalId) - .isEqualTo(filtered.getTransferExternalId()).extractingData(LoanOwnershipTransferDataV1::getSettlementDate) - .isEqualTo(FORMATTER_EVENTS.format(filtered.getSettlementDate())) - .extractingBigDecimal(LoanOwnershipTransferDataV1::getTotalOutstandingBalanceAmount) - .isEqualTo(totalOutstandingBalanceAmountExpected) - .extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingPrincipalPortion) - .isEqualTo(outstandingPrincipalPortionExpected).extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingFeePortion) - .isEqualTo(outstandingFeePortionExpected).extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingPenaltyPortion) - .isEqualTo(outstandingPenaltyPortionExpected) - .extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingInterestPortion) - .isEqualTo(outstandingInterestPortionExpected).extractingBigDecimal(LoanOwnershipTransferDataV1::getOverPaymentPortion) - .isEqualTo(overPaymentPortionExpected).extractingData(LoanOwnershipTransferDataV1::getType).isEqualTo(transferType) - .extractingData(LoanOwnershipTransferDataV1::getAssetOwnerExternalId).isEqualTo(assetOwner) - .extractingData(LoanOwnershipTransferDataV1::getPreviousOwnerExternalId).isEqualTo(previousAssetOwner); + private record ExpectedTransferAmounts(BigDecimal totalOutstanding, BigDecimal principal, BigDecimal fee, BigDecimal penalty, + BigDecimal interest, BigDecimal overpayment) { + + static final ExpectedTransferAmounts EMPTY = new ExpectedTransferAmounts(null, null, null, null, null, null); + } + + private ExpectedTransferAmounts expectedAmountsOf(final ExternalTransferDataDetails details) { + return new ExpectedTransferAmounts(zeroConversion(details.getTotalOutstanding()), + zeroConversion(details.getTotalPrincipalOutstanding()), zeroConversion(details.getTotalFeeChargesOutstanding()), + zeroConversion(details.getTotalPenaltyChargesOutstanding()), zeroConversion(details.getTotalInterestOutstanding()), + zeroConversion(details.getTotalOverpaid())); + } + + private EventAssertion.EventAssertionBuilder loanOwnershipTransferEventBaseCheck(final Long loanId, + final ExternalTransferData filtered, final ExpectedTransferAmounts expected) { + return eventAssertion.assertEvent(LoanOwnershipTransferEvent.class, loanId)// + .extractingData(LoanOwnershipTransferDataV1::getLoanId).isEqualTo(loanId)// + .extractingData(LoanOwnershipTransferDataV1::getAssetOwnerExternalId).isEqualTo(filtered.getOwner().getExternalId())// + .extractingData(LoanOwnershipTransferDataV1::getTransferExternalId).isEqualTo(filtered.getTransferExternalId())// + .extractingData(LoanOwnershipTransferDataV1::getSettlementDate) + .isEqualTo(FORMATTER_EVENTS.format(filtered.getSettlementDate()))// + .extractingBigDecimal(LoanOwnershipTransferDataV1::getTotalOutstandingBalanceAmount).isEqualTo(expected.totalOutstanding())// + .extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingPrincipalPortion).isEqualTo(expected.principal())// + .extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingFeePortion).isEqualTo(expected.fee())// + .extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingPenaltyPortion).isEqualTo(expected.penalty())// + .extractingBigDecimal(LoanOwnershipTransferDataV1::getOutstandingInterestPortion).isEqualTo(expected.interest())// + .extractingBigDecimal(LoanOwnershipTransferDataV1::getOverPaymentPortion).isEqualTo(expected.overpayment()); } public void loanAccountSnapshotBusinessEventCheck(Long loanId, Long transferId) { waitForTransactionCommit(); - PageExternalTransferData response = ok(() -> fineractClient.externalAssetOwners().getTransfers(Map.of("loanId", loanId))); - List content = response.getContent(); - - ExternalTransferData filtered = content.stream().filter(t -> transferId.equals(t.getTransferId())).reduce((first, second) -> second) - .orElseThrow(() -> new IllegalStateException("No element found")); + ExternalTransferData filtered = findLastTransfer(loanId, transferId); BigDecimal totalOutstandingBalanceAmountExpected = zeroConversion(filtered.getDetails().getTotalOutstanding()); BigDecimal outstandingInterestPortionExpected = zeroConversion(filtered.getDetails().getTotalInterestOutstanding()); @@ -788,90 +688,238 @@ public void workingCapitalLoanStatusChangedEventCheck(final Long loanId) { workingCapitalLoanAccountDataV1Check(WorkingCapitalLoanStatusChangedEvent.class, loanId); } - public void workingCapitalLoanBalanceChangedEventCheck(final Long loanId) { + private GetWorkingCapitalLoansLoanIdResponse fetchWorkingCapitalLoan(final Long loanId) { + return ok(() -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId)); + } + + private void workingCapitalLoanEventMatchesApiCheck(final Class eventClazz, + final Long loanId, final BiConsumer payloadAsserts) { + waitForTransactionCommit(); + final GetWorkingCapitalLoansLoanIdResponse body = fetchWorkingCapitalLoan(loanId); + eventAssertion.assertEvent(eventClazz, loanId).extractingData(event -> { + payloadAsserts.accept(event, body); + return null; + }); + } + + private void workingCapitalLoanEventPayloadCheck(final Class eventClazz, final Long loanId, + final Consumer payloadAsserts) { waitForTransactionCommit(); - final GetWorkingCapitalLoansLoanIdResponse body = ok( - () -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId)); + eventAssertion.assertEvent(eventClazz, loanId).extractingData(event -> { + payloadAsserts.accept(event); + return null; + }); + } - eventAssertion.assertEvent(WorkingCapitalLoanBalanceChangedEvent.class, loanId).extractingData(event -> { + public void workingCapitalLoanBalanceChangedEventCheck(final Long loanId) { + workingCapitalLoanEventMatchesApiCheck(WorkingCapitalLoanBalanceChangedEvent.class, loanId, (event, body) -> { assertWorkingCapitalLoanAccountData(event, body); final WorkingCapitalLoanSummaryDataV1 eventSummary = event.getSummary(); final GetWorkingCapitalLoanSummary bodySummary = body.getSummary(); assertThat(eventSummary).isNotNull(); assertThat(bodySummary).isNotNull(); - assertThat(areBigDecimalValuesEqual(eventSummary.getPrincipalPaid(), bodySummary.getPrincipalPaid())).isTrue(); - assertThat(areBigDecimalValuesEqual(eventSummary.getPrincipalOutstanding(), bodySummary.getPrincipalOutstanding())).isTrue(); - assertThat(areBigDecimalValuesEqual(eventSummary.getTotalOutstanding(), bodySummary.getTotalOutstanding())).isTrue(); - assertThat(areBigDecimalValuesEqual(event.getTotalOverpaid(), bodySummary.getOverpayment())).isTrue(); - return null; + assertAmountEquals("summary.principalPaid", eventSummary.getPrincipalPaid(), bodySummary.getPrincipalPaid()); + assertAmountEquals("summary.principalOutstanding", eventSummary.getPrincipalOutstanding(), + bodySummary.getPrincipalOutstanding()); + assertAmountEquals("summary.totalOutstanding", eventSummary.getTotalOutstanding(), bodySummary.getTotalOutstanding()); + assertAmountEquals("totalOverpaid", event.getTotalOverpaid(), bodySummary.getOverpayment()); }); } public void workingCapitalLoanBalanceChangedEventChargesCheck(final Long loanId, final List> expectedCharges) { - waitForTransactionCommit(); - eventAssertion.assertEvent(WorkingCapitalLoanBalanceChangedEvent.class, loanId).extractingData(event -> { + workingCapitalLoanEventPayloadCheck(WorkingCapitalLoanBalanceChangedEvent.class, loanId, event -> { final List eventCharges = event.getCharges(); assertThat(eventCharges).isNotNull().hasSize(expectedCharges.size()); IntStream.range(0, expectedCharges.size()).forEach(i -> { final Map expected = expectedCharges.get(i); final WorkingCapitalLoanChargeDataV1 actual = eventCharges.get(i); - assertThat(actual.getAmount()).isNotNull(); - assertThat(actual.getAmountAccrued()).isNotNull(); - assertThat(actual.getAmountUnrecognized()).isNotNull(); - assertThat(areBigDecimalValuesEqual(actual.getAmount(), new BigDecimal(expected.get("amount")))).isTrue(); - assertThat(areBigDecimalValuesEqual(actual.getAmountAccrued(), new BigDecimal(expected.get("amountAccrued")))).isTrue(); - assertThat(areBigDecimalValuesEqual(actual.getAmountUnrecognized(), new BigDecimal(expected.get("amountUnrecognized")))) - .isTrue(); + assertAmountEquals("charges[" + i + "].amount", actual.getAmount(), new BigDecimal(expected.get("amount"))); + assertAmountEquals("charges[" + i + "].amountAccrued", actual.getAmountAccrued(), + new BigDecimal(expected.get("amountAccrued"))); + assertAmountEquals("charges[" + i + "].amountUnrecognized", actual.getAmountUnrecognized(), + new BigDecimal(expected.get("amountUnrecognized"))); }); - return null; }); } public void workingCapitalLoanDelinquencyRangeChangeEventCheck(final Long loanId) { - waitForTransactionCommit(); - final GetWorkingCapitalLoansLoanIdResponse body = ok( - () -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId)); - - eventAssertion.assertEvent(WorkingCapitalLoanDelinquencyRangeChangeEvent.class, loanId).extractingData(event -> { + workingCapitalLoanEventMatchesApiCheck(WorkingCapitalLoanDelinquencyRangeChangeEvent.class, loanId, (event, body) -> { assertWorkingCapitalLoanAccountData(event, body); final WorkingCapitalLoanCollectionDataV1 eventDelinquent = event.getDelinquent(); final WorkingCapitalCollection bodyDelinquent = body.getDelinquent(); assertThat(eventDelinquent).isNotNull(); assertThat(bodyDelinquent).isNotNull(); - assertThat(areBigDecimalValuesEqual(eventDelinquent.getDelinquentAmount(), bodyDelinquent.getDelinquentAmount())).isTrue(); - assertThat(areBigDecimalValuesEqual(eventDelinquent.getTotalDelinquentAmount(), bodyDelinquent.getDelinquentPrincipal())) - .isTrue(); + assertAmountEquals("delinquent.delinquentAmount", eventDelinquent.getDelinquentAmount(), bodyDelinquent.getDelinquentAmount()); + assertAmountEquals("delinquent.totalDelinquentAmount", eventDelinquent.getTotalDelinquentAmount(), + bodyDelinquent.getDelinquentPrincipal()); assertThat(eventDelinquent.getDelinquencySchedule()).isNotEmpty(); - return null; }); } - private void workingCapitalLoanAccountDataV1Check(final Class eventClazz, - final Long loanId) { - waitForTransactionCommit(); - final GetWorkingCapitalLoansLoanIdResponse body = ok( - () -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId)); + public void workingCapitalLoanBalanceChangedEventSummaryTotalsCheck(final Long loanId, final Map expectedTotals) { + workingCapitalLoanEventPayloadCheck(WorkingCapitalLoanBalanceChangedEvent.class, loanId, event -> { + final WorkingCapitalLoanSummaryDataV1 eventSummary = event.getSummary(); + assertThat(eventSummary).isNotNull(); + expectedTotals.forEach((column, expectedValue) -> assertAmountEquals("summary." + column, summaryTotalOf(eventSummary, column), + new BigDecimal(expectedValue))); + }); + } - eventAssertion.assertEvent(eventClazz, loanId).extractingData(event -> { + private BigDecimal summaryTotalOf(final WorkingCapitalLoanSummaryDataV1 summary, final String columnName) { + return switch (columnName) { + case "totalPayoutRefund" -> summary.getTotalPayoutRefund(); + case "totalPayoutRefundReversed" -> summary.getTotalPayoutRefundReversed(); + case "totalGoodwillCredit" -> summary.getTotalGoodwillCredit(); + case "totalGoodwillCreditReversed" -> summary.getTotalGoodwillCreditReversed(); + case "totalChargeAdjustment" -> summary.getTotalChargeAdjustment(); + case "totalChargeAdjustmentReversed" -> summary.getTotalChargeAdjustmentReversed(); + case "totalCreditBalanceRefund" -> summary.getTotalCreditBalanceRefund(); + case "totalCreditBalanceRefundReversed" -> summary.getTotalCreditBalanceRefundReversed(); + case "totalRepaymentTransaction" -> summary.getTotalRepaymentTransaction(); + case "totalRepaymentTransactionReversed" -> summary.getTotalRepaymentTransactionReversed(); + default -> throw new IllegalArgumentException("Unsupported summary transaction type total column: " + columnName); + }; + } + + public void workingCapitalLoanBalanceChangedEventChargesWithoutAccrualCheck(final Long loanId) { + workingCapitalLoanEventPayloadCheck(WorkingCapitalLoanBalanceChangedEvent.class, loanId, event -> { + final List eventCharges = event.getCharges(); + assertThat(eventCharges).isNotNull().isNotEmpty(); + eventCharges.forEach(charge -> { + assertThat(charge.getAmountAccrued()).as("amountAccrued of charge %s", charge.getId()).isNull(); + assertThat(charge.getAmountUnrecognized()).as("amountUnrecognized of charge %s", charge.getId()).isNull(); + }); + }); + } + + public void workingCapitalLoanDisbursalEventDeepPayloadCheck(final Long loanId) { + workingCapitalLoanEventMatchesApiCheck(WorkingCapitalLoanDisbursalEvent.class, loanId, (event, body) -> { assertWorkingCapitalLoanAccountData(event, body); - return null; + assertAmountEquals("principal", event.getPrincipal(), body.getPrincipal()); + assertAmountEquals("approvedPrincipal", event.getApprovedPrincipal(), body.getApprovedPrincipal()); + assertAmountEquals("netDisbursalAmount", event.getNetDisbursalAmount(), body.getNetDisbursalAmount()); + assertWorkingCapitalLoanTimelineData(event.getTimeline(), body.getTimeline()); + assertWorkingCapitalLoanBreachData(event.getBreach(), body); + assertWorkingCapitalLoanDelinquencyData(event.getDelinquency(), body); + }); + } + + private void assertWorkingCapitalLoanTimelineData(final LoanApplicationTimelineDataV1 eventTimeline, + final GetWorkingCapitalLoansLoanIdTimeline bodyTimeline) { + assertThat(eventTimeline).isNotNull(); + assertThat(bodyTimeline).isNotNull(); + assertEventDateEqualsApiDate("timeline.submittedOnDate", eventTimeline.getSubmittedOnDate(), bodyTimeline.getSubmittedOnDate()); + assertEventDateEqualsApiDate("timeline.approvedOnDate", eventTimeline.getApprovedOnDate(), bodyTimeline.getApprovedOnDate()); + assertEventDateEqualsApiDate("timeline.expectedDisbursementDate", eventTimeline.getExpectedDisbursementDate(), + bodyTimeline.getExpectedDisbursementDate()); + assertEventDateEqualsApiDate("timeline.actualDisbursementDate", eventTimeline.getActualDisbursementDate(), + bodyTimeline.getActualDisbursementDate()); + assertEventDateEqualsApiDate("timeline.expectedMaturityDate", eventTimeline.getExpectedMaturityDate(), + bodyTimeline.getExpectedMaturityDate()); + assertEventDateEqualsApiDate("timeline.closedOnDate", eventTimeline.getClosedOnDate(), bodyTimeline.getClosedOnDate()); + assertThat(eventTimeline.getSubmittedByUsername()).as("timeline.submittedByUsername") + .isEqualTo(bodyTimeline.getSubmittedByUsername()); + assertThat(eventTimeline.getApprovedByUsername()).as("timeline.approvedByUsername").isEqualTo(bodyTimeline.getApprovedByUsername()); + assertThat(eventTimeline.getDisbursedByUsername()).as("timeline.disbursedByUsername") + .isEqualTo(bodyTimeline.getDisbursedByUsername()); + } + + private void assertWorkingCapitalLoanBreachData(final WorkingCapitalBreachDataV1 eventBreach, + final GetWorkingCapitalLoansLoanIdResponse body) { + if (body.getBreach() == null && body.getBreachGraceDays() == null && body.getBreachStartDate() == null) { + return; + } + assertThat(eventBreach).as("breach").isNotNull(); + Optional.ofNullable(body.getBreach()).ifPresent(bodyBreach -> { + assertThat(eventBreach.getId()).as("breach.id").isEqualTo(bodyBreach.getId()); + assertThat(eventBreach.getName()).as("breach.name").isEqualTo(bodyBreach.getName()); + assertThat(eventBreach.getBreachFrequency()).as("breach.breachFrequency").isEqualTo(bodyBreach.getBreachFrequency()); + assertAmountEquals("breach.breachAmount", eventBreach.getBreachAmount(), bodyBreach.getBreachAmount()); }); + assertThat(eventBreach.getBreachGraceDays()).as("breach.breachGraceDays").isEqualTo(body.getBreachGraceDays()); + assertEventDateEqualsApiDate("breach.breachStartDate", eventBreach.getBreachStartDate(), body.getBreachStartDate()); + Optional.ofNullable(body.getNearBreach()).ifPresent(bodyNearBreach -> { + assertThat(eventBreach.getNearBreach()).as("breach.nearBreach").isNotNull(); + assertThat(eventBreach.getNearBreach().getFrequency()).as("breach.nearBreach.frequency") + .isEqualTo(bodyNearBreach.getFrequency()); + assertAmountEquals("breach.nearBreach.threshold", eventBreach.getNearBreach().getThreshold(), bodyNearBreach.getThreshold()); + }); + } + + private void assertWorkingCapitalLoanDelinquencyData(final WorkingCapitalLoanDelinquencyDataV1 eventDelinquency, + final GetWorkingCapitalLoansLoanIdResponse body) { + if (body.getDelinquencyStartType() == null && body.getDelinquencyStartDate() == null) { + return; + } + assertThat(eventDelinquency).as("delinquency").isNotNull(); + Optional.ofNullable(body.getDelinquencyStartType()).ifPresent(bodyStartType -> { + assertThat(eventDelinquency.getDelinquencyStartType()).as("delinquency.delinquencyStartType").isNotNull(); + assertThat(eventDelinquency.getDelinquencyStartType().getCode()).as("delinquency.delinquencyStartType.code") + .isEqualTo(bodyStartType.getCode()); + }); + assertEventDateEqualsApiDate("delinquency.delinquencyStartDate", eventDelinquency.getDelinquencyStartDate(), + body.getDelinquencyStartDate()); + } + + public void workingCapitalLoanStatusChangedEventTimelineCheck(final Long loanId) { + workingCapitalLoanEventMatchesApiCheck(WorkingCapitalLoanStatusChangedEvent.class, loanId, (event, body) -> { + assertWorkingCapitalLoanAccountData(event, body); + assertWorkingCapitalLoanTimelineData(event.getTimeline(), body.getTimeline()); + }); + } + + public void workingCapitalLoanBalanceChangedEventPausePeriodsCheck(final Long loanId) { + workingCapitalLoanEventMatchesApiCheck(WorkingCapitalLoanBalanceChangedEvent.class, loanId, (event, body) -> { + assertThat(event.getDelinquent()).isNotNull(); + final List eventPausePeriods = event.getDelinquent().getDelinquencyPausePeriods(); + assertThat(eventPausePeriods).isNotNull().isNotEmpty(); + assertThat(body.getDelinquent()).isNotNull(); + final List bodyPausePeriods = body.getDelinquent().getDelinquencyPausePeriods(); + assertThat(bodyPausePeriods).isNotNull().hasSize(eventPausePeriods.size()); + IntStream.range(0, eventPausePeriods.size()).forEach(i -> { + final DelinquencyPausePeriodV1 actual = eventPausePeriods.get(i); + final WorkingCapitalCollectionDelinquencyPausePeriod expected = bodyPausePeriods.get(i); + assertThat(actual.getActive()).as("delinquencyPausePeriods[%s].active", i).isEqualTo(expected.getActive()); + assertEventDateEqualsApiDate("delinquencyPausePeriods[" + i + "].pausePeriodStart", actual.getPausePeriodStart(), + expected.getPausePeriodStart()); + assertEventDateEqualsApiDate("delinquencyPausePeriods[" + i + "].pausePeriodEnd", actual.getPausePeriodEnd(), + expected.getPausePeriodEnd()); + }); + }); + } + + public void workingCapitalLoanStatusChangedEventNotRaisedCheck(final Long loanId) { + waitForTransactionCommit(); + eventAssertion.assertEventNotRaised(WorkingCapitalLoanStatusChangedEvent.class, loanId); + } + + public void workingCapitalLoanBalanceChangedEventNotRaisedCheck(final Long loanId) { + waitForTransactionCommit(); + eventAssertion.assertEventNotRaised(WorkingCapitalLoanBalanceChangedEvent.class, loanId); + } + + private static void assertEventDateEqualsApiDate(final String description, final String eventDate, final LocalDate apiDate) { + assertThat(eventDate).as(description).isEqualTo(apiDate == null ? null : FORMATTER_EVENTS.format(apiDate)); + } + + private void workingCapitalLoanAccountDataV1Check(final Class eventClazz, + final Long loanId) { + workingCapitalLoanEventMatchesApiCheck(eventClazz, loanId, this::assertWorkingCapitalLoanAccountData); } private void assertWorkingCapitalLoanAccountData(final WorkingCapitalLoanAccountDataV1 event, final GetWorkingCapitalLoansLoanIdResponse body) { - Assertions.assertNotNull(body.getStatus()); - assertThat(event.getStatus().getId().longValue()).isEqualTo(body.getStatus().getId()); + assertThat(body.getStatus()).isNotNull(); + assertThat(event.getStatus().getId().longValue()).as("status.id").isEqualTo(body.getStatus().getId()); assertThat(event.getDelinquent()).isNotNull(); assertThat(event.getDelinquent().getDelinquencySchedule()).isNotNull(); if (event.getDisbursementDetails() != null && !event.getDisbursementDetails().isEmpty() && body.getNetDisbursalAmount() != null) { - event.getDisbursementDetails() - .forEach(tranche -> assertThat(areBigDecimalValuesEqual(tranche.getNetDisbursalAmount(), body.getNetDisbursalAmount())) - .isTrue()); + event.getDisbursementDetails().forEach(tranche -> assertAmountEquals("disbursementDetails.netDisbursalAmount", + tranche.getNetDisbursalAmount(), body.getNetDisbursalAmount())); } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java index ceefd3d73e7..5c683bfa1f6 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java @@ -1501,6 +1501,41 @@ public void aWorkingCapitalLoanDelinquencyRangeChangeBusinessEventIsRaised() { eventCheckHelper.workingCapitalLoanDelinquencyRangeChangeEventCheck(getCreatedLoanId()); } + @Then("a Working Capital Loan Balance Changed business event is raised with transaction type totals:") + public void aWorkingCapitalLoanBalanceChangedBusinessEventIsRaisedWithTransactionTypeTotals(final DataTable table) { + eventCheckHelper.workingCapitalLoanBalanceChangedEventSummaryTotalsCheck(getCreatedLoanId(), table.asMaps().get(0)); + } + + @Then("a Working Capital Loan Balance Changed business event is raised where charge accrual fields are not populated") + public void aWorkingCapitalLoanBalanceChangedBusinessEventIsRaisedWithoutChargeAccrualFields() { + eventCheckHelper.workingCapitalLoanBalanceChangedEventChargesWithoutAccrualCheck(getCreatedLoanId()); + } + + @Then("a Working Capital Loan Disbursal business event is raised with loan details matching the API") + public void aWorkingCapitalLoanDisbursalBusinessEventIsRaisedWithLoanDetailsMatchingTheApi() { + eventCheckHelper.workingCapitalLoanDisbursalEventDeepPayloadCheck(getCreatedLoanId()); + } + + @Then("a Working Capital Loan Status Changed business event is raised with timeline matching the API") + public void aWorkingCapitalLoanStatusChangedBusinessEventIsRaisedWithTimelineMatchingTheApi() { + eventCheckHelper.workingCapitalLoanStatusChangedEventTimelineCheck(getCreatedLoanId()); + } + + @Then("a Working Capital Loan Balance Changed business event is raised with the delinquency pause periods") + public void aWorkingCapitalLoanBalanceChangedBusinessEventIsRaisedWithDelinquencyPausePeriods() { + eventCheckHelper.workingCapitalLoanBalanceChangedEventPausePeriodsCheck(getCreatedLoanId()); + } + + @Then("no Working Capital Loan Status Changed business event is raised") + public void noWorkingCapitalLoanStatusChangedBusinessEventIsRaised() { + eventCheckHelper.workingCapitalLoanStatusChangedEventNotRaisedCheck(getCreatedLoanId()); + } + + @Then("no Working Capital Loan Balance Changed business event is raised") + public void noWorkingCapitalLoanBalanceChangedBusinessEventIsRaised() { + eventCheckHelper.workingCapitalLoanBalanceChangedEventNotRaisedCheck(getCreatedLoanId()); + } + @Then("Admin fails to disburse the Working Capital loan on {string} with {string} EUR transaction amount because of not approved") public void disburseWCLoanFailureWithNotApproved(String actualDisbursementDate, String transactionAmount) { final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccountBusinessEvents.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccountBusinessEvents.feature index f5396866f43..7b8cc5dc1ca 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccountBusinessEvents.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccountBusinessEvents.feature @@ -1,7 +1,9 @@ @WorkingCapital @WorkingCapitalLoanAccountBusinessEventsFeature +@SerialChargeAccrualConfig Feature: Working Capital Loan Account Business Events + @TestRailId:C89791 Scenario: Working Capital loan raises Created, Application Modified, Approved, Undo Approval, Disbursal and Undo Disbursal business events across its lifecycle When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -14,10 +16,12 @@ Feature: Working Capital Loan Account Business Events | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | | | | 80.0 | | | | Then a Working Capital Loan Application Modified business event is raised + And no Working Capital Loan Status Changed business event is raised When Admin successfully approves the working capital loan on "01 January 2026" with "80" amount and expected disbursement date on "01 January 2026" Then Working capital loan approval was successful And a Working Capital Loan Approved business event is raised And a Working Capital Loan Status Changed business event is raised + And no Working Capital Loan Balance Changed business event is raised When Admin makes undo approval on the working capital loan Then Working capital loan undo approval was successful And a Working Capital Loan Undo Approval business event is raised @@ -27,12 +31,13 @@ Feature: Working Capital Loan Account Business Events And a Working Capital Loan Approved business event is raised And a Working Capital Loan Status Changed business event is raised When Admin successfully disburse the Working Capital loan on "01 January 2026" with "80" EUR transaction amount - Then a Working Capital Loan Disbursal business event is raised + Then a Working Capital Loan Disbursal business event is raised with loan details matching the API And a Working Capital Loan Status Changed business event is raised When Admin successfully undo Working Capital disbursal Then a Working Capital Loan Undo Disbursal business event is raised And a Working Capital Loan Status Changed business event is raised + @TestRailId:C89792 Scenario: Rejected Working Capital loan raises Rejected and Status Changed business events When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -45,6 +50,7 @@ Feature: Working Capital Loan Account Business Events And a Working Capital Loan Rejected business event is raised And a Working Capital Loan Status Changed business event is raised + @TestRailId:C89793 Scenario: Working Capital loan raises Balance Changed on partial repayment and Status Changed on full repayment When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -63,11 +69,16 @@ Feature: Working Capital Loan Account Business Events And Customer makes repayment on "10 January 2026" with 4000.0 transaction amount on Working Capital loan Then Working Capital loan status will be "ACTIVE" And a Working Capital Loan Balance Changed business event is raised + And no Working Capital Loan Status Changed business event is raised When Admin sets the business date to "20 January 2026" And Customer makes repayment on "20 January 2026" with 5000.0 transaction amount on Working Capital loan Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" - And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Status Changed business event is raised with timeline matching the API + And a Working Capital Loan Balance Changed business event is raised with transaction type totals: + | totalRepaymentTransaction | totalRepaymentTransactionReversed | + | 9000.0 | 0.0 | + @TestRailId:C89794 Scenario: Overpaid Working Capital loan raises Balance Changed and Status Changed business events through credit balance refund When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -90,8 +101,11 @@ Feature: Working Capital Loan Account Business Events When Customer makes credit balance refund on "20 January 2026" with 200.0 transaction amount on Working Capital loan Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" And a Working Capital Loan Status Changed business event is raised - And a Working Capital Loan Balance Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised with transaction type totals: + | totalRepaymentTransaction | totalCreditBalanceRefund | totalCreditBalanceRefundReversed | + | 9200.0 | 200.0 | 0.0 | + @TestRailId:C89795 Scenario: Delinquent Working Capital loan raises a Delinquency Range Change business event When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -111,7 +125,11 @@ Feature: Working Capital Loan Account Business Events When Admin closes the Working Capital loan with a full repayment on "15 February 2026" Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" And a Working Capital Loan Status Changed business event is raised + When Admin sets the business date to "16 February 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then a Working Capital Loan Delinquency Range Change business event is raised + @TestRailId:C89796 Scenario: Working Capital loan raises Balance Changed on discount fee, discount fee adjustment and undo of the adjustment When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -135,7 +153,7 @@ Feature: Working Capital Loan Account Business Events Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" And a Working Capital Loan Status Changed business event is raised - @SerialChargeAccrualConfig + @TestRailId:C89797 Scenario: Working Capital loan Balance Changed event exposes charge amountAccrued and amountUnrecognized before and after accrual Given Admin sets the business date to "01 January 2028" And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: @@ -146,6 +164,7 @@ Feature: Working Capital Loan Account Business Events And Admin sets the business date to "05 January 2028" And Admin runs inline COB job for Working Capital Loan by loanId And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "15 January 2028" due date and 50.0 transaction amount + And a Working Capital Loan Balance Changed business event is raised And Admin sets the business date to "10 January 2028" And Customer makes repayment on "10 January 2028" with 100.0 transaction amount on Working Capital loan Then a Working Capital Loan Balance Changed business event is raised with charges: @@ -157,3 +176,203 @@ Feature: Working Capital Loan Account Business Events And a Working Capital Loan Balance Changed business event is raised with charges: | amount | amountAccrued | amountUnrecognized | | 50.0 | 50.0 | 0.0 | + + @TestRailId:C89798 + Scenario: Working Capital loan Balance Changed events carry payout refund, goodwill credit and reversal totals in the summary + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + Then Working capital loan creation was successful + When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Balance Changed business event is raised + When Admin sets the business date to "05 January 2026" + And Customer makes "PAYOUT_REFUND" transaction on "05 January 2026" with 1000.0 transaction amount on Working Capital loan + Then a Working Capital Loan Balance Changed business event is raised with transaction type totals: + | totalPayoutRefund | totalPayoutRefundReversed | totalGoodwillCredit | totalRepaymentTransaction | + | 1000.0 | 0.0 | 0.0 | 0.0 | + When Customer makes "GOODWILL_CREDIT" transaction on "05 January 2026" with 500.0 transaction amount on Working Capital loan + Then a Working Capital Loan Balance Changed business event is raised with transaction type totals: + | totalPayoutRefund | totalGoodwillCredit | totalGoodwillCreditReversed | + | 1000.0 | 500.0 | 0.0 | + When Customer undo "1"th "PAYOUT_REFUND" transaction made on "05 January 2026" on Working Capital loan + Then a Working Capital Loan Balance Changed business event is raised with transaction type totals: + | totalPayoutRefund | totalPayoutRefundReversed | totalGoodwillCredit | + | 0.0 | 1000.0 | 500.0 | + When Admin closes the Working Capital loan with a full repayment on "05 January 2026" + + @TestRailId:C89799 + Scenario: Undoing a closing repayment reopens the Working Capital loan and raises Status Changed and Balance Changed business events + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | 0 | + Then Working capital loan creation was successful + When Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then a Working Capital Loan Status Changed business event is raised + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + When Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 100.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And a Working Capital Loan Status Changed business event is raised with timeline matching the API + And a Working Capital Loan Balance Changed business event is raised with transaction type totals: + | totalRepaymentTransaction | totalRepaymentTransactionReversed | + | 100.0 | 0.0 | + When Customer undo "1"th "REPAYMENT" transaction made on "10 January 2026" on Working Capital loan + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised with transaction type totals: + | totalRepaymentTransaction | totalRepaymentTransactionReversed | + | 0.0 | 100.0 | + When Admin closes the Working Capital loan with a full repayment on "10 January 2026" + + @TestRailId:C89800 + Scenario: Working Capital loan Balance Changed event exposes the delinquency pause periods + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + Then Working capital loan creation was successful + When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Balance Changed business event is raised + When Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan delinquency pause with startDate "01 January 2026" and endDate "15 January 2026" + And Admin sets the business date to "06 January 2026" + And Customer makes repayment on "06 January 2026" with 100.0 transaction amount on Working Capital loan + Then a Working Capital Loan Balance Changed business event is raised with the delinquency pause periods + When Admin closes the Working Capital loan with a full repayment on "06 January 2026" + + @TestRailId:C89801 + Scenario: Working Capital loan Balance Changed event leaves charge accrual fields empty for products without deferred revenue accrual accounting + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + Then Working capital loan creation was successful + When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Balance Changed business event is raised + When Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "15 January 2026" due date and 50.0 transaction amount + And Admin sets the business date to "10 January 2026" + And Customer makes repayment on "10 January 2026" with 100.0 transaction amount on Working Capital loan + Then a Working Capital Loan Balance Changed business event is raised where charge accrual fields are not populated + When Admin closes the Working Capital loan with a full repayment on "10 January 2026" + + @TestRailId:C89802 + Scenario: Adding a charge to a closed Working Capital loan reopens it and raises Status Changed and Balance Changed business events + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | 0 | + Then Working capital loan creation was successful + When Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then a Working Capital Loan Status Changed business event is raised + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 100.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + When Admin sets the business date to "10 January 2026" + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "20 January 2026" due date and 50.0 transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + When Admin closes the Working Capital loan with a full repayment on "10 January 2026" + + @TestRailId:C89803 + Scenario: A charge adjustment that settles the outstanding charge closes the Working Capital loan and raises Status Changed and Balance Changed business events + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | 0 | + Then Working capital loan creation was successful + When Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then a Working Capital Loan Status Changed business event is raised + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 100.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + When Admin sets the business date to "10 January 2026" + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "20 January 2026" due date and 50.0 transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + When Admin makes a charge adjustment for the last added charge with 50.0 amount on working capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + + @TestRailId:C89804 + Scenario: A partial charge adjustment on an active Working Capital loan raises Balance Changed without Status Changed + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + Then Working capital loan creation was successful + When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then a Working Capital Loan Status Changed business event is raised + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + When Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "15 January 2026" due date and 50.0 transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Balance Changed business event is raised + And no Working Capital Loan Status Changed business event is raised + When Admin makes a charge adjustment for the last added charge with 20.0 amount on working capital loan + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Balance Changed business event is raised + And no Working Capital Loan Status Changed business event is raised + When Admin closes the Working Capital loan with a full repayment on "01 January 2026" + + @TestRailId:C89805 + Scenario: Undoing a discount fee adjustment is rejected once the adjustment has closed the Working Capital loan and no loan events are raised + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | | + Then Working capital loan creation was successful + When Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then a Working Capital Loan Status Changed business event is raised + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + When Admin adds Discount fee with "12" amount on Working Capital loan account for last disbursement + Then a Working Capital Loan Balance Changed business event is raised + When Admin sets the business date to "05 January 2026" + And Customer makes repayment on "05 January 2026" with 100.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Balance Changed business event is raised + When Admin adds Discount fee adjustment with "12" amount on Working Capital loan account for last discount + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised + Then Undo the last Discount fee adjustment on Working Capital loan account failed due to non active loan with status code 400 + And no Working Capital Loan Status Changed business event is raised + And no Working Capital Loan Balance Changed business event is raised From 4a2e488d66f5d2593167f22ca88032b73893d8f5 Mon Sep 17 00:00:00 2001 From: Oleksii Novikov Date: Wed, 29 Jul 2026 11:06:08 +0300 Subject: [PATCH 3/3] FINERACT-2455: Name delinquency range in WC events. Add breach schedule. Allow undo of closed discount fee adjustment --- .../v1/WorkingCapitalBreachDataV1.avsc | 12 +++ ...CapitalLoanBreachSchedulePeriodDataV1.avsc | 79 +++++++++++++++++++ ...alLoanDelinquencySchedulePeriodDataV1.avsc | 12 +++ ...pitalLoanDelinquencyScheduleTagDataV1.avsc | 48 +++++++++++ .../v1/WorkingCapitalLoanSummaryDataV1.avsc | 1 - .../test/helper/ErrorMessageHelper.java | 2 +- .../messaging/event/EventCheckHelper.java | 59 ++++++++++++++ .../WorkingCapitalLoanAccountStepDef.java | 5 ++ ...ngCapitalLoanAccountBusinessEvents.feature | 11 +-- .../WorkingCapitalLoanDataValidator.java | 6 +- .../WorkingCapitalLoanAccountDataMapper.java | 14 +++- ...ingCapitalLoanBusinessEventSerializer.java | 31 +++++++- ...ngCapitalLoanWritePlatformServiceImpl.java | 9 +++ ...rkingCapitalLoanAccountDataMapperTest.java | 38 +++++++++ 14 files changed, 315 insertions(+), 12 deletions(-) create mode 100644 fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanBreachSchedulePeriodDataV1.avsc create mode 100644 fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencyScheduleTagDataV1.avsc diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalBreachDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalBreachDataV1.avsc index 9096e788453..10f967f787d 100644 --- a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalBreachDataV1.avsc +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalBreachDataV1.avsc @@ -74,6 +74,18 @@ "null", "org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalNearBreachDataV1" ] + }, + { + "default": null, + "name": "breachSchedule", + "doc": "Current breach schedule periods.", + "type": [ + "null", + { + "type": "array", + "items": "org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanBreachSchedulePeriodDataV1" + } + ] } ] } diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanBreachSchedulePeriodDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanBreachSchedulePeriodDataV1.avsc new file mode 100644 index 00000000000..14ea5f12293 --- /dev/null +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanBreachSchedulePeriodDataV1.avsc @@ -0,0 +1,79 @@ +{ + "name": "WorkingCapitalLoanBreachSchedulePeriodDataV1", + "namespace": "org.apache.fineract.avro.workingcapitalloan.v1", + "type": "record", + "fields": [ + { + "default": null, + "name": "periodNumber", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "fromDate", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "toDate", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "numberOfDays", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "minPaymentAmount", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "outstandingAmount", + "type": [ + "null", + "bigdecimal" + ] + }, + { + "default": null, + "name": "nearBreach", + "type": [ + "null", + "boolean" + ] + }, + { + "default": null, + "name": "breach", + "type": [ + "null", + "boolean" + ] + }, + { + "default": null, + "name": "reset", + "type": [ + "null", + "boolean" + ] + } + ] +} diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencySchedulePeriodDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencySchedulePeriodDataV1.avsc index 9ec0007da07..b53436485ab 100644 --- a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencySchedulePeriodDataV1.avsc +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencySchedulePeriodDataV1.avsc @@ -74,6 +74,18 @@ "null", "bigdecimal" ] + }, + { + "default": null, + "name": "tags", + "doc": "Active delinquency range tags currently applied to this schedule period.", + "type": [ + "null", + { + "type": "array", + "items": "org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencyScheduleTagDataV1" + } + ] } ] } diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencyScheduleTagDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencyScheduleTagDataV1.avsc new file mode 100644 index 00000000000..a43c8f895a7 --- /dev/null +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanDelinquencyScheduleTagDataV1.avsc @@ -0,0 +1,48 @@ +{ + "name": "WorkingCapitalLoanDelinquencyScheduleTagDataV1", + "namespace": "org.apache.fineract.avro.workingcapitalloan.v1", + "type": "record", + "fields": [ + { + "default": null, + "name": "rangeId", + "type": [ + "null", + "long" + ] + }, + { + "default": null, + "name": "classification", + "doc": "Name of the delinquency range this schedule period is currently classified into.", + "type": [ + "null", + "string" + ] + }, + { + "default": null, + "name": "minimumAgeDays", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "maximumAgeDays", + "type": [ + "null", + "int" + ] + }, + { + "default": null, + "name": "delinquentAmount", + "type": [ + "null", + "bigdecimal" + ] + } + ] +} diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanSummaryDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanSummaryDataV1.avsc index 3ffae5d0e92..e01c157c357 100644 --- a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanSummaryDataV1.avsc +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanSummaryDataV1.avsc @@ -22,7 +22,6 @@ { "default": null, "name": "principalAdjustments", - "doc": "Always null: working capital has no chargeback, the only source of principal adjustments.", "type": [ "null", "bigdecimal" diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java index d9bcc74c6f0..7f9b07674a4 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java @@ -1151,7 +1151,7 @@ public static String discountAdjustmentUndoTransactionNotFoundFailure() { } public static String discountAdjustmentUndoNotActiveLoanFailure() { - return "Failed data validation due to: undo.discount.adjustment.only.allowed.for.active.loan."; + return "Failed data validation due to: undo.transaction.not.allowed.for.loan.status."; } public static String nearBreachCannotEnableWithoutBreachFailure() { diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java index 8bb1fd5f2d9..99aa90d340c 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/messaging/event/EventCheckHelper.java @@ -26,8 +26,10 @@ import java.math.MathContext; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -48,6 +50,8 @@ import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanChargeDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanCollectionDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencyDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencySchedulePeriodDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencyScheduleTagDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanSummaryDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanTransactionDataV1; import org.apache.fineract.client.feign.FineractFeignClient; @@ -70,6 +74,7 @@ import org.apache.fineract.client.models.PostLoansResponse; import org.apache.fineract.client.models.WorkingCapitalCollection; import org.apache.fineract.client.models.WorkingCapitalCollectionDelinquencyPausePeriod; +import org.apache.fineract.client.models.WorkingCapitalCollectionRangeScheduleDelinquency; import org.apache.fineract.test.data.AssetExternalizationTransferStatus; import org.apache.fineract.test.data.AssetExternalizationTransferStatusReason; import org.apache.fineract.test.data.TransactionType; @@ -758,6 +763,57 @@ public void workingCapitalLoanDelinquencyRangeChangeEventCheck(final Long loanId }); } + public void workingCapitalLoanDelinquencyRangeChangeEventNamesRangeCheck(final Long loanId) { + workingCapitalLoanEventMatchesApiCheck(WorkingCapitalLoanDelinquencyRangeChangeEvent.class, loanId, (event, body) -> { + assertWorkingCapitalLoanAccountData(event, body); + + final WorkingCapitalLoanCollectionDataV1 eventDelinquent = event.getDelinquent(); + final WorkingCapitalCollection bodyDelinquent = body.getDelinquent(); + assertThat(eventDelinquent).isNotNull(); + assertThat(bodyDelinquent).isNotNull(); + assertAmountEquals("delinquent.delinquentAmount", eventDelinquent.getDelinquentAmount(), bodyDelinquent.getDelinquentAmount()); + assertAmountEquals("delinquent.totalDelinquentAmount", eventDelinquent.getTotalDelinquentAmount(), + bodyDelinquent.getDelinquentPrincipal()); + + final List schedule = eventDelinquent.getDelinquencySchedule(); + assertThat(schedule).as("delinquent.delinquencySchedule").isNotEmpty(); + final List eventTags = schedule.stream() + .map(WorkingCapitalLoanDelinquencySchedulePeriodDataV1::getTags).filter(Objects::nonNull).flatMap(List::stream) + .sorted(Comparator + .comparing(WorkingCapitalLoanDelinquencyScheduleTagDataV1::getRangeId, + Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparing(WorkingCapitalLoanDelinquencyScheduleTagDataV1::getDelinquentAmount, + Comparator.nullsFirst(Comparator.naturalOrder()))) + .toList(); + assertThat(eventTags).as("delinquent.delinquencySchedule[].tags").isNotEmpty(); + + final List apiTags = Optional + .ofNullable(bodyDelinquent.getInstallmentLevelDelinquency()).orElse(List.of()).stream() + .sorted(Comparator + .comparing(WorkingCapitalCollectionRangeScheduleDelinquency::getRangeId, + Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparing(WorkingCapitalCollectionRangeScheduleDelinquency::getDelinquentAmount, + Comparator.nullsFirst(Comparator.naturalOrder()))) + .toList(); + assertThat(eventTags).as("delinquent.delinquencySchedule[].tags vs API delinquent.installmentLevelDelinquency") + .hasSize(apiTags.size()); + IntStream.range(0, eventTags.size()).forEach(i -> { + final WorkingCapitalLoanDelinquencyScheduleTagDataV1 actual = eventTags.get(i); + final WorkingCapitalCollectionRangeScheduleDelinquency expected = apiTags.get(i); + assertThat(actual.getRangeId()).as("tags[%s].rangeId", i).isEqualTo(expected.getRangeId()); + assertThat(actual.getClassification()).as("tags[%s].classification", i).isNotBlank() + .isEqualTo(expected.getClassification()); + assertThat(actual.getMinimumAgeDays()).as("tags[%s].minimumAgeDays", i).isEqualTo(expected.getMinimumAgeDays()); + assertThat(actual.getMaximumAgeDays()).as("tags[%s].maximumAgeDays", i).isEqualTo(expected.getMaximumAgeDays()); + if (expected.getDelinquentAmount() == null) { + assertThat(actual.getDelinquentAmount()).as("tags[%s].delinquentAmount", i).isNull(); + } else { + assertAmountEquals("tags[" + i + "].delinquentAmount", actual.getDelinquentAmount(), expected.getDelinquentAmount()); + } + }); + }); + } + public void workingCapitalLoanBalanceChangedEventSummaryTotalsCheck(final Long loanId, final Map expectedTotals) { workingCapitalLoanEventPayloadCheck(WorkingCapitalLoanBalanceChangedEvent.class, loanId, event -> { final WorkingCapitalLoanSummaryDataV1 eventSummary = event.getSummary(); @@ -917,6 +973,9 @@ private void assertWorkingCapitalLoanAccountData(final WorkingCapitalLoanAccount assertThat(event.getDelinquent()).isNotNull(); assertThat(event.getDelinquent().getDelinquencySchedule()).isNotNull(); + assertThat(event.getBreach()).isNotNull(); + assertThat(event.getBreach().getBreachSchedule()).isNotNull(); + if (event.getDisbursementDetails() != null && !event.getDisbursementDetails().isEmpty() && body.getNetDisbursalAmount() != null) { event.getDisbursementDetails().forEach(tranche -> assertAmountEquals("disbursementDetails.netDisbursalAmount", tranche.getNetDisbursalAmount(), body.getNetDisbursalAmount())); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java index 5c683bfa1f6..9883024505c 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java @@ -1501,6 +1501,11 @@ public void aWorkingCapitalLoanDelinquencyRangeChangeBusinessEventIsRaised() { eventCheckHelper.workingCapitalLoanDelinquencyRangeChangeEventCheck(getCreatedLoanId()); } + @Then("a Working Capital Loan Delinquency Range Change business event is raised naming the delinquency range") + public void aWorkingCapitalLoanDelinquencyRangeChangeBusinessEventIsRaisedNamingTheDelinquencyRange() { + eventCheckHelper.workingCapitalLoanDelinquencyRangeChangeEventNamesRangeCheck(getCreatedLoanId()); + } + @Then("a Working Capital Loan Balance Changed business event is raised with transaction type totals:") public void aWorkingCapitalLoanBalanceChangedBusinessEventIsRaisedWithTransactionTypeTotals(final DataTable table) { eventCheckHelper.workingCapitalLoanBalanceChangedEventSummaryTotalsCheck(getCreatedLoanId(), table.asMaps().get(0)); diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccountBusinessEvents.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccountBusinessEvents.feature index 7b8cc5dc1ca..191b108cad9 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccountBusinessEvents.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccountBusinessEvents.feature @@ -121,7 +121,7 @@ Feature: Working Capital Loan Account Business Events And Admin runs inline COB job for Working Capital Loan by loanId When Admin sets the business date to "15 February 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then a Working Capital Loan Delinquency Range Change business event is raised + Then a Working Capital Loan Delinquency Range Change business event is raised naming the delinquency range When Admin closes the Working Capital loan with a full repayment on "15 February 2026" Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" And a Working Capital Loan Status Changed business event is raised @@ -350,7 +350,7 @@ Feature: Working Capital Loan Account Business Events When Admin closes the Working Capital loan with a full repayment on "01 January 2026" @TestRailId:C89805 - Scenario: Undoing a discount fee adjustment is rejected once the adjustment has closed the Working Capital loan and no loan events are raised + Scenario: Undoing a discount fee adjustment that closed the Working Capital loan reopens it and raises Status Changed and Balance Changed business events When Admin sets the business date to "01 January 2026" And Admin creates a client with random data And Admin creates a working capital loan with the following data: @@ -373,6 +373,7 @@ Feature: Working Capital Loan Account Business Events Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" And a Working Capital Loan Status Changed business event is raised And a Working Capital Loan Balance Changed business event is raised - Then Undo the last Discount fee adjustment on Working Capital loan account failed due to non active loan with status code 400 - And no Working Capital Loan Status Changed business event is raised - And no Working Capital Loan Balance Changed business event is raised + When Admin undo the last Discount fee adjustment on Working Capital loan account + Then Working Capital loan status will be "ACTIVE" + And a Working Capital Loan Status Changed business event is raised + And a Working Capital Loan Balance Changed business event is raised diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java index 604f5944d9e..37f7804ed7d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java @@ -275,9 +275,11 @@ public void validateUndoDiscountAdjustmentTransaction(final WorkingCapitalLoan l baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.loanProductRelatedDetailsParamName) .failWithCode("discount.not.available"); } - if (loan.getLoanStatus() == null || !loan.getLoanStatus().isActive()) { + final LoanStatus loanStatus = loan.getLoanStatus(); + final boolean undoAllowedForStatus = LoanStatus.ACTIVE.equals(loanStatus) || LoanStatus.CLOSED_OBLIGATIONS_MET.equals(loanStatus); + if (!undoAllowedForStatus) { baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.loanStatusParamName) - .failWithCode("undo.discount.adjustment.only.allowed.for.active.loan"); + .failWithCode("undo.transaction.not.allowed.for.loan.status"); } throwExceptionIfValidationWarningsExist(dataValidationErrors); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapper.java index f00232e6d4a..aaf5e315754 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapper.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapper.java @@ -22,17 +22,21 @@ import java.math.RoundingMode; import java.util.List; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanAccountDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanBreachSchedulePeriodDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanChargeDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanCollectionDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencySchedulePeriodDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencyScheduleTagDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDisbursementDetailDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanSummaryDataV1; import org.apache.fineract.infrastructure.event.external.service.serialization.mapper.support.AvroMapperConfig; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanBreachScheduleData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanChargeData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanCollectionData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDelinquencyRangeScheduleData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDisbursementDetailData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanRangeScheduleDelinquencyData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanSummaryData; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @@ -59,6 +63,7 @@ public interface WorkingCapitalLoanAccountDataMapper { @Mapping(source = "breachGraceDays", target = "breach.breachGraceDays") @Mapping(source = "breachStartDate", target = "breach.breachStartDate") @Mapping(source = "nearBreach", target = "breach.nearBreach") + @Mapping(target = "breach.breachSchedule", ignore = true) @Mapping(target = "overpaidOnDate", ignore = true) @Mapping(target = "customData", ignore = true) WorkingCapitalLoanAccountDataV1 map(WorkingCapitalLoanData source); @@ -71,7 +76,7 @@ public interface WorkingCapitalLoanAccountDataMapper { @Mapping(source = "penalty", target = "penaltyChargesCharged") @Mapping(source = "penaltyPaid", target = "penaltyChargesPaid") @Mapping(source = "penaltyOutstanding", target = "penaltyChargesOutstanding") - @Mapping(target = "principalAdjustments", ignore = true) + @Mapping(source = "principalAdjustment", target = "principalAdjustments") @Mapping(target = "principalWrittenOff", ignore = true) @Mapping(target = "feeChargesWrittenOff", ignore = true) @Mapping(target = "penaltyChargesWrittenOff", ignore = true) @@ -110,11 +115,18 @@ public interface WorkingCapitalLoanAccountDataMapper { @Mapping(target = "netDisbursalAmount", ignore = true) WorkingCapitalLoanDisbursementDetailDataV1 map(WorkingCapitalLoanDisbursementDetailData source); + @Mapping(target = "tags", ignore = true) WorkingCapitalLoanDelinquencySchedulePeriodDataV1 map(WorkingCapitalLoanDelinquencyRangeScheduleData source); List mapDelinquencySchedule( List source); + WorkingCapitalLoanDelinquencyScheduleTagDataV1 map(WorkingCapitalLoanRangeScheduleDelinquencyData source); + + WorkingCapitalLoanBreachSchedulePeriodDataV1 map(WorkingCapitalLoanBreachScheduleData source); + + List mapBreachSchedule(List source); + @Named("toAvroDecimalScale") default BigDecimal toAvroDecimalScale(final BigDecimal value) { return value == null ? null : value.setScale(AVRO_DECIMAL_SCALE, RoundingMode.HALF_UP); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/serializer/WorkingCapitalLoanBusinessEventSerializer.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/serializer/WorkingCapitalLoanBusinessEventSerializer.java index 19cb10f1701..18308f62bed 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/serializer/WorkingCapitalLoanBusinessEventSerializer.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/serializer/WorkingCapitalLoanBusinessEventSerializer.java @@ -28,8 +28,11 @@ import lombok.RequiredArgsConstructor; import org.apache.avro.generic.GenericContainer; import org.apache.fineract.avro.generator.ByteBufferSerializable; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalBreachDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanAccountDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanCollectionDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencySchedulePeriodDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencyScheduleTagDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanSummaryDataV1; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.event.business.domain.BusinessEvent; @@ -45,8 +48,12 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelationRepository; +import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanBreachScheduleMapper; import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanDelinquencyRangeScheduleMapper; +import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanDelinquencyRangeScheduleTagHistoryMapper; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBreachScheduleRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyRangeScheduleRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanDelinquencyRangeScheduleTagHistoryRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.apache.fineract.portfolio.workingcapitalloan.serialization.mapper.WorkingCapitalLoanAccountDataMapper; import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanApplicationReadPlatformService; @@ -67,6 +74,10 @@ public class WorkingCapitalLoanBusinessEventSerializer private final WorkingCapitalLoanTransactionRepository transactionRepository; private final WorkingCapitalLoanDelinquencyRangeScheduleRepository rangeScheduleRepository; private final WorkingCapitalLoanDelinquencyRangeScheduleMapper rangeScheduleMapper; + private final WorkingCapitalLoanDelinquencyRangeScheduleTagHistoryRepository tagHistoryRepository; + private final WorkingCapitalLoanDelinquencyRangeScheduleTagHistoryMapper tagHistoryMapper; + private final WorkingCapitalLoanBreachScheduleRepository breachScheduleRepository; + private final WorkingCapitalLoanBreachScheduleMapper breachScheduleMapper; private final AvroDateTimeMapper avroDateTimeMapper; private final List> externalEventCustomDataSerializers; private final List chargeExternalEventCustomDataSerializers; @@ -90,6 +101,7 @@ public ByteBufferSerializable toAvroDTO(BusinessEvent rawEvent) { populateSummaryTransactionTypeTotals(loan, result); populateLastTransactions(loan, result); populateDelinquencySchedule(loan, result); + populateBreachSchedule(loan, result); result.setCustomData(collectCustomData(event)); return result; } @@ -176,8 +188,23 @@ private void populateDelinquencySchedule(final WorkingCapitalLoan loan, final Wo if (delinquent == null) { return; } - delinquent.setDelinquencySchedule(mapper.mapDelinquencySchedule( - rangeScheduleMapper.toDataList(rangeScheduleRepository.findByLoanIdOrderByPeriodNumberAsc(loan.getId())))); + final List periods = mapper.mapDelinquencySchedule( + rangeScheduleMapper.toDataList(rangeScheduleRepository.findByLoanIdOrderByPeriodNumberAsc(loan.getId()))); + final Map> tagsByPeriod = tagHistoryRepository + .findByLoanIdAndLiftedOnDateIsNull(loan.getId()).stream() + .collect(Collectors.groupingBy(tag -> tag.getRangeSchedule().getPeriodNumber(), + Collectors.mapping(tag -> mapper.map(tagHistoryMapper.mapForCollectionData(tag)), Collectors.toList()))); + periods.forEach(period -> period.setTags(tagsByPeriod.get(period.getPeriodNumber()))); + delinquent.setDelinquencySchedule(periods); + } + + private void populateBreachSchedule(final WorkingCapitalLoan loan, final WorkingCapitalLoanAccountDataV1 result) { + final WorkingCapitalBreachDataV1 breach = result.getBreach(); + if (breach == null) { + return; + } + breach.setBreachSchedule(mapper.mapBreachSchedule( + breachScheduleMapper.toDataList(breachScheduleRepository.findByLoanIdOrderByPeriodNumberAsc(loan.getId())))); } @Override diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java index 63d4abcd976..281898cac35 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java @@ -677,14 +677,23 @@ private CommandProcessingResult undoDiscountFeeAdjustment(final WorkingCapitalLo updateBalanceForDiscountChange(loan, adjustmentTransaction.getTransactionAmount().negate(), true); // The principal change moves the remaining-balance cap, so the delinquency schedule must be re-derived. delinquencyRangeScheduleService.reprocessDelinquencySchedule(loan); + + // Restoring the outstanding can reopen a loan the adjustment had closed, so re-run the status transition. + final LocalDate reversedOnDate = adjustmentTransaction.getReversedOnDate(); + final LoanStatus oldStatus = loan.getLoanStatus(); + + stateMachine.determineAndTransition(loan, reversedOnDate); + loanRepository.saveAndFlush(loan); final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName); createNote(noteText, loan); notifyBalanceChanged(loan); + notifyStatusChanged(loan, oldStatus); final Map changes = new LinkedHashMap<>(); + changes.put("status", loan.getLoanStatus()); if (StringUtils.isNotBlank(noteText)) { changes.put(WorkingCapitalLoanConstants.noteParamName, noteText); } diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapperTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapperTest.java index 332a7ea799c..1244be9d6aa 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapperTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/mapper/WorkingCapitalLoanAccountDataMapperTest.java @@ -31,6 +31,7 @@ import org.apache.fineract.avro.loan.v1.LoanStatusEnumDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalBreachDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanAccountDataV1; +import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanBreachSchedulePeriodDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanChargeDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanCollectionDataV1; import org.apache.fineract.avro.workingcapitalloan.v1.WorkingCapitalLoanDelinquencyDataV1; @@ -49,6 +50,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanApplicationTimelineData; import org.apache.fineract.portfolio.loanaccount.data.LoanStatusEnumData; import org.apache.fineract.portfolio.loanorigination.data.LoanOriginatorData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanBreachScheduleData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanChargeData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanCollectionData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanData; @@ -254,6 +256,9 @@ void map_loanData_mapsBreachWithNearBreachFully() { assertEquals(1, nearBreach.getFrequency()); assertStringEnum(fullNearBreach().getFrequencyType(), nearBreach.getFrequencyType()); assertEquals(new BigDecimal("450.00"), nearBreach.getThreshold()); + + // serializer-only field stays unmapped + assertNull(breach.getBreachSchedule()); } @Test @@ -384,6 +389,34 @@ void mapDelinquencySchedule_mapsEveryPeriod() { assertEquals(2, result.getFirst().getPeriodNumber()); } + @Test + void map_breachSchedulePeriod_coversAllFields() { + final WorkingCapitalLoanBreachScheduleData source = fullBreachSchedulePeriod(); + + final WorkingCapitalLoanBreachSchedulePeriodDataV1 result = mapper.map(source); + + assertNotNull(result); + assertEquals(4, result.getPeriodNumber()); + assertEquals("2024-02-01", result.getFromDate()); + assertEquals("2024-03-01", result.getToDate()); + assertEquals(29, result.getNumberOfDays()); + assertEquals(new BigDecimal("120.00"), result.getMinPaymentAmount()); + assertEquals(new BigDecimal("70.00"), result.getOutstandingAmount()); + assertEquals(Boolean.TRUE, result.getNearBreach()); + assertEquals(Boolean.FALSE, result.getBreach()); + assertEquals(Boolean.FALSE, result.getReset()); + } + + @Test + void mapBreachSchedule_mapsEveryPeriod() { + final List result = mapper + .mapBreachSchedule(List.of(fullBreachSchedulePeriod(), fullBreachSchedulePeriod())); + + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals(4, result.getFirst().getPeriodNumber()); + } + @Test void map_charge_coversAllFields() { final WorkingCapitalLoanChargeData source = fullCharge(); @@ -538,6 +571,11 @@ private static WorkingCapitalLoanDelinquencyRangeScheduleData fullSchedulePeriod new BigDecimal("150.00"), new BigDecimal("50.00"), new BigDecimal("100.00"), Boolean.FALSE, 12L, new BigDecimal("100.00")); } + private static WorkingCapitalLoanBreachScheduleData fullBreachSchedulePeriod() { + return new WorkingCapitalLoanBreachScheduleData(15L, 101L, 4, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1), 29, + new BigDecimal("120.00"), new BigDecimal("70.00"), Boolean.TRUE, Boolean.FALSE, Boolean.FALSE); + } + private static WorkingCapitalLoanChargeData fullCharge() { return WorkingCapitalLoanChargeData.builder().id(7L).chargeId(11L).name("Processing fee") .chargeTimeType(new EnumOptionData(1L, "chargeTimeType.disbursement", "Disbursement"))