Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
643427bd1a | ||
|
|
6c7c0f8445 | ||
|
|
88157ee9d6 | ||
|
|
760fff8d3e | ||
|
|
d7ff4e4a7b | ||
|
|
7068d65422 |
+1
-1
@@ -154,7 +154,7 @@ gitVersioning.apply {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
rev {
|
rev {
|
||||||
version = '${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT'
|
version = '1.15.0-SNAPSHOT'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -33,4 +33,6 @@ public interface TellerRepository extends JpaRepository<Teller, Long>, JpaSpecif
|
|||||||
Collection<Teller> findTellerByOfficeId(Long officeId);
|
Collection<Teller> findTellerByOfficeId(Long officeId);
|
||||||
|
|
||||||
long countByDeletedFalse();
|
long countByDeletedFalse();
|
||||||
|
|
||||||
|
long countByOfficeIdAndDeletedFalse(Long officeId);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -58,4 +58,9 @@ public class TellerRepositoryWrapper {
|
|||||||
public boolean existsAny() {
|
public boolean existsAny() {
|
||||||
return this.repository.countByDeletedFalse() > 0;
|
return this.repository.countByDeletedFalse() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the branch already has a (not deleted) teller — each branch keeps one vault of its own. */
|
||||||
|
public boolean existsInOffice(final Long officeId) {
|
||||||
|
return this.repository.countByOfficeIdAndDeletedFalse(officeId) > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -28,4 +28,9 @@ public class TellerAlreadyExistsException extends AbstractPlatformDomainRuleExce
|
|||||||
public TellerAlreadyExistsException() {
|
public TellerAlreadyExistsException() {
|
||||||
super(ERROR_MESSAGE_CODE, DEFAULT_ERROR_MESSAGE);
|
super(ERROR_MESSAGE_CODE, DEFAULT_ERROR_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public TellerAlreadyExistsException(final String officeName) {
|
||||||
|
super(ERROR_MESSAGE_CODE, "Cannot create teller: " + officeName + " already has a teller. Each branch has one teller.",
|
||||||
|
officeName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-10
@@ -137,16 +137,7 @@ public class CommandSourceService {
|
|||||||
String permission = commandSource.getPermissionCode();
|
String permission = commandSource.getPermissionCode();
|
||||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||||
// isApprovedByChecker is only ever true here for a genuine checker approval
|
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
||||||
// (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run
|
|
||||||
// validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user
|
|
||||||
// editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker
|
|
||||||
// genuinely does not apply there). On the ORIGINAL submission this is always false and `user`
|
|
||||||
// here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any
|
|
||||||
// other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's
|
|
||||||
// request without needing the specific <TASK>_CHECKER permission, it must never let a maker
|
|
||||||
// auto-approve their own submission just by holding it.
|
|
||||||
if (isApprovedByChecker) {
|
|
||||||
commandSource.markAsChecked(user);
|
commandSource.markAsChecked(user);
|
||||||
} else {
|
} else {
|
||||||
if (commandSource.isSanitized()) {
|
if (commandSource.isSanitized()) {
|
||||||
@@ -160,6 +151,25 @@ public class CommandSourceService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
||||||
|
if (!user.isCheckerSuperUser()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+8
-20
@@ -48,12 +48,6 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
|||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private final CommandProcessingService processAndLogCommandService;
|
private final CommandProcessingService processAndLogCommandService;
|
||||||
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
|
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
|
||||||
// No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user
|
|
||||||
// can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no
|
|
||||||
// longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a
|
|
||||||
// live, independently-configurable global switch (System -> Configurations); deleting this field,
|
|
||||||
// and by extension deciding that switch should stop existing altogether, is a separate decision
|
|
||||||
// from closing this specific self-approval loophole.
|
|
||||||
private final ConfigurationDomainService configurationService;
|
private final ConfigurationDomainService configurationService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -129,20 +123,14 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
|||||||
AppUser appUser = this.context.authenticatedUser();
|
AppUser appUser = this.context.authenticatedUser();
|
||||||
String permissionCode = commandSource.getPermissionCode();
|
String permissionCode = commandSource.getPermissionCode();
|
||||||
appUser.validateHasCheckerPermissionTo(permissionCode);
|
appUser.validateHasCheckerPermissionTo(permissionCode);
|
||||||
// Maker-identity rule: the user who submitted a command may never be the one who approves
|
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
|
||||||
// (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that
|
AppUser maker = commandSource.getMaker();
|
||||||
// permission still lets its holder action a DIFFERENT user's request without needing the
|
if (maker == null) {
|
||||||
// specific <TASK>_CHECKER permission - see validateHasCheckerPermissionTo() above - it just no
|
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
||||||
// longer lets them action their own) and not overridable by the enable-same-maker-checker
|
}
|
||||||
// global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that
|
if (Objects.equals(appUser.getId(), maker.getId())) {
|
||||||
// configuration is left in place and unused for this decision rather than removed, since
|
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
||||||
// deleting a still-configurable global setting is a separate decision from this one).
|
}
|
||||||
AppUser maker = commandSource.getMaker();
|
|
||||||
if (maker == null) {
|
|
||||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
|
||||||
}
|
|
||||||
if (Objects.equals(appUser.getId(), maker.getId())) {
|
|
||||||
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
|
||||||
}
|
}
|
||||||
return commandSource;
|
return commandSource;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -130,7 +130,12 @@ public class TellerManagementReadPlatformServiceImpl implements TellerManagement
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Collection<TellerData> getTellers(Long officeId) {
|
public Collection<TellerData> getTellers(Long officeId) {
|
||||||
return retrieveAllTellers(false);
|
if (officeId == null) {
|
||||||
|
return retrieveAllTellers(false);
|
||||||
|
}
|
||||||
|
// The tellers of that one branch. The officeId used to be ignored, so the head office, whose
|
||||||
|
// hierarchy covers every branch, counted each branch's vault and cashiers as its own at day close.
|
||||||
|
return retrieveAllTellers(false).stream().filter(t -> officeId.equals(t.getOfficeId())).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+6
-4
@@ -70,10 +70,6 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
|
|||||||
try {
|
try {
|
||||||
this.context.authenticatedUser();
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
if (this.tellerRepositoryWrapper.existsAny()) {
|
|
||||||
throw new TellerAlreadyExistsException();
|
|
||||||
}
|
|
||||||
|
|
||||||
final Long officeId = command.longValueOfParameterNamed("officeId");
|
final Long officeId = command.longValueOfParameterNamed("officeId");
|
||||||
|
|
||||||
this.fromApiJsonDeserializer.validateForCreateAndUpdateTeller(command.json());
|
this.fromApiJsonDeserializer.validateForCreateAndUpdateTeller(command.json());
|
||||||
@@ -81,6 +77,12 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
|
|||||||
// final Office parent =
|
// final Office parent =
|
||||||
// validateUserPriviledgeOnOfficeAndRetrieve(currentUser, officeId);
|
// validateUserPriviledgeOnOfficeAndRetrieve(currentUser, officeId);
|
||||||
final Office tellerOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
final Office tellerOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||||
|
|
||||||
|
// One teller (vault) per branch, not one for the whole bank: each branch holds and settles
|
||||||
|
// its own cash, so a second branch needs a vault of its own.
|
||||||
|
if (this.tellerRepositoryWrapper.existsInOffice(officeId)) {
|
||||||
|
throw new TellerAlreadyExistsException(tellerOffice.getName());
|
||||||
|
}
|
||||||
final Teller teller = Teller.fromJson(tellerOffice, command);
|
final Teller teller = Teller.fromJson(tellerOffice, command);
|
||||||
|
|
||||||
// pre save to generate id for use in office hierarchy
|
// pre save to generate id for use in office hierarchy
|
||||||
|
|||||||
+29
-20
@@ -71,6 +71,20 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
public class BranchDayRegisterApiResource {
|
public class BranchDayRegisterApiResource {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branch whose counter took a breakdown {@code std}: the teller office of its officer's cashier
|
||||||
|
* allocation if that allocation already existed when the cash was counted, otherwise the officer's
|
||||||
|
* own office. A person moved to another branch's teller keeps what they took at the old branch
|
||||||
|
* there.
|
||||||
|
*/
|
||||||
|
private static final String COUNTER_OFFICE_OF_STD = """
|
||||||
|
COALESCE((SELECT t.office_id FROM m_appuser au JOIN m_cashiers mc ON mc.staff_id = au.staff_id
|
||||||
|
JOIN m_tellers t ON t.id = mc.teller_id
|
||||||
|
WHERE au.id = std.officer_id""" + CountedCashLedger.SINCE_ALLOCATION + """
|
||||||
|
LIMIT 1),
|
||||||
|
(SELECT au.office_id FROM m_appuser au WHERE au.id = std.officer_id))
|
||||||
|
""";
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
@@ -195,8 +209,8 @@ public class BranchDayRegisterApiResource {
|
|||||||
// Scoped to this office through the officer who took the cash (appuser -> staff -> cashier ->
|
// Scoped to this office through the officer who took the cash (appuser -> staff -> cashier ->
|
||||||
// teller -> office). Unscoped, a branch's own "does the physical count agree with the books"
|
// teller -> office). Unscoped, a branch's own "does the physical count agree with the books"
|
||||||
// cross-check was being compared against every branch's counted cash at once, which makes it
|
// cross-check was being compared against every branch's counted cash at once, which makes it
|
||||||
// wrong at every office but the first. A row whose officer maps to no cashier is still
|
// wrong at every office but the first. A row whose officer has no cashier allocation counts
|
||||||
// counted, for the same reason as the withdrawal legs in bookBreakdown.
|
// at the officer's own office (see COUNTER_OFFICE_OF_STD), not at every office.
|
||||||
//
|
//
|
||||||
// Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the
|
// Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the
|
||||||
// note there on why a manual GL entry's decoded reference is kept and a stamped millisecond
|
// note there on why a manual GL entry's decoded reference is kept and a stamped millisecond
|
||||||
@@ -205,23 +219,15 @@ public class BranchDayRegisterApiResource {
|
|||||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||||
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
||||||
AND """ + CountedCashLedger.COUNTED_ROW + """
|
AND """ + CountedCashLedger.COUNTED_ROW + """
|
||||||
AND (std.officer_id IS NULL
|
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
|
||||||
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
) = ?)
|
||||||
WHERE au.id = std.officer_id)
|
|
||||||
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
|
||||||
JOIN m_tellers t ON t.id = c.teller_id
|
|
||||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
|
||||||
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||||
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||||
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
||||||
AND """ + CountedCashLedger.COUNTED_ROW + """
|
AND """ + CountedCashLedger.COUNTED_ROW + """
|
||||||
AND (std.officer_id IS NULL
|
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
|
||||||
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
) = ?)
|
||||||
WHERE au.id = std.officer_id)
|
|
||||||
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
|
||||||
JOIN m_tellers t ON t.id = c.teller_id
|
|
||||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
|
||||||
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||||
result.put("denominationIn", countedIn);
|
result.put("denominationIn", countedIn);
|
||||||
result.put("denominationOut", countedOut);
|
result.put("denominationOut", countedOut);
|
||||||
@@ -609,11 +615,9 @@ public class BranchDayRegisterApiResource {
|
|||||||
// never opened is not waited for.
|
// never opened is not waited for.
|
||||||
final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId);
|
final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId);
|
||||||
if (isHeadOffice) {
|
if (isHeadOffice) {
|
||||||
final List<String> stillOpen = jdbcTemplate.queryForList("""
|
// Any day still open counts, not only today's: a branch whose earlier day was never closed is
|
||||||
SELECT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id
|
// still working and has not posted its interest.
|
||||||
WHERE r.business_date = ? AND r.closed_at IS NULL AND r.office_id <> ?
|
final List<String> stillOpen = interestPostingReportService.branchesStillOpen(businessDate, officeId);
|
||||||
ORDER BY o.name
|
|
||||||
""", String.class, Date.valueOf(businessDate), officeId);
|
|
||||||
if (!stillOpen.isEmpty()) {
|
if (!stillOpen.isEmpty()) {
|
||||||
throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open",
|
throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open",
|
||||||
"Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen)
|
"Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen)
|
||||||
@@ -1058,6 +1062,9 @@ public class BranchDayRegisterApiResource {
|
|||||||
final Map<String, Object> r = rows.get(0);
|
final Map<String, Object> r = rows.get(0);
|
||||||
final Map<String, Object> m = new LinkedHashMap<>();
|
final Map<String, Object> m = new LinkedHashMap<>();
|
||||||
m.put("id", r.get("id"));
|
m.put("id", r.get("id"));
|
||||||
|
// The close screen fetches this office's tellers with it; left out, it fetched every teller
|
||||||
|
// the user can see, and the head office counted each branch's vault as its own.
|
||||||
|
m.put("officeId", r.get("office_id"));
|
||||||
m.put("businessDate", str(r.get("business_date")));
|
m.put("businessDate", str(r.get("business_date")));
|
||||||
m.put("openedByName", r.get("opened_by_name"));
|
m.put("openedByName", r.get("opened_by_name"));
|
||||||
m.put("openedAt", str(r.get("opened_at")));
|
m.put("openedAt", str(r.get("opened_at")));
|
||||||
@@ -1111,11 +1118,13 @@ public class BranchDayRegisterApiResource {
|
|||||||
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
|
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
|
||||||
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
|
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
|
||||||
|
|
||||||
|
// By the counter that took the cash. A savings deposit carries no office_id, and the old
|
||||||
|
// "OR office_id IS NULL" counted every such deposit in the bank at every branch.
|
||||||
final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
|
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
|
||||||
WHERE status = 'APPROVED'
|
WHERE status = 'APPROVED'
|
||||||
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
|
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
|
||||||
AND (office_id = ? OR office_id IS NULL)
|
AND COALESCE(counter_office_id, office_id) = ?
|
||||||
""", BigDecimal.class, date, officeId));
|
""", BigDecimal.class, date, officeId));
|
||||||
|
|
||||||
// Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
|
// Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
|
||||||
|
|||||||
+17
-3
@@ -166,8 +166,8 @@ public class CashierDepositRequestApiResource {
|
|||||||
client_id, client_name, amount,
|
client_id, client_name, amount,
|
||||||
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
|
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
|
||||||
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at,
|
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at,
|
||||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id)
|
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id, counter_office_id)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
Long.class,
|
Long.class,
|
||||||
@@ -179,7 +179,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
"PENDING",
|
"PENDING",
|
||||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||||
denominationData, activityLog, Timestamp.from(Instant.now()),
|
denominationData, activityLog, Timestamp.from(Instant.now()),
|
||||||
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId
|
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId, counterOfficeId(currentUser)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Globally unique voucher number from the central m_voucher register
|
// Globally unique voucher number from the central m_voucher register
|
||||||
@@ -691,6 +691,20 @@ public class CashierDepositRequestApiResource {
|
|||||||
.orElse(Map.of());
|
.orElse(Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branch whose counter took the cash: the office of the teller the user is allocated to as a
|
||||||
|
* cashier, otherwise the user's own office. office_id cannot say this -- a savings deposit carries
|
||||||
|
* none, and a GL deposit's is the GL's branch -- and the day close totals each branch's cash by it.
|
||||||
|
*/
|
||||||
|
private Long counterOfficeId(final AppUser user) {
|
||||||
|
final List<Long> tellerOffice = user.getStaff() == null ? List.of()
|
||||||
|
: jdbcTemplate.queryForList("""
|
||||||
|
SELECT t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE c.staff_id = ? LIMIT 1
|
||||||
|
""", Long.class, user.getStaff().getId());
|
||||||
|
return tellerOffice.isEmpty() ? user.getOffice().getId() : tellerOffice.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, voucher_no, account_no, account_type, savings_account_id,
|
SELECT id, voucher_no, account_no, account_type, savings_account_id,
|
||||||
|
|||||||
+2
-2
@@ -93,7 +93,7 @@ public class CashReconciliationApiResource {
|
|||||||
+ "AND " + CountedCashLedger.COUNTED_ROW
|
+ "AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
|
||||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||||
|
|
||||||
// Out: settlements to vault
|
// Out: settlements to vault
|
||||||
@@ -123,7 +123,7 @@ public class CashReconciliationApiResource {
|
|||||||
+ "AND " + CountedCashLedger.COUNTED_ROW
|
+ "AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
|
||||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||||
|
|
||||||
// In: cash exchange — customer gave denomination TO cashier (RECEIVED direction)
|
// In: cash exchange — customer gave denomination TO cashier (RECEIVED direction)
|
||||||
|
|||||||
+9
-9
@@ -671,25 +671,25 @@ public class CashReportApiResource {
|
|||||||
// day itself: taken from the vault, in over the counter, out over the counter, settled
|
// day itself: taken from the vault, in over the counter, out over the counter, settled
|
||||||
// back. What is left is what should physically be in the drawer.
|
// back. What is left is what should physically be in the drawer.
|
||||||
result.put("todayVaultIn", dayLegTotal(
|
result.put("todayVaultIn", dayLegTotal(
|
||||||
"SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
|
"SELECT COALESCE(SUM(CAST(ctd.count AS BIGINT) * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
|
||||||
+ " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
|
+ " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
|
||||||
+ " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)"
|
+ " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)"
|
||||||
+ " AND COALESCE(ct.is_reversed, false) = false",
|
+ " AND COALESCE(ct.is_reversed, false) = false",
|
||||||
scopedCashierId, txnDate));
|
scopedCashierId, txnDate));
|
||||||
result.put("todaySettledOut", dayLegTotal(
|
result.put("todaySettledOut", dayLegTotal(
|
||||||
"SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
|
"SELECT COALESCE(SUM(CAST(csd.count AS BIGINT) * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
|
||||||
+ " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
|
+ " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
|
||||||
+ " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)"
|
+ " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)"
|
||||||
+ " AND COALESCE(cs.is_reversed, false) = false",
|
+ " AND COALESCE(cs.is_reversed, false) = false",
|
||||||
scopedCashierId, txnDate));
|
scopedCashierId, txnDate));
|
||||||
result.put("todayInterCashierIn", dayLegTotal(
|
result.put("todayInterCashierIn", dayLegTotal(
|
||||||
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||||
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
||||||
+ " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
+ " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
||||||
+ " AND COALESCE(ic.is_reversed, false) = false",
|
+ " AND COALESCE(ic.is_reversed, false) = false",
|
||||||
scopedCashierId, txnDate));
|
scopedCashierId, txnDate));
|
||||||
result.put("todayInterCashierOut", dayLegTotal(
|
result.put("todayInterCashierOut", dayLegTotal(
|
||||||
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||||
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
||||||
+ " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
+ " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
||||||
+ " AND COALESCE(ic.is_reversed, false) = false",
|
+ " AND COALESCE(ic.is_reversed, false) = false",
|
||||||
@@ -721,7 +721,7 @@ public class CashReportApiResource {
|
|||||||
final Long scopedCashierId) {
|
final Long scopedCashierId) {
|
||||||
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
|
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " : "";
|
||||||
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
|
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
|
||||||
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
|
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
|
||||||
|
|
||||||
@@ -1171,7 +1171,7 @@ public class CashReportApiResource {
|
|||||||
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
|
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
|
||||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||||
|
|
||||||
// Settlements back to vault
|
// Settlements back to vault
|
||||||
@@ -1197,7 +1197,7 @@ public class CashReportApiResource {
|
|||||||
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
|
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
|
||||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||||
|
|
||||||
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
|
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
|
||||||
@@ -1315,13 +1315,13 @@ public class CashReportApiResource {
|
|||||||
if (cashierId != null) {
|
if (cashierId != null) {
|
||||||
officerFilter = "AND std.officer_id IN ("
|
officerFilter = "AND std.officer_id IN ("
|
||||||
+ "SELECT au.id FROM m_appuser au "
|
+ "SELECT au.id FROM m_appuser au "
|
||||||
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)";
|
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
|
||||||
baseParams = new Object[] { txnDate, cashierId };
|
baseParams = new Object[] { txnDate, cashierId };
|
||||||
} else if (tellerId != null) {
|
} else if (tellerId != null) {
|
||||||
officerFilter = "AND std.officer_id IN ("
|
officerFilter = "AND std.officer_id IN ("
|
||||||
+ "SELECT au.id FROM m_appuser au "
|
+ "SELECT au.id FROM m_appuser au "
|
||||||
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id "
|
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id "
|
||||||
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?)";
|
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
|
||||||
baseParams = new Object[] { txnDate, tellerId };
|
baseParams = new Object[] { txnDate, tellerId };
|
||||||
} else {
|
} else {
|
||||||
officerFilter = "AND std.officer_id IN ("
|
officerFilter = "AND std.officer_id IN ("
|
||||||
|
|||||||
+17
-4
@@ -451,8 +451,18 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
// not-applicable rather than failed for a GL request — a share account with no linked
|
// not-applicable rather than failed for a GL request — a share account with no linked
|
||||||
// savings is a perfectly valid GL-settlement case.
|
// savings is a perfectly valid GL-settlement case.
|
||||||
final boolean glSettlementMode = "GL".equals(record.get("settlement_mode"));
|
final boolean glSettlementMode = "GL".equals(record.get("settlement_mode"));
|
||||||
checks.add(glSettlementMode
|
// 'NONE' is the third mode approve() handles, and submit() produces it on its own: a clerk who
|
||||||
? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer")
|
// names the product's own Share Sundry GL as the destination is saying "stop here", so the
|
||||||
|
// request is downgraded from GL to NONE and approve() posts Dr Share Capital / Cr Share Sundry
|
||||||
|
// and nothing further. Like a GL settlement it never reaches a savings account, so the
|
||||||
|
// savings-side checks below do not apply to it either — running them reports a missing fund
|
||||||
|
// source mapping that no posting in this mode is ever going to read.
|
||||||
|
final boolean sundryOnlySettlement = "NONE".equals(record.get("settlement_mode"));
|
||||||
|
final boolean noSavingsLeg = glSettlementMode || sundryOnlySettlement;
|
||||||
|
checks.add(noSavingsLeg
|
||||||
|
? checkResult("Linked savings account is Active", true, sundryOnlySettlement
|
||||||
|
? "Not applicable — the redemption rests in Share Sundry"
|
||||||
|
: "Not applicable — settlement is a GL book-transfer")
|
||||||
: checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
|
: checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
|
||||||
savingsAccountId == null ? "No linked savings account configured"
|
savingsAccountId == null ? "No linked savings account configured"
|
||||||
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
|
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
|
||||||
@@ -541,6 +551,9 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
final boolean settlementGlPresent = glAccountIdObj != null;
|
final boolean settlementGlPresent = glAccountIdObj != null;
|
||||||
checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent,
|
checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent,
|
||||||
settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing"));
|
settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing"));
|
||||||
|
} else if (sundryOnlySettlement) {
|
||||||
|
checks.add(checkResult("Settlement destination recorded", true,
|
||||||
|
"Share Sundry — the redemption stops there, so no fund source mapping is read"));
|
||||||
} else {
|
} else {
|
||||||
boolean glMappingOk = false;
|
boolean glMappingOk = false;
|
||||||
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
|
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
|
||||||
@@ -992,8 +1005,8 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
// The purchase that issued the holding - type 500. Its unit price is what the member paid.
|
// The purchase that issued the holding - type 500. Its unit price is what the member paid.
|
||||||
final BigDecimal unitPrice = jdbcTemplate
|
final BigDecimal unitPrice = jdbcTemplate
|
||||||
.queryForList("""
|
.queryForList("""
|
||||||
SELECT unit_price FROM m_share_account_transaction
|
SELECT unit_price FROM m_share_account_transactions
|
||||||
WHERE share_account_id = ? AND type_enum = 500 AND unit_price IS NOT NULL
|
WHERE account_id = ? AND is_active = true AND type_enum = 500 AND unit_price IS NOT NULL
|
||||||
ORDER BY id DESC LIMIT 1
|
ORDER BY id DESC LIMIT 1
|
||||||
""", BigDecimal.class, shareAccountId)
|
""", BigDecimal.class, shareAccountId)
|
||||||
.stream().findFirst().orElse(null);
|
.stream().findFirst().orElse(null);
|
||||||
|
|||||||
+11
-1
@@ -130,9 +130,19 @@ public final class CountedCashLedger {
|
|||||||
+ " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))"
|
+ " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))"
|
||||||
+ " AND" + REAL_TXN_ID + ") ";
|
+ " AND" + REAL_TXN_ID + ") ";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Over the aliases {@code std} and {@code mc} (the cashier a breakdown's officer maps to): only what
|
||||||
|
* the login counted once this allocation existed. A breakdown is tied to a cashier through its
|
||||||
|
* officer's login, so without this a person moved from one branch's teller to another's carried
|
||||||
|
* every rupee they had ever taken -- at the old branch too -- into the new drawer. An allocation
|
||||||
|
* with no recorded creation time keeps its whole history.
|
||||||
|
*/
|
||||||
|
public static final String SINCE_ALLOCATION = " AND (mc.created_at IS NULL OR std.created_at IS NULL"
|
||||||
|
+ " OR std.created_at >= mc.created_at)";
|
||||||
|
|
||||||
/** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */
|
/** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */
|
||||||
private static String breakdownDiffersFrom(final String amountColumn) {
|
private static String breakdownDiffersFrom(final String amountColumn) {
|
||||||
return "ABS(" + amountColumn + " - (SELECT SUM(sd.count * sd.denomination_value)"
|
return "ABS(" + amountColumn + " - (SELECT SUM(CAST(sd.count AS BIGINT) * sd.denomination_value)"
|
||||||
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id"
|
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id"
|
||||||
+ " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1";
|
+ " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1";
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -288,7 +288,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
+ " UNION ALL"
|
+ " UNION ALL"
|
||||||
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
|
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
|
||||||
@@ -312,7 +312,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
+ " UNION ALL"
|
+ " UNION ALL"
|
||||||
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
|
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
|
||||||
@@ -409,7 +409,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
+ " UNION ALL"
|
+ " UNION ALL"
|
||||||
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
|
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
|
||||||
@@ -433,7 +433,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
+ " UNION ALL"
|
+ " UNION ALL"
|
||||||
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
|
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
|
||||||
|
|||||||
+130
-31
@@ -222,9 +222,10 @@ public class SavingsInterestPostingReportService {
|
|||||||
return jdbcTemplate.queryForList("""
|
return jdbcTemplate.queryForList("""
|
||||||
SELECT r.id FROM m_savings_interest_posting_report r
|
SELECT r.id FROM m_savings_interest_posting_report r
|
||||||
WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ?
|
WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ?
|
||||||
AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id AND b.business_date = ?)
|
AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id
|
||||||
|
AND (b.business_date = ? OR (b.closed_at IS NULL AND b.business_date <= ?)))
|
||||||
ORDER BY r.posting_date, r.office_id, r.savings_product_id
|
ORDER BY r.posting_date, r.office_id, r.savings_product_id
|
||||||
""", Long.class, businessDate, headOfficeId, businessDate);
|
""", Long.class, businessDate, headOfficeId, businessDate, businessDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every approved report still waiting, for the nightly safety net. */
|
/** Every approved report still waiting, for the nightly safety net. */
|
||||||
@@ -245,15 +246,15 @@ public class SavingsInterestPostingReportService {
|
|||||||
// ── reading ──────────────────────────────────────────────────────────────────────────────────
|
// ── reading ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reports for the screen. A branch user sees only their branch; a head office user sees every
|
* Reports for the screen. {@code scope} "own" (the default) is the user's own branch; "others", for a
|
||||||
* branch, so it can act for one that did not open. canAct says whether this user may regenerate
|
* head office user, is every other branch (or one, with officeId). canAct says whether this user
|
||||||
* or approve the report.
|
* may regenerate or approve the report, and blockedReason why not.
|
||||||
*/
|
*/
|
||||||
public List<Map<String, Object>> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId,
|
public List<Map<String, Object>> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId,
|
||||||
final AppUser user) {
|
final String scope, final AppUser user) {
|
||||||
final Long userOffice = officeOf(user);
|
final Long userOffice = officeOf(user);
|
||||||
final boolean headOffice = isHeadOffice(userOffice);
|
final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice);
|
||||||
final Long branch = headOffice ? officeId : userOffice;
|
final Long branch = others ? officeId : userOffice;
|
||||||
|
|
||||||
final StringBuilder sql = new StringBuilder("""
|
final StringBuilder sql = new StringBuilder("""
|
||||||
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
|
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
|
||||||
@@ -287,6 +288,10 @@ public class SavingsInterestPostingReportService {
|
|||||||
sql.append(" AND r.office_id = ?");
|
sql.append(" AND r.office_id = ?");
|
||||||
params.add(branch);
|
params.add(branch);
|
||||||
}
|
}
|
||||||
|
if (others) {
|
||||||
|
sql.append(" AND r.office_id <> ?");
|
||||||
|
params.add(userOffice);
|
||||||
|
}
|
||||||
sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC");
|
sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC");
|
||||||
|
|
||||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||||
@@ -294,7 +299,9 @@ public class SavingsInterestPostingReportService {
|
|||||||
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
|
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
|
||||||
final Map<String, Object> out = new LinkedHashMap<>(row);
|
final Map<String, Object> out = new LinkedHashMap<>(row);
|
||||||
final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue();
|
final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue();
|
||||||
out.put("canAct", reportOffice != null && mayAct(userOffice, reportOffice, businessDate));
|
final String blocked = reportOffice == null ? "No branch on this report." : actBlockedReason(userOffice, reportOffice, businessDate);
|
||||||
|
out.put("canAct", blocked == null);
|
||||||
|
out.put("blockedReason", blocked);
|
||||||
rows.add(out);
|
rows.add(out);
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
@@ -339,6 +346,45 @@ public class SavingsInterestPostingReportService {
|
|||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What each savings account has earned so far in its current cycle — what it would be paid if
|
||||||
|
* interest were posted today. Read-only: nothing is saved, approved or posted. The ledger is first
|
||||||
|
* brought up to the business date (which moves no money) so the figure includes today.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A branch user sees their own branch; a head office user every branch, or one with officeId.
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> preview(final Long officeId, final AppUser user) {
|
||||||
|
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||||
|
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate));
|
||||||
|
|
||||||
|
final Long userOffice = officeOf(user);
|
||||||
|
final Long branch = isHeadOffice(userOffice) ? officeId : userOffice;
|
||||||
|
final StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT COALESCE(c.office_id, g.office_id) AS "officeId", o.name AS "officeName",
|
||||||
|
p.id AS "productId", p.name AS "productName", sa.id AS "savingsAccountId", sa.account_no AS "accountNo",
|
||||||
|
COALESCE(c.display_name, g.display_name) AS "clientName", d.closing_balance AS "balance",
|
||||||
|
d.interest_rate AS "rate", d.cycle_start_date AS "cycleStart", d.posting_date AS "postingDate",
|
||||||
|
d.balance_date AS "asOf", ROUND(d.cycle_cumulative_interest, 2) AS "interest"
|
||||||
|
FROM m_savings_daily_interest d
|
||||||
|
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||||
|
JOIN m_savings_product p ON p.id = d.savings_product_id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||||
|
LEFT JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||||
|
WHERE p.deposit_type_enum = 100
|
||||||
|
AND sa.status_enum = ?
|
||||||
|
AND d.savings_transaction_id IS NULL
|
||||||
|
""");
|
||||||
|
final List<Object> params = new ArrayList<>(List.of(ACTIVE));
|
||||||
|
if (branch != null) {
|
||||||
|
sql.append(" AND COALESCE(c.office_id, g.office_id) = ?");
|
||||||
|
params.add(branch);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY o.name, p.name, sa.account_no");
|
||||||
|
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Branch and product interest for the head office: per report, how many customers and how much
|
* Branch and product interest for the head office: per report, how many customers and how much
|
||||||
* interest, and where it stands. Superseded reports are left out — each line is the report that
|
* interest, and where it stands. Superseded reports are left out — each line is the report that
|
||||||
@@ -375,9 +421,13 @@ public class SavingsInterestPostingReportService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Branch and product pairs with interest due by the date that have no report yet — the noon job ran
|
* Branch and product pairs with interest due by the date that have no report yet — the noon job ran
|
||||||
* before they had anything due, or failed. Limited to what the user may act on.
|
* before they had anything due, or failed.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* {@code scope} "own" (the default) lists the user's own branch. "others", for a head office user,
|
||||||
|
* lists the other branches — each with canAct, and where the head office may not act for it, why.
|
||||||
*/
|
*/
|
||||||
public List<Map<String, Object>> dueWithoutReport(final LocalDate postingDate, final AppUser user) {
|
public List<Map<String, Object>> dueWithoutReport(final LocalDate postingDate, final AppUser user, final String scope) {
|
||||||
final List<Map<String, Object>> out = new ArrayList<>();
|
final List<Map<String, Object>> out = new ArrayList<>();
|
||||||
// A future date has nothing due yet, and offering to generate for it would label this cycle's
|
// A future date has nothing due yet, and offering to generate for it would label this cycle's
|
||||||
// part-earned interest with a date it has not reached.
|
// part-earned interest with a date it has not reached.
|
||||||
@@ -386,15 +436,21 @@ public class SavingsInterestPostingReportService {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
final Long userOffice = officeOf(user);
|
final Long userOffice = officeOf(user);
|
||||||
for (final DueKey due : dueKeys(postingDate, isHeadOffice(userOffice) ? null : userOffice)) {
|
final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice);
|
||||||
if (activeReportId(due.productId(), due.officeId(), postingDate) == null
|
for (final DueKey due : dueKeys(postingDate, others ? null : userOffice)) {
|
||||||
&& mayAct(userOffice, due.officeId(), businessDate)) {
|
if (others == due.officeId().equals(userOffice)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (activeReportId(due.productId(), due.officeId(), postingDate) == null) {
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put("productId", due.productId());
|
row.put("productId", due.productId());
|
||||||
row.put("productName", productName(due.productId()));
|
row.put("productName", productName(due.productId()));
|
||||||
row.put("officeId", due.officeId());
|
row.put("officeId", due.officeId());
|
||||||
row.put("officeName", jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, due.officeId()));
|
row.put("officeName", officeName(due.officeId()));
|
||||||
row.put("postingDate", postingDate.toString());
|
row.put("postingDate", postingDate.toString());
|
||||||
|
final String blocked = actBlockedReason(userOffice, due.officeId(), businessDate);
|
||||||
|
row.put("canAct", blocked == null);
|
||||||
|
row.put("blockedReason", blocked);
|
||||||
out.add(row);
|
out.add(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,36 +464,79 @@ public class SavingsInterestPostingReportService {
|
|||||||
return !parent.isEmpty() && parent.get(0) == null;
|
return !parent.isEmpty() && parent.get(0) == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the branch opened its day on the date — the head office may act only for one that did not. */
|
/**
|
||||||
|
* Whether the branch is working on the date: it opened that day, or a day it opened earlier is still
|
||||||
|
* not closed. A branch with yesterday's day still open is open — it closes that day and goes on.
|
||||||
|
*/
|
||||||
public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) {
|
public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) {
|
||||||
final Long rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
|
final Long rows = jdbcTemplate.queryForObject("""
|
||||||
Long.class, officeId, businessDate);
|
SELECT COUNT(*) FROM m_branch_day_register
|
||||||
|
WHERE office_id = ? AND (business_date = ? OR (closed_at IS NULL AND business_date <= ?))
|
||||||
|
""", Long.class, officeId, businessDate, businessDate);
|
||||||
return rows != null && rows > 0;
|
return rows != null && rows > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
|
/** The branches, other than those given, that still have a day open on or before the date. */
|
||||||
|
public List<String> branchesStillOpen(final LocalDate businessDate, final Long... excludeOfficeIds) {
|
||||||
|
final StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT DISTINCT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id
|
||||||
|
WHERE r.closed_at IS NULL AND r.business_date <= ?
|
||||||
|
""");
|
||||||
|
final List<Object> params = new ArrayList<>(List.of(businessDate));
|
||||||
|
for (final Long exclude : excludeOfficeIds) {
|
||||||
|
sql.append(" AND r.office_id <> ?");
|
||||||
|
params.add(exclude);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY o.name");
|
||||||
|
return jdbcTemplate.queryForList(sql.toString(), String.class, params.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null where the user may generate or approve the branch's report, otherwise why not.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A branch always acts for itself. The head office acts for another branch only when that branch
|
||||||
|
* has not opened at all, and only once every branch that did open has closed — the head office's
|
||||||
|
* own close being the last of the day, so nothing is taken out of a branch that may still open.
|
||||||
|
*/
|
||||||
|
private String actBlockedReason(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
|
||||||
if (userOffice == null) {
|
if (userOffice == null) {
|
||||||
return false;
|
return "No branch is set for this user.";
|
||||||
}
|
}
|
||||||
if (userOffice.equals(reportOffice)) {
|
if (userOffice.equals(reportOffice)) {
|
||||||
return true;
|
return null;
|
||||||
}
|
}
|
||||||
return isHeadOffice(userOffice) && !branchOpenedOn(reportOffice, businessDate);
|
final String branch = officeName(reportOffice);
|
||||||
|
if (!isHeadOffice(userOffice)) {
|
||||||
|
return "Only " + branch + " can generate or approve its own interest posting report.";
|
||||||
|
}
|
||||||
|
if (branchOpenedOn(reportOffice, businessDate)) {
|
||||||
|
return branch + " is open — it generates and approves its own report.";
|
||||||
|
}
|
||||||
|
final List<String> stillOpen = branchesStillOpen(businessDate, userOffice, reportOffice);
|
||||||
|
if (!stillOpen.isEmpty()) {
|
||||||
|
return "The head office can act for " + branch + " only after every open branch has closed. Still open: "
|
||||||
|
+ String.join(", ", stillOpen) + ".";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
|
||||||
|
return actBlockedReason(userOffice, reportOffice, businessDate) == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return true where the head office is acting on a branch's behalf */
|
/** @return true where the head office is acting on a branch's behalf */
|
||||||
private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) {
|
private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) {
|
||||||
final Long userOffice = officeOf(user);
|
final Long userOffice = officeOf(user);
|
||||||
if (userOffice != null && userOffice.equals(reportOffice)) {
|
final String blocked = actBlockedReason(userOffice, reportOffice, businessDate);
|
||||||
return false;
|
if (blocked != null) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch", blocked);
|
||||||
}
|
}
|
||||||
if (mayAct(userOffice, reportOffice, businessDate)) {
|
return !reportOffice.equals(userOffice);
|
||||||
return true;
|
}
|
||||||
}
|
|
||||||
final String branch = jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, reportOffice);
|
private String officeName(final Long officeId) {
|
||||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch",
|
return jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, officeId);
|
||||||
"Only " + branch + " can generate or approve its own interest posting report. The head office may do so only on a day "
|
|
||||||
+ branch + " has not opened.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Long officeOf(final AppUser user) {
|
private static Long officeOf(final AppUser user) {
|
||||||
|
|||||||
+27
-4
@@ -65,20 +65,34 @@ public class SavingsInterestPostingReportApiResource {
|
|||||||
/** A branch user sees their own branch; a head office user every branch, or one with officeId. */
|
/** A branch user sees their own branch; a head office user every branch, or one with officeId. */
|
||||||
@GET
|
@GET
|
||||||
public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status,
|
public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status,
|
||||||
@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId) {
|
@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId,
|
||||||
|
@QueryParam("scope") final String scope) {
|
||||||
final AppUser user = context.authenticatedUser();
|
final AppUser user = context.authenticatedUser();
|
||||||
user.validateHasReadPermission(RESOURCE);
|
user.validateHasReadPermission(RESOURCE);
|
||||||
return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, user).stream().map(this::plain).toList());
|
return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream().map(this::plain).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who is looking: their branch, and whether it is the head office (which gets the Other branches tab). */
|
||||||
|
@GET
|
||||||
|
@Path("/context")
|
||||||
|
public String userContext() {
|
||||||
|
final AppUser user = context.authenticatedUser();
|
||||||
|
final Long officeId = user.getOffice().getId();
|
||||||
|
final Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("officeId", officeId);
|
||||||
|
result.put("officeName", user.getOffice().getName());
|
||||||
|
result.put("headOffice", reportService.isHeadOffice(officeId));
|
||||||
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
|
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
|
||||||
@GET
|
@GET
|
||||||
@Path("/due-without-report")
|
@Path("/due-without-report")
|
||||||
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate) {
|
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) {
|
||||||
final AppUser user = context.authenticatedUser();
|
final AppUser user = context.authenticatedUser();
|
||||||
user.validateHasReadPermission(RESOURCE);
|
user.validateHasReadPermission(RESOURCE);
|
||||||
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
|
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
|
||||||
return gson.toJson(reportService.dueWithoutReport(date, user));
|
return gson.toJson(reportService.dueWithoutReport(date, user, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,6 +113,15 @@ public class SavingsInterestPostingReportApiResource {
|
|||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Interest each account has earned so far this cycle, as if posted today. View only — nothing is saved. */
|
||||||
|
@GET
|
||||||
|
@Path("/preview")
|
||||||
|
public String preview(@QueryParam("officeId") final Long officeId) {
|
||||||
|
final AppUser user = context.authenticatedUser();
|
||||||
|
user.validateHasReadPermission(RESOURCE);
|
||||||
|
return gson.toJson(reportService.preview(officeId, user).stream().map(this::plain).toList());
|
||||||
|
}
|
||||||
|
|
||||||
/** The head office's view: per branch and product, customers paid and interest, over a date range. */
|
/** The head office's view: per branch and product, customers paid and interest, over a date range. */
|
||||||
@GET
|
@GET
|
||||||
@Path("/summary")
|
@Path("/summary")
|
||||||
|
|||||||
+2
-10
@@ -250,11 +250,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement elsewhere.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,11 +306,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement elsewhere.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -260,11 +260,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,11 +350,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -264,11 +264,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,11 +357,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -203,11 +203,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,11 +265,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -420,11 +420,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,11 +524,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-12
@@ -172,12 +172,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
|
|||||||
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
|
|
||||||
// it just no longer lets them bypass the hold on their own (matches
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
|
|
||||||
// equivalent rule for the classic command pipeline).
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,12 +214,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
|
|||||||
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
|
|
||||||
// it just no longer lets them bypass the hold on their own (matches
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
|
|
||||||
// equivalent rule for the classic command pipeline).
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -882,11 +882,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,11 +968,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
||||||
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
||||||
|
|
||||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||||
// hold CHECKER_SUPER_USER - see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
|
||||||
// equivalent enforcement in the classic command pipeline.
|
|
||||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
|
||||||
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,5 +163,6 @@
|
|||||||
<include file="parts/0588_add_premature_closure_reason.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0588_add_premature_closure_reason.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0589_savings_interest_posting_report.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0589_savings_interest_posting_report.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0590_savings_interest_posting_report_by_branch.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0590_savings_interest_posting_report_by_branch.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0588_persist_manually_enabled_maker_checker_permissions.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0591_cashier_allocation_time_and_deposit_counter_office.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0592_enable_teller_cashier_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
-73
@@ -1,73 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<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.1.xsd">
|
|
||||||
|
|
||||||
<!-- Converts manually-applied Configure Maker Checker Tasks changes into a repeatable migration.
|
|
||||||
During Admin -> Organization and Admin -> System testing, can_maker_checker was switched on
|
|
||||||
(via System -> Configure Maker Checker Tasks) for the 28 permissions below, directly in the
|
|
||||||
dev database - none of that was ever captured by a migration, so a fresh/other environment
|
|
||||||
restored from this changelog history would come up with every one of them still at its
|
|
||||||
originally-seeded false value. Every code here was independently confirmed, via read-only
|
|
||||||
inspection of both its origin changeset and the live database, to be seeded can_maker_checker
|
|
||||||
= false and currently true in the running dev DB with no existing changeset already covering
|
|
||||||
the flip (the one precedent for this pattern, DELETE_OFFICE, was flipped by changeset
|
|
||||||
0538_enable_maker_checker_for_delete_office and is deliberately left untouched here).
|
|
||||||
|
|
||||||
This changeset only ever moves can_maker_checker from false to true for these exact codes -
|
|
||||||
entity_name, action_name, grouping and every other column are left exactly as their origin
|
|
||||||
changeset defined them (including the pre-existing DELETE_ACCOUNTNUMBERFORMAT entity_name
|
|
||||||
drift, which is out of scope for this change and is not corrected here). No _CHECKER row is
|
|
||||||
touched: those already exist for every code below (added earlier by 0583/0586, or by base
|
|
||||||
Fineract for the stock codes), and can_maker_checker is not a column on a _CHECKER row.
|
|
||||||
|
|
||||||
Grouped by the screen each permission belongs to:
|
|
||||||
- Currency Configuration: UPDATE_CURRENCY
|
|
||||||
- Manage Funds: CREATE_FUND
|
|
||||||
- Working Days: UPDATE_WORKINGDAYS
|
|
||||||
- Payment Type: CREATE_PAYMENTTYPE, UPDATE_PAYMENTTYPE, DELETE_PAYMENTTYPE
|
|
||||||
- Sponsor Banks: CREATE_SPONSORBANK, UPDATE_SPONSORBANK
|
|
||||||
- Branch Profile: UPDATE_BRANCHPROFILE
|
|
||||||
- Manage Data Tables: CREATE_DATATABLE, UPDATE_DATATABLE, DELETE_DATATABLE
|
|
||||||
- Manage Codes: CREATE_CODE, UPDATE_CODE, DELETE_CODE,
|
|
||||||
CREATE_CODEVALUE, UPDATE_CODEVALUE, DELETE_CODEVALUE
|
|
||||||
- Manage Reports: CREATE_REPORT, UPDATE_REPORT, DELETE_REPORT
|
|
||||||
- Manage Hooks: CREATE_HOOK, UPDATE_HOOK, DELETE_HOOK
|
|
||||||
- Account Number Preferences: CREATE_ACCOUNTNUMBERFORMAT, DELETE_ACCOUNTNUMBERFORMAT
|
|
||||||
- Manage User Agreement: CREATE_USERAGREEMENT
|
|
||||||
- Manage Jobs (COB Workflow Step Reorder): UPDATE_BATCH_BUSINESS_STEP
|
|
||||||
|
|
||||||
Deliberately NOT included (confirmed still false, or out of scope, and left untouched):
|
|
||||||
- UPDATE_PERMISSION, UPDATE_CONFIGURATION (the Configure Maker Checker Tasks self-lockout
|
|
||||||
exclusion added by PermissionReadPlatformServiceImpl stays in effect; these remain false)
|
|
||||||
- UPDATE_SPONSORDDGLMAPPING, all Entity-to-Entity Mapping, External Services and Manage
|
|
||||||
Survey permissions (confirmed still false - never actually toggled on)
|
|
||||||
- CREATE_ROLE/UPDATE_ROLE/DELETE_ROLE/etc., CREATE_GLACCOUNT, CREATE_CHARGE,
|
|
||||||
CREATE_LOANPRODUCT, CREATE_SAVINGSPRODUCT and other permissions observed to already be
|
|
||||||
true in the live DB but belonging to Accounting/Products/Roles modules, not Admin ->
|
|
||||||
Organization or Admin -> System - out of scope for this change and left as pre-existing
|
|
||||||
drift for a separate decision. -->
|
|
||||||
<changeSet id="0588_persist_manually_enabled_maker_checker_permissions" author="cbs-anshul">
|
|
||||||
<update tableName="m_permission">
|
|
||||||
<column name="can_maker_checker" valueBoolean="true"/>
|
|
||||||
<where>code IN (
|
|
||||||
'UPDATE_CURRENCY',
|
|
||||||
'CREATE_FUND',
|
|
||||||
'UPDATE_WORKINGDAYS',
|
|
||||||
'CREATE_PAYMENTTYPE', 'UPDATE_PAYMENTTYPE', 'DELETE_PAYMENTTYPE',
|
|
||||||
'CREATE_SPONSORBANK', 'UPDATE_SPONSORBANK',
|
|
||||||
'UPDATE_BRANCHPROFILE',
|
|
||||||
'CREATE_DATATABLE', 'UPDATE_DATATABLE', 'DELETE_DATATABLE',
|
|
||||||
'CREATE_CODE', 'UPDATE_CODE', 'DELETE_CODE',
|
|
||||||
'CREATE_CODEVALUE', 'UPDATE_CODEVALUE', 'DELETE_CODEVALUE',
|
|
||||||
'CREATE_REPORT', 'UPDATE_REPORT', 'DELETE_REPORT',
|
|
||||||
'CREATE_HOOK', 'UPDATE_HOOK', 'DELETE_HOOK',
|
|
||||||
'CREATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT',
|
|
||||||
'CREATE_USERAGREEMENT',
|
|
||||||
'UPDATE_BATCH_BUSINESS_STEP'
|
|
||||||
)</where>
|
|
||||||
</update>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
</databaseChangeLog>
|
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<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.1.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Cash taken over a counter belongs to the drawer and the branch that took it, not to whoever the
|
||||||
|
same login is allocated to today.
|
||||||
|
|
||||||
|
m_cashiers.created_at: a cashier position is built from the counter deposits and withdrawals of
|
||||||
|
the cashier's login, so a person moved from one branch's teller to another's carried every
|
||||||
|
rupee they had ever taken into the new drawer. The position now counts only what was taken
|
||||||
|
once the allocation existed. Existing rows take the time their ALLOCATECASHIER command ran;
|
||||||
|
a row with no such command stays NULL and keeps its whole history, as before.
|
||||||
|
|
||||||
|
m_cashier_deposit_request.counter_office_id: savings deposits are submitted with no office_id,
|
||||||
|
and the day close counted every such deposit at every branch. The counter's office is now
|
||||||
|
stored at submit. Existing rows take the teller office of the submitter's allocation if it
|
||||||
|
already existed when the deposit was submitted, otherwise the submitter's own office.
|
||||||
|
-->
|
||||||
|
<changeSet id="0591_cashier_created_at" author="fineract">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not>
|
||||||
|
<columnExists tableName="m_cashiers" columnName="created_at"/>
|
||||||
|
</not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_cashiers">
|
||||||
|
<column name="created_at" type="TIMESTAMP"/>
|
||||||
|
</addColumn>
|
||||||
|
<sql>
|
||||||
|
UPDATE m_cashiers mc
|
||||||
|
SET created_at = (SELECT CAST(MIN(cs.made_on_date_utc) AS TIMESTAMP)
|
||||||
|
FROM m_portfolio_command_source cs
|
||||||
|
WHERE cs.entity_name = 'TELLER' AND cs.action_name = 'ALLOCATECASHIER'
|
||||||
|
AND cs.subresource_id = mc.id)
|
||||||
|
</sql>
|
||||||
|
<addDefaultValue tableName="m_cashiers" columnName="created_at" columnDataType="TIMESTAMP"
|
||||||
|
defaultValueComputed="CURRENT_TIMESTAMP"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0591_deposit_request_counter_office" author="fineract">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not>
|
||||||
|
<columnExists tableName="m_cashier_deposit_request" columnName="counter_office_id"/>
|
||||||
|
</not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_cashier_deposit_request">
|
||||||
|
<column name="counter_office_id" type="BIGINT"/>
|
||||||
|
</addColumn>
|
||||||
|
<sql>
|
||||||
|
UPDATE m_cashier_deposit_request r
|
||||||
|
SET counter_office_id = COALESCE(
|
||||||
|
(SELECT t.office_id FROM m_appuser au
|
||||||
|
JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||||
|
JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE au.id = r.cashier_id
|
||||||
|
AND (c.created_at IS NULL OR r.submitted_at IS NULL OR r.submitted_at >= c.created_at)
|
||||||
|
LIMIT 1),
|
||||||
|
(SELECT au.office_id FROM m_appuser au WHERE au.id = r.cashier_id),
|
||||||
|
r.office_id)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<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.1.xsd">
|
||||||
|
|
||||||
|
<!-- Same pattern as 0588_persist_manually_enabled_maker_checker_permissions: converts a
|
||||||
|
manually-applied Configure Maker Checker Tasks change into a repeatable migration, this time
|
||||||
|
for the Teller/Cashier module. Found by diffing can_maker_checker across every permission code
|
||||||
|
common to fineract_lincpay and a reference environment (fineract_default) - these 4 were true
|
||||||
|
in the reference but still seeded false here, confirming they were toggled on manually there
|
||||||
|
and never captured for any other environment (including this one and UAT).
|
||||||
|
|
||||||
|
Grouped by the screen each permission belongs to (Organization -> Manage Tellers -> allocate/
|
||||||
|
settle cash for a cashier):
|
||||||
|
ALLOCATECASHIER_TELLER, ALLOCATECASHTOCASHIER_TELLER,
|
||||||
|
SETTLECASHFROMCASHIER_TELLER, UPDATECASHIERALLOCATION_TELLER
|
||||||
|
|
||||||
|
No _CHECKER row is touched: those already exist for every code below (seeded by base
|
||||||
|
Fineract), and can_maker_checker is not a column on a _CHECKER row. -->
|
||||||
|
<changeSet id="0592_enable_teller_cashier_maker_checker" author="cbs-nitesh">
|
||||||
|
<update tableName="m_permission">
|
||||||
|
<column name="can_maker_checker" valueBoolean="true"/>
|
||||||
|
<where>code IN (
|
||||||
|
'ALLOCATECASHIER_TELLER',
|
||||||
|
'ALLOCATECASHTOCASHIER_TELLER',
|
||||||
|
'SETTLECASHFROMCASHIER_TELLER',
|
||||||
|
'UPDATECASHIERALLOCATION_TELLER'
|
||||||
|
)</where>
|
||||||
|
</update>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+6
-12
@@ -116,11 +116,7 @@ public class CommandSourceServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testProcessCommand_NonGLAccount_SuperUser_OwnSubmission_DoesNotBypassMakerChecker() {
|
public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() {
|
||||||
// A maker who happens to hold CHECKER_SUPER_USER must not auto-approve their own submission -
|
|
||||||
// CHECKER_SUPER_USER only ever lets its holder approve a DIFFERENT user's request (see
|
|
||||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction()). This
|
|
||||||
// replaces the old bypass this test used to assert.
|
|
||||||
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
|
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
|
||||||
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
|
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
|
||||||
CommandSource commandSource = Mockito.mock(CommandSource.class);
|
CommandSource commandSource = Mockito.mock(CommandSource.class);
|
||||||
@@ -129,8 +125,6 @@ public class CommandSourceServiceTest {
|
|||||||
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
|
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
|
||||||
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
|
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
|
||||||
Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW");
|
Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW");
|
||||||
Mockito.when(commandSource.getId()).thenReturn(300L);
|
|
||||||
Mockito.when(commandSource.getResourceId()).thenReturn(400L);
|
|
||||||
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true);
|
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true);
|
||||||
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
|
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
|
||||||
|
|
||||||
@@ -138,12 +132,12 @@ public class CommandSourceServiceTest {
|
|||||||
.withCommandId(1L).build();
|
.withCommandId(1L).build();
|
||||||
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
|
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
|
||||||
|
|
||||||
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
|
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
|
||||||
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
|
commandSource, superUser, false);
|
||||||
});
|
|
||||||
|
|
||||||
Mockito.verify(commandSource).markAsAwaitingApproval();
|
Assertions.assertNotNull(result);
|
||||||
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
|
Mockito.verify(commandSource).markAsChecked(superUser);
|
||||||
|
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user