feat(shares, cashier): enforce Share Collection entry type on listing, single receipt fetch, product summary
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 10 of 10) (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 (main, cucumber) (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 E2E Tests / E2E Tests (Shard 1 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 6 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 - 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-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 Messaging Smoke Tests / Smoke Test with Kafka (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-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

Only vouchers tagged with the "Share Collection" entry type reach a share
account. The adoption path already checked this, but the listing did not, so an
already-adopted "General" voucher kept reappearing at Apply Collection and Apply
Additional Shares time. SHARE_COLLECTION_TAGGED_ONLY now filters the listing
query itself, against both the cashier-deposit and clerk-disbursement request
tables.

/unattributed takes an optional clientId and answers with both lists in one
response -- that member's own unapplied receipts under "own", every other
available receipt under "rows", with the member's own excluded from the latter.
The two endpoints read the same table under the same filters, so asking twice
only ever fetched the same rows twice, and keeping the query in two places is
what let the entry-type filter land on one and miss the other. Both now share
unappliedFor().

The entry fee is never posted a second time. A receipt already sitting in an
INCOME GL was recognised as income by the cashier's own entry when it was taken,
so applying the collection now skips both legs for it and counts it only towards
verifying the fee was collected.

Adds GET /sharereports/product-summary: authorised, issued, available and unpaid
shares per product, with the distinctive range and account count.

Adds a series-overlap pre-check to sponsor DD inventory registration, which
previously surfaced as a bare duplicate-key error.

Adds migration 0568 for the columns the clerk share-withdrawal settlement path
reads (settlement_mode, tok_no, cash_settlement_txn_id, cashier_id, disbursed_by,
disbursed_at, disbursement_denomination_data), each changeset guarded so it is a
no-op where the column already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:58:40 +05:30
co-authored by Claude Opus 5
parent 6046134b51
commit dd4bb4a1c0
5 changed files with 330 additions and 20 deletions
@@ -97,12 +97,42 @@ public class ShareAdvanceCollectionApiResource {
private static final int DEBIT = 2;
private static final int CREDIT = 1;
// ── acc_gl_account.classification_enum ─────────────────────────────────
// Fineract's GLAccountType: 1 = ASSET, 2 = LIABILITY, 3 = EQUITY, 4 = INCOME, 5 = EXPENSE.
private static final int GL_CLASSIFICATION_INCOME = 4;
// ── CashAccountsForShares financial_account_type ───────────────────────
private static final int FA_SHARES_REFERENCE = 1;
private static final int FA_SHARES_SUSPENSE = 2;
private static final int FA_INCOME_FROM_FEES = 3;
private static final int FA_SHARES_EQUITY = 4;
/**
* Restricts a {@code m_share_advance_collection c} query to receipts whose money was actually
* collected FOR a share collection, and nothing else.
*
* <p>
* The Entry Type the counter picks at save time is the only thing that says what a GL receipt was
* taken for, so it decides this too: a receipt left General, or tagged for some other purpose
* entirely, never reaches an Apply Collection or Additional Shares screen. Guarding only the
* adoption path was not enough -- a receipt adopted before that guard existed stayed on these
* lists for ever, which is exactly how a General voucher kept reappearing against share accounts.
*
* <p>
* A collection with no voucher of its own is the one thing this lets through: those come from the
* direct Share Advance Collection endpoint, which exists for nothing but share collections, so
* there is no counter tag to read and none needed.
*/
private static final String SHARE_COLLECTION_TAGGED_ONLY = """
AND (c.voucher_no IS NULL
OR EXISTS (SELECT 1 FROM m_cashier_deposit_request d
JOIN m_code_value dcv ON dcv.id = d.entry_type_cv_id
WHERE d.voucher_no = c.voucher_no AND dcv.code_value = 'Share Collection')
OR EXISTS (SELECT 1 FROM m_clerk_disbursement_request r
JOIN m_code_value rcv ON rcv.id = r.entry_type_cv_id
WHERE r.voucher_no = c.voucher_no AND rcv.code_value = 'Share Collection'))
""";
// ── acc_product_mapping.product_type for shares ────────────────────────
private static final int PRODUCT_TYPE_SHARES = 4;
@@ -198,6 +228,21 @@ public class ShareAdvanceCollectionApiResource {
// needs the by-voucher search below, same as before.
autoAdoptShareCollectionTagged();
final List<Map<String, Object>> rows = unappliedFor(clientId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("unappliedCollections", rows);
out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
return Response.ok(gson.toJson(out)).build();
}
/**
* Unapplied receipts ready to be ticked, for one member or for everybody.
*
* Defined once and read by both this endpoint and {@link #listUnattributed}, which returns the
* same rows under {@code own} so an Apply screen needs a single call rather than two that were
* only ever querying the same table with the same filters.
*/
private List<Map<String, Object>> unappliedFor(final Long clientId) {
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
+ " cl.display_name AS client_name, "
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name,"
@@ -207,17 +252,13 @@ public class ShareAdvanceCollectionApiResource {
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
+ " ORDER BY je.id LIMIT 1) AS gl_code "
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' ");
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' "
+ SHARE_COLLECTION_TAGGED_ONLY);
if (clientId != null) {
sql.append(" AND c.client_id = ").append(clientId);
}
sql.append(" ORDER BY c.collection_date, c.id");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString());
final Map<String, Object> out = new LinkedHashMap<>();
out.put("unappliedCollections", rows);
out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
return Response.ok(gson.toJson(out)).build();
return jdbcTemplate.queryForList(sql.toString());
}
// ══════════════════════════════════════════════════════════════════════
@@ -253,9 +294,17 @@ public class ShareAdvanceCollectionApiResource {
* mismatch rather than silently moving the money -- this list only makes the voucher visible, it
* does not reassign it.
*/
/**
* @param clientId
* the member whose own Apply screen this is. Their own unapplied receipts come back
* separately under {@code own} -- the same rows, in the same shape, that
* {@link #listUnapplied} returns -- so a screen needs ONE call rather than two, and
* those rows are left out of {@code rows} so the same receipt is never listed twice on
* one screen. Omit it and everything available comes back under {@code rows}.
*/
@GET
@Path("/unattributed")
public Response listUnattributed() {
public Response listUnattributed(@QueryParam("clientId") final Long clientId) {
this.context.authenticatedUser();
// Same eager conversion as listUnapplied() -- run unconditionally (no client filter) so a
@@ -311,10 +360,15 @@ public class ShareAdvanceCollectionApiResource {
+ " WHERE c.status = 'UNAPPLIED'"
+ " AND ((cdr.id IS NULL AND clr.id IS NULL)"
+ " OR COALESCE(cdr_cv.code_value, clr_cv.code_value) = 'Share Collection')"
// This member's own receipts come back under "own" below, ready to tick; listing
// them here as well would put the same receipt on screen twice, once needing a
// tick and once an Attach, for no difference in what it is.
+ (clientId != null ? " AND c.client_id <> " + clientId : "")
+ " ORDER BY \"txnDate\" DESC");
final Map<String, Object> out = new LinkedHashMap<>();
out.put("rows", rows);
out.put("own", clientId == null ? List.of() : unappliedFor(clientId));
return Response.ok(gson.toJson(out)).build();
}
@@ -653,28 +707,47 @@ public class ShareAdvanceCollectionApiResource {
// that had never received it, and left the account that had holding the balance for ever.
// Debiting each receipt's own account is also what the clerk sees: the GL picked against the
// voucher on the apply screen is the account being cleared here.
//
// A receipt that already sits in an INCOME-classified GL is left exactly where it is, not
// debited out here at all -- an entry fee the cashier deposited straight into its own income
// account (the ordinary case: "Share Entry", "Income From Fees", whatever the chart calls it)
// was already recognised as income the moment it was banked. Debiting it out and crediting a
// fresh income leg below for the same rupees would recognise it a second time -- the
// duplicate a counter tagging every entry-fee GL as Income now stops before it is ever posted.
// A ticket genuinely sitting in a non-income account (Share Sundry, most often -- the legacy
// single-receipt case where share value and fee arrived together) still needs clearing, so it
// is debited as before and its amount still moves to Income below.
BigDecimal feeAlreadyRecognised = BigDecimal.ZERO;
for (final Map<String, Object> row : collections) {
final Long receiptGl = receiptGlOf((String) row.get("gl_transaction_id"), glSuspense);
insertEntry(officeId, receiptGl, DEBIT, (BigDecimal) row.get("amount"), txnId, entryDate, shareAccountId, userId, now,
currencyCode, "Share application settled | Receipt " + row.get("receipt_no") + " | " + accountNo);
final BigDecimal rowAmount = (BigDecimal) row.get("amount");
if (chargeColl == null && isIncomeGl(receiptGl)) {
feeAlreadyRecognised = feeAlreadyRecognised.add(rowAmount);
continue;
}
insertEntry(officeId, receiptGl, DEBIT, rowAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share application settled | Receipt " + row.get("receipt_no") + " | " + accountNo);
}
// Cr Share Capital for the share value.
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share capital issued for " + accountNo);
// Cr Income from Fees for the entry fee, but only when it was NOT verified as its own receipt
// above -- that money already sits in whichever GL the cashier deposited it to (often the fee
// income account itself), so moving it again here would double it. When it was bundled into
// the same Sundry receipt(s) as the share value (chargeColl == null), it genuinely still needs
// to move, exactly as before.
// Cr Income from Fees for the entry fee, but only for whatever part of it is not already
// sitting recognised as income (feeAlreadyRecognised, resolved above) -- that part already
// sits in whichever GL the cashier deposited it to, so moving it again here would double it.
// When it was bundled into the same Sundry receipt(s) as the share value (chargeColl == null)
// and genuinely never touched an income account, it still needs to move, exactly as before.
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
if (chargeColl == null) {
// Resolved from the share product's "Income from Fees" mapping, which points at the
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share entry fee for " + accountNo);
final BigDecimal feeStillToRecognise = chargeAmount.subtract(feeAlreadyRecognised);
if (feeStillToRecognise.compareTo(BigDecimal.ZERO) > 0) {
// Resolved from the share product's "Income from Fees" mapping, which points at the
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, glIncome, CREDIT, feeStillToRecognise, txnId, entryDate, shareAccountId, userId, now,
currencyCode, "Share entry fee for " + accountNo);
}
}
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
@@ -1182,6 +1255,24 @@ public class ShareAdvanceCollectionApiResource {
return rows.isEmpty() ? fallbackGlId : rows.get(0);
}
/**
* Whether a GL account is already an INCOME account (Fineract's own {@code classification_enum}
* for the "Income" account type on {@code acc_gl_account}).
*
* <p>
* Used to tell an entry-fee receipt that already sits recognised as income (deposited straight
* into its own income GL by the cashier) apart from one still sitting in a holding account like
* Share Sundry — only the latter still needs moving into income here.
*/
private boolean isIncomeGl(final Long glAccountId) {
if (glAccountId == null) {
return false;
}
final Integer classification = jdbcTemplate.queryForObject(
"SELECT classification_enum FROM acc_gl_account WHERE id = ?", Integer.class, glAccountId);
return classification != null && classification == GL_CLASSIFICATION_INCOME;
}
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
final Long glId = resolveGl(productId, financialAccountType);
if (glId == null) {
@@ -2182,6 +2182,90 @@ public class ShareReportsApiResource {
return args.toArray();
}
// ══════════════════════════════════════════════════════════════════════
// GET /product-summary — each share class against its own authorised capital
// ══════════════════════════════════════════════════════════════════════
/**
* Every share product's authorised capital measured against what has actually been paid up —
* how many of its shares are issued, how many remain, and the distinctive-number range issued so
* far — the numbers a board asks for when it wants to know how close a share class is to its own
* ceiling.
*
* <p>
* "Issued" here is paid-up shares, computed the same way every other report in this file computes
* them (settled purchases less redemptions), not {@code m_share_product.totalsubscribed_shares}.
* That column is Fineract's own running count of shares allotted, updated the moment an
* application is submitted — before any money has necessarily arrived — so it already includes
* the allotments this whole file exists to separate out. A board reading "202 of 1,000 issued"
* when the GL Share Capital account shows only 152 shares' worth of money received would be
* planning against a ceiling that is not really as close as the platform's own figure claims.
*/
@GET
@Path("/product-summary")
@Operation(summary = "Share capital issued and remaining, by product",
description = "Authorised shares, paid-up shares issued, shares remaining, unpaid allotments and the distinctive-number range, per share product.")
public Response productSummary() {
this.context.authenticatedUser();
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT sp.id AS "productId",
sp.name AS "productName",
sp.short_name AS "productShortName",
sp.unit_price AS "unitPrice",
sp.minimum_client_shares AS "minimumClientShares",
sp.nominal_client_shares AS "nominalClientShares",
sp.maximum_client_shares AS "maximumClientShares",
COALESCE(sp.total_shares, 0) AS "authorisedShares",
COALESCE(sp.total_shares, 0) * COALESCE(sp.unit_price, 0) AS "authorisedCapital",
COALESCE(issued.shares, 0) AS "issuedShares",
COALESCE(issued.shares, 0) * COALESCE(sp.unit_price, 0) AS "issuedCapital",
COALESCE(sp.total_shares, 0) - COALESCE(issued.shares, 0) AS "availableShares",
COALESCE(unpaid.accounts, 0) AS "unpaidAccounts",
COALESCE(unpaid.shares, 0) AS "unpaidShares",
COALESCE(unpaid.amount, 0) AS "unpaidAmount",
CASE WHEN COALESCE(issued.ever_allotted, 0) > 0 THEN 1 END AS "distinctiveFrom",
NULLIF(COALESCE(issued.ever_allotted, 0), 0) AS "distinctiveTo",
COALESCE(acct."accountCount", 0) AS "accountCount",
COALESCE(acct."memberCount", 0) AS "memberCount"
FROM m_share_product sp
LEFT JOIN (
SELECT sa.product_id,
SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END)
- SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares,
SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS ever_allotted
FROM m_share_account_transactions t
JOIN m_share_account sa ON sa.id = t.account_id
WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d)
AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR sa.collection_applied = true)
GROUP BY sa.product_id
) issued ON issued.product_id = sp.id
LEFT JOIN (
SELECT sa.product_id,
COUNT(*) AS accounts,
SUM(COALESCE(t.total_shares, 0)) AS shares,
SUM(COALESCE(t.amount, 0)) AS amount
FROM m_share_account_transactions t
JOIN m_share_account sa ON sa.id = t.account_id
WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum = %d
AND t.funding_source IS NULL AND COALESCE(sa.collection_applied, false) = false
AND sa.status_enum <> %d
GROUP BY sa.product_id
) unpaid ON unpaid.product_id = sp.id
LEFT JOIN (
SELECT sa.product_id, COUNT(*) AS "accountCount", COUNT(DISTINCT sa.client_id) AS "memberCount"
FROM m_share_account sa
WHERE sa.status_enum = %d
GROUP BY sa.product_id
) acct ON acct.product_id = sp.id
ORDER BY sp.name
""".formatted(TYPE_PURCHASED, TYPE_REDEEMED, TYPE_PURCHASED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED,
TXN_APPROVED, TYPE_PURCHASED, ACCOUNT_REJECTED, TXN_APPROVED));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("rows", rows);
return Response.ok(gson.toJson(out)).build();
}
private long toLong(final Object value) {
return value instanceof Number number ? number.longValue() : 0L;
}
@@ -108,6 +108,21 @@ public class SponsorDDWritePlatformServiceImpl implements SponsorDDWritePlatform
throw new IllegalArgumentException("Series start number must be less than or equal to series end number.");
}
// dd_number is globally unique across every inventory batch (sdd_leaf_unq_dd_number), but
// nothing before this checked a fresh batch's own range against numbers already registered --
// the first colliding number failed the bulk insert below with a raw duplicate-key exception,
// rolling the whole batch back and leaving the counter reading a database stack trace instead
// of the actual problem: this range overlaps one already on file.
final List<String> overlapping = this.jdbcTemplate.queryForList(
"SELECT dd_number FROM m_sponsor_dd_leaflet WHERE dd_number ~ '^[0-9]+$' "
+ " AND dd_number::bigint BETWEEN ? AND ? ORDER BY dd_number::bigint LIMIT 5",
String.class, seriesStart, seriesEnd);
if (!overlapping.isEmpty()) {
throw new IllegalArgumentException("Series " + seriesStart + "-" + seriesEnd
+ " overlaps DD number(s) already registered in another inventory batch (for example " + overlapping.get(0)
+ "). Choose a series that does not overlap an existing one.");
}
final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId)
.orElseThrow(() -> new IllegalArgumentException("Sponsor Bank not found with ID: " + sponsorBankId));
final Office branch = this.officeRepositoryWrapper.findOneWithNotFoundDetection(branchId);
@@ -113,4 +113,5 @@
<include file="parts/0565_convert_entry_type_to_code_value.xml" relativeToChangelogFile="true"/>
<include file="parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0567_create_share_transfer.xml" relativeToChangelogFile="true"/>
<include file="parts/0568_add_settlement_mode_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,119 @@
<?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">
<!-- These columns back ClerkShareWithdrawalRequestApiResource's SAVINGS / GL / NONE settlement
modes and cash-token disbursement, and were already sitting on some environments (local dev
included) from an earlier iteration of this same feature, added there directly rather than
through a tracked migration. That earlier iteration's own experimental columns were since
reverted out of the Java code, but settlement_mode itself was kept and given real, current
meaning by the GL/NONE settlement work (see ClerkShareWithdrawalRequestApiResource); no
migration ever existed to create it on a fresh database, which is exactly what left it
missing wherever these columns were never manually added. Every changeset below is
independently guarded, so this reconciles any environment regardless of which of these
columns it already happens to have. -->
<changeSet author="fineract" id="0568-1-add-settlement-mode">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_mode"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="settlement_mode" type="VARCHAR(10)" defaultValue="SAVINGS">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0568-2-add-tok-no">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="tok_no"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="tok_no" type="VARCHAR(50)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0568-3-add-tok-no-index">
<preConditions onFail="MARK_RAN">
<not>
<indexExists tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_tok_no"/>
</not>
</preConditions>
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_tok_no">
<column name="tok_no"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0568-4-add-cash-settlement-txn-id">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="cash_settlement_txn_id"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="cash_settlement_txn_id" type="VARCHAR(100)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0568-5-add-cashier-id">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="cashier_id"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="cashier_id" type="BIGINT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0568-6-add-disbursed-by">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="disbursed_by"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="disbursed_by" type="VARCHAR(100)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0568-7-add-disbursed-at">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="disbursed_at"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="disbursed_at" type="TIMESTAMP">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0568-8-add-disbursement-denomination-data">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="disbursement_denomination_data"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="disbursement_denomination_data" type="TEXT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>