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)); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java index 0e1dcb514..f53fd1cf9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java @@ -718,6 +718,7 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService builder.append("c.nationality as nationality, "); builder.append("c.is_minor as isMinor, "); builder.append("c.guardian_client_id as guardianClientId, "); + builder.append("gc.display_name as guardianClientDisplayName, "); builder.append("c.guardian_relation as guardianRelation, "); builder.append("c.alternate_mobile_no as alternateMobileNo, "); builder.append("c.dip_verified as dipVerified, ");