diff --git a/fineract-document/src/main/java/org/apache/fineract/infrastructure/contentstore/policy/MimeContentPolicy.java b/fineract-document/src/main/java/org/apache/fineract/infrastructure/contentstore/policy/MimeContentPolicy.java index e1218d564..823f42490 100644 --- a/fineract-document/src/main/java/org/apache/fineract/infrastructure/contentstore/policy/MimeContentPolicy.java +++ b/fineract-document/src/main/java/org/apache/fineract/infrastructure/contentstore/policy/MimeContentPolicy.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.infrastructure.contentstore.policy; +import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.Strings; @@ -31,6 +32,10 @@ import org.springframework.stereotype.Component; @Component public class MimeContentPolicy implements ContentPolicy { + // CSV has no content-level magic bytes, so byte-sniffing a delimited text file legitimately + // detects it as text/plain even though it was uploaded (and named) as text/csv. + private static final Set INTERCHANGEABLE_TEXT_MIME_TYPES = Set.of("text/csv", "text/plain"); + private final ContentDetectorManager contentDetectorManager; @Override @@ -39,7 +44,11 @@ public class MimeContentPolicy implements ContentPolicy { var result = contentDetectorManager .detect(ContentDetectorContext.builder().inputStream(ctx.getInputStream()).inputStreamEnabled(true).build()); - if (!Strings.CI.equals(result.getMimeType(), ctx.getMimeType())) { + boolean matches = Strings.CI.equals(result.getMimeType(), ctx.getMimeType()) + || (INTERCHANGEABLE_TEXT_MIME_TYPES.contains(result.getMimeType()) + && INTERCHANGEABLE_TEXT_MIME_TYPES.contains(ctx.getMimeType())); + + if (!matches) { throw new ContentPolicyException(String.format("Detected file type (%s), but mime type (%s) was provided. Mismatch!", result.getMimeType(), ctx.getMimeType())); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/api/LoanOriginationPipelineApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/api/LoanOriginationPipelineApiResource.java new file mode 100644 index 000000000..188ca999d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/api/LoanOriginationPipelineApiResource.java @@ -0,0 +1,277 @@ +/** + * 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.loanoriginationpipeline.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationPipelineService; +import org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationStageService; +import org.springframework.stereotype.Component; + +/** + * REST endpoints for the Loan Origination Pipeline — the sanctioning workflow an application walks + * through before a real loan account is ever opened (Application → KYC/OVD → CIC Credit Bureau Check + * → Field Investigation → Personal Discussion → Credit Committee → Risk Approval → Legal Approval → + * Customer Signing → Cheque/ACH → Post-Disbursement Check → Disbursed). + * + *
+ *   GET  /v1/loan-origination/applications                    – list (optional branchCode/stage/status filters)
+ *   GET  /v1/loan-origination/applications/{applicationNo}    – one application, in full
+ *   POST /v1/loan-origination/applications                    – create from the New Application wizard
+ *   PUT  /v1/loan-origination/applications/{applicationNo}    – save the whole application back
+ *   POST /v1/loan-origination/applications/seed               – one-time seed; no-ops if already seeded
+ * 
+ * + *

+ * Requests/responses are the application object verbatim, so the request body is passed straight + * through as JSON rather than being bound to a DTO. A DTO would have to mirror all ~111 workflow + * fields and would silently drop any field it had not been updated to know about — the exact failure + * this module has to avoid, since the workflow gains fields regularly. + * + *

+ * Authorisation is a normal authenticated staff session. These endpoints deliberately do not define + * their own permission codes: the pipeline is the sanctioning process for lending, so it is gated by + * the same authenticated-staff check the rest of the loan screens use, and per-stage authority + * (who may sit on the Credit Committee, who signs off Risk/Legal) is a workflow concern enforced on + * the application record itself, not a Fineract entity permission. + */ +@Path("/v1/loan-origination/applications") +@Component +@Tag(name = "Loan Origination Pipeline", + description = "Pre-disbursement sanctioning workflow: KYC/OVD, CIC credit bureau check, Field Investigation, " + + "Personal Discussion, Credit Committee, Risk & Legal approval, signing and disbursement.") +@RequiredArgsConstructor +public class LoanOriginationPipelineApiResource { + + private final PlatformSecurityContext context; + private final LoanOriginationPipelineService service; + private final LoanOriginationStageService stageService; + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List loan origination applications", + description = "Newest first. Filters are optional — the Pipeline screen also filters client-side.") + public String retrieveAll(@QueryParam("branchCode") final String branchCode, @QueryParam("stage") final String stage, + @QueryParam("status") final String status) { + this.context.authenticatedUser(); + return this.service.findAllAsJson(branchCode, stage, status); + } + + @GET + @Path("{applicationNo}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Retrieve one loan origination application in full") + public String retrieveOne(@PathParam("applicationNo") final String applicationNo) { + this.context.authenticatedUser(); + return this.service.findOneAsJson(applicationNo); + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Create a loan origination application", + description = "Body is the application object as built by the New Application wizard.") + public String create(final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.service.create(apiRequestBodyAsJson); + } + + @PUT + @Path("{applicationNo}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Save a loan origination application", + description = "Full-object save: the body replaces the stored application, its stage history, " + + "approvals, field-investigation checks and document checklists.") + public String save(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.service.save(applicationNo, apiRequestBodyAsJson); + } + + @POST + @Path("seed") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Seed the pipeline once", + description = "Inserts the supplied applications only when the pipeline is empty, so reloading the " + + "frontend cannot duplicate the book. Returns the number actually inserted.") + public String seed(final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return "{\"seeded\":" + this.service.seed(apiRequestBodyAsJson) + "}"; + } + + // ───────────────────────────────────────────────────────────────────────── + // Per-stage transitions. + // + // Each of these takes only its own step's inputs (who acted, remarks, a resolution number) and + // lets the server validate the stage's prerequisites against the stored record before applying + // the change. The full-object PUT above still exists for inline field edits; these exist because + // a sanctioning decision should not be something a caller can simply assert. Every one returns + // the updated application, so the client replaces its copy rather than guessing the result. + // ───────────────────────────────────────────────────────────────────────── + + @POST + @Path("{applicationNo}/kyc-complete") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Complete KYC & Document Collection", + description = "Requires Aadhaar/PAN verified, income proof, bank statement and address proof submitted, " + + "income assessed, and every product-specific document submitted.") + public String completeKyc(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.stageService.completeKyc(applicationNo, apiRequestBodyAsJson); + } + + @POST + @Path("{applicationNo}/cic-complete") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Complete the CIC / credit bureau check", + description = "Requires the borrower's signed consent, the collected bureau charge and an uploaded report.") + public String completeCicCheck(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.stageService.completeCicCheck(applicationNo, apiRequestBodyAsJson); + } + + @POST + @Path("{applicationNo}/field-investigation/{checkKey}/verify") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Mark one field investigation check verified", + description = "Requires an assigned officer, GPS coordinates and at least one site photograph " + + "to already be recorded against the visit.") + public String verifyFieldCheck(@PathParam("applicationNo") final String applicationNo, + @PathParam("checkKey") final String checkKey, final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.stageService.verifyFieldCheck(applicationNo, checkKey, apiRequestBodyAsJson); + } + + @POST + @Path("{applicationNo}/field-investigation/{checkKey}/reject") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Mark one field investigation check failed", description = "A reason is mandatory.") + public String rejectFieldCheck(@PathParam("applicationNo") final String applicationNo, + @PathParam("checkKey") final String checkKey, final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.stageService.rejectFieldCheck(applicationNo, checkKey, apiRequestBodyAsJson); + } + + @POST + @Path("{applicationNo}/field-investigation/complete") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Complete Field Investigation", + description = "Requires every applicable check (address, office, plus property when secured and shop " + + "when the applicant runs one) to be verified.") + public String completeFieldInvestigation(@PathParam("applicationNo") final String applicationNo, + final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.stageService.completeFieldInvestigation(applicationNo, apiRequestBodyAsJson); + } + + @POST + @Path("{applicationNo}/personal-discussion/clear") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Clear the personal discussion and proceed") + public String clearPersonalDiscussion(@PathParam("applicationNo") final String applicationNo, + final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.stageService.clearPersonalDiscussion(applicationNo, apiRequestBodyAsJson); + } + + @POST + @Path("{applicationNo}/personal-discussion/flag") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Flag a discrepancy and hold the application", + description = "A discrepancy type and notes are both mandatory.") + public String flagDiscrepancy(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.stageService.flagDiscrepancy(applicationNo, apiRequestBodyAsJson); + } + + @POST + @Path("{applicationNo}/personal-discussion/resolve") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Resolve a held discrepancy and proceed") + public String resolveDiscrepancy(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.stageService.resolveDiscrepancy(applicationNo, apiRequestBodyAsJson); + } + + @POST + @Path("{applicationNo}/approvals/{field}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Record an approval decision", + description = "`field` is one of creditCommitteeApproval, riskApproval, legalApproval, postCheckApproval. " + + "An APPROVED decision is checked against that stage's own checklist — the Credit Committee also " + + "requires a resolution number and date. A REJECTED decision is always permitted.") + public String decideApproval(@PathParam("applicationNo") final String applicationNo, @PathParam("field") final String field, + final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.stageService.decideApproval(applicationNo, field, apiRequestBodyAsJson); + } + + @POST + @Path("{applicationNo}/signing") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Record the customer's signing of the loan agreement") + public String markSigned(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.stageService.markSigned(applicationNo, apiRequestBodyAsJson); + } + + @POST + @Path("{applicationNo}/cheque-ach") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Confirm security cheques received and ACH mandate signed") + public String confirmChequeAch(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.stageService.confirmChequeAch(applicationNo, apiRequestBodyAsJson); + } + + @POST + @Path("{applicationNo}/disburse") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Disburse the loan", + description = "Requires all four approval levels to stand as approved and the customer to have signed. " + + "Optionally records the real Fineract loan account created at disbursement.") + public String disburse(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) { + this.context.authenticatedUser(); + return this.stageService.disburse(applicationNo, apiRequestBodyAsJson); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationJson.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationJson.java new file mode 100644 index 000000000..e214489b6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationJson.java @@ -0,0 +1,138 @@ +/** + * 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.loanoriginationpipeline.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.math.BigDecimal; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; + +/** + * Null-safe accessors for the loan origination application object. + * + *

+ * The application is handled as JSON rather than a typed entity (see + * {@link LoanOriginationPipelineService} for why), so every read has to tolerate a key being absent, + * explicitly null, or the wrong JSON type — an older application stored before a field existed is a + * normal, expected case, not an error. These helpers turn all three into a sensible default so no + * caller has to repeat the checks. + */ +public final class LoanOriginationJson { + + private LoanOriginationJson() {} + + public static JsonObject parseObject(final String body) { + if (body == null || body.isBlank()) { + // An action with no inputs (e.g. "mark signed") legitimately posts an empty body. + return new JsonObject(); + } + final JsonElement parsed = JsonParser.parseString(body); + if (!parsed.isJsonObject()) { + throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.body.invalid", + "Request body must be a JSON object."); + } + return parsed.getAsJsonObject(); + } + + public static boolean present(final JsonObject obj, final String key) { + return obj != null && obj.has(key) && !obj.get(key).isJsonNull(); + } + + public static String str(final JsonObject obj, final String key) { + return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null; + } + + /** Trimmed value, or the fallback when absent/blank. */ + public static String strOr(final JsonObject obj, final String key, final String fallback) { + final String value = str(obj, key); + return value == null || value.isBlank() ? fallback : value.trim(); + } + + /** + * True when a field holds real content. Treats the {@code '—'} em-dash as empty: the workflow uses + * it as a "nothing recorded yet" placeholder throughout, so a check for "has this been filled in" + * has to reject it or every blank field would read as populated. + */ + public static boolean filled(final JsonObject obj, final String key) { + final String value = str(obj, key); + return value != null && !value.trim().isEmpty() && !"—".equals(value.trim()); + } + + public static Long lng(final JsonObject obj, final String key) { + try { + return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null; + } catch (final NumberFormatException e) { + return null; + } + } + + public static BigDecimal dec(final JsonObject obj, final String key) { + try { + return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsBigDecimal() : null; + } catch (final NumberFormatException e) { + return null; + } + } + + public static boolean bool(final JsonObject obj, final String key) { + return present(obj, key) && obj.get(key).isJsonPrimitive() && obj.get(key).getAsBoolean(); + } + + public static int intOr(final JsonObject obj, final String key, final int fallback) { + try { + return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsInt() : fallback; + } catch (final NumberFormatException e) { + return fallback; + } + } + + public static JsonObject obj(final JsonObject parent, final String key) { + return present(parent, key) && parent.get(key).isJsonObject() ? parent.getAsJsonObject(key) : null; + } + + /** The nested object, creating and attaching an empty one if it isn't there yet. */ + public static JsonObject objOrCreate(final JsonObject parent, final String key) { + final JsonObject existing = obj(parent, key); + if (existing != null) { + return existing; + } + final JsonObject created = new JsonObject(); + parent.add(key, created); + return created; + } + + public static JsonArray arr(final JsonObject parent, final String key) { + return present(parent, key) && parent.get(key).isJsonArray() ? parent.getAsJsonArray(key) : new JsonArray(); + } + + public static JsonArray arrOrCreate(final JsonObject parent, final String key) { + if (present(parent, key) && parent.get(key).isJsonArray()) { + return parent.getAsJsonArray(key); + } + final JsonArray created = new JsonArray(); + parent.add(key, created); + return created; + } + + public static String rawJson(final JsonObject obj, final String key) { + return present(obj, key) ? obj.get(key).toString() : null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationPipelineService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationPipelineService.java new file mode 100644 index 000000000..5b23c1a50 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationPipelineService.java @@ -0,0 +1,403 @@ +/** + * 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.loanoriginationpipeline.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Persistence for the Loan Origination Pipeline — the pre-disbursement sanctioning workflow + * (Application → KYC → CIC Check → Field Investigation → Personal Discussion → Credit Committee → + * Risk → Legal → Signing → Cheque/ACH → Post-Check → Disbursed). + * + *

+ * Storage shape. {@code payload} holds the complete application object as JSON and is the + * single source of truth for reads. The dozen real columns on + * {@code m_loan_origination_application}, and the four child tables, are derived indexes — + * they exist so the Pipeline list, the Channel/BC/Share reports, Target-vs-Actual and audit queries + * ("every application this officer approved") can be plain SQL instead of scanning JSON. They are + * never read back into the application object. + * + *

+ * That deliberately stores those fields twice, which is the right trade here: every write goes + * through {@link #writeApplication} in one transaction and rewrites the payload, the columns and all + * child rows together from the same source, so the copies cannot drift. The alternative — splitting + * fields so nothing repeats — would force the read path to reconstruct a dozen values back out of + * SQL types (a {@code DECIMAL(19,6)} returning {@code 100000.000000} where the UI expects + * {@code 100000}, a null {@code BIGINT} vs an absent key), which is exactly the class of silent + * field-level corruption this module cannot afford. + * + *

+ * The one field kept out of {@code payload} is {@code cicParsedAnalysis} — a parsed credit-bureau + * report with a per-account seven-year day-past-due grid. It is large, opaque and only ever rendered + * whole, so it gets its own column rather than being duplicated alongside everything else. + * + *

+ * Because the payload is "the whole object minus that one field" rather than an allowlist of known + * keys, a field added to the workflow later persists with no schema or code change here. Given how + * fast this workflow gains fields, an allowlist would drop them silently. + */ +@Service +@RequiredArgsConstructor +public class LoanOriginationPipelineService { + + /** Pulled out of the payload into its own column — see class javadoc. */ + private static final String CIC_ANALYSIS_KEY = "cicParsedAnalysis"; + + /** + * The row's primary key. Kept out of the payload so it can only ever come from the column — a + * stale id round-tripped inside the JSON would silently point uploaded documents at the wrong + * application. + */ + private static final String ID_KEY = "id"; + + private static final String SELECT_COLUMNS = "id, payload, cic_parsed_analysis"; + + private final JdbcTemplate jdbcTemplate; + + // ── Read ───────────────────────────────────────────────────────────────── + + /** + * Every application, newest first. Optional filters are pushed into SQL; the Pipeline screen also + * filters in-memory, so passing nothing here is perfectly valid. + */ + public String findAllAsJson(final String branchCode, final String stage, final String status) { + final StringBuilder sql = new StringBuilder("SELECT " + SELECT_COLUMNS + " FROM m_loan_origination_application WHERE 1=1"); + final List params = new ArrayList<>(); + if (isSet(branchCode)) { + sql.append(" AND branch_code = ?"); + params.add(branchCode); + } + if (isSet(stage)) { + sql.append(" AND current_stage = ?"); + params.add(stage); + } + if (isSet(status)) { + sql.append(" AND overall_status = ?"); + params.add(status); + } + sql.append(" ORDER BY id DESC"); + + final List> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray()); + final JsonArray out = new JsonArray(); + for (final Map row : rows) { + out.add(reassemble(row)); + } + return out.toString(); + } + + public String findOneAsJson(final String applicationNo) { + return loadApplication(applicationNo).toString(); + } + + /** + * Loads one application as a mutable object. This is the entry point the per-stage endpoints use: + * they validate and apply their transition against the state that is actually stored, never + * against what a caller claims it is. + */ + public JsonObject loadApplication(final String applicationNo) { + final List> rows = jdbcTemplate + .queryForList("SELECT " + SELECT_COLUMNS + " FROM m_loan_origination_application WHERE application_no = ?", + applicationNo); + if (rows.isEmpty()) { + throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.not.found", + "No loan origination application found with number " + applicationNo + ".", applicationNo); + } + return reassemble(rows.get(0)); + } + + /** + * Rebuilds the application object: the stored payload, with the bureau report re-attached and the + * row's own id surfaced. The id is what uploaded files hang off — documents are stored against + * {@code m_document.parent_entity_id}, so the client needs it to upload and fetch attachments. + */ + private JsonObject reassemble(final Map row) { + final JsonObject app = parseObject(asString(row.get("payload"))); + final Object id = row.get("id"); + if (id != null) { + app.addProperty("id", Long.valueOf(id.toString())); + } + final String cic = asString(row.get("cic_parsed_analysis")); + if (cic != null && !cic.isBlank()) { + app.add(CIC_ANALYSIS_KEY, JsonParser.parseString(cic)); + } + return app; + } + + // ── Write ──────────────────────────────────────────────────────────────── + + /** + * Creates an application. The caller (the New Application wizard) has already derived the + * application number and the blank per-stage structures, so this stores the object as given + * rather than re-deriving a second, competing set of defaults server-side. + */ + @Transactional + public String create(final String requestBodyAsJson) { + final JsonObject app = parseObject(requestBodyAsJson); + final String applicationNo = stringOrNull(app, "applicationNo"); + if (applicationNo == null || applicationNo.isBlank()) { + throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.no.required", + "applicationNo is required."); + } + final Long existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_loan_origination_application WHERE application_no = ?", Long.class, applicationNo); + if (existing != null && existing > 0) { + throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.duplicate", + "An application numbered " + applicationNo + " already exists.", applicationNo); + } + // Read back rather than echoing the request: the caller needs the assigned id to be able to + // attach documents to this application. + insertApplication(app); + return findOneAsJson(applicationNo); + } + + /** + * Full-object save. The workflow's stage transitions are all applied client-side against the + * whole application object, so the whole object is what gets written back — there is no + * per-action field allowlist that could quietly omit a field. + */ + @Transactional + public String save(final String applicationNo, final String requestBodyAsJson) { + return storeApplication(applicationNo, parseObject(requestBodyAsJson)); + } + + /** Writes a whole application object back. Shared by the full-object save and every per-stage + * endpoint, so both paths persist through exactly the same code. */ + @Transactional + public String storeApplication(final String applicationNo, final JsonObject app) { + final Long id = findIdOrThrow(applicationNo); + + final JsonObject payload = app.deepCopy(); + payload.remove(CIC_ANALYSIS_KEY); + payload.remove(ID_KEY); + + jdbcTemplate.update(""" + UPDATE m_loan_origination_application SET + applicant_name = ?, client_id = ?, branch_code = ?, channel = ?, bc_agent_name = ?, + product_code = ?, requested_amount = ?, current_stage = ?, overall_status = ?, + is_share_member = ?, fineract_loan_id = ?, payload = ?, cic_parsed_analysis = ?, + last_modified_date = CURRENT_TIMESTAMP + WHERE id = ? + """, stringOrNull(app, "applicantName"), longOrNull(app, "clientId"), stringOrNull(app, "branchCode"), + stringOrNull(app, "channel"), stringOrNull(app, "bcAgentName"), stringOrNull(app, "productCode"), + decimalOrNull(app, "requestedAmount"), stringOrNull(app, "currentStage"), stringOrNull(app, "overallStatus"), + boolOrFalse(app, "isShareMember"), longOrNull(app, "fineractLoanId"), payload.toString(), + childJsonOrNull(app, CIC_ANALYSIS_KEY), id); + + writeChildren(id, app); + return findOneAsJson(applicationNo); + } + + /** + * One-time seed. Idempotent by design: it is called on first page load and must not duplicate the + * book if the frontend is reloaded, so a non-empty table short-circuits to 0. + */ + @Transactional + public int seed(final String requestBodyAsJson) { + final Long existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_loan_origination_application", Long.class); + if (existing != null && existing > 0) { + return 0; + } + final JsonElement parsed = JsonParser.parseString(requestBodyAsJson == null ? "[]" : requestBodyAsJson); + if (!parsed.isJsonArray()) { + throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.seed.invalid", + "Seed body must be a JSON array of applications."); + } + int seeded = 0; + for (final JsonElement element : parsed.getAsJsonArray()) { + if (element.isJsonObject()) { + insertApplication(element.getAsJsonObject()); + seeded++; + } + } + return seeded; + } + + private void insertApplication(final JsonObject app) { + final JsonObject payload = app.deepCopy(); + payload.remove(CIC_ANALYSIS_KEY); + payload.remove(ID_KEY); + + jdbcTemplate.update(""" + INSERT INTO m_loan_origination_application + (application_no, applicant_name, client_id, branch_code, channel, bc_agent_name, + product_code, requested_amount, current_stage, overall_status, is_share_member, + fineract_loan_id, payload, cic_parsed_analysis) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, stringOrNull(app, "applicationNo"), stringOrNull(app, "applicantName"), longOrNull(app, "clientId"), + stringOrNull(app, "branchCode"), stringOrNull(app, "channel"), stringOrNull(app, "bcAgentName"), + stringOrNull(app, "productCode"), decimalOrNull(app, "requestedAmount"), stringOrNull(app, "currentStage"), + stringOrNull(app, "overallStatus"), boolOrFalse(app, "isShareMember"), longOrNull(app, "fineractLoanId"), + payload.toString(), childJsonOrNull(app, CIC_ANALYSIS_KEY)); + + final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_loan_origination_application WHERE application_no = ?", + Long.class, stringOrNull(app, "applicationNo")); + writeChildren(id, app); + } + + /** + * Rewrites the derived child rows for one application. Delete-then-insert rather than a per-row + * diff: these collections are small (a stage history is a dozen rows), and a full rewrite cannot + * leave a stale row behind the way a partial diff can. + */ + private void writeChildren(final Long id, final JsonObject app) { + jdbcTemplate.update("DELETE FROM m_loan_origination_stage_history WHERE application_id = ?", id); + jdbcTemplate.update("DELETE FROM m_loan_origination_approval WHERE application_id = ?", id); + jdbcTemplate.update("DELETE FROM m_loan_origination_fi_check WHERE application_id = ?", id); + jdbcTemplate.update("DELETE FROM m_loan_origination_document WHERE application_id = ?", id); + + final JsonArray history = arrayOrEmpty(app, "stageHistory"); + for (int i = 0; i < history.size(); i++) { + if (!history.get(i).isJsonObject()) { + continue; + } + final JsonObject entry = history.get(i).getAsJsonObject(); + jdbcTemplate.update(""" + INSERT INTO m_loan_origination_stage_history + (application_id, seq_no, stage, action_date, action_by, outcome, remarks) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, id, i, stringOrNull(entry, "stage"), stringOrNull(entry, "date"), stringOrNull(entry, "actionBy"), + stringOrNull(entry, "outcome"), stringOrNull(entry, "remarks")); + } + + for (final String field : new String[] { "creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval" }) { + final JsonObject approval = objectOrNull(app, field); + if (approval == null) { + continue; + } + jdbcTemplate.update(""" + INSERT INTO m_loan_origination_approval + (application_id, approval_field, decision, approver_name, approver_role, decision_date, remarks) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, id, field, stringOrNull(approval, "decision"), stringOrNull(approval, "approverName"), + stringOrNull(approval, "approverRole"), stringOrNull(approval, "date"), stringOrNull(approval, "remarks")); + } + + for (final String key : new String[] { "addressVerification", "officeVerification", "propertyVerification", + "shopVerification" }) { + final JsonObject check = objectOrNull(app, key); + if (check == null) { + continue; + } + jdbcTemplate.update(""" + INSERT INTO m_loan_origination_fi_check + (application_id, check_key, status, verified_by, verified_date, remarks, officer_assigned, + assigned_date, assigned_at, latitude, longitude, photo_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, id, key, stringOrNull(check, "status"), stringOrNull(check, "verifiedBy"), + stringOrNull(check, "verifiedDate"), stringOrNull(check, "remarks"), stringOrNull(check, "officerAssigned"), + stringOrNull(check, "assignedDate"), longOrNull(check, "assignedAt"), stringOrNull(check, "latitude"), + stringOrNull(check, "longitude"), intOrZero(check, "photoCount")); + } + + insertDocuments(id, arrayOrEmpty(app, "productDocuments"), "PRODUCT"); + insertDocuments(id, arrayOrEmpty(app, "legalDocuments"), "LEGAL"); + } + + private void insertDocuments(final Long id, final JsonArray docs, final String kind) { + for (int i = 0; i < docs.size(); i++) { + if (!docs.get(i).isJsonObject()) { + continue; + } + final JsonObject doc = docs.get(i).getAsJsonObject(); + jdbcTemplate.update(""" + INSERT INTO m_loan_origination_document + (application_id, doc_kind, doc_key, label, hint, is_custom, submitted, seq_no) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, id, kind, stringOrNull(doc, "key"), stringOrNull(doc, "label"), stringOrNull(doc, "hint"), + boolOrFalse(doc, "custom"), boolOrFalse(doc, "submitted"), i); + } + } + + private Long findIdOrThrow(final String applicationNo) { + final List ids = jdbcTemplate.queryForList("SELECT id FROM m_loan_origination_application WHERE application_no = ?", + Long.class, applicationNo); + if (ids.isEmpty()) { + throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.not.found", + "No loan origination application found with number " + applicationNo + ".", applicationNo); + } + return ids.get(0); + } + + // ── JSON helpers ───────────────────────────────────────────────────────── + // Thin aliases over LoanOriginationJson so this class and the per-stage service share one + // implementation of the null/absent/wrong-type handling rather than each keeping its own. + + private JsonObject parseObject(final String body) { + if (body == null || body.isBlank()) { + throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.body.required", "A request body is required."); + } + return LoanOriginationJson.parseObject(body); + } + + private boolean present(final JsonObject obj, final String key) { + return LoanOriginationJson.present(obj, key); + } + + private String stringOrNull(final JsonObject obj, final String key) { + return LoanOriginationJson.str(obj, key); + } + + private Long longOrNull(final JsonObject obj, final String key) { + return LoanOriginationJson.lng(obj, key); + } + + private BigDecimal decimalOrNull(final JsonObject obj, final String key) { + return LoanOriginationJson.dec(obj, key); + } + + private boolean boolOrFalse(final JsonObject obj, final String key) { + return LoanOriginationJson.bool(obj, key); + } + + private int intOrZero(final JsonObject obj, final String key) { + return LoanOriginationJson.intOr(obj, key, 0); + } + + private JsonObject objectOrNull(final JsonObject obj, final String key) { + return LoanOriginationJson.obj(obj, key); + } + + private JsonArray arrayOrEmpty(final JsonObject obj, final String key) { + return LoanOriginationJson.arr(obj, key); + } + + private String childJsonOrNull(final JsonObject obj, final String key) { + return LoanOriginationJson.rawJson(obj, key); + } + + private String asString(final Object value) { + return value == null ? null : value.toString(); + } + + private boolean isSet(final String value) { + return value != null && !value.isBlank(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationStageService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationStageService.java new file mode 100644 index 000000000..e056d2809 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationStageService.java @@ -0,0 +1,517 @@ +/** + * 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.loanoriginationpipeline.service; + +import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.arr; +import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.arrOrCreate; +import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.bool; +import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.filled; +import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.intOr; +import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.obj; +import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.objOrCreate; +import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.parseObject; +import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.str; +import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.strOr; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Server-side stage transitions for the Loan Origination Pipeline. + * + *

+ * Each method here owns one sanctioning step: it loads the application as stored, checks that + * step's preconditions against that stored state, applies the change, appends the audit-trail entry + * and saves. The caller supplies only that step's own inputs (who acted, remarks, a resolution + * number) — never the resulting state — so a client cannot approve a stage whose prerequisites are + * not actually met, mark a field visit verified without the GPS fix and photographs really being on + * file, or minute a Credit Committee decision without a resolution number. + * + *

+ * That distinction matters for a sanctioning workflow: the screen also disables those buttons, but UI + * gating is a convenience, not a control — it is bypassed by any direct call to the API. These checks + * are the control. They are deliberately the same rules the screen enforces, so an officer never sees + * an enabled button that the server then rejects. + * + *

+ * Dates are written in the {@code dd-MM-yyyy} form the rest of the workflow record already uses, and + * "nothing recorded" stays the {@code '—'} placeholder the existing data uses, so a server-applied + * transition is indistinguishable in shape from the client-applied ones already stored. + */ +@Service +@RequiredArgsConstructor +public class LoanOriginationStageService { + + private static final DateTimeFormatter WORKFLOW_DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + + /** Must stay in step with STAGE_ORDER on the Application Detail screen. */ + private static final List STAGE_ORDER = List.of("APPLICATION", "KYC_DOCUMENTS", "CIC_CHECK", "FIELD_INVESTIGATION", + "PERSONAL_DISCUSSION", "CREDIT_COMMITTEE", "RISK_APPROVAL", "LEGAL_APPROVAL", "CUSTOMER_SIGNING", "CHEQUE_ACH", + "POST_CHECK", "DISBURSED"); + + private static final List FI_CHECK_KEYS = List.of("addressVerification", "officeVerification", "propertyVerification", + "shopVerification"); + + private final LoanOriginationPipelineService pipelineService; + + // ── KYC & Documents ────────────────────────────────────────────────────── + + @Transactional + public String completeKyc(final String applicationNo, final String body) { + final JsonObject app = pipelineService.loadApplication(applicationNo); + final JsonObject input = parseObject(body); + requireStage(app, "KYC_DOCUMENTS"); + + require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified."); + require(bool(app, "panVerified"), "PAN has not been verified."); + require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted."); + require(bool(app, "bankStatementSubmitted"), "Bank statement has not been submitted."); + require(bool(app, "electricityBillSubmitted"), "Address proof (electricity bill) has not been submitted."); + require(intOr(app, "monthlyIncome", 0) > 0, "Monthly income has not been assessed."); + for (final JsonElement element : arr(app, "productDocuments")) { + final JsonObject doc = element.getAsJsonObject(); + require(bool(doc, "submitted"), "Required document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document"))); + } + + advance(app, actor(input), "KYC Complete", strOr(input, "remarks", "KYC and document collection complete.")); + return pipelineService.storeApplication(applicationNo, app); + } + + // ── CIC / Credit Bureau ────────────────────────────────────────────────── + + @Transactional + public String completeCicCheck(final String applicationNo, final String body) { + final JsonObject app = pipelineService.loadApplication(applicationNo); + final JsonObject input = parseObject(body); + requireStage(app, "CIC_CHECK"); + + // RBI's Fair Practices Code requires the borrower's written consent before a bureau pull, and + // the bureau's per-pull fee has to have actually been collected. + require(bool(app, "cicConsentSigned"), "The borrower's signed credit-bureau consent is not on file."); + require(bool(app, "cicChargeCollected"), "The credit bureau check charge has not been collected."); + require(bool(app, "cicReportUploaded"), "No credit bureau report has been uploaded."); + + advance(app, actor(input), "CIC Check Complete", + strOr(input, "remarks", "Credit bureau report obtained and reviewed. Score " + intOr(app, "cibilScore", 0) + ".")); + return pipelineService.storeApplication(applicationNo, app); + } + + // ── Field Investigation ────────────────────────────────────────────────── + + /** + * Marks one field-investigation check verified. The GPS fix and site photographs are checked + * against what is stored, which is the point: "I visited" with nothing recorded is exactly what a + * field-investigation control exists to prevent. + */ + @Transactional + public String verifyFieldCheck(final String applicationNo, final String checkKey, final String body) { + final JsonObject app = pipelineService.loadApplication(applicationNo); + final JsonObject input = parseObject(body); + requireStage(app, "FIELD_INVESTIGATION"); + require(FI_CHECK_KEYS.contains(checkKey), "Unknown field investigation check: " + checkKey); + + final JsonObject check = objOrCreate(app, checkKey); + require(filled(check, "officerAssigned"), "No field officer has been assigned to this visit."); + require(filled(check, "latitude") && filled(check, "longitude"), "The visit has no GPS coordinates recorded."); + require(intOr(check, "photoCount", 0) > 0, "No site photograph has been captured for this visit."); + + check.addProperty("status", "VERIFIED"); + check.addProperty("verifiedBy", strOr(input, "verifiedBy", strOr(check, "officerAssigned", "Field Officer"))); + check.addProperty("verifiedDate", today()); + check.addProperty("remarks", strOr(input, "remarks", "Visited and confirmed correct.")); + + return pipelineService.storeApplication(applicationNo, app); + } + + @Transactional + public String rejectFieldCheck(final String applicationNo, final String checkKey, final String body) { + final JsonObject app = pipelineService.loadApplication(applicationNo); + final JsonObject input = parseObject(body); + requireStage(app, "FIELD_INVESTIGATION"); + require(FI_CHECK_KEYS.contains(checkKey), "Unknown field investigation check: " + checkKey); + // A failed visit must say why -- it is what the Personal Discussion stage then works from. + final String remarks = strOr(input, "remarks", ""); + require(!remarks.isBlank(), "A reason is required when a field visit fails."); + + final JsonObject check = objOrCreate(app, checkKey); + check.addProperty("status", "FAILED"); + check.addProperty("verifiedBy", strOr(input, "verifiedBy", strOr(check, "officerAssigned", "Field Officer"))); + check.addProperty("verifiedDate", today()); + check.addProperty("remarks", remarks); + + return pipelineService.storeApplication(applicationNo, app); + } + + @Transactional + public String completeFieldInvestigation(final String applicationNo, final String body) { + final JsonObject app = pipelineService.loadApplication(applicationNo); + final JsonObject input = parseObject(body); + requireStage(app, "FIELD_INVESTIGATION"); + requireFieldInvestigationComplete(app); + + advance(app, actor(input), "FI Complete", strOr(input, "remarks", "Field investigation complete on all applicable checks.")); + return pipelineService.storeApplication(applicationNo, app); + } + + // ── Personal Discussion ────────────────────────────────────────────────── + + @Transactional + public String clearPersonalDiscussion(final String applicationNo, final String body) { + final JsonObject app = pipelineService.loadApplication(applicationNo); + final JsonObject input = parseObject(body); + requireStage(app, "PERSONAL_DISCUSSION"); + require(bool(app, "meetingScheduled"), "The personal discussion meeting has not been scheduled."); + + final String remarks = strOr(input, "remarks", "No discrepancy found — cleared to proceed to Loan Committee."); + app.addProperty("discrepancyType", "NONE"); + app.addProperty("overallStatus", "IN_PROGRESS"); + app.addProperty("personalDiscussionDate", today()); + app.addProperty("personalDiscussionNotes", remarks); + app.addProperty("declarationTaken", true); + app.addProperty("declarationDate", today()); + + advance(app, actor(input), "Cleared", remarks); + return pipelineService.storeApplication(applicationNo, app); + } + + @Transactional + public String flagDiscrepancy(final String applicationNo, final String body) { + final JsonObject app = pipelineService.loadApplication(applicationNo); + final JsonObject input = parseObject(body); + requireStage(app, "PERSONAL_DISCUSSION"); + + final String type = strOr(input, "discrepancyType", ""); + require(!type.isBlank() && !"NONE".equals(type), "A discrepancy type is required."); + final String notes = strOr(input, "remarks", ""); + require(!notes.isBlank(), "Notes are required when flagging a discrepancy."); + + app.addProperty("discrepancyType", type); + app.addProperty("personalDiscussionDate", today()); + app.addProperty("personalDiscussionNotes", notes); + // Held rather than rejected: a discrepancy is resolvable, and the application has to stay + // visible as outstanding work rather than dropping out of the pipeline. + app.addProperty("overallStatus", "ON_HOLD"); + + appendHistory(app, str(app, "currentStage"), actor(input), "Discrepancy Flagged", notes); + return pipelineService.storeApplication(applicationNo, app); + } + + @Transactional + public String resolveDiscrepancy(final String applicationNo, final String body) { + final JsonObject app = pipelineService.loadApplication(applicationNo); + final JsonObject input = parseObject(body); + requireStage(app, "PERSONAL_DISCUSSION"); + require("ON_HOLD".equals(str(app, "overallStatus")), "This application is not on hold."); + + final String remarks = strOr(input, "remarks", "Discrepancy resolved on further review — cleared to proceed."); + app.addProperty("discrepancyType", "NONE"); + app.addProperty("overallStatus", "IN_PROGRESS"); + app.addProperty("personalDiscussionNotes", remarks); + app.addProperty("declarationTaken", true); + app.addProperty("declarationDate", today()); + + advance(app, actor(input), "Resolved", remarks); + return pipelineService.storeApplication(applicationNo, app); + } + + // ── Approvals ──────────────────────────────────────────────────────────── + + /** + * Records a Credit Committee / Risk / Legal / Post-Check decision. + * + * @param field one of creditCommitteeApproval, riskApproval, legalApproval, postCheckApproval + */ + @Transactional + public String decideApproval(final String applicationNo, final String field, final String body) { + final JsonObject app = pipelineService.loadApplication(applicationNo); + final JsonObject input = parseObject(body); + + final String decision = strOr(input, "decision", "APPROVED"); + require(List.of("APPROVED", "REJECTED", "EXCEPTION_APPROVED").contains(decision), "Unknown decision: " + decision); + + final String expectedStage = switch (field) { + case "creditCommitteeApproval" -> "CREDIT_COMMITTEE"; + case "riskApproval" -> "RISK_APPROVAL"; + case "legalApproval" -> "LEGAL_APPROVAL"; + case "postCheckApproval" -> "POST_CHECK"; + default -> throw badRequest("Unknown approval stage: " + field); + }; + requireStage(app, expectedStage); + + // Prerequisites are only enforced for a positive decision: a rejection has to remain possible + // at any point, and blocking it on an incomplete checklist would trap the application. + if (!"REJECTED".equals(decision)) { + switch (field) { + case "creditCommitteeApproval" -> requireCommitteeReportsReady(app, input); + case "riskApproval" -> requireRiskChecklistReady(app); + case "legalApproval" -> requireLegalChecklistDone(app); + case "postCheckApproval" -> requirePostCheckSignatures(app); + default -> throw badRequest("Unknown approval stage: " + field); + } + } + + String remarks = strOr(input, "remarks", "REJECTED".equals(decision) ? "Declined on review." : "Reviewed and approved."); + if ("creditCommitteeApproval".equals(field) && !"REJECTED".equals(decision)) { + // The resolution number and date are what the sanction letter later quotes, so they are + // recorded on the application and restated in the audit trail. + final String resolutionNo = strOr(input, "resolutionNo", ""); + final String resolutionDate = strOr(input, "resolutionDate", ""); + app.addProperty("committeeResolutionNo", resolutionNo); + app.addProperty("committeeResolutionDate", resolutionDate); + remarks = remarks + " (Resolution No. " + resolutionNo + " dated " + resolutionDate + ")"; + } + + final JsonObject approval = objOrCreate(app, field); + approval.addProperty("decision", decision); + approval.addProperty("approverName", strOr(input, "approverName", "Bank Staff")); + approval.addProperty("approverRole", strOr(input, "approverRole", expectedStage)); + approval.addProperty("date", today()); + approval.addProperty("remarks", remarks); + + if ("REJECTED".equals(decision)) { + app.addProperty("overallStatus", "REJECTED"); + appendHistory(app, str(app, "currentStage"), actor(input), "Rejected", remarks); + } else { + advance(app, actor(input), "EXCEPTION_APPROVED".equals(decision) ? "Approved on Exception" : "Approved", remarks); + } + return pipelineService.storeApplication(applicationNo, app); + } + + // ── Signing / Cheque-ACH / Disbursement ────────────────────────────────── + + @Transactional + public String markSigned(final String applicationNo, final String body) { + final JsonObject app = pipelineService.loadApplication(applicationNo); + final JsonObject input = parseObject(body); + requireStage(app, "CUSTOMER_SIGNING"); + require(filled(app, "agreementSigningSlot"), "No agreement signing slot has been recorded."); + + app.addProperty("customerSigningDate", today()); + advance(app, strOr(input, "actionBy", strOr(app, "applicantName", "Customer")), "Signed", + strOr(input, "remarks", "Loan agreement signed by the customer.")); + return pipelineService.storeApplication(applicationNo, app); + } + + @Transactional + public String confirmChequeAch(final String applicationNo, final String body) { + final JsonObject app = pipelineService.loadApplication(applicationNo); + final JsonObject input = parseObject(body); + requireStage(app, "CHEQUE_ACH"); + require(bool(app, "chequeReceived"), "Security cheques have not been received."); + require(bool(app, "achSigned"), "The ACH/NACH mandate has not been signed."); + + advance(app, actor(input), "Cheque & ACH Received", + strOr(input, "remarks", "Security cheques received and ACH mandate signed.")); + return pipelineService.storeApplication(applicationNo, app); + } + + /** + * Final disbursement. Requires every approval level to actually stand as approved on the stored + * record — the last point at which an application could otherwise slip through with a rejected or + * still-pending sign-off. + */ + @Transactional + public String disburse(final String applicationNo, final String body) { + final JsonObject app = pipelineService.loadApplication(applicationNo); + final JsonObject input = parseObject(body); + requireStage(app, "DISBURSED"); + require(!"DISBURSED".equals(str(app, "overallStatus")), "This application has already been disbursed."); + + for (final String field : List.of("creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval")) { + final JsonObject approval = obj(app, field); + final String decision = approval == null ? null : str(approval, "decision"); + require("APPROVED".equals(decision) || "EXCEPTION_APPROVED".equals(decision), + "Cannot disburse: " + field + " is not approved."); + } + require(filled(app, "customerSigningDate"), "The customer has not signed the loan agreement."); + + final String mode = strOr(input, "disbursementMode", "Account Credit"); + app.addProperty("disbursementDate", today()); + app.addProperty("disbursementMode", mode); + if (!filled(app, "achConfirmationRef")) { + final String suffix = applicationNo.length() >= 6 ? applicationNo.substring(applicationNo.length() - 6) : applicationNo; + app.addProperty("achConfirmationRef", "ACH" + suffix); + } + app.addProperty("emiActivationConfirmed", true); + app.addProperty("emiActivationDate", today()); + app.addProperty("overallStatus", "DISBURSED"); + if (LoanOriginationJson.present(input, "fineractLoanId")) { + app.addProperty("fineractLoanId", LoanOriginationJson.lng(input, "fineractLoanId")); + app.addProperty("fineractLoanAccountNo", str(input, "fineractLoanAccountNo")); + } + + appendHistory(app, "DISBURSED", actor(input), "Disbursed", strOr(input, "remarks", + "Disbursed via " + mode + " — ACH confirmation " + strOr(app, "achConfirmationRef", "—") + ". EMI activation confirmed.")); + return pipelineService.storeApplication(applicationNo, app); + } + + // ── Shared checklist rules (mirror the Application Detail screen's gating) ─ + + private void requireFieldInvestigationComplete(final JsonObject app) { + requireCheckVerified(app, "addressVerification", "Address verification"); + requireCheckVerified(app, "officeVerification", "Office/employment verification"); + if (bool(app, "secured")) { + requireCheckVerified(app, "propertyVerification", "Property verification"); + } + if (bool(app, "hasShop")) { + requireCheckVerified(app, "shopVerification", "Shop verification"); + } + } + + private void requireCheckVerified(final JsonObject app, final String key, final String label) { + final JsonObject check = obj(app, key); + require(check != null && "VERIFIED".equals(str(check, "status")), label + " is not verified."); + } + + private void requireCommitteeReportsReady(final JsonObject app, final JsonObject input) { + require(bool(app, "cicReportUploaded"), "The credit bureau report is not on file."); + require(bool(app, "backgroundCheckDone"), "The background check has not been done."); + if (bool(app, "secured")) { + require(bool(app, "cersaiSearchDone"), "The CERSAI search has not been done."); + } + require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified."); + require(bool(app, "panVerified"), "PAN has not been verified."); + require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted."); + requireFieldInvestigationComplete(app); + require(bool(app, "managerMeetingDone"), "The manager meeting has not been recorded."); + require(!strOr(input, "resolutionNo", "").isBlank(), "A committee resolution number is required."); + require(!strOr(input, "resolutionDate", "").isBlank(), "A committee resolution date is required."); + } + + private void requireRiskChecklistReady(final JsonObject app) { + requireCheckVerified(app, "addressVerification", "Address verification"); + require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted."); + require(bool(app, "previousLoanDeclarationDone"), "The previous-loan declaration has not been taken."); + if (bool(app, "secured")) { + require(bool(app, "propertySearchReportDone"), "The property search report is not on file."); + require(bool(app, "valuationReportConfirmed"), "The valuation report has not been confirmed."); + } + requireFieldInvestigationComplete(app); + require(bool(app, "backgroundCheckDone"), "The background check has not been done."); + if (bool(app, "guarantorRequired")) { + // A guarantor at a co-operative bank has to be an existing member, not just a name: an + // onboarded client id AND a live share account. + require(LoanOriginationJson.lng(app, "guarantorClientId") != null, + "A guarantor is required but no existing client has been selected."); + require(bool(app, "guarantorHasShareAccount"), "The selected guarantor has no active share account."); + } + } + + private void requireLegalChecklistDone(final JsonObject app) { + require(bool(app, "feesAndAccountOpened"), "Fees and account opening are not complete."); + if (bool(app, "secured")) { + require(bool(app, "mortgageCreated"), "The mortgage has not been created."); + } + require(bool(app, "legalStampDone"), "Legal stamping is not complete."); + require(bool(app, "fileFilingDone"), "File filing is not complete."); + for (final JsonElement element : arr(app, "legalDocuments")) { + final JsonObject doc = element.getAsJsonObject(); + require(bool(doc, "submitted"), "Legal document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document"))); + } + } + + private void requirePostCheckSignatures(final JsonObject app) { + require(bool(app, "signedByAgent"), "The agent has not signed off."); + require(bool(app, "signedByManager"), "The manager has not signed off."); + require(bool(app, "signedByCommittee"), "The committee has not signed off."); + require(bool(app, "signedByRiskApproval"), "Risk approval has not signed off."); + require(bool(app, "signedByLegalApproval"), "Legal approval has not signed off."); + } + + // ── Stage / history mechanics ──────────────────────────────────────────── + + private void requireStage(final JsonObject app, final String expected) { + final String actual = str(app, "currentStage"); + if (!expected.equals(actual)) { + throw badRequest("This action applies at the " + expected + " stage, but the application is at " + actual + "."); + } + final String status = str(app, "overallStatus"); + if ("REJECTED".equals(status)) { + throw badRequest("This application has been rejected and cannot be progressed."); + } + } + + /** + * Moves the application to the next stage and appends the audit entry. {@code furthestStageIndex} + * only ever increases, so jumping back to an earlier stage for a correction does not make + * already-completed stages look unfinished. + */ + private void advance(final JsonObject app, final String actionBy, final String outcome, final String remarks) { + final String current = str(app, "currentStage"); + appendHistory(app, current, actionBy, outcome, remarks); + + final int index = STAGE_ORDER.indexOf(current); + if (index >= 0 && index < STAGE_ORDER.size() - 1) { + final int next = index + 1; + app.addProperty("currentStage", STAGE_ORDER.get(next)); + app.addProperty("furthestStageIndex", Math.max(intOr(app, "furthestStageIndex", 0), next)); + } + } + + private void appendHistory(final JsonObject app, final String stage, final String actionBy, final String outcome, + final String remarks) { + final JsonArray history = arrOrCreate(app, "stageHistory"); + final JsonObject entry = new JsonObject(); + entry.addProperty("stage", stage); + entry.addProperty("date", today()); + entry.addProperty("actionBy", actionBy); + entry.addProperty("outcome", outcome); + entry.addProperty("remarks", remarks); + history.add(entry); + } + + private String actor(final JsonObject input) { + return strOr(input, "actionBy", "Bank Staff"); + } + + private String today() { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + return (businessDate == null ? LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) : businessDate).format(WORKFLOW_DATE); + } + + private void require(final boolean condition, final String message) { + if (!condition) { + throw badRequest(message); + } + } + + private GeneralPlatformDomainRuleException badRequest(final String message) { + return new GeneralPlatformDomainRuleException("error.msg.loan.origination.stage.invalid", message); + } + + /** Kept for symmetry with the check-key list above; used by callers validating a key up front. */ + public static boolean isKnownFieldCheck(final String checkKey) { + return FI_CHECK_KEYS.contains(checkKey); + } + + /** Exposed so the API layer can advertise the stage order without duplicating it. */ + public static List stageOrder() { + return Arrays.asList(STAGE_ORDER.toArray(new String[0])); + } +} diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index fbf1fd99b..18fcdd30f 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -181,9 +181,9 @@ fineract.loan.transactionprocessor.error-not-found-fail=${FINERACT_LOAN_TRANSACT fineract.loan.status-change-history-statuses=${FINERACT_LOAN_STATUS_CHANGE_HISTORY_STATUSES:NONE} fineract.content.regex-whitelist-enabled=${FINERACT_CONTENT_REGEX_WHITELIST_ENABLED:true} -fineract.content.regex-whitelist=${FINERACT_CONTENT_REGEX_WHITELIST:.*\\.pdf$,.*\\.doc,.*\\.docx,.*\\.xls,.*\\.xlsx,.*\\.jpg,.*\\.jpeg,.*\\.png,.*\\.zip$} +fineract.content.regex-whitelist=${FINERACT_CONTENT_REGEX_WHITELIST:.*\\.pdf$,.*\\.doc,.*\\.docx,.*\\.xls,.*\\.xlsx,.*\\.jpg,.*\\.jpeg,.*\\.png,.*\\.zip$,.*\\.csv$} fineract.content.mime-whitelist-enabled=${FINERACT_CONTENT_MIME_WHITELIST_ENABLED:true} -fineract.content.mime-whitelist=${FINERACT_CONTENT_MIME_WHITELIST:application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,image/jpeg,image/png,application/zip,application/x-zip-compressed,application/octet-stream} +fineract.content.mime-whitelist=${FINERACT_CONTENT_MIME_WHITELIST:application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,image/jpeg,image/png,application/zip,application/x-zip-compressed,application/octet-stream,text/csv} fineract.content.default-buffer-size=${FINERACT_CONTENT_DEFAULT_BUFFER_SIZE:8192} fineract.content.filesystem.enabled=${FINERACT_CONTENT_FILESYSTEM_ENABLED:true} fineract.content.filesystem.rootFolder=${FINERACT_CONTENT_FILESYSTEM_ROOT_FOLDER:${user.home}/.fineract} @@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 70bbb4537..e60f5d5fc 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -532,4 +532,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0465_create_loan_origination_pipeline.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0465_create_loan_origination_pipeline.xml new file mode 100644 index 000000000..0109c6910 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0465_create_loan_origination_pipeline.xml @@ -0,0 +1,224 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +