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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,15 @@ public CommandWrapperBuilder updatePeriodPaymentRateWorkingCapitalLoanApplicatio
return this;
}

public CommandWrapperBuilder markAsFraudWorkingCapitalLoan(final Long loanId) {
this.actionName = "SETFRAUD";
this.entityName = "WORKINGCAPITALLOAN";
this.entityId = loanId;
this.loanId = loanId;
this.href = "/working-capital-loans/" + loanId + "/mark-as-fraud";
return this;
}

public CommandWrapperBuilder createNearBreachActionWorkingCapitalLoan(final Long loanId) {
this.actionName = "CREATE";
this.entityName = "WC_NEAR_BREACH_ACTION";
Expand Down
1 change: 1 addition & 0 deletions fineract-doc/src/docs/en/chapters/features/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ include::working-capital-disable/index.adoc[leveloffset=+1]
include::working-capital-accrual-deferred-revenue-amortization-accounting.adoc[leveloffset=+1]
include::working-capital-eir-calculation-accounting.adoc[leveloffset=+1]
include::working-capital-loan-start-dates.adoc[leveloffset=+1]
include::working-capital-mark-as-fraud.adoc[leveloffset=+1]
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
= Working Capital Loan — Mark as Fraud

== Purpose

Marking a Working Capital Loan as fraudulent flags an account that has been identified as fraud, so that a
later charge-off routes the write-off to a dedicated *fraud expense* account instead of the regular
charge-off expense account.

The fraud flag is *independent of the charge-off action itself*: it only records that the loan is
fraudulent. It has no accounting effect until the loan is charged off.

== Functionality

* `fraud` is a boolean flag on the loan account (column `is_fraud` on `m_wc_loan`), defaulting to `false`.
* A loan can be marked as fraud *only while it is active*. Clearing the flag is allowed regardless of status.
* Marking a loan as fraud *does not change the loan status* — the loan keeps its `ACTIVE` status.
* The operation is idempotent: re-sending the same value is a no-op and reports no changes.

== Validation Rules

* `fraud` is *required* and must be a boolean (`true` or `false`).
* The loan must be *active* to be marked as fraud (`fraud = true`). Clearing the flag (`fraud = false`) is
allowed in any status.

== Authorization

The command requires the `SETFRAUD_WORKINGCAPITALLOAN` permission. Users with `ALL_FUNCTIONS` (super user)
are allowed implicitly. Maker-checker is supported and can be enabled for this permission; it is disabled by
default.

== API

[source]
----
PUT /v1/working-capital-loans/{loanId}/mark-as-fraud
----

[source,json]
----
{
"fraud": true
}
----

Response (`CommandProcessingResult`):

[source,json]
----
{
"officeId": 1,
"clientId": 5,
"loanId": 12,
"resourceId": 12,
"changes": {
"fraud": true
}
}
----

`changes` is empty when the flag already had the requested value.

== Retrieval

The current value is exposed as the `fraud` field of the loan retrieval response:

[source]
----
GET /v1/working-capital-loans/{loanId}
----

== Accounting Impact

Marking a loan as fraud produces *no journal entries* on its own. The flag is consumed at charge-off time:
a charged-off fraudulent loan debits the *Charge-Off Fraud Expense* account instead of the regular
*Charge-Off Expense* account. All other charge-off processing is identical.

NOTE: This command is served through the legacy command-source pipeline, like every other Working Capital
Loan command, so it is recorded in the command audit trail and can participate in maker-checker.
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ public Long insertLoan(final LoanStatus status, final LocalDate lastClosedBusine
.addValue("principal_amount_proposed", DEFAULT_PRINCIPAL)//
.addValue("approved_principal", DEFAULT_PRINCIPAL)//
.addValue("total_payment_volume", DEFAULT_PRINCIPAL)//
.addValue("breach_start_type", "DISBURSEMENT");
.addValue("breach_start_type", "DISBURSEMENT")//
.addValue("is_fraud", Boolean.FALSE);
final Number key = wcLoanInsert.executeAndReturnKey(params);
return Objects.requireNonNull(key, "Generated key must not be null").longValue();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ Feature: Working Capital Loan Details
| paymentAllocation.0.paymentAllocationOrder | present |
| originators.size | 0 |
| enableInstallmentLevelDelinquency | true |
| fraud | null |
| fraud | false |
| chargedOff | null |
And Working capital loan details has the auto-generated fields present

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ private WorkingCapitalLoanConstants() {
public static final String discountExternalIdParameterName = "discountExternalId";
public static final String noteParamName = "note";
public static final String rejectedOnDateParamName = "rejectedOnDate";
public static final String fraudParamName = "fraud";

// Disbursal / Undo disbursal parameters
public static final String actualDisbursementDateParamName = "actualDisbursementDate";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants;
import org.apache.fineract.portfolio.workingcapitalloan.data.MarkWorkingCapitalLoanAsFraudRequest;
import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanData;
import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDelinquencyTagHistoryData;
import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanPeriodPaymentRateChangeData;
Expand Down Expand Up @@ -159,6 +160,20 @@ public CommandProcessingResult modifyLoanApplicationById(
return modifyLoanApplication(loanId, null, apiRequestBodyAsJson);
}

@PUT
@Path("{loanId}/mark-as-fraud")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(operationId = "markWorkingCapitalLoanAsFraud", summary = "Mark or unmark a Working Capital Loan as fraudulent", description = "Flags the loan as fraudulent. When the loan is later charged off, a fraudulent loan is routed to the charge-off fraud expense account instead of the regular charge-off expense account. Does not change the loan status.")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = MarkWorkingCapitalLoanAsFraudRequest.class)))
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = CommandProcessingResult.class)))
public CommandProcessingResult markAsFraud(@PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson)
.markAsFraudWorkingCapitalLoan(loanId).build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
}

@DELETE
@Path("{loanId}")
@Produces({ MediaType.APPLICATION_JSON })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ private GetWorkingCapitalLoanSummary() {}
public Boolean enableInstallmentLevelDelinquency;
@Schema(description = "List of originators associated with this loan")
public List<GetWorkingCapitalLoansLoanIdOriginatorData> originators;
@Schema(description = "Fraud flag. Placeholder: null until the WCP fraud feature is implemented")
@Schema(description = "Fraud flag. True when the loan has been marked as fraudulent", example = "false")
public Boolean fraud;
@Schema(description = "Charge-off flag. Placeholder: null until the WCP charge-off feature is implemented")
public Boolean chargedOff;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* 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 io.swagger.v3.oas.annotations.media.Schema;

/**
* Request body schema for the mark-as-fraud endpoint. Used for OpenAPI documentation; the command is served through the
* legacy command-source pipeline, which parses the raw JSON body.
*/
public class MarkWorkingCapitalLoanAsFraudRequest {

@Schema(description = "Whether the loan should be flagged as fraudulent", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
public Boolean fraud;
}
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ public class WorkingCapitalLoan extends AbstractAuditableWithUTCDateTimeCustom<L
@Column(name = "total_payment_volume", scale = 6, precision = 19, nullable = false)
private BigDecimal totalPaymentVolume;

/**
* Flags the loan as fraudulent. When charged off, a fraudulent loan is routed to the charge-off fraud expense
* account instead of the regular charge-off expense account. Independent of the charge-off state itself.
*/
@Setter
@Column(name = "is_fraud", nullable = false)
private boolean fraud = false;

public Long getOfficeId() {
return client != null && client.getOffice() != null ? client.getOffice().getId() : null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* 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.handler;

import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanFraudWriteService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@CommandType(entity = "WORKINGCAPITALLOAN", action = "SETFRAUD")
public class MarkWorkingCapitalLoanAsFraudCommandHandler implements NewCommandSourceHandler {

private final WorkingCapitalLoanFraudWriteService writeService;

@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writeService.markAsFraud(command.entityId(), command);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public interface WorkingCapitalLoanMapper {
@Mapping(target = "netDisbursalAmount", ignore = true)
@Mapping(target = "charges", ignore = true)
@Mapping(target = "originators", ignore = true)
@Mapping(target = "fraud", ignore = true)
@Mapping(target = "fraud", source = "fraud")
@Mapping(target = "chargedOff", ignore = true)
WorkingCapitalLoanData toData(WorkingCapitalLoan loan);

Expand Down
Original file line number Diff line number Diff line change
@@ -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.portfolio.workingcapitalloan.service;

import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;

public interface WorkingCapitalLoanFraudWriteService {

CommandProcessingResult markAsFraud(Long loanId, JsonCommand command);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* 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.service;

import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan;
import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException;
import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class WorkingCapitalLoanFraudWriteServiceImpl implements WorkingCapitalLoanFraudWriteService {

private final WorkingCapitalLoanRepository loanRepository;
private final FromJsonHelper fromApiJsonHelper;

@Transactional
@Override
public CommandProcessingResult markAsFraud(final Long loanId, final JsonCommand command) {
final boolean fraud = validateAndExtractFraud(command);

final WorkingCapitalLoan loan = this.loanRepository.findById(loanId)
.orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId));

// A loan can only be marked as fraud while it is active. Clearing the flag and idempotent re-marks are
// allowed regardless of status.
final boolean markingAsFraud = fraud && !loan.isFraud();
if (markingAsFraud && loan.getLoanStatus() != LoanStatus.ACTIVE) {
throw new PlatformApiDataValidationException("validation.msg.wc.loan.mark.as.fraud.not.allowed",
"Marking a loan as fraud is allowed only for active loans", "loanStatus");
}

final Map<String, Object> changes = new LinkedHashMap<>();
if (loan.isFraud() != fraud) {
loan.setFraud(fraud);
this.loanRepository.saveAndFlush(loan);
changes.put(WorkingCapitalLoanConstants.fraudParamName, fraud);
}

return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(loan.getId()) //
.withEntityExternalId(loan.getExternalId()) //
.withOfficeId(loan.getOfficeId()) //
.withClientId(loan.getClientId()) //
.withLoanId(loan.getId()) //
.with(changes) //
.build();
}

private boolean validateAndExtractFraud(final JsonCommand command) {
final String json = command.json();
final Set<String> supportedParameters = Set.of(WorkingCapitalLoanConstants.fraudParamName);
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, supportedParameters);

final JsonElement element = this.fromApiJsonHelper.parse(json);
final Boolean fraud = this.fromApiJsonHelper.extractBooleanNamed(WorkingCapitalLoanConstants.fraudParamName, element);

final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
new DataValidatorBuilder(dataValidationErrors).resource("workingcapitalloan.markasfraud")
.parameter(WorkingCapitalLoanConstants.fraudParamName).value(fraud).notNull();
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}

return fraud;
}
}
Loading
Loading