KYC: PAN entity-branch, defensive LincPay parse, family/nominee rules for non-individuals
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
"<X> verification failed — vendor returned non-JSON: <first 200 chars>"
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d5157ef701
commit
5a98a89839
+8
@@ -184,6 +184,14 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
||||
return constitutionId;
|
||||
}
|
||||
|
||||
public CodeValue getConstitution() {
|
||||
return this.constitution;
|
||||
}
|
||||
|
||||
public LocalDate getDateOfRegistration() {
|
||||
return this.dateOfRegistration;
|
||||
}
|
||||
|
||||
public Long mainBusinessLineId() {
|
||||
Long mainBusinessLineId = null;
|
||||
if (this.mainBusinessLine != null) {
|
||||
|
||||
+63
-2
@@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+24
@@ -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);
|
||||
|
||||
+37
-14
@@ -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);
|
||||
|
||||
|
||||
+37
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user