fix(clients): stale signature bug, CKYC fixes, monthly turnover/annual income, senior citizen/PWD classification

- Add m_client.signature_id (single-slot pointer, mirrors image_id) plus
  ClientSignatureIdAdapter/EntitySignatureIdAdapter so re-uploading a signature updates the
  existing m_document row in place instead of leaving duplicate rows behind that readers picked
  inconsistently.
- Fix CkycBatchBuilderServiceImpl address-proof classification to read the client identifier's
  actual m_code (KYCAddressProofType vs KYCIdProofType) instead of a hardcoded string set that
  disagreed with the CKYC tab and silently dropped Aadhaar-as-address-proof identifiers.
- Organize the CKYC ZIP into per-artefact-type folders (PHOTO/SIGNATURE/ID_PROOF/ADDRESS_PROOF).
- Fix CkycBatchBuilderServiceImpl.createBatch() using the daily batch sequence number as the ZIP
  document's parent_entity_id instead of the real m_ckyc_batch.id, which 404'd on download
  whenever the two diverged (i.e. almost always).
- Add monthlyTurnover and annualIncome fields alongside the existing annualTurnover/monthlyIncome.
- Add isSeniorCitizen, isPwd, and pwdCategory (proper m_code_value-backed PwdCategory code, not a
  free-text column) to m_client, following the same pattern as occupation/religion/marital status.
- Add Diploma as its own Education Level (with diploma-specific DegreeQualification values) and
  Graduate as a level distinct from Undergraduate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 10:50:16 +05:30
co-authored by Claude Sonnet 5
parent bc30241950
commit 67214ec0f8
20 changed files with 771 additions and 45 deletions
@@ -32,7 +32,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import lombok.RequiredArgsConstructor;
@@ -61,16 +60,22 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService {
private static final DateTimeFormatter ZIP_NAME_TS_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss");
/**
* Names of code values (from {@code KYCAddressProofType}) that unambiguously indicate an
* address proof. Aadhaar / Passport / DL / Voter ID can be both ID and address proof — we
* default them to ID (PI) unless the UI tags them explicitly.
* Code (m_code.code_name) under which an identifier's document type must be classified for it
* to count as an address proof. Matches the same m_code_value config that
* KycProofTypesApiResource (GET /kyc/proof-types) reads to populate the CKYC tab's
* "Address Proof Document" card — e.g. Aadhaar Card is seeded as a document type under BOTH
* KYCIdProofType and KYCAddressProofType (two separate m_client_identifier rows), so trusting
* the code a given identifier was actually tagged under is the only way this can agree with
* what the CKYC tab already shows as "available".
*/
private static final Set<String> ADDRESS_PROOF_ONLY = Set.of(
"UTILITY BILLS", "UTILITY BILL", "ELECTRICITY BILL", "WATER BILL", "GAS BILL", "INTERNET BILL",
"BANK ACCOUNT STATEMENT / PASSBOOK", "BANK STATEMENT", "POSTPAID MOBILE BILL",
"REGISTERED RENT AGREEMENT", "RENT AGREEMENT", "PROPERTY TAX RECEIPT",
"EMPLOYER LETTER (FOR SALARIED EMPLOYEES)", "EMPLOYER LETTER",
"PENSION PAYMENT ORDERS (FOR RETIREES)", "PENSION PAYMENT ORDER");
private static final String ADDRESS_PROOF_CODE_NAME = "KYCAddressProofType";
// Top-level ZIP folders — one per artefact type, so every client's file of a given type lands
// in the same folder instead of all 4 types being interleaved flat at the ZIP root.
private static final String PHOTO_FOLDER = "PHOTO/";
private static final String SIGNATURE_FOLDER = "SIGNATURE/";
private static final String ID_PROOF_FOLDER = "ID_PROOF/";
private static final String ADDRESS_PROOF_FOLDER = "ADDRESS_PROOF/";
private final JdbcTemplate jdbcTemplate;
private final DocumentReadPlatformService documentReadPlatformService;
@@ -111,13 +116,30 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService {
final byte[] zipBytes = zipBytesOut.toByteArray();
final String checksum = "sha256:" + sha256Hex(zipBytes);
// Persist the ZIP as an m_document row (parent_entity_type='ckyc_batch', parent_entity_id=seq)
// Persistence happens BEFORE we know m_ckyc_batch.id, so we use the synthetic seq as a
// temporary parent_entity_id. The real FK is via m_ckyc_batch.document_id after save.
// Persist the batch registry row FIRST (without documentId) so we have its real,
// DB-generated id before creating the ZIP's m_document row. The document's
// parent_entity_id must be the actual m_ckyc_batch.id — using the daily `seq` here
// (as a stand-in for an id that doesn't exist yet) was wrong: seq resets every day
// while m_ckyc_batch.id is a global auto-increment, so GET
// /ckyc_batch/{id}/documents/{documentId}/attachment (which matches on the REAL batch
// id) 404'd for any batch whose seq didn't coincidentally equal its DB id.
final CkycBatch batch = new CkycBatch();
batch.setBatchId(batchId);
batch.setFilename(filename);
batch.setChecksumSha256(checksum);
batch.setRecordCount(includedCount);
batch.setSizeBytes((long) zipBytes.length);
batch.setCreatedOnDate(today);
batch.setUploadedToCersai(false);
batch.setManifestJson(new Gson().toJson(manifest));
final CkycBatch saved = ckycBatchRepository.saveAndFlush(batch);
// Now persist the ZIP as an m_document row keyed to the batch's real id.
final var docResponse = documentWritePlatformService.createDocument(
DocumentCreateRequest.builder()
.entityType("ckyc_batch")
.entityId((long) seq)
.entityId(saved.getId())
.name(batchId)
.fileName(filename)
.description(StringUtils.defaultIfEmpty(remarks, "CERSAI CKYC batch ZIP"))
@@ -126,19 +148,8 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService {
.stream(new ByteArrayInputStream(zipBytes))
.build());
// Persist the batch registry row
final CkycBatch batch = new CkycBatch();
batch.setBatchId(batchId);
batch.setFilename(filename);
batch.setChecksumSha256(checksum);
batch.setRecordCount(includedCount);
batch.setSizeBytes((long) zipBytes.length);
batch.setDocumentId(docResponse.getResourceId() != null ? docResponse.getResourceId().longValue() : null);
batch.setCreatedOnDate(today);
batch.setUploadedToCersai(false);
batch.setManifestJson(new Gson().toJson(manifest));
final CkycBatch saved = ckycBatchRepository.saveAndFlush(batch);
saved.setDocumentId(docResponse.getResourceId() != null ? docResponse.getResourceId().longValue() : null);
ckycBatchRepository.saveAndFlush(saved);
log.info("CKYC batch created: id={}, batchId={}, filename={}, recordCount={}/{}, bytes={}",
saved.getId(), batchId, filename, includedCount, clientIds.size(), zipBytes.length);
@@ -184,32 +195,40 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService {
entry.setCifNumber(cifNumber);
entry.setDisplayName(displayName);
// Photo — packaged as "<CIF>-PI-PHOTO.<ext>" using m_client.image_id → m_image
// Photo — packaged as "PHOTO/<CIF>-PI-PHOTO.<ext>" using m_client.image_id → m_image.
// Every artefact type gets its own top-level ZIP folder, so a batch of N clients produces
// one PHOTO/ folder containing all N client photos (and likewise for the other 3 types),
// rather than every client's files interleaved flat at the ZIP root.
final Map<String, Object> photo = new LinkedHashMap<>();
photo.put("present", imageId != null);
entry.setPhoto(photo);
if (imageId != null && cifNumber != null) {
packageClientImage(zip, clientId, cifNumber + "-PI-PHOTO", photo);
packageClientImage(zip, clientId, PHOTO_FOLDER + cifNumber + "-PI-PHOTO", photo);
}
// Signature — packaged as "<CIF>-PI-SIGNATURE.<ext>"
// Signature — packaged as "SIGNATURE/<CIF>-PI-SIGNATURE.<ext>"
final Long signatureDocId = findSignatureDocId(clientId);
final Map<String, Object> signature = new LinkedHashMap<>();
signature.put("present", signatureDocId != null);
entry.setSignature(signature);
if (signatureDocId != null && cifNumber != null) {
packageFile(zip, "clients", clientId, signatureDocId,
cifNumber + "-PI-SIGNATURE", signature);
SIGNATURE_FOLDER + cifNumber + "-PI-SIGNATURE", signature);
}
// ID proof + Address proof: iterate m_client_identifier, classify by code value name.
// ID proof + Address proof: iterate m_client_identifier, classify by the m_code the
// identifier's document type actually belongs to (KYCIdProofType vs KYCAddressProofType) —
// the same source of truth KycProofTypesApiResource uses for the CKYC tab, so a document
// the client sees as "Address Proof: Available" is never silently dropped here.
// For each identifier, find the attached m_document (one-to-one, latest wins).
// NOTE: the `active` column in m_client_identifier stores the status code (200 = active)
// not a 1/0 boolean — so we filter on `status = 200` (ClientIdentifierStatus.ACTIVE).
final List<Map<String, Object>> identifiers = jdbcTemplate.queryForList(
"select ci.id as identifierId, ci.document_key as docKey, cv.code_value as codeValue "
"select ci.id as identifierId, ci.document_key as docKey, cv.code_value as codeValue, "
+ " c.code_name as codeName "
+ " from m_client_identifier ci "
+ " join m_code_value cv on cv.id = ci.document_type_id "
+ " join m_code c on c.id = cv.code_id "
+ " where ci.client_id = ? and (ci.status = 200 or ci.status is null)",
clientId);
@@ -221,8 +240,9 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService {
for (final Map<String, Object> idRow : identifiers) {
final Long identifierId = ((Number) idRow.get("identifierId")).longValue();
final String codeValue = (String) idRow.get("codeValue");
final String codeName = (String) idRow.get("codeName");
final String docKey = (String) idRow.get("docKey");
final boolean isAddress = ADDRESS_PROOF_ONLY.contains(codeValue == null ? "" : codeValue.toUpperCase(Locale.ROOT));
final boolean isAddress = ADDRESS_PROOF_CODE_NAME.equalsIgnoreCase(codeName);
final String role = isAddress ? "PA" : "PI";
final Long attachedDocId = findLatestDocForIdentifier(identifierId);
@@ -242,8 +262,9 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService {
bucket.put("key", docKey);
if (cifNumber != null) {
final String folder = isAddress ? ADDRESS_PROOF_FOLDER : ID_PROOF_FOLDER;
packageFile(zip, "client_identifiers", identifierId, attachedDocId,
cifNumber + "-" + role + "-" + shortCode, bucket);
folder + cifNumber + "-" + role + "-" + shortCode, bucket);
}
}
@@ -32,8 +32,9 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
dateOfBirthParamName, genderIdParamName, salutationIdParamName, clientTypeIdParamName, clientClassificationIdParamName,
occupationCategoryIdParamName, occupationIdParamName, maritalStatusIdParamName,
educationLevelIdParamName, degreeQualificationIdParamName,
religionIdParamName, socialCategoryIdParamName, monthlyIncomeParamName, annualTurnoverParamName,
agricultureIncomeParamName, nonAgricultureIncomeParamName,
religionIdParamName, socialCategoryIdParamName, monthlyIncomeParamName, annualIncomeParamName, annualTurnoverParamName,
monthlyTurnoverParamName, agricultureIncomeParamName, nonAgricultureIncomeParamName,
isSeniorCitizenParamName, isPwdParamName, pwdCategoryIdParamName,
ageYearsParamName, ageMonthsParamName, ageDaysParamName,
nationalityIdParamName, isMinorParamName, guardianClientIdParamName, guardianRelationParamName,
dipVerifiedParamName, dipLookupLogIdParamName, dipTimestampParamName,
@@ -55,8 +56,9 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
dateOfBirthParamName, genderIdParamName, salutationIdParamName, clientTypeIdParamName, clientClassificationIdParamName,
occupationCategoryIdParamName, occupationIdParamName, maritalStatusIdParamName,
educationLevelIdParamName, degreeQualificationIdParamName,
religionIdParamName, socialCategoryIdParamName, monthlyIncomeParamName, annualTurnoverParamName,
agricultureIncomeParamName, nonAgricultureIncomeParamName,
religionIdParamName, socialCategoryIdParamName, monthlyIncomeParamName, annualIncomeParamName, annualTurnoverParamName,
monthlyTurnoverParamName, agricultureIncomeParamName, nonAgricultureIncomeParamName,
isSeniorCitizenParamName, isPwdParamName, pwdCategoryIdParamName,
ageYearsParamName, ageMonthsParamName, ageDaysParamName,
nationalityIdParamName, isMinorParamName, guardianClientIdParamName, guardianRelationParamName,
dipVerifiedParamName, dipLookupLogIdParamName, dipTimestampParamName,
@@ -65,12 +65,14 @@ public interface ClientMapper {
@Mapping(target = "degreeQualification", source = "source", qualifiedByName = "degreeQualificationCode")
@Mapping(target = "religion", source = "source", qualifiedByName = "religionCode")
@Mapping(target = "socialCategory", source = "source", qualifiedByName = "socialCategoryCode")
@Mapping(target = "pwdCategory", source = "source", qualifiedByName = "pwdCategoryCode")
@Mapping(target = "occupationCategoryOptions", ignore = true)
@Mapping(target = "occupationOptions", ignore = true)
@Mapping(target = "educationLevelOptions", ignore = true)
@Mapping(target = "degreeQualificationOptions", ignore = true)
@Mapping(target = "religionOptions", ignore = true)
@Mapping(target = "socialCategoryOptions", ignore = true)
@Mapping(target = "pwdCategoryOptions", ignore = true)
@Mapping(target = "legalForm", source = "source", qualifiedByName = "clientLegalFormEnum")
@Mapping(target = "isStaff", source = "source", qualifiedByName = "clientIsStaff")
@Mapping(target = "ageYears", ignore = true)
@@ -196,6 +198,15 @@ public interface ClientMapper {
return CodeValueData.instance(code.getId(), code.getLabel());
}
@Named("pwdCategoryCode")
default CodeValueData pwdCategoryCode(Client client) {
final CodeValue code = client.getPwdCategory();
if (code == null) {
return null;
}
return CodeValueData.instance(code.getId(), code.getLabel());
}
@Named("clientSubStatusCode")
default CodeValueData clientSubStatusCode(Client client) {
final CodeValue code = client.getSubStatus();
@@ -321,7 +321,13 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
sqlBuilder.append("c.occupation_cv_id as occupationId, ");
sqlBuilder.append("cvOccupation.code_value as occupationValue, ");
sqlBuilder.append("c.monthly_income as monthlyIncome, ");
sqlBuilder.append("c.annual_income as annualIncome, ");
sqlBuilder.append("c.annual_turnover as annualTurnover, ");
sqlBuilder.append("c.monthly_turnover as monthlyTurnover, ");
sqlBuilder.append("c.is_senior_citizen as isSeniorCitizen, ");
sqlBuilder.append("c.is_pwd as isPwd, ");
sqlBuilder.append("c.pwd_category_cv_id as pwdCategoryId, ");
sqlBuilder.append("cvPwdCategory.code_value as pwdCategoryValue, ");
sqlBuilder.append("c.nationality as nationality, ");
sqlBuilder.append("c.is_minor as isMinor, ");
sqlBuilder.append("c.guardian_client_id as guardianClientId, ");
@@ -383,6 +389,7 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
sqlBuilder.append("left join m_code_value cvclassification on cvclassification.id = c.client_classification_cv_id ");
sqlBuilder.append("left join m_code_value cvSubStatus on cvSubStatus.id = c.sub_status ");
sqlBuilder.append("left join m_code_value cvOccupationCategory on cvOccupationCategory.id = c.occupation_category_cv_id ");
sqlBuilder.append("left join m_code_value cvPwdCategory on cvPwdCategory.id = c.pwd_category_cv_id ");
sqlBuilder.append("left join m_code_value cvOccupation on cvOccupation.id = c.occupation_cv_id ");
sqlBuilder.append("left join m_client gc on gc.id = c.guardian_client_id ");
sqlBuilder.append("left join m_code_value cvConstitution on cvConstitution.id = cnp.constitution_cv_id ");
@@ -482,7 +489,14 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
final CodeValueData occupation = CodeValueData.instance(occupationId, occupationValue);
final BigDecimal monthlyIncome1 = rs.getBigDecimal("monthlyIncome");
final BigDecimal annualIncome1 = rs.getBigDecimal("annualIncome");
final BigDecimal annualTurnover1 = rs.getBigDecimal("annualTurnover");
final BigDecimal monthlyTurnover1 = rs.getBigDecimal("monthlyTurnover");
final Boolean isSeniorCitizen1 = rs.getBoolean("isSeniorCitizen");
final Boolean isPwd1 = rs.getBoolean("isPwd");
final Long pwdCategoryId1 = JdbcSupport.getLong(rs, "pwdCategoryId");
final String pwdCategoryValue1 = rs.getString("pwdCategoryValue");
final CodeValueData pwdCategory1 = CodeValueData.instance(pwdCategoryId1, pwdCategoryValue1);
Integer ageYears1 = null;
Integer ageMonths1 = null;
@@ -520,7 +534,12 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
savingsProductName, savingsAccountId, clienttype, classification, occupationCategory, occupation, legalForm,
clientNonPerson, isStaff);
clientData.setMonthlyIncome(monthlyIncome1);
clientData.setAnnualIncome(annualIncome1);
clientData.setAnnualTurnover(annualTurnover1);
clientData.setMonthlyTurnover(monthlyTurnover1);
clientData.setIsSeniorCitizen(isSeniorCitizen1);
clientData.setIsPwd(isPwd1);
clientData.setPwdCategory(pwdCategory1);
clientData.setAgeYears(ageYears1);
clientData.setAgeMonths(ageMonths1);
clientData.setAgeDays(ageDays1);
@@ -714,7 +733,13 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
builder.append("c.occupation_cv_id as occupationId, ");
builder.append("cvOccupation.code_value as occupationValue, ");
builder.append("c.monthly_income as monthlyIncome, ");
builder.append("c.annual_income as annualIncome, ");
builder.append("c.annual_turnover as annualTurnover, ");
builder.append("c.monthly_turnover as monthlyTurnover, ");
builder.append("c.is_senior_citizen as isSeniorCitizen, ");
builder.append("c.is_pwd as isPwd, ");
builder.append("c.pwd_category_cv_id as pwdCategoryId, ");
builder.append("cvPwdCategory.code_value as pwdCategoryValue, ");
builder.append("c.nationality as nationality, ");
builder.append("c.is_minor as isMinor, ");
builder.append("c.guardian_client_id as guardianClientId, ");
@@ -775,6 +800,7 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
builder.append("left join m_code_value cvclassification on cvclassification.id = c.client_classification_cv_id ");
builder.append("left join m_code_value cvSubStatus on cvSubStatus.id = c.sub_status ");
builder.append("left join m_code_value cvOccupationCategory on cvOccupationCategory.id = c.occupation_category_cv_id ");
builder.append("left join m_code_value cvPwdCategory on cvPwdCategory.id = c.pwd_category_cv_id ");
builder.append("left join m_code_value cvOccupation on cvOccupation.id = c.occupation_cv_id ");
builder.append("left join m_client gc on gc.id = c.guardian_client_id ");
builder.append("left join m_code_value cvConstitution on cvConstitution.id = cnp.constitution_cv_id ");
@@ -873,7 +899,14 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
final CodeValueData occupation = CodeValueData.instance(occupationId, occupationValue);
final BigDecimal monthlyIncome = rs.getBigDecimal("monthlyIncome");
final BigDecimal annualIncome = rs.getBigDecimal("annualIncome");
final BigDecimal annualTurnover = rs.getBigDecimal("annualTurnover");
final BigDecimal monthlyTurnover = rs.getBigDecimal("monthlyTurnover");
final Boolean isSeniorCitizen = rs.getBoolean("isSeniorCitizen");
final Boolean isPwd = rs.getBoolean("isPwd");
final Long pwdCategoryId = JdbcSupport.getLong(rs, "pwdCategoryId");
final String pwdCategoryValue = rs.getString("pwdCategoryValue");
final CodeValueData pwdCategory = CodeValueData.instance(pwdCategoryId, pwdCategoryValue);
Integer ageYears = null;
Integer ageMonths = null;
@@ -911,7 +944,12 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
savingsProductName, savingsAccountId, clienttype, classification, occupationCategory, occupation, legalForm,
clientNonPerson, isStaff);
clientData.setMonthlyIncome(monthlyIncome);
clientData.setAnnualIncome(annualIncome);
clientData.setAnnualTurnover(annualTurnover);
clientData.setMonthlyTurnover(monthlyTurnover);
clientData.setIsSeniorCitizen(isSeniorCitizen);
clientData.setIsPwd(isPwd);
clientData.setPwdCategory(pwdCategory);
clientData.setAgeYears(ageYears);
clientData.setAgeMonths(ageMonths);
clientData.setAgeDays(ageDays);
@@ -126,6 +126,9 @@ public class ClientTemplateReadPlatformServiceImpl implements ClientTemplateRead
final List<CodeValueData> socialCategoryOptions = new ArrayList<>(
this.codeValueReadPlatformService.retrieveCodeValuesByCode(ClientApiConstants.SOCIAL_CATEGORY));
final List<CodeValueData> pwdCategoryOptions = new ArrayList<>(
this.codeValueReadPlatformService.retrieveCodeValuesByCode(ClientApiConstants.PWD_CATEGORY));
final List<CodeValueData> clientNonPersonConstitutionOptions = new ArrayList<>(
this.codeValueReadPlatformService.retrieveCodeValuesByCode(ClientApiConstants.CLIENT_NON_PERSON_CONSTITUTION));
@@ -146,6 +149,7 @@ public class ClientTemplateReadPlatformServiceImpl implements ClientTemplateRead
templateData.setDegreeQualificationOptions(degreeQualificationOptions);
templateData.setReligionOptions(religionOptions);
templateData.setSocialCategoryOptions(socialCategoryOptions);
templateData.setPwdCategoryOptions(pwdCategoryOptions);
return templateData;
}
@@ -395,6 +395,13 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
.findOneByCodeNameAndIdWithNotFoundDetection(ClientApiConstants.SOCIAL_CATEGORY, socialCategoryId);
}
CodeValue pwdCategory = null;
final Long pwdCategoryId = command.longValueOfParameterNamed(ClientApiConstants.pwdCategoryIdParamName);
if (pwdCategoryId != null) {
pwdCategory = this.codeValueRepository
.findOneByCodeNameAndIdWithNotFoundDetection(ClientApiConstants.PWD_CATEGORY, pwdCategoryId);
}
final Long savingsProductId = command.longValueOfParameterNamed(ClientApiConstants.savingsProductIdParamName);
if (savingsProductId != null) {
this.savingsProductRepository.findById(savingsProductId)
@@ -481,11 +488,31 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
newClient.setMonthlyIncome(monthlyIncome);
}
final java.math.BigDecimal annualIncome = command.bigDecimalValueOfParameterNamed(ClientApiConstants.annualIncomeParamName);
if (annualIncome != null) {
newClient.setAnnualIncome(annualIncome);
}
final java.math.BigDecimal annualTurnover = command.bigDecimalValueOfParameterNamed(ClientApiConstants.annualTurnoverParamName);
if (annualTurnover != null) {
newClient.setAnnualTurnover(annualTurnover);
}
final java.math.BigDecimal monthlyTurnover = command.bigDecimalValueOfParameterNamed(ClientApiConstants.monthlyTurnoverParamName);
if (monthlyTurnover != null) {
newClient.setMonthlyTurnover(monthlyTurnover);
}
final Boolean isSeniorCitizen = command.booleanObjectValueOfParameterNamed(ClientApiConstants.isSeniorCitizenParamName);
newClient.setIsSeniorCitizen(Boolean.TRUE.equals(isSeniorCitizen));
final Boolean isPwd = command.booleanObjectValueOfParameterNamed(ClientApiConstants.isPwdParamName);
newClient.setIsPwd(Boolean.TRUE.equals(isPwd));
if (pwdCategory != null) {
newClient.updatePwdCategory(pwdCategory);
}
final java.math.BigDecimal agricultureIncome = command.bigDecimalValueOfParameterNamed(ClientApiConstants.agricultureIncomeParamName);
if (agricultureIncome != null) {
newClient.setAgricultureIncome(agricultureIncome);
@@ -964,12 +991,47 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
clientForUpdate.setMonthlyIncome(newValue);
}
if (command.isChangeInBigDecimalParameterNamed(ClientApiConstants.annualIncomeParamName, clientForUpdate.getAnnualIncome())) {
final java.math.BigDecimal newValue = command.bigDecimalValueOfParameterNamed(ClientApiConstants.annualIncomeParamName);
changes.put(ClientApiConstants.annualIncomeParamName, newValue);
clientForUpdate.setAnnualIncome(newValue);
}
if (command.isChangeInBigDecimalParameterNamed(ClientApiConstants.annualTurnoverParamName, clientForUpdate.getAnnualTurnover())) {
final java.math.BigDecimal newValue = command.bigDecimalValueOfParameterNamed(ClientApiConstants.annualTurnoverParamName);
changes.put(ClientApiConstants.annualTurnoverParamName, newValue);
clientForUpdate.setAnnualTurnover(newValue);
}
if (command.isChangeInBigDecimalParameterNamed(ClientApiConstants.monthlyTurnoverParamName, clientForUpdate.getMonthlyTurnover())) {
final java.math.BigDecimal newValue = command.bigDecimalValueOfParameterNamed(ClientApiConstants.monthlyTurnoverParamName);
changes.put(ClientApiConstants.monthlyTurnoverParamName, newValue);
clientForUpdate.setMonthlyTurnover(newValue);
}
if (command.isChangeInBooleanParameterNamed(ClientApiConstants.isSeniorCitizenParamName, clientForUpdate.getIsSeniorCitizen())) {
final Boolean newValue = command.booleanObjectValueOfParameterNamed(ClientApiConstants.isSeniorCitizenParamName);
changes.put(ClientApiConstants.isSeniorCitizenParamName, newValue);
clientForUpdate.setIsSeniorCitizen(Boolean.TRUE.equals(newValue));
}
if (command.isChangeInBooleanParameterNamed(ClientApiConstants.isPwdParamName, clientForUpdate.getIsPwd())) {
final Boolean newValue = command.booleanObjectValueOfParameterNamed(ClientApiConstants.isPwdParamName);
changes.put(ClientApiConstants.isPwdParamName, newValue);
clientForUpdate.setIsPwd(Boolean.TRUE.equals(newValue));
}
if (command.isChangeInLongParameterNamed(ClientApiConstants.pwdCategoryIdParamName, clientForUpdate.pwdCategoryId())) {
final Long newValue = command.longValueOfParameterNamed(ClientApiConstants.pwdCategoryIdParamName);
changes.put(ClientApiConstants.pwdCategoryIdParamName, newValue);
CodeValue newCodeVal = null;
if (newValue != null) {
newCodeVal = this.codeValueRepository
.findOneByCodeNameAndIdWithNotFoundDetection(ClientApiConstants.PWD_CATEGORY, newValue);
}
clientForUpdate.updatePwdCategory(newCodeVal);
}
if (command.isChangeInBigDecimalParameterNamed(ClientApiConstants.agricultureIncomeParamName, clientForUpdate.getAgricultureIncome())) {
final java.math.BigDecimal newValue = command.bigDecimalValueOfParameterNamed(ClientApiConstants.agricultureIncomeParamName);
changes.put(ClientApiConstants.agricultureIncomeParamName, newValue);
@@ -441,6 +441,12 @@
<include file="parts/0418_add_user_agreement_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0419_revert_first_login_gate_column_and_config.xml" relativeToChangelogFile="true"/>
<include file="parts/0420_add_missing_clientkyc_action_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0421_add_signature_id_to_client.xml" relativeToChangelogFile="true"/>
<include file="parts/0422_add_monthly_turnover_to_client.xml" relativeToChangelogFile="true"/>
<include file="parts/0423_add_annual_income_to_client.xml" relativeToChangelogFile="true"/>
<include file="parts/0424_add_senior_citizen_pwd_to_client.xml" relativeToChangelogFile="true"/>
<include file="parts/0425_add_diploma_degree_qualification.xml" relativeToChangelogFile="true"/>
<include file="parts/0426_add_graduate_education_level.xml" relativeToChangelogFile="true"/>
<!-- NOTE: the numbers below (0341-0359) re-use prefixes already used above by the chequebook/CTS
clearing catchup migrations — these come from a long-diverged branch (uate) that assigned its own
sequence independently. Liquibase tracks changesets by filename + id + author, not by the numeric
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<!--
m_client.image_id is a single-slot pointer into m_image: every photo upload overwrites the
same row, so every reader always sees the latest photo. The client signature had no
equivalent — every upload/re-crop created a brand-new m_document row named
'clientSignature' and never touched the previous one. Readers that don't explicitly
`order by id desc` (CKYC Documents tab, view-signature dialog, delete-signature dialog)
picked the FIRST matching row from an unordered query, i.e. the oldest signature, even
after the client re-cropped it — while CkycBatchBuilderServiceImpl (which does order by id
desc) correctly zipped up the latest one. This adds a signature_id pointer mirroring
image_id so signature storage becomes single-slot too, and backfills it from the latest
existing 'clientSignature' document per client so it starts out consistent with what the
CKYC ZIP builder already considered "current".
-->
<changeSet author="CBS" id="0421-1-add-signature-id-to-client">
<addColumn tableName="m_client">
<column name="signature_id" type="BIGINT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
<changeSet author="CBS" id="0421-2-add-signature-id-index">
<createIndex indexName="FK_m_client_m_document_signature" tableName="m_client">
<column name="signature_id"/>
</createIndex>
</changeSet>
<changeSet author="CBS" id="0421-3-add-signature-id-fk">
<addForeignKeyConstraint baseColumnNames="signature_id" baseTableName="m_client"
constraintName="FK_m_client_m_document_signature" deferrable="false" initiallyDeferred="false"
onDelete="SET NULL" onUpdate="RESTRICT" referencedColumnNames="id"
referencedTableName="m_document" validate="true"/>
</changeSet>
<changeSet author="CBS" id="0421-4-backfill-signature-id">
<sql>
UPDATE m_client c
SET signature_id = (
SELECT MAX(d.id) FROM m_document d
WHERE d.parent_entity_type = 'clients' AND d.parent_entity_id = c.id AND d.name = 'clientSignature'
)
WHERE EXISTS (
SELECT 1 FROM m_document d
WHERE d.parent_entity_type = 'clients' AND d.parent_entity_id = c.id AND d.name = 'clientSignature'
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<!-- Mirrors 0238_add_annual_turnover.xml — "Annual Turnover (Last FY)" is now labelled
"Yearly Turnover" in the client form, alongside this new "Monthly Turnover" field. -->
<changeSet author="CBS" id="0422-1">
<addColumn tableName="m_client">
<column name="monthly_turnover" type="DECIMAL(19,2)" defaultValue="null"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<!-- Pairs with monthly_income the same way monthly_turnover pairs with annual_turnover
(0422_add_monthly_turnover_to_client.xml) — "Annual Income" alongside "Monthly Income". -->
<changeSet author="CBS" id="0423-1">
<addColumn tableName="m_client">
<column name="annual_income" type="DECIMAL(19,2)" defaultValue="null"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<!-- Senior Citizen / Persons-with-Disability flags for RBI-mandated accessible-banking
provisions (doorstep banking eligibility for senior citizens 70+ and differently-abled/
infirm customers, per RBI's customer service circulars). isSeniorCitizen is auto-detected
client-side from Date of Birth (not user-editable); PWD is a manual flag. -->
<changeSet author="CBS" id="0424-1">
<addColumn tableName="m_client">
<column name="is_senior_citizen" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="is_pwd" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<!-- pwd_category_cv_id follows the same m_code_value FK pattern as occupation_category_cv_id /
marital_status_cv_id / religion_cv_id etc — a fixed free-text column would need a Java
change every time a new disability category is needed; a code value lets an admin add,
reorder, or deactivate categories from System -> Manage Codes with zero code changes. -->
<changeSet author="CBS" id="0424-2">
<addColumn tableName="m_client">
<column name="pwd_category_cv_id" type="BIGINT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
<changeSet author="CBS" id="0424-3">
<createIndex indexName="FK_m_client_pwd_category_cv_id" tableName="m_client">
<column name="pwd_category_cv_id"/>
</createIndex>
</changeSet>
<changeSet author="CBS" id="0424-4">
<addForeignKeyConstraint baseColumnNames="pwd_category_cv_id" baseTableName="m_client"
constraintName="FK_m_client_pwd_category_cv_id" deferrable="false" initiallyDeferred="false"
onDelete="RESTRICT" onUpdate="RESTRICT" referencedColumnNames="id"
referencedTableName="m_code_value" validate="true"/>
</changeSet>
<!-- ==================== PwdCategory code ====================
Categories per the Rights of Persons with Disabilities Act, 2016 (RPWD Act), the same
classification referenced by RBI's accessible-banking circulars. -->
<changeSet author="CBS" id="0424-5">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_code WHERE code_name = 'PwdCategory'</sqlCheck>
</preConditions>
<insert tableName="m_code">
<column name="code_name" value="PwdCategory"/>
<column name="is_system_defined" valueBoolean="true"/>
</insert>
</changeSet>
<changeSet author="CBS" id="0424-6">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_code_value WHERE code_id = (SELECT id FROM m_code WHERE code_name = 'PwdCategory')</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_code_value (code_id, code_value, code_description, order_position, is_active, is_mandatory)
SELECT id, val, val, pos, true, false FROM m_code
CROSS JOIN (VALUES
('Blindness', 1),
('Low Vision', 2),
('Leprosy Cured Persons', 3),
('Hearing Impairment (Deaf and Hard of Hearing)', 4),
('Locomotor Disability', 5),
('Dwarfism', 6),
('Intellectual Disability', 7),
('Mental Illness', 8),
('Autism Spectrum Disorder', 9),
('Cerebral Palsy', 10),
('Muscular Dystrophy', 11),
('Chronic Neurological Conditions', 12),
('Specific Learning Disabilities', 13),
('Multiple Sclerosis', 14),
('Speech and Language Disability', 15),
('Thalassemia', 16),
('Hemophilia', 17),
('Sickle Cell Disease', 18),
('Multiple Disabilities', 19),
('Acid Attack Victim', 20),
('Parkinson''s Disease', 21)
) AS t(val, pos)
WHERE code_name = 'PwdCategory';
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<!-- "Diploma" becomes its own Education Level (alongside School / Undergraduate / Postgraduate
/ Doctorate) rather than a single generic entry nested under either of those — polytechnic
diploma holders are neither "School" nor "Undergraduate" in the strict sense. The
"Degree / Qualification" dropdown then offers diploma-specific branches when this level
is selected. -->
<changeSet id="0425_01_insert_diploma_education_level" author="CBS">
<sql>
INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory)
SELECT c.id, 'Diploma',
(SELECT COALESCE(MAX(cv.order_position), 0) FROM m_code_value cv WHERE cv.code_id = c.id) + 1,
true, false
FROM m_code c
WHERE c.code_name = 'EducationLevel'
AND NOT EXISTS (
SELECT 1 FROM m_code_value cv WHERE cv.code_id = c.id AND cv.code_value = 'Diploma'
);
</sql>
</changeSet>
<changeSet id="0425_02_insert_diploma_degree_values" author="CBS">
<sql>
INSERT INTO m_code_value (code_id, code_value, code_description, order_position, is_active, is_mandatory)
SELECT c.id, v.code_value, 'Diploma',
(SELECT COALESCE(MAX(cv.order_position), 0) FROM m_code_value cv WHERE cv.code_id = c.id) + v.rn,
true, false
FROM m_code c
CROSS JOIN (
VALUES
('Diploma in Mechanical Engineering', 1),
('Diploma in Civil Engineering', 2),
('Diploma in Electrical Engineering', 3),
('Diploma in Electronics &amp; Communication', 4),
('Diploma in Computer Science / IT', 5),
('Diploma in Automobile Engineering', 6),
('ITI Certificate', 7),
('Polytechnic Diploma (Other)', 8),
('Other Diploma', 9)
) AS v(code_value, rn)
WHERE c.code_name = 'DegreeQualification'
AND NOT EXISTS (
SELECT 1 FROM m_code_value cv
WHERE cv.code_id = c.id AND cv.code_value = v.code_value AND cv.code_description = 'Diploma'
);
</sql>
</changeSet>
<!-- The original "Diploma" DegreeQualification row (0256_add_education_level_degree_codes.xml)
was tagged under School — it's superseded by the standalone Diploma Education Level above,
so deactivate rather than delete (existing client records referencing it must keep resolving). -->
<changeSet id="0425_03_deactivate_school_diploma_degree_value" author="CBS">
<sql>
UPDATE m_code_value cv
SET is_active = false
WHERE cv.code_id = (SELECT id FROM m_code WHERE code_name = 'DegreeQualification')
AND cv.code_value = 'Diploma'
AND cv.code_description = 'School';
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<!-- "Undergraduate" means currently pursuing a bachelor's degree (not yet completed);
"Graduate" means it has been completed. These are distinct statuses, so Graduate gets
its own Education Level rather than being folded into Undergraduate.
No frontend change needed for degree filtering: getEducationLevelCategory() in
client-general-step.component.ts already matches any name containing "graduate"
(Undergraduate OR Graduate) to the same 'undergraduate' bucket, so both levels correctly
show the same specific-degree list (B.Tech, B.A., B.Sc., ..., Other UG). -->
<changeSet id="0426_01_insert_graduate_education_level" author="CBS">
<sql>
INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory)
SELECT c.id, 'Graduate',
(SELECT COALESCE(MAX(cv.order_position), 0) FROM m_code_value cv WHERE cv.code_id = c.id) + 1,
true, false
FROM m_code c
WHERE c.code_name = 'EducationLevel'
AND NOT EXISTS (
SELECT 1 FROM m_code_value cv WHERE cv.code_id = c.id AND cv.code_value = 'Graduate'
);
</sql>
</changeSet>
</databaseChangeLog>