From 5a98a89839830bd16211ec52e34c6ae45de540be Mon Sep 17 00:00:00 2001 From: Aditya Date: Thu, 21 May 2026 18:46:34 +0530 Subject: [PATCH] KYC: PAN entity-branch, defensive LincPay parse, family/nominee rules for non-individuals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PAN verify for non-individual clients was sending name="" + dob=null to LincPay because the code path always read firstname/middlename/lastname (NULL on entities) and client.dateOfBirth (also NULL on entities). LincPay then returned plain-text "Invalid DOB format. Required format is dd/MM/yyyy", which Gson can't parse (MalformedJsonException at line 1 col 10), surfacing as a useless 503. Now ClientKycWritePlatformServiceJpa- RepositoryImpl.verifyPan branches on LegalForm: - PERSON → name = firstname + middlename + lastname, dob = client.dateOfBirth - ENTITY → name = client.fullname, dob = clientNonPerson.dateOfRegistration Injected ClientNonPersonRepositoryWrapper for the entity branch. ClientNonPerson got two missing getters (getConstitution, getDateOfRegistration) needed by the above plus the family/nominee rules below. Defensive JSON parse in LincPayKycVerificationServiceImpl: parseJson now returns null when the body doesn't even start with '{' or '[' (i.e. vendor sent plain text or HTML), logs the literal body at WARN, and every verify method (PAN, GSTIN, Aadhaar OTP gen, Aadhaar OTP verify, CIN, DIN) checks for null and returns " verification failed — vendor returned non-JSON: " So future vendor outages surface the actual response text instead of a MalformedJson- Exception with no context. Non-individual client rules: - ClientFamilyMembersWritePlatformService: addFamilyMember / addClientFamilyMember / updateFamilyMember now refuse with PlatformApiDataValidationException when the client is an entity. Family members are an individual-only concept. Delete is intentionally unguarded so legacy rows can be cleaned up. - ClientNomineeWritePlatformService: addNominee / updateNominee refuse for non-individual clients UNLESS the constitution normalises to PROPRIETORSHIP (matches both "PROPRIETOR SHIP" and "PROPRIETORSHIP"). Proprietorships are operationally individuals with a separate trade name, so they keep nominee support; companies / LLPs / trusts / societies / HUFs / partnerships don't carry a nomination concept. Delete unguarded. Verified the LincPay endpoints directly: /validate-pan AQRPV8345E + "Aditya Vyas" + 19/12/1988 → valid (individual) /validate-pan AAMCG5406B + "GIZMOGLADE..." + 01/12/2025 → valid (company) /validate-pan AAMCG5406B + "" + "" → "Invalid DOB format" plain text (the failing path) /validate-gstn 23AADCL9984A1ZH → 200 with full company data /validate-gstn 21AAMCG5406BIZ6 → 400 "Invalid GSTIN pattern" (bad checksum, not our bug) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client/domain/ClientNonPerson.java | 8 +++ .../LincPayKycVerificationServiceImpl.java | 65 ++++++++++++++++++- ...FamilyMembersWritePlatformServiceImpl.java | 24 +++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 51 +++++++++++---- ...ClientNomineeWritePlatformServiceImpl.java | 37 +++++++++++ 5 files changed, 169 insertions(+), 16 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNonPerson.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNonPerson.java index f55518622..894c2a92a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNonPerson.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNonPerson.java @@ -184,6 +184,14 @@ public class ClientNonPerson extends AbstractPersistableCustom { return constitutionId; } + public CodeValue getConstitution() { + return this.constitution; + } + + public LocalDate getDateOfRegistration() { + return this.dateOfRegistration; + } + public Long mainBusinessLineId() { Long mainBusinessLineId = null; if (this.mainBusinessLine != null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/LincPayKycVerificationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/LincPayKycVerificationServiceImpl.java index a7a68bc61..b315e79eb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/LincPayKycVerificationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/LincPayKycVerificationServiceImpl.java @@ -121,6 +121,11 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService final String rawResponse = post(url, requestBody.toString()); final JsonObject response = parseJson(rawResponse); + if (response == null) { + return KycVerificationResult.failure( + "Aadhaar OTP generation failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse), + rawResponse); + } if (isSuccess(response)) { // LincPay nests reference_id inside "data" object String refNumber = null; @@ -177,6 +182,11 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService final String rawResponse = post(url, requestBody.toString()); final JsonObject response = parseJson(rawResponse); + if (response == null) { + return KycVerificationResult.failure( + "Aadhaar OTP verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse), + rawResponse); + } if (isSuccess(response)) { LOG.info("LincPay Aadhaar OTP verified successfully for refNumber: {}", refNumber); return KycVerificationResult.success(refNumber, rawResponse); @@ -220,6 +230,12 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService LOG.info("LincPay PAN raw response: {}", rawResponse); final JsonObject response = parseJson(rawResponse); + if (response == null) { + // Vendor returned non-JSON (e.g. "Invalid DOB format. Required format is dd/MM/yyyy") + return KycVerificationResult.failure( + "PAN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse), + rawResponse); + } if (isSuccess(response)) { LOG.info("LincPay PAN verified successfully for PAN: {}", panNumber); return KycVerificationResult.success("PAN-" + panNumber, rawResponse); @@ -259,6 +275,11 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService final String rawResponse = post(url, requestBody.toString()); final JsonObject response = parseJson(rawResponse); + if (response == null) { + return KycVerificationResult.failure( + "GSTIN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse), + rawResponse); + } if (isSuccess(response)) { LOG.info("LincPay GSTIN verified successfully for GSTIN: {}", gstinNumber); return KycVerificationResult.success("GSTN-" + gstinNumber, rawResponse); @@ -300,6 +321,11 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService LOG.info("LincPay CIN raw response: {}", rawResponse); final JsonObject response = parseJson(rawResponse); + if (response == null) { + return KycVerificationResult.failure( + "CIN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse), + rawResponse); + } if (isSuccess(response)) { LOG.info("LincPay CIN verified successfully for CIN: {}", cinNumber); return KycVerificationResult.success("CIN-" + cinNumber, rawResponse); @@ -341,6 +367,11 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService LOG.info("LincPay DIN raw response: {}", rawResponse); final JsonObject response = parseJson(rawResponse); + if (response == null) { + return KycVerificationResult.failure( + "DIN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse), + rawResponse); + } if (isSuccess(response)) { LOG.info("LincPay DIN verified successfully for DIN: {}", dinNumber); return KycVerificationResult.success("DIN-" + dinNumber, rawResponse); @@ -374,12 +405,42 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService return response.getBody(); } + /** + * Parse LincPay's response defensively. The vendor sometimes returns plain text instead of + * JSON (e.g. "Invalid DOB format. Required format is dd/MM/yyyy" when the input is missing + * a field, or an HTML error page when the upstream service is down). In those cases we + * used to throw MalformedJsonException at column 10 with no clue what the body actually + * contained — now we return null and let the caller surface the raw text. + */ private JsonObject parseJson(final String raw) { if (raw == null || raw.isBlank()) { return new JsonObject(); } - final JsonElement parsed = JsonParser.parseString(raw); - return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject(); + final String trimmed = raw.trim(); + final char first = trimmed.charAt(0); + if (first != '{' && first != '[') { + // Not even shaped like JSON — vendor returned plain text or HTML. + LOG.warn("LincPay returned non-JSON body (first 200 chars): {}", + trimmed.substring(0, Math.min(200, trimmed.length()))); + return null; + } + try { + final JsonElement parsed = JsonParser.parseString(trimmed); + return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject(); + } catch (final Exception e) { + LOG.warn("LincPay JSON parse failed: {} | raw body: {}", e.getMessage(), + trimmed.substring(0, Math.min(200, trimmed.length()))); + return null; + } + } + + /** Build a clean failure message that includes the actual vendor text (truncated) for ops debugging. */ + private String vendorBodySnippet(final String raw) { + if (raw == null) { + return "(empty body)"; + } + final String t = raw.trim(); + return t.isEmpty() ? "(empty body)" : t.substring(0, Math.min(200, t.length())); } /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java index eca15dc08..029cd82e2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java @@ -112,6 +112,25 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily } } + /** + * Family members are an individual-client concept (mother/father/spouse etc.). Non-individual + * entities — companies, trusts, LLPs, even proprietorships — don't have "family" semantics; + * any relational role they need is handled via nominees + authorized signatories instead. + * Refuse the add/update so we don't end up with stray rows on entity clients. + */ + private void assertCanHaveFamilyMembers(final Client client) { + if (client == null || client.getLegalForm() == null) { + return; + } + if (org.apache.fineract.portfolio.client.domain.LegalForm.fromInt(client.getLegalForm()).isEntity()) { + throw new PlatformApiDataValidationException(java.util.List.of( + ApiParameterError.parameterError( + "error.msg.client.family.member.not.allowed.for.non.individual", + "Family members are not allowed for non-individual clients.", + "legalForm", client.getLegalForm()))); + } + } + @Override public CommandProcessingResult addFamilyMember(final long clientId, final JsonCommand command) { @@ -139,6 +158,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily apiJsonDeserializer.validateForCreate(clientId, command.json()); Client client = clientRepositoryWrapper.findOneWithNotFoundDetection(clientId); + assertCanHaveFamilyMembers(client); firstName = command.stringValueOfParameterNamed("firstName"); middleName = command.stringValueOfParameterNamed("middleName"); lastName = command.stringValueOfParameterNamed("lastName"); @@ -203,6 +223,8 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily @Override public CommandProcessingResult addClientFamilyMember(final Client client, final JsonCommand command) { + assertCanHaveFamilyMembers(client); + Long relationshipId = null; CodeValue relationship = null; CodeValue maritalStatus = null; @@ -394,6 +416,8 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily */ ClientFamilyMembers clientFamilyMember = clientFamilyRepository.getReferenceById(familyMemberId); + // Reject updates if the parent client is non-individual — same rule as add. + assertCanHaveFamilyMembers(clientFamilyMember.getClient()); // Client // client=clientRepositoryWrapper.findOneWithNotFoundDetection(clientId); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java index 6dd5f998d..e1642e84b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java @@ -43,7 +43,10 @@ import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.client.domain.ClientKyc; import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper; import org.apache.fineract.portfolio.client.domain.ClientKycStatus; +import org.apache.fineract.portfolio.client.domain.ClientNonPerson; +import org.apache.fineract.portfolio.client.domain.ClientNonPersonRepositoryWrapper; import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.LegalForm; import org.apache.fineract.portfolio.client.exception.ClientKycAlreadyExistsException; import org.apache.fineract.portfolio.client.exception.ClientKycInvalidStateTransitionException; import org.apache.fineract.portfolio.client.kyc.KycVerificationResult; @@ -64,6 +67,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc private final PlatformSecurityContext context; private final ClientRepositoryWrapper clientRepository; + private final ClientNonPersonRepositoryWrapper clientNonPersonRepository; private final ClientKycRepositoryWrapper kycRepositoryWrapper; private final ClientKycCommandFromApiJsonDeserializer deserializer; private final KycVerificationService kycVerificationService; @@ -344,21 +348,40 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc "PAN number is not set on this KYC record. Update the KYC record first."); } - final String dob = client.dateOfBirth() != null - ? client.dateOfBirth().format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) - : null; - // Build name without salutation: firstname + middlename (if not blank) + lastname - final StringBuilder panName = new StringBuilder(); - if (client.getFirstname() != null && !client.getFirstname().isBlank()) { - panName.append(client.getFirstname().trim()); + // Non-individual clients store their identity in different fields than individuals: + // firstname/middlename/lastname ← individual columns; NULL for entities + // fullname ← the entity name lives here + // dateOfBirth ← only set for individuals + // ClientNonPerson.dateOfRegistration ← the incorporation date for entities + // LincPay's /validate-pan rejects empty/missing DOB with a PLAIN-TEXT "Invalid DOB format" + // response (not JSON) — Gson then throws MalformedJsonException at column 10. Build the + // right (name, dob) pair per legal form so we always send LincPay the data it expects. + final String clientNameForPan; + final String dob; + if (client.getLegalForm() != null && LegalForm.fromInt(client.getLegalForm()).isEntity()) { + clientNameForPan = client.getFullname() != null ? client.getFullname().trim() : ""; + final ClientNonPerson np = this.clientNonPersonRepository.findOneByClientId(client.getId()); + final java.time.LocalDate regDate = (np != null) ? np.getDateOfRegistration() : null; + dob = regDate != null + ? regDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) + : null; + } else { + dob = client.dateOfBirth() != null + ? client.dateOfBirth().format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) + : null; + // Build name without salutation: firstname + middlename (if not blank) + lastname + final StringBuilder panName = new StringBuilder(); + if (client.getFirstname() != null && !client.getFirstname().isBlank()) { + panName.append(client.getFirstname().trim()); + } + if (client.getMiddlename() != null && !client.getMiddlename().isBlank()) { + panName.append(" ").append(client.getMiddlename().trim()); + } + if (client.getLastname() != null && !client.getLastname().isBlank()) { + panName.append(" ").append(client.getLastname().trim()); + } + clientNameForPan = panName.toString().trim(); } - if (client.getMiddlename() != null && !client.getMiddlename().isBlank()) { - panName.append(" ").append(client.getMiddlename().trim()); - } - if (client.getLastname() != null && !client.getLastname().isBlank()) { - panName.append(" ").append(client.getLastname().trim()); - } - final String clientNameForPan = panName.toString().trim(); LOG.info("verifyPan: calling LincPay with panNumber='{}', name='{}', dob='{}'", kyc.getPanNumber(), clientNameForPan, dob); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java index 2a899d6d6..92ed605f1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java @@ -28,14 +28,19 @@ import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrapper; 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.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.client.domain.ClientNominee; import org.apache.fineract.portfolio.client.domain.ClientNomineeRepository; +import org.apache.fineract.portfolio.client.domain.ClientNonPerson; +import org.apache.fineract.portfolio.client.domain.ClientNonPersonRepositoryWrapper; import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.LegalForm; import org.apache.fineract.portfolio.client.exception.ClientNotFoundException; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository; @@ -58,12 +63,42 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite private final CodeValueRepositoryWrapper codeValueRepository; private final SavingsAccountRepository savingsAccountRepository; private final LoanRepository loanRepository; + private final ClientNonPersonRepositoryWrapper clientNonPersonRepository; + + /** + * Nominees apply to individual clients and to non-individual proprietorships only. Other + * non-individual constitutions (company, LLP, trust, society, HUF, partnership, etc.) don't + * carry a nomination concept — succession is governed by their constitutional documents and + * authorized signatories. Refuse the add/update so we don't end up with stray rows. + */ + private void assertCanHaveNominees(final Client client) { + if (client == null || client.getLegalForm() == null) { + return; + } + if (LegalForm.fromInt(client.getLegalForm()).isPerson()) { + return; // individual — always allowed + } + // Non-individual: allow only if constitution is proprietorship. + final ClientNonPerson nonPerson = this.clientNonPersonRepository.findOneByClientId(client.getId()); + final String constitutionLabel = (nonPerson != null && nonPerson.getConstitution() != null) + ? nonPerson.getConstitution().getLabel() : null; + final String normalized = constitutionLabel == null ? "" : constitutionLabel.toUpperCase().replace(" ", ""); + if (!"PROPRIETORSHIP".equals(normalized) && !"PROPRIETOR".equals(normalized)) { + throw new PlatformApiDataValidationException(java.util.List.of( + ApiParameterError.parameterError( + "error.msg.client.nominee.not.allowed.for.non.individual", + "Nominees are only allowed for individual clients and proprietorship entities. " + + "Constitution: " + (constitutionLabel != null ? constitutionLabel : "(none)"), + "legalForm", client.getLegalForm()))); + } + } @Override @Transactional public CommandProcessingResult addNominee(final Long clientId, final JsonCommand command) { this.context.authenticatedUser(); final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId); + assertCanHaveNominees(client); // ── Account linkage (MANDATORY) ── final String accountNo = command.stringValueOfParameterNamed("accountNo"); @@ -142,6 +177,8 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite @Transactional public CommandProcessingResult updateNominee(final Long clientId, final Long nomineeId, final JsonCommand command) { this.context.authenticatedUser(); + // Same constitution rule as add — refuse updates on entities other than proprietorship. + assertCanHaveNominees(this.clientRepository.findOneWithNotFoundDetection(clientId)); final ClientNominee nominee = nomineeRepository.findByClientIdAndId(clientId, nomineeId) .orElseThrow(() -> new ClientNotFoundException(nomineeId));