feat(aml): add API endpoints and services for manual journal entry referrals
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
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 1 of 10) (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
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 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 - 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 - MySQL / 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 - 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-1) (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-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 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 Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

This commit is contained in:
2026-08-03 10:49:37 +05:30
parent c890faee78
commit 7773a9e9c6
9 changed files with 423 additions and 0 deletions
@@ -35,4 +35,10 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
@Param("since") LocalDateTime since);
long countByClientId(Long clientId);
/**
* Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
*/
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
}
@@ -19,9 +19,17 @@
package org.apache.fineract.portfolio.aml.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
/**
* Looks a rule up by its code irrespective of {@code is_active}. Needed for MANUAL_REFERRAL, which is deliberately
* seeded inactive so the evaluation engine never fires it, yet must still be attachable to an alert a user raises
* by hand from the ledger.
*/
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
}
@@ -33,8 +33,10 @@ import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
import org.apache.fineract.portfolio.aml.service.AmlAlertReadPlatformService;
import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService;
import org.apache.fineract.portfolio.aml.service.JournalEntryReferralService;
import org.springframework.stereotype.Component;
@Path("/v1/aml/alerts")
@@ -46,7 +48,9 @@ public class AmlAlertApiResource {
private final PlatformSecurityContext context;
private final AmlAlertReadPlatformService readPlatformService;
private final AmlAlertWritePlatformService writePlatformService;
private final JournalEntryReferralService referralService;
private final DefaultToApiJsonSerializer<AmlAlertData> toApiJsonSerializer;
private final DefaultToApiJsonSerializer<ManualReferralData> referralJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -76,4 +80,28 @@ public class AmlAlertApiResource {
.orElse(null);
return this.toApiJsonSerializer.serialize(updated);
}
@GET
@Path("manual-referrals")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List ledger entries marked by a user",
description = "Every entry marked cleared or suspicious from the general ledger screen, keyed on journalEntryId so the ledger can show what has already been dealt with.")
public String retrieveManualReferrals() {
this.context.authenticatedUser();
return this.referralJsonSerializer.serialize(this.referralService.retrieveAll());
}
@POST
@Path("manual-referral")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Mark a ledger entry cleared or suspicious",
description = "WHITELIST clears the entry with no report. ESCALATE raises an alert against the MANUAL_REFERRAL rule and escalates it, so an STR case opens for the Principal Officer exactly as an engine alert would. The entry must have a customer behind it.")
public String referJournalEntry(final ManualReferralRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
final ManualReferralData referral = this.referralService.refer(request.getJournalEntryId(), request.getDisposition(),
request.getNotes());
return this.referralJsonSerializer.serialize(referral);
}
}
@@ -0,0 +1,39 @@
/**
* 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.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Request body for {@code POST /v1/aml/alerts/manual-referral}.
*
* {@code journalEntryId} is acc_gl_journal_entry.id as shown in the ledger. {@code disposition} is WHITELIST (cleared,
* no report) or ESCALATE (raises an STR case for the Principal Officer).
*/
@Getter
@Setter
@NoArgsConstructor
public class ManualReferralRequest {
private Long journalEntryId;
private String disposition;
private String notes;
}
@@ -0,0 +1,33 @@
/**
* 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.aml.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* One ledger entry a user has marked, so the ledger screen can show what has already been dealt with.
*
* {@code journalEntryId} is the acc_gl_journal_entry row the mark was made against - it is what the ledger table keys
* on. {@code strCaseId} is populated only where the mark escalated into an STR case.
*/
public record ManualReferralData(Long alertId, Long journalEntryId, Long clientId, String clientDisplayName,
BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status, String disposition,
String reviewedByUsername, String reviewNotes, Long strCaseId) implements Serializable {}
@@ -0,0 +1,39 @@
/**
* 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.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
/**
* Lets a user mark a ledger entry as cleared or suspicious from the general ledger screen.
*
* The rule engine raises alerts on its own; this is the manual path for an entry a reviewer's eye catches that no rule
* fired on. A suspicious mark goes down exactly the same road as an escalated engine alert - it raises an
* {@link org.apache.fineract.portfolio.aml.domain.AmlAlert} against the MANUAL_REFERRAL rule and escalates it, so the
* case lands in the Principal Officer's STR queue with the usual seven working day FIU clock running.
*/
public interface JournalEntryReferralService {
/** disposition: WHITELIST | ESCALATE. */
ManualReferralData refer(Long journalEntryId, String disposition, String notes);
/** Every mark made from the ledger, so the screen can show what has already been dealt with. */
List<ManualReferralData> retrieveAll();
}
@@ -0,0 +1,224 @@
/**
* 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.aml.service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class JournalEntryReferralServiceImpl implements JournalEntryReferralService {
/** Seeded inactive by 0443 so the evaluation engine never fires it. */
private static final String MANUAL_RULE_CODE = "MANUAL_REFERRAL";
private final JdbcTemplate jdbcTemplate;
private final AmlAlertRepository alertRepository;
private final AmlStrCaseRepository strCaseRepository;
private final AmlTransactionRuleRepository ruleRepository;
private final ClientRepositoryWrapper clientRepository;
private final PlatformSecurityContext context;
private final ClientRiskEscalationService clientRiskEscalationService;
/**
* A journal entry carries no customer of its own, so the customer has to be reached through whatever the entry is
* posted against.
*
* The entity_id / entity_type_enum pair is the primary route and the one that matters: it is exactly how
* JournalEntryReadPlatformServiceImpl derives the account number the ledger screen shows, so anything displaying an
* account number resolves here. entity_type_enum follows PortfolioProductType - LOAN(1), SAVING(2), CLIENT(5) - and
* a savings-type entity may be either a savings or a current account, which live in separate tables.
*
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
*/
private static final String RESOLVE_SQL = """
SELECT je.amount AS amount,
je.currency_code AS currencyCode,
je.savings_transaction_id AS savingsTxnId,
je.loan_transaction_id AS loanTxnId,
je.entity_type_enum AS entityType,
COALESCE(
esa.client_id,
eca.client_id,
eloan.client_id,
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
sa.client_id,
l.client_id,
ct.client_id
) AS clientId
FROM acc_gl_journal_entry je
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
LEFT JOIN m_loan_transaction lt ON lt.id = je.loan_transaction_id
LEFT JOIN m_loan l ON l.id = lt.loan_id
LEFT JOIN m_client_transaction ct ON ct.id = je.client_transaction_id
WHERE je.id = ?
""";
private static final String LIST_SQL = """
SELECT a.id AS alertId,
a.transaction_id AS journalEntryId,
a.client_id AS clientId,
c.display_name AS clientDisplayName,
a.amount AS amount,
a.currency_code AS currencyCode,
a.raised_on AS raisedOn,
a.status AS status,
a.disposition AS disposition,
u.username AS reviewedByUsername,
a.review_notes AS reviewNotes,
s.id AS strCaseId
FROM m_aml_alert a
JOIN m_aml_transaction_rule r ON r.id = a.rule_id AND r.rule_code = ?
LEFT JOIN m_client c ON c.id = a.client_id
LEFT JOIN m_appuser u ON u.id = a.reviewed_by
LEFT JOIN m_aml_str_case s ON s.alert_id = a.id
ORDER BY a.raised_on DESC
""";
@Override
public ManualReferralData refer(final Long journalEntryId, final String disposition, final String notes) {
if (journalEntryId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.required",
"A journal entry must be given to refer.");
}
final boolean escalate = "ESCALATE".equalsIgnoreCase(disposition);
if (!escalate && !"WHITELIST".equalsIgnoreCase(disposition)) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.disposition.invalid",
"Disposition must be WHITELIST or ESCALATE.", disposition);
}
final AmlTransactionRule rule = ruleRepository.findByRuleCode(MANUAL_RULE_CODE)
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.aml.referral.rule.missing",
"The MANUAL_REFERRAL rule is not seeded; run the tenant migrations."));
// Marking the same entry twice would put two cases in front of the Principal Officer for
// one transaction, so the first mark stands until it is explicitly reworked.
if (!alertRepository.findByRule_IdAndTransactionId(rule.getId(), journalEntryId).isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.already.marked",
"This ledger entry has already been marked.", journalEntryId);
}
final Map<String, Object> row = loadEntry(journalEntryId);
final Number clientIdValue = (Number) row.get("clientId");
if (clientIdValue == null) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.no.client",
"This ledger entry has no customer behind it, so it cannot be referred.", journalEntryId);
}
final Client client = clientRepository.findOneWithNotFoundDetection(clientIdValue.longValue());
final BigDecimal amount = (BigDecimal) row.get("amount");
final String currencyCode = (String) row.get("currencyCode");
// transaction_id holds the journal entry id, which is what the ledger screen keys on. The
// MANUAL_REFERRAL rule tells these apart from engine alerts, whose transaction_id is a
// savings or loan transaction id.
final AmlAlert alert = AmlAlert.raise(transactionTypeOf(row), journalEntryId, client, rule, amount, currencyCode,
LocalDateTime.now());
alertRepository.save(alert);
Long strCaseId = null;
if (escalate) {
alert.escalate(context.authenticatedUser(), notes);
alertRepository.save(alert);
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
strCaseId = strCase.getId();
} else {
alert.whitelist(context.authenticatedUser(), notes);
alertRepository.save(alert);
}
return new ManualReferralData(alert.getId(), journalEntryId, client.getId(), client.getDisplayName(), amount,
currencyCode, alert.getRaisedOn(), alert.getStatus().name(),
alert.getDisposition() == null ? null : alert.getDisposition().name(),
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewNotes(),
strCaseId);
}
@Override
@Transactional(readOnly = true)
public List<ManualReferralData> retrieveAll() {
return jdbcTemplate.query(LIST_SQL, (rs, rowNum) -> new ManualReferralData(rs.getLong("alertId"),
rs.getLong("journalEntryId"), rs.getLong("clientId"), rs.getString("clientDisplayName"),
rs.getBigDecimal("amount"), rs.getString("currencyCode"),
rs.getTimestamp("raisedOn") == null ? null : rs.getTimestamp("raisedOn").toLocalDateTime(),
rs.getString("status"), rs.getString("disposition"), rs.getString("reviewedByUsername"),
rs.getString("reviewNotes"), rs.getObject("strCaseId") == null ? null : rs.getLong("strCaseId")),
MANUAL_RULE_CODE);
}
private Map<String, Object> loadEntry(final Long journalEntryId) {
try {
return jdbcTemplate.queryForMap(RESOLVE_SQL, journalEntryId);
} catch (final EmptyResultDataAccessException e) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.not.found",
"Journal entry " + journalEntryId + " does not exist.", journalEntryId);
}
}
/**
* TRANSFER covers everything that is neither a savings nor a loan posting.
*
* The entity type is checked first because a system-generated entry often carries entity_id without a transaction
* id; falling straight through to the transaction columns would mislabel those as transfers.
*/
private AmlTransactionType transactionTypeOf(final Map<String, Object> row) {
final Number entityType = (Number) row.get("entityType");
if (entityType != null) {
if (entityType.intValue() == 2) {
return AmlTransactionType.SAVINGS;
}
if (entityType.intValue() == 1) {
return AmlTransactionType.LOAN;
}
}
if (row.get("savingsTxnId") != null) {
return AmlTransactionType.SAVINGS;
}
if (row.get("loanTxnId") != null) {
return AmlTransactionType.LOAN;
}
return AmlTransactionType.TRANSFER;
}
}
@@ -495,4 +495,5 @@
<include file="parts/0440_seed_aml_test_alerts.xml" relativeToChangelogFile="true"/>
<include file="parts/0441_add_aml_rule_limit_bands.xml" relativeToChangelogFile="true"/>
<include file="parts/0442_seed_branch_offices.xml" relativeToChangelogFile="true"/>
<include file="parts/0443_seed_aml_manual_referral_rule.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<!--
m_aml_alert.rule_id is NOT NULL, so an alert a user raises by hand from the ledger still
needs a rule to hang off. MANUAL_REFERRAL is that rule.
It is seeded with is_active = false ON PURPOSE. AmlRuleEvaluationServiceImpl selects
candidates with findByActiveTrueAndAppliesTo, so an active ALL-scoped rule with a null
threshold would be evaluated against every live transaction. Inactive keeps it out of the
engine entirely; the referral service loads it by rule_code instead.
-->
<changeSet author="fineract" id="0443-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'MANUAL_REFERRAL'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_aml_transaction_rule (rule_code, description, applies_to, threshold_amount, rule_type, window_days, source_client_risk_rule_id, is_active)
VALUES ('MANUAL_REFERRAL', 'Marked suspicious by a user from the general ledger - no automated rule fired', 'ALL', null, 'THRESHOLD', null, null, false);
</sql>
</changeSet>
</databaseChangeLog>