fix(cashier): prevent duplicate cashier lookup crash + add missing migrations

- DenominationBreakdownWriter: add ORDER BY mc.id DESC LIMIT 1 to all cashier
  lookup queries so multiple cashier records per staff do not crash queryForObject
- CashReportApiResource: same LIMIT 1 fix for cashier-day-status and position endpoints
- 0311: fix m_client and m_savings_account NOT NULL columns (created_by, last_modified_by,
  created_on_utc, last_modified_on_utc, cif_number, is_lien_allowed, etc.)
- 0312: create CASHIER role if missing + grant all 34 cashier permissions (incl FD/RD)
- 0313: add missing m_cash_exchange columns (cashier_id, is_reversed, reversed_by,
  reversed_at, reverse_reason, customer_id_proof)
- 0314: unique constraint on m_cashiers.staff_id to prevent duplicate allocations
- application.properties: restore local postgres password after merge
- ExecuteOverdueAndCurrentStandingInstructionsTest: fix constructor call (5 args)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-01 18:23:31 +05:30
co-authored by Claude Sonnet 4.6
parent d4262ebf6f
commit 8bf28a8033
9 changed files with 259 additions and 74 deletions
@@ -207,7 +207,8 @@ public class CashReportApiResource {
try {
cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ? "
+ "ORDER BY mc.id DESC LIMIT 1",
Long.class, appUserId);
} catch (Exception e) {
// Not a cashier user — no restriction applies
@@ -311,7 +312,7 @@ public class CashReportApiResource {
Long cashierId;
try {
cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ? ORDER BY mc.id DESC LIMIT 1",
Long.class, appUserId);
} catch (Exception e) {
Map<String, Object> empty = new LinkedHashMap<>();
@@ -155,7 +155,8 @@ public class DenominationBreakdownWriter {
final AppUser user = context.authenticatedUser();
cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ? "
+ "ORDER BY mc.id DESC LIMIT 1",
Long.class, user.getId());
} catch (Exception e) {
return; // not a cashier user — skip
@@ -194,7 +195,8 @@ public class DenominationBreakdownWriter {
final AppUser user = context.authenticatedUser();
cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ? "
+ "ORDER BY mc.id DESC LIMIT 1",
Long.class, user.getId());
} catch (Exception e) {
return; // not a cashier user — skip
@@ -234,7 +236,8 @@ public class DenominationBreakdownWriter {
final AppUser user = context.authenticatedUser();
cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ? "
+ "ORDER BY mc.id DESC LIMIT 1",
Long.class, user.getId());
} catch (Exception e) {
return; // not a cashier user — skip
@@ -331,7 +334,8 @@ public class DenominationBreakdownWriter {
final AppUser user = context.authenticatedUser();
cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ? "
+ "ORDER BY mc.id DESC LIMIT 1",
Long.class, user.getId());
} catch (Exception e) {
return; // not a cashier user — skip stock check
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:root}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -339,4 +339,7 @@
<include file="parts/0309_phase3_jobs_and_recall_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0310_fund_transfer_roles_and_token_cleanup_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0311_create_fund_transfer_suspense_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0312_grant_cashier_fd_rd_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0313_alter_cash_exchange_add_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0314_cashier_staff_unique_constraint.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -42,12 +42,18 @@
INSERT INTO m_client (
account_no, external_id, status_enum, office_id, display_name,
fullname, is_staff, activation_date, office_joining_date,
submittedon_date, legal_form_enum
submittedon_date, legal_form_enum,
created_on_utc, created_by,
last_modified_on_utc, last_modified_by,
cif_number
)
VALUES (
'HOUSE0000001', 'FUNDTRANSFER_SUSPENSE_HOUSE', 300, 1, 'House (Fund Transfer Suspense)',
'House (Fund Transfer Suspense)', false, CURRENT_DATE, CURRENT_DATE,
CURRENT_DATE, 2
CURRENT_DATE, 2,
NOW(), 1,
NOW(), 1,
'CIF_SUSPENSE_HOUSE'
)
</sql>
</changeSet>
@@ -74,7 +80,9 @@
withdrawal_fee_for_transfer, allow_overdraft, overdraft_limit,
nominal_annual_interest_rate_overdraft, min_overdraft_for_interest_calculation,
enforce_min_required_balance, account_balance_derived,
version, withhold_tax
version, withhold_tax,
created_by, last_modified_by, created_on_utc, last_modified_on_utc,
is_lien_allowed, nomination_facility, auto_debit_facility, is_senior_citizen
)
SELECT
'SUSPENSE01', 'FUNDTRANSFER_SUSPENSE',
@@ -89,7 +97,9 @@
false, false, NULL,
0.000000, 0.000000,
false, 0.000000,
1, false
1, false,
1, 1, NOW(), NOW(),
false, false, false, false
FROM m_savings_product sp
WHERE sp.id = (SELECT MIN(id) FROM m_savings_product)
</sql>
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<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">
<!-- Step 1: Create CASHIER role if not exists -->
<changeSet id="0312_create_cashier_role" author="fineract">
<sql>
INSERT INTO m_role (name, description, is_disabled)
SELECT 'CASHIER', 'Cashier role with cash module permissions', false
WHERE NOT EXISTS (SELECT 1 FROM m_role WHERE name = 'CASHIER');
</sql>
</changeSet>
<!-- Step 2: Grant all CASHIER permissions -->
<changeSet id="0312_grant_cashier_all_permissions" author="fineract">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
JOIN m_permission p ON p.code IN (
-- Cash module
'CREATE_CASHTRANSFER',
'READ_CASHTRANSFER',
'REVERSE_CASHTRANSFER',
'CREATE_CASHSETTLEMENT',
'READ_CASHSETTLEMENT',
'REVERSE_CASHSETTLEMENT',
'CREATE_CASHINTERCASHIER',
'READ_CASHINTERCASHIER',
'REVERSE_CASHINTERCASHIER',
'CREATE_CASHRECONCILIATION',
'READ_CASHRECONCILIATION',
-- Savings
'DEPOSIT_SAVINGSACCOUNT',
'WITHDRAWAL_SAVINGSACCOUNT',
'READ_SAVINGSACCOUNT',
'READ_SAVINGSPRODUCT',
'READ_Savings Transactions',
-- Current Account
'DEPOSIT_CURRENTACCOUNT',
'WITHDRAWAL_CURRENTACCOUNT',
'READ_CURRENTACCOUNT',
'READ_CURRENTPRODUCT',
'READ_Client Saving Transactions',
-- Fixed Deposit
'DEPOSIT_FIXEDDEPOSITACCOUNT',
'WITHDRAWAL_FIXEDDEPOSITACCOUNT',
'READ_FIXEDDEPOSITACCOUNT',
'READ_FIXEDDEPOSITPRODUCT',
-- Recurring Deposit
'DEPOSIT_RECURRINGDEPOSITACCOUNT',
'WITHDRAWAL_RECURRINGDEPOSITACCOUNT',
'READ_RECURRINGDEPOSITACCOUNT',
'READ_RECURRINGDEPOSITPRODUCT',
-- Client
'READ_CLIENT',
-- Menu
'MENU_CASHIER',
'MENU_DEPOSITS',
'MENU_INSTITUTION',
'MENU_TASKS'
)
WHERE r.name = 'CASHIER'
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<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">
<changeSet id="0313_cash_exchange_add_cashier_id" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_cash_exchange" columnName="cashier_id"/></not>
</preConditions>
<addColumn tableName="m_cash_exchange">
<column name="cashier_id" type="BIGINT"/>
</addColumn>
</changeSet>
<changeSet id="0313_cash_exchange_add_customer_id_proof" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_cash_exchange" columnName="customer_id_proof"/></not>
</preConditions>
<addColumn tableName="m_cash_exchange">
<column name="customer_id_proof" type="VARCHAR(50)"/>
</addColumn>
</changeSet>
<changeSet id="0313_cash_exchange_add_is_reversed" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_cash_exchange" columnName="is_reversed"/></not>
</preConditions>
<addColumn tableName="m_cash_exchange">
<column name="is_reversed" type="BOOLEAN" defaultValueBoolean="false"/>
</addColumn>
</changeSet>
<changeSet id="0313_cash_exchange_add_reversed_by" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_cash_exchange" columnName="reversed_by"/></not>
</preConditions>
<addColumn tableName="m_cash_exchange">
<column name="reversed_by" type="BIGINT"/>
</addColumn>
</changeSet>
<changeSet id="0313_cash_exchange_add_reversed_at" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_cash_exchange" columnName="reversed_at"/></not>
</preConditions>
<addColumn tableName="m_cash_exchange">
<column name="reversed_at" type="TIMESTAMP"/>
</addColumn>
</changeSet>
<changeSet id="0313_cash_exchange_add_reverse_reason" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_cash_exchange" columnName="reverse_reason"/></not>
</preConditions>
<addColumn tableName="m_cash_exchange">
<column name="reverse_reason" type="VARCHAR(500)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,25 @@
<?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">
<!--
Adds a unique constraint on m_cashiers.staff_id so one staff member
cannot be allocated as a cashier more than once.
preCondition: skip if duplicates still exist (manual cleanup needed first).
-->
<changeSet id="0314_cashier_staff_unique_index" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM (
SELECT staff_id FROM m_cashiers GROUP BY staff_id HAVING COUNT(*) > 1
) duplicates
</sqlCheck>
</preConditions>
<createIndex tableName="m_cashiers" indexName="uq_cashiers_staff_id" unique="true">
<column name="staff_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -1,63 +1,63 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.account.jobs.executestandinginstructions;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.core.domain.ActionContext;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.account.data.StandingInstructionDuesData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class ExecuteOverdueAndCurrentStandingInstructionsTest {
private final LocalDate currentDate = LocalDate.now(Clock.systemUTC());
private final LocalDate previousDate = currentDate.minusDays(2);
@BeforeEach
public void setUp() {
ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Africa/Kampala", null));
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
}
@AfterEach
public void tearDown() {
ThreadLocalContextUtil.reset();
}
@Test
public void testAcceptPreviousDateAsDue() {
ExecuteStandingInstructionsTasklet tasklet = new ExecuteStandingInstructionsTasklet(null, null, null, null);
boolean isDueForTransfer = tasklet.isDueForTransfer(new StandingInstructionDuesData(previousDate, BigDecimal.ONE));
assertThat(isDueForTransfer).isTrue().describedAs("Earlier instructions are accepted as due");
}
@Test
public void testAcceptCurrentDateAsDue() {
ExecuteStandingInstructionsTasklet tasklet = new ExecuteStandingInstructionsTasklet(null, null, null, null);
boolean isDueForTransfer = tasklet.isDueForTransfer(new StandingInstructionDuesData(currentDate, BigDecimal.ONE));
assertThat(isDueForTransfer).isTrue().describedAs("Current day instructions are accepted as due");
}
}
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.account.jobs.executestandinginstructions;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.core.domain.ActionContext;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.account.data.StandingInstructionDuesData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class ExecuteOverdueAndCurrentStandingInstructionsTest {
private final LocalDate currentDate = LocalDate.now(Clock.systemUTC());
private final LocalDate previousDate = currentDate.minusDays(2);
@BeforeEach
public void setUp() {
ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Africa/Kampala", null));
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
}
@AfterEach
public void tearDown() {
ThreadLocalContextUtil.reset();
}
@Test
public void testAcceptPreviousDateAsDue() {
ExecuteStandingInstructionsTasklet tasklet = new ExecuteStandingInstructionsTasklet(null, null, null, null, null);
boolean isDueForTransfer = tasklet.isDueForTransfer(new StandingInstructionDuesData(previousDate, BigDecimal.ONE));
assertThat(isDueForTransfer).isTrue().describedAs("Earlier instructions are accepted as due");
}
@Test
public void testAcceptCurrentDateAsDue() {
ExecuteStandingInstructionsTasklet tasklet = new ExecuteStandingInstructionsTasklet(null, null, null, null, null);
boolean isDueForTransfer = tasklet.isDueForTransfer(new StandingInstructionDuesData(currentDate, BigDecimal.ONE));
assertThat(isDueForTransfer).isTrue().describedAs("Current day instructions are accepted as due");
}
}