feat(savings): facility charge mapping admin API, enforce mapped-charge validation, INR-only currency, GL chart backfill
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s

Adds the admin read/write API for m_facility_charge_mapping (list, template,
update) so facilities can be pointed at a real charge instead of relying on
the seeder's implicit auto-link, and makes enable-on-account paths (activation
and edit-approval, both savings and current accounts) throw instead of
silently skipping the charge when a facility isn't mapped or is inactive.
Migrations 0497-0509 back this up: reset all facility mappings to
unmapped/inactive by default, backfill lost roles/permissions/charges, enforce
INR as the only organisation currency, backfill the full real-world chart of
accounts (44 core + 60 priority GL heads) with standardized gl_codes, and
retire the unused system-defined payment types (Share Redemption Transfer and
friends) in favor of Cash/Transfer only, matching the teller dashboard's
simplified payment-type flow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 18:41:42 +05:30
co-authored by Claude Sonnet 5
parent 58187c23d0
commit 1bc9d0901d
29 changed files with 2957 additions and 70 deletions
@@ -0,0 +1,83 @@
/**
* 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.savings.data;
import java.math.BigDecimal;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
/**
* One row of {@code m_facility_charge_mapping}: which {@link org.apache.fineract.portfolio.charge.domain.Charge}
* is applied when a named account facility (ATM_FACILITY, UPI_FACILITY, ...) is enabled. {@link #chargeOptions}
* is populated only on the template response, for the admin screen's "change the mapped charge" dropdown.
*/
@Getter
@Setter
public class FacilityChargeMappingData {
private Long id;
private String facilityType;
private Long chargeId;
private String chargeName;
private BigDecimal defaultAmount;
private Integer chargeTimeType;
private Integer chargeAppliesTo;
private Boolean isActive;
/**
* False when the mapped charge has since been deleted or deactivated (and true whenever there is simply no
* charge mapped at all). {@link #chargeName} still shows whatever name was cached at mapping time either
* way, so the admin screen needs this flag to tell "correctly mapped" apart from "was mapped, now broken".
*/
private Boolean chargeAvailable;
private List<FacilityChargeOptionData> chargeOptions;
public FacilityChargeMappingData() {}
public FacilityChargeMappingData(final Long id, final String facilityType, final Long chargeId, final String chargeName,
final BigDecimal defaultAmount, final Integer chargeTimeType, final Integer chargeAppliesTo, final Boolean isActive,
final Boolean chargeAvailable) {
this.id = id;
this.facilityType = facilityType;
this.chargeId = chargeId;
this.chargeName = chargeName;
this.defaultAmount = defaultAmount;
this.chargeTimeType = chargeTimeType;
this.chargeAppliesTo = chargeAppliesTo;
this.isActive = isActive;
this.chargeAvailable = chargeAvailable;
}
@Getter
@Setter
public static class FacilityChargeOptionData {
private final Long id;
private final String name;
private final BigDecimal amount;
public FacilityChargeOptionData(final Long id, final String name, final BigDecimal amount) {
this.id = id;
this.name = name;
this.amount = amount;
}
}
}
@@ -0,0 +1,28 @@
/**
* 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.savings.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class FacilityChargeMappingNotFoundException extends AbstractPlatformResourceNotFoundException {
public FacilityChargeMappingNotFoundException(final Long id) {
super("error.msg.facilitychargemapping.id.invalid", "Facility charge mapping with identifier " + id + " does not exist", id);
}
}
@@ -25,6 +25,7 @@ import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.charge.domain.Charge;
@@ -117,18 +118,13 @@ public class FacilityChargeAutoApplyService {
public List<SavingsAccountCharge> applyChargesForFacilities(final SavingsAccount account, final java.util.Set<String> facilityTypes) {
final List<SavingsAccountCharge> chargesToApply = new ArrayList<>();
for (final String facilityType : facilityTypes) {
mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> {
final Charge charge = mapping.getCharge();
if (charge == null) {
return;
}
final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge);
final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(charge, charge.getAmount(),
null, null, resolveDueDate(charge), true, feeOnMonthDay, charge.getFeeInterval());
accountCharge.update(account);
account.charges().add(accountCharge);
chargesToApply.add(accountCharge);
});
final Charge charge = requireMappedCharge(facilityType);
final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge);
final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(charge, charge.getAmount(),
null, null, resolveDueDate(charge), true, feeOnMonthDay, charge.getFeeInterval());
accountCharge.update(account);
account.charges().add(accountCharge);
chargesToApply.add(accountCharge);
}
if (!chargesToApply.isEmpty()) {
savingsAccountChargeRepository.saveAll(chargesToApply);
@@ -219,25 +215,49 @@ public class FacilityChargeAutoApplyService {
&& c.getCharge().getName().equals(getChargeName(facilityType)));
if (alreadyApplied) return;
mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> {
final Charge charge = mapping.getCharge();
if (charge == null) return;
final Charge charge = requireMappedCharge(facilityType);
final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge);
final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(
charge,
charge.getAmount(),
null, // chargeTime derived from Charge definition
null, // chargeCalculation derived from Charge definition
resolveDueDate(charge),
true,
feeOnMonthDay,
charge.getFeeInterval());
final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge);
final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(
charge,
charge.getAmount(),
null, // chargeTime derived from Charge definition
null, // chargeCalculation derived from Charge definition
resolveDueDate(charge),
true,
feeOnMonthDay,
charge.getFeeInterval());
accountCharge.update(account);
account.charges().add(accountCharge);
collector.add(accountCharge);
});
accountCharge.update(account);
account.charges().add(accountCharge);
collector.add(accountCharge);
}
/**
* Looks up the charge mapped to {@code facilityType}, throwing rather than silently letting the facility be
* enabled with no charge (or a charge that no longer exists) if the mapping is missing, has no charge
* configured, or its charge has since been deleted/deactivated. Configure it via Products {@literal >}
* Facility Charge Mappings first.
*/
private Charge requireMappedCharge(final String facilityType) {
final FacilityChargeMapping mapping = mappingRepository.findByFacilityType(facilityType).orElse(null);
// An inactive mapping is treated the same as an unmapped one: the Facility Charge Mappings screen's
// "Active" toggle is how an admin marks a mapping as genuinely configured, so a charge sitting on an
// inactive row must not silently apply.
if (mapping == null || mapping.getCharge() == null || !Boolean.TRUE.equals(mapping.getIsActive())) {
throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.not.mapped",
"A charge has not been mapped for the '" + facilityType
+ "' facility. Configure it under Products > Facility Charge Mappings before enabling it.",
facilityType);
}
final Charge charge = mapping.getCharge();
if (charge.isDeleted() || !charge.isActive()) {
throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.deleted",
"The charge mapped to the '" + facilityType
+ "' facility has been deleted or deactivated. Update the mapping under Products > Facility Charge Mappings before enabling it.",
facilityType);
}
return charge;
}
private MonthDay resolveFeeOnMonthDay(final Charge charge) {
@@ -0,0 +1,32 @@
/**
* 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.savings.service;
import java.util.List;
import org.apache.fineract.portfolio.savings.data.FacilityChargeMappingData;
public interface FacilityChargeMappingReadPlatformService {
List<FacilityChargeMappingData> retrieveAll();
FacilityChargeMappingData retrieveOne(Long id);
/** All active charges, for the admin screen's "change the mapped charge" dropdown. */
FacilityChargeMappingData retrieveTemplate();
}
@@ -0,0 +1,78 @@
/**
* 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.savings.service;
import java.util.Comparator;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.charge.service.ChargeReadPlatformService;
import org.apache.fineract.portfolio.savings.data.FacilityChargeMappingData;
import org.apache.fineract.portfolio.savings.data.FacilityChargeMappingData.FacilityChargeOptionData;
import org.apache.fineract.portfolio.savings.domain.FacilityChargeMapping;
import org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository;
import org.apache.fineract.portfolio.savings.exception.FacilityChargeMappingNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class FacilityChargeMappingReadPlatformServiceImpl implements FacilityChargeMappingReadPlatformService {
private final FacilityChargeMappingRepository facilityChargeMappingRepository;
private final ChargeReadPlatformService chargeReadPlatformService;
@Override
public List<FacilityChargeMappingData> retrieveAll() {
return facilityChargeMappingRepository.findAll().stream().sorted(Comparator.comparing(FacilityChargeMapping::getFacilityType))
.map(this::toData).toList();
}
@Override
public FacilityChargeMappingData retrieveOne(final Long id) {
final FacilityChargeMapping mapping = facilityChargeMappingRepository.findById(id)
.orElseThrow(() -> new FacilityChargeMappingNotFoundException(id));
return toData(mapping);
}
@Override
public FacilityChargeMappingData retrieveTemplate() {
final FacilityChargeMappingData template = new FacilityChargeMappingData();
template.setChargeOptions(chargeReadPlatformService.retrieveAllCharges().stream()
.map(charge -> new FacilityChargeOptionData(charge.getId(), charge.getName(), charge.getAmount())).toList());
return template;
}
private FacilityChargeMappingData toData(final FacilityChargeMapping mapping) {
final Charge charge = mapping.getCharge();
final boolean chargeAvailable = charge == null || (!charge.isDeleted() && charge.isActive());
// FacilityChargeAutoApplyService always charges charge.getAmount() (the charge's own, live amount) --
// mapping.getDefaultAmount() is a separate, unrelated stored column that nothing in the actual charging
// path reads, so showing it here would silently drift from what an account is really charged the moment
// someone edits the charge itself. Show the charge's real amount instead; edits to it happen on the
// Charge (Products > Charges), not here.
// When no charge is mapped, mapping.getChargeName()/getDefaultAmount() are stale leftovers from the
// pre-seed migration -- report null so the screen shows the facility as genuinely unmapped, not as
// if a charge (with a name and amount) were already chosen.
final String chargeName = charge != null ? charge.getName() : null;
final java.math.BigDecimal amount = charge != null ? charge.getAmount() : null;
return new FacilityChargeMappingData(mapping.getId(), mapping.getFacilityType(), charge == null ? null : charge.getId(),
chargeName, amount, mapping.getChargeTimeType(), mapping.getChargeAppliesTo(), mapping.getIsActive(),
chargeAvailable);
}
}
@@ -0,0 +1,27 @@
/**
* 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.savings.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface FacilityChargeMappingWritePlatformService {
CommandProcessingResult updateMapping(Long id, JsonCommand command);
}
@@ -0,0 +1,102 @@
/**
* 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.savings.service;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
import org.apache.fineract.portfolio.savings.domain.FacilityChargeMapping;
import org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository;
import org.apache.fineract.portfolio.savings.exception.FacilityChargeMappingNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class FacilityChargeMappingWritePlatformServiceImpl implements FacilityChargeMappingWritePlatformService {
private static final String CHARGE_ID_PARAM = "chargeId";
private static final String IS_ACTIVE_PARAM = "isActive";
private final FacilityChargeMappingRepository facilityChargeMappingRepository;
private final ChargeRepositoryWrapper chargeRepositoryWrapper;
@Transactional
@Override
public CommandProcessingResult updateMapping(final Long id, final JsonCommand command) {
final FacilityChargeMapping mapping = facilityChargeMappingRepository.findById(id)
.orElseThrow(() -> new FacilityChargeMappingNotFoundException(id));
final Map<String, Object> changes = new HashMap<>();
final Long existingChargeId = mapping.getCharge() == null ? null : mapping.getCharge().getId();
if (command.isChangeInLongParameterNamed(CHARGE_ID_PARAM, existingChargeId)) {
final Long newChargeId = command.longValueOfParameterNamed(CHARGE_ID_PARAM);
final Charge charge = chargeRepositoryWrapper.findOneWithNotFoundDetection(newChargeId);
// Each facility's charge must be its own: FacilityChargeAutoApplyService#applyIfEnabled dedups a
// newly-enabled facility's charge by matching an existing SavingsAccountCharge's charge NAME, so if
// two facilities shared the same charge, enabling the second one on an account that already has the
// first would be silently skipped as "already applied" -- under-charging the account by one facility
// worth of fee. Reject the mapping change here instead of letting that surface downstream.
final Optional<FacilityChargeMapping> conflict = facilityChargeMappingRepository.findAllByIsActiveTrue().stream()
.filter(other -> !other.getId().equals(mapping.getId())) //
.filter(other -> other.getCharge() != null && newChargeId.equals(other.getCharge().getId())) //
.findFirst();
if (conflict.isPresent()) {
throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.already.mapped",
"This charge is already mapped to the '" + conflict.get().getFacilityType()
+ "' facility. Each charge can only be mapped to one facility, to avoid a shared charge being"
+ " under-applied when both facilities are enabled on the same account.",
conflict.get().getFacilityType());
}
mapping.setCharge(charge);
mapping.setChargeName(charge.getName());
// Keep the stored column in step with the charge actually being applied, even though nothing
// currently reads it directly (the read side reports charge.getAmount() live) -- avoids a future
// reader finding a stale value here.
mapping.setDefaultAmount(charge.getAmount());
changes.put(CHARGE_ID_PARAM, newChargeId);
}
if (command.isChangeInBooleanParameterNamed(IS_ACTIVE_PARAM, mapping.getIsActive())) {
final Boolean newIsActive = command.booleanObjectValueOfParameterNamed(IS_ACTIVE_PARAM);
mapping.setIsActive(newIsActive);
changes.put(IS_ACTIVE_PARAM, newIsActive);
}
if (!changes.isEmpty()) {
facilityChargeMappingRepository.saveAndFlush(mapping);
}
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(id) //
.with(changes) //
.build();
}
}
@@ -29,11 +29,12 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Seeds default facility charges on service startup via JDBC.
* For each facility type (ATM, UPI, NEFT etc.), ensures a charge exists in m_charge
* and a mapping exists in m_facility_charge_mapping.
* Bank staff can change amounts from the Charges admin screen later.
* Seeder only creates records that are missing — safe to run repeatedly.
* Seeds default facility charges on service startup via JDBC, one per facility type (ATM, UPI,
* NEFT etc.), so the Facility Charge Mappings admin screen has ready-made charges to choose from.
* Deliberately does NOT link a charge to its facility's m_facility_charge_mapping row or activate
* it — that mapping must be an explicit admin choice made through that screen (an unmapped facility
* is blocked from being enabled on an account by FacilityChargeAutoApplyService). Seeder only
* creates charges that are missing — safe to run repeatedly.
*/
@Service
@RequiredArgsConstructor
@@ -82,23 +83,13 @@ public class FacilityChargeSeederService {
final int chargeTimeType = (int) def[2];
final int chargeAppliesTo = (int) def[3];
// 1. Check if mapping row exists and already has a charge linked
List<Long> existingChargeId = jdbcTemplate.queryForList(
"SELECT charge_id FROM m_facility_charge_mapping WHERE facility_type = ? AND charge_id IS NOT NULL",
Long.class, facilityType);
if (!existingChargeId.isEmpty()) {
continue; // Already seeded
}
// 2. Check if charge with this name already exists in m_charge
// 1. Ensure a default charge exists in m_charge for this facility (creates it if missing) —
// purely so it's available as a ready-made option in the mapping screen's charge dropdown.
List<Long> chargeIds = jdbcTemplate.queryForList(
"SELECT id FROM m_charge WHERE name = ? AND is_deleted = false LIMIT 1",
Long.class, chargeName);
Long chargeId;
if (chargeIds.isEmpty()) {
// 3. Insert charge into m_charge
jdbcTemplate.update(
"INSERT INTO m_charge (name, currency_code, amount, charge_applies_to_enum, "
+ "charge_time_enum, charge_calculation_enum, is_penalty, is_active, is_deleted, "
@@ -106,29 +97,24 @@ public class FacilityChargeSeederService {
+ "VALUES (?, 'INR', ?, ?, ?, true, false, true, false, false, false, ?)",
chargeName, amount, chargeAppliesTo, chargeTimeType, incomeGlAccountId);
chargeId = jdbcTemplate.queryForObject(
"SELECT id FROM m_charge WHERE name = ? AND is_deleted = false ORDER BY id DESC LIMIT 1",
Long.class, chargeName);
log.info("FacilityChargeSeeder: inserted charge '{}' id={}", chargeName, chargeId);
} else {
chargeId = chargeIds.get(0);
log.info("FacilityChargeSeeder: inserted charge '{}'", chargeName);
}
// 4. Update or insert mapping row
int updated = jdbcTemplate.update(
"UPDATE m_facility_charge_mapping SET charge_id = ? WHERE facility_type = ?",
chargeId, facilityType);
// 2. Ensure the facility_charge_mapping row itself exists so the admin screen lists every
// facility type, but leave it unmapped/inactive — an admin must explicitly pick a charge
// for it via the Facility Charge Mappings screen before it can be enabled on an account.
List<Long> existingMapping = jdbcTemplate.queryForList(
"SELECT id FROM m_facility_charge_mapping WHERE facility_type = ?", Long.class, facilityType);
if (updated == 0) {
if (existingMapping.isEmpty()) {
jdbcTemplate.update(
"INSERT INTO m_facility_charge_mapping "
+ "(facility_type, charge_id, charge_name, default_amount, charge_time_type, charge_applies_to, is_active) "
+ "VALUES (?, ?, ?, ?, ?, ?, 1)",
facilityType, chargeId, chargeName, amount, chargeTimeType, chargeAppliesTo);
}
+ "VALUES (?, NULL, ?, ?, ?, ?, false)",
facilityType, chargeName, amount, chargeTimeType, chargeAppliesTo);
log.info("FacilityChargeSeeder: mapped facility '{}' → charge id={}", facilityType, chargeId);
log.info("FacilityChargeSeeder: added unmapped facility row '{}'", facilityType);
}
}
log.info("FacilityChargeSeeder: seeding complete.");