feat(clerk-withdrawal): add maker-checker withdrawal disbursement API
- ClerkWithdrawalRequestApiResource: full CRUD for withdrawal requests - Endpoints: submit, list, myRequests, getById, getByToken, approve, reject, disburse, update - Token uniqueness validated per day (validateTokenUniqueToday) - Cashier disburse: amount match, race condition guard (WHERE status=APPROVED) - Disbursement denomination saved to m_savings_txn_denomination for vault position tracking - Liquibase migrations: 0321-0326 (create table, tok_no, manager fields, disbursement fields, drop unused counter) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
db94056144
commit
8d5b4d1ce9
+675
@@ -0,0 +1,675 @@
|
||||
/**
|
||||
* 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.clerkwithdrawal.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Path("/v1/clerk/withdrawal-requests")
|
||||
@Component
|
||||
@Tag(name = "Clerk Withdrawal Requests", description = "Maker-checker withdrawal request workflow for clerk and bank manager.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClerkWithdrawalRequestApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PlatformSecurityContext context;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
// ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ─────────
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String submit(final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
|
||||
final String accountNo = fromJsonHelper.extractStringNamed("accountNo", root);
|
||||
final String accountType = Optional.ofNullable(fromJsonHelper.extractStringNamed("accountType", root)).orElse("SAVINGS");
|
||||
final Long savingsAccountId = fromJsonHelper.extractLongNamed("savingsAccountId", root);
|
||||
final Long clientId = fromJsonHelper.extractLongNamed("clientId", root);
|
||||
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
|
||||
final BigDecimal amount = parseBigDecimal(fromJsonHelper.extractStringNamed("amount", root));
|
||||
final Long paymentTypeId = fromJsonHelper.extractLongNamed("paymentTypeId", root);
|
||||
final String paymentTypeName = fromJsonHelper.extractStringNamed("paymentTypeName", root);
|
||||
final String description = fromJsonHelper.extractStringNamed("description", root);
|
||||
final String chequeNo = fromJsonHelper.extractStringNamed("chequeNo", root);
|
||||
final String bankName = fromJsonHelper.extractStringNamed("bankName", root);
|
||||
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
|
||||
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
throw new IllegalArgumentException("Account number is required");
|
||||
}
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new IllegalArgumentException("Amount must be greater than zero");
|
||||
}
|
||||
if (tokNo == null || tokNo.isBlank()) {
|
||||
throw new IllegalArgumentException("Token number (TOK. NO.) is required");
|
||||
}
|
||||
validateTokenUniqueToday(tokNo, null);
|
||||
|
||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||
|
||||
final Long newId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_clerk_withdrawal_request
|
||||
(account_no, account_type, savings_account_id, client_id, client_name, amount,
|
||||
payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, txn_date,
|
||||
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class,
|
||||
accountNo, accountType, savingsAccountId, clientId, clientName, amount,
|
||||
paymentTypeId, paymentTypeName, description, chequeNo, bankName, tokNo,
|
||||
txnDate != null ? Date.valueOf(txnDate) : null,
|
||||
"PENDING",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
denominationData, activityLog,
|
||||
Timestamp.from(Instant.now())
|
||||
);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/withdrawal-requests ── Manager sees all requests ──────────
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list(
|
||||
@QueryParam("status") @DefaultValue("PENDING") final String status,
|
||||
@QueryParam("offset") @DefaultValue("0") final int offset,
|
||||
@QueryParam("limit") @DefaultValue("20") final int limit,
|
||||
@QueryParam("fromDate") final String fromDate,
|
||||
@QueryParam("toDate") final String toDate) {
|
||||
|
||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) >= ?");
|
||||
baseParams.add(Date.valueOf(fromDate));
|
||||
}
|
||||
if (toDate != null && !toDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) <= ?");
|
||||
baseParams.add(Date.valueOf(toDate));
|
||||
}
|
||||
|
||||
final long total = Optional.ofNullable(
|
||||
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request " + where, Long.class, baseParams.toArray())
|
||||
).orElse(0L);
|
||||
|
||||
final List<Object> pageParams = new ArrayList<>(baseParams);
|
||||
pageParams.add(limit);
|
||||
pageParams.add(offset);
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT id, account_no, account_type, savings_account_id, client_id, client_name,"
|
||||
+ " amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no,"
|
||||
+ " txn_date, status, clerk_id, clerk_name, clerk_username,"
|
||||
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
|
||||
+ " denomination_data, activity_log, submitted_at, actioned_at,"
|
||||
+ " cashier_id, disbursed_by, disbursed_at"
|
||||
+ " FROM m_clerk_withdrawal_request " + where
|
||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray());
|
||||
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("totalFilteredRecords", total);
|
||||
result.add("pageItems", gson.toJsonTree(rows));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/withdrawal-requests/my ── Clerk's own requests ─────────────
|
||||
|
||||
@GET
|
||||
@Path("/my")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String myRequests(
|
||||
@QueryParam("status") final String status,
|
||||
@QueryParam("offset") @DefaultValue("0") final int offset,
|
||||
@QueryParam("limit") @DefaultValue("20") final int limit,
|
||||
@QueryParam("fromDate") final String fromDate,
|
||||
@QueryParam("toDate") final String toDate) {
|
||||
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final StringBuilder where = new StringBuilder("WHERE clerk_id = ?");
|
||||
final List<Object> params = new ArrayList<>(List.of(currentUser.getId()));
|
||||
|
||||
if (status != null && !status.isBlank()) {
|
||||
where.append(" AND status = ?");
|
||||
params.add(status);
|
||||
}
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) >= ?");
|
||||
params.add(Date.valueOf(fromDate));
|
||||
}
|
||||
if (toDate != null && !toDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) <= ?");
|
||||
params.add(Date.valueOf(toDate));
|
||||
}
|
||||
|
||||
final long total = Optional.ofNullable(
|
||||
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request " + where, Long.class, params.toArray())
|
||||
).orElse(0L);
|
||||
|
||||
final List<Object> pageParams = new ArrayList<>(params);
|
||||
pageParams.add(limit);
|
||||
pageParams.add(offset);
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"""
|
||||
SELECT id, account_no, account_type, client_name, amount,
|
||||
payment_type_name, description, tok_no, txn_date, status,
|
||||
clerk_name, manager_name, manager_username, rejection_reason, txn_id,
|
||||
submitted_at, actioned_at,
|
||||
cashier_id, disbursed_by, disbursed_at
|
||||
FROM m_clerk_withdrawal_request
|
||||
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray()
|
||||
);
|
||||
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("totalFilteredRecords", total);
|
||||
result.add("pageItems", gson.toJsonTree(rows));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/withdrawal-requests/{id} ── Full detail ───────────────────
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getById(@PathParam("id") final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||
amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no,
|
||||
txn_date, status, clerk_id, clerk_name, clerk_username,
|
||||
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
||||
denomination_data, activity_log, submitted_at, actioned_at,
|
||||
cashier_id, disbursed_by, disbursed_at
|
||||
FROM m_clerk_withdrawal_request WHERE id = ?
|
||||
""", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new NotFoundException("Withdrawal request not found: " + id);
|
||||
}
|
||||
return gson.toJson(rows.get(0));
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/withdrawal-requests/by-token/{token} ── Cashier token lookup ──
|
||||
|
||||
@GET
|
||||
@Path("/by-token/{token}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getByToken(@PathParam("token") final String token) {
|
||||
// Query without status filter to give specific error per status
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||
amount, payment_type_id, payment_type_name, description, tok_no, status,
|
||||
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at
|
||||
FROM m_clerk_withdrawal_request
|
||||
WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE
|
||||
ORDER BY submitted_at DESC LIMIT 1
|
||||
""", token);
|
||||
|
||||
if (rows.isEmpty()) {
|
||||
throw tokenError(404, "Token '" + token + "' not found for today. Please check the token number.");
|
||||
}
|
||||
|
||||
final String status = String.valueOf(rows.get(0).get("status"));
|
||||
return switch (status) {
|
||||
case "APPROVED" -> gson.toJson(rows.get(0));
|
||||
case "PENDING" -> throw tokenError(422,
|
||||
"Token '" + token + "' is still PENDING — manager has not approved this request yet.");
|
||||
case "DISBURSED" -> throw tokenError(422,
|
||||
"Token '" + token + "' has already been disbursed. Cash was handed over by another cashier.");
|
||||
case "REJECTED" -> throw tokenError(422,
|
||||
"Token '" + token + "' was REJECTED by the manager. This withdrawal cannot be processed.");
|
||||
default -> throw tokenError(422,
|
||||
"Token '" + token + "' has unexpected status: " + status);
|
||||
};
|
||||
}
|
||||
|
||||
private WebApplicationException tokenError(final int httpStatus, final String message) {
|
||||
final String body = gson.toJson(Map.of("message", message, "httpStatus", httpStatus));
|
||||
return new WebApplicationException(
|
||||
Response.status(httpStatus).entity(body).type(MediaType.APPLICATION_JSON).build()
|
||||
);
|
||||
}
|
||||
|
||||
// ── PUT /v1/clerk/withdrawal-requests/{id} ── Clerk edits rejected request ──
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String update(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
if (!"REJECTED".equals(record.get("status"))) {
|
||||
throw new IllegalStateException("Only REJECTED requests can be edited. Current status: " + record.get("status"));
|
||||
}
|
||||
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
final BigDecimal amount = parseBigDecimal(fromJsonHelper.extractStringNamed("amount", root));
|
||||
final Long paymentTypeId = fromJsonHelper.extractLongNamed("paymentTypeId", root);
|
||||
final String paymentTypeName = fromJsonHelper.extractStringNamed("paymentTypeName", root);
|
||||
final String description = fromJsonHelper.extractStringNamed("description", root);
|
||||
final String chequeNo = fromJsonHelper.extractStringNamed("chequeNo", root);
|
||||
final String bankName = fromJsonHelper.extractStringNamed("bankName", root);
|
||||
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
|
||||
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new IllegalArgumentException("Amount must be greater than zero");
|
||||
}
|
||||
if (tokNo == null || tokNo.isBlank()) {
|
||||
throw new IllegalArgumentException("Token number (TOK. NO.) is required");
|
||||
}
|
||||
validateTokenUniqueToday(tokNo, id);
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "RESUBMITTED", currentUser.getUsername(), null
|
||||
);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_clerk_withdrawal_request SET
|
||||
amount = ?, payment_type_id = ?, payment_type_name = ?,
|
||||
description = ?, cheque_no = ?, bank_name = ?, tok_no = ?, txn_date = ?,
|
||||
denomination_data = ?, status = 'PENDING',
|
||||
rejection_reason = NULL, manager_id = NULL, manager_name = NULL, manager_username = NULL,
|
||||
activity_log = ?, submitted_at = ?, actioned_at = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
amount, paymentTypeId, paymentTypeName,
|
||||
description, chequeNo, bankName, tokNo,
|
||||
txnDate != null ? Date.valueOf(txnDate) : null,
|
||||
denominationData,
|
||||
updatedLog, Timestamp.from(Instant.now()), id
|
||||
);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "PENDING"));
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/withdrawal-requests/{id}/approve ── Manager approves ──────
|
||||
|
||||
@POST
|
||||
@Path("/{id}/approve")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String approve(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw new IllegalStateException("Only PENDING requests can be approved. Current status: " + record.get("status"));
|
||||
}
|
||||
|
||||
final Long savingsAccountId = record.get("savings_account_id") != null
|
||||
? ((Number) record.get("savings_account_id")).longValue() : null;
|
||||
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
|
||||
final Object ptId = record.get("payment_type_id");
|
||||
final Object txnDateObj = record.get("txn_date");
|
||||
final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null;
|
||||
final String description = (String) record.get("description");
|
||||
|
||||
String txnId = null;
|
||||
if (savingsAccountId != null) {
|
||||
txnId = executeSavingsWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
}
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||
);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_clerk_withdrawal_request SET
|
||||
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
txn_id = ?, activity_log = ?, actioned_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
txnId, updatedLog, Timestamp.from(Instant.now()), id
|
||||
);
|
||||
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("resourceId", id);
|
||||
res.addProperty("status", "APPROVED");
|
||||
if (txnId != null) res.addProperty("txnId", txnId);
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/withdrawal-requests/{id}/reject ── Manager rejects ────────
|
||||
|
||||
@POST
|
||||
@Path("/{id}/reject")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String reject(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw new IllegalStateException("Only PENDING requests can be rejected. Current status: " + record.get("status"));
|
||||
}
|
||||
|
||||
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
|
||||
final String reason = fromJsonHelper.extractStringNamed("reason", root);
|
||||
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("Rejection reason is required");
|
||||
}
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "REJECTED", currentUser.getUsername(), reason
|
||||
);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_clerk_withdrawal_request SET
|
||||
status = 'REJECTED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
rejection_reason = ?, activity_log = ?, actioned_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
reason, updatedLog, Timestamp.from(Instant.now()), id
|
||||
);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/withdrawal-requests/{id}/disburse ── Cashier confirms cash disbursement ──
|
||||
|
||||
@POST
|
||||
@Path("/{id}/disburse")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String disburse(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"APPROVED".equals(record.get("status"))) {
|
||||
throw new IllegalStateException(
|
||||
"Only APPROVED requests can be disbursed. Current status: " + record.get("status"));
|
||||
}
|
||||
|
||||
String denominationData = null;
|
||||
BigDecimal transactionAmount = null;
|
||||
if (body != null && !body.isBlank()) {
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||
transactionAmount = parseBigDecimal(fromJsonHelper.extractStringNamed("transactionAmount", root));
|
||||
}
|
||||
|
||||
// transactionAmount is mandatory — must always be provided
|
||||
if (transactionAmount == null || transactionAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"transactionAmount is required and must be greater than zero for disbursement."
|
||||
);
|
||||
}
|
||||
|
||||
// Amount must exactly match the approved withdrawal — no tolerance for manipulation
|
||||
final BigDecimal approvedAmount = new BigDecimal(record.get("amount").toString());
|
||||
if (approvedAmount.compareTo(transactionAmount) != 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"Transaction amount ₹" + transactionAmount
|
||||
+ " does not match approved withdrawal amount ₹" + approvedAmount
|
||||
+ ". Cash denomination total must equal the approved amount exactly."
|
||||
);
|
||||
}
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "DISBURSED", currentUser.getUsername(), null
|
||||
);
|
||||
|
||||
// WHERE status = 'APPROVED' prevents race condition — only one cashier can win
|
||||
final int updated = jdbcTemplate.update("""
|
||||
UPDATE m_clerk_withdrawal_request SET
|
||||
status = 'DISBURSED', cashier_id = ?, disbursed_by = ?,
|
||||
disbursement_denomination_data = ?, activity_log = ?, disbursed_at = ?
|
||||
WHERE id = ? AND status = 'APPROVED'
|
||||
""",
|
||||
currentUser.getId(), currentUser.getDisplayName(),
|
||||
denominationData, updatedLog, Timestamp.from(Instant.now()), id
|
||||
);
|
||||
|
||||
if (updated == 0) {
|
||||
throw new IllegalStateException(
|
||||
"This withdrawal has already been disbursed by another cashier. Double disbursement prevented."
|
||||
);
|
||||
}
|
||||
|
||||
// Record denomination breakdown in m_savings_txn_denomination so cashier vault position updates
|
||||
if (denominationData != null && !denominationData.isBlank()) {
|
||||
saveDisbursementDenomination(denominationData, record, currentUser);
|
||||
}
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the cashier's denomination breakdown for a disbursement into m_savings_txn_denomination.
|
||||
* denominationData format: {"note:500": 2, "coin:1": 10} (kind:value → count)
|
||||
* This ensures the cashier's vault position report reflects the cash given out.
|
||||
*/
|
||||
private void saveDisbursementDenomination(final String denominationData,
|
||||
final Map<?, ?> record, final AppUser cashier) {
|
||||
final com.google.gson.JsonObject denomMap;
|
||||
try {
|
||||
denomMap = gson.fromJson(denominationData, com.google.gson.JsonObject.class);
|
||||
} catch (Exception e) {
|
||||
return; // malformed JSON — skip silently
|
||||
}
|
||||
if (denomMap == null || denomMap.size() == 0) return;
|
||||
|
||||
final Object txnIdObj = record.get("txn_id");
|
||||
final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null;
|
||||
if (txnId == null) return; // no savings txn linked — cannot record denomination
|
||||
|
||||
final Object saObj = record.get("savings_account_id");
|
||||
final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null;
|
||||
final String accountNo = (String) record.get("account_no");
|
||||
final Object clientIdObj = record.get("client_id");
|
||||
final Long clientId = clientIdObj != null ? Long.valueOf(clientIdObj.toString()) : null;
|
||||
|
||||
for (final Map.Entry<String, com.google.gson.JsonElement> entry : denomMap.entrySet()) {
|
||||
final int count;
|
||||
try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; }
|
||||
if (count <= 0) continue;
|
||||
|
||||
final String[] parts = entry.getKey().split(":", 2); // "note:500" or "coin:1"
|
||||
if (parts.length != 2) continue;
|
||||
final String kind = parts[0];
|
||||
final int value;
|
||||
try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; }
|
||||
|
||||
// Resolve denomination_id from master
|
||||
final List<Long> ids = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ?"
|
||||
+ " AND (deleted IS NULL OR deleted = false) LIMIT 1",
|
||||
Long.class, value, kind);
|
||||
if (ids.isEmpty()) continue;
|
||||
final Long denomId = ids.get(0);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_savings_txn_denomination
|
||||
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?)
|
||||
""",
|
||||
txnId, savingsAccountId, accountNo, clientId, denomId,
|
||||
value, count, BigDecimal.valueOf((long) value * count),
|
||||
cashier.getId(), cashier.getUsername()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||
amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no,
|
||||
txn_date, status, clerk_id, clerk_name, clerk_username,
|
||||
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
||||
denomination_data, activity_log, submitted_at, actioned_at,
|
||||
cashier_id, disbursed_by, disbursed_at
|
||||
FROM m_clerk_withdrawal_request WHERE id = ?
|
||||
""", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new NotFoundException("Withdrawal request not found: " + id);
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private String executeSavingsWithdrawal(
|
||||
final Long savingsAccountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", date);
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
if (paymentTypeId != null) {
|
||||
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||
}
|
||||
if (note != null && !note.isBlank()) {
|
||||
payload.addProperty("note", note);
|
||||
}
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||
.savingsAccountWithdrawal(savingsAccountId)
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
private String formatForFineract(final String isoDate) {
|
||||
try {
|
||||
final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
} catch (Exception e) {
|
||||
return LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal parseBigDecimal(final String raw) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
try {
|
||||
return new BigDecimal(raw);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the token number is unique for today's date among non-rejected requests.
|
||||
* {@code excludeId} is set during resubmit to skip the record being edited.
|
||||
*/
|
||||
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
||||
final StringBuilder sql = new StringBuilder(
|
||||
"SELECT COUNT(*) FROM m_clerk_withdrawal_request "
|
||||
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
params.add(tokNo);
|
||||
if (excludeId != null) {
|
||||
sql.append(" AND id <> ?");
|
||||
params.add(excludeId);
|
||||
}
|
||||
final long count = Optional.ofNullable(
|
||||
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
||||
).orElse(0L);
|
||||
if (count > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
||||
}
|
||||
}
|
||||
|
||||
private String buildActivityLog(final String existingJson, final String action,
|
||||
final String username, final String note) {
|
||||
JsonArray log = new JsonArray();
|
||||
if (existingJson != null && !existingJson.isBlank()) {
|
||||
try {
|
||||
log = fromJsonHelper.parse(existingJson).getAsJsonArray();
|
||||
} catch (Exception e) {
|
||||
log = new JsonArray();
|
||||
}
|
||||
}
|
||||
|
||||
final JsonObject entry = new JsonObject();
|
||||
entry.addProperty("timestamp", Instant.now().toString());
|
||||
entry.addProperty("action", action);
|
||||
entry.addProperty("user", username);
|
||||
if (note != null && !note.isBlank()) {
|
||||
entry.addProperty("note", note);
|
||||
}
|
||||
log.add(entry);
|
||||
return gson.toJson(log);
|
||||
}
|
||||
}
|
||||
@@ -348,4 +348,10 @@
|
||||
<include file="parts/0318_allow_nullable_savings_account_denomination.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0319_allow_nullable_txn_id_denomination.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0320_add_payment_type_to_denomination.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0321_create_clerk_withdrawal_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0322_add_tok_no_to_clerk_withdrawal_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0323_add_manager_name_to_clerk_withdrawal_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0324_add_daily_token_counter.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0325_add_disbursement_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0326_drop_daily_token_counter.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
<?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.1.xsd">
|
||||
|
||||
<changeSet id="0321_create_clerk_withdrawal_request" author="CBS">
|
||||
<createTable tableName="m_clerk_withdrawal_request">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="account_no" type="VARCHAR(50)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="account_type" type="VARCHAR(20)" defaultValue="SAVINGS">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="savings_account_id" type="BIGINT"/>
|
||||
<column name="client_id" type="BIGINT"/>
|
||||
<column name="client_name" type="VARCHAR(255)"/>
|
||||
<column name="amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="payment_type_id" type="INT"/>
|
||||
<column name="payment_type_name" type="VARCHAR(100)"/>
|
||||
<column name="description" type="TEXT"/>
|
||||
<column name="cheque_no" type="VARCHAR(50)"/>
|
||||
<column name="bank_name" type="VARCHAR(100)"/>
|
||||
<column name="txn_date" type="DATE"/>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="clerk_id" type="BIGINT"/>
|
||||
<column name="clerk_name" type="VARCHAR(255)"/>
|
||||
<column name="clerk_username" type="VARCHAR(100)"/>
|
||||
<column name="manager_id" type="BIGINT"/>
|
||||
<column name="manager_username" type="VARCHAR(100)"/>
|
||||
<column name="rejection_reason" type="TEXT"/>
|
||||
<column name="txn_id" type="VARCHAR(100)"/>
|
||||
<column name="denomination_data" type="TEXT"/>
|
||||
<column name="activity_log" type="TEXT"/>
|
||||
<column name="submitted_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="actioned_at" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
|
||||
<createIndex tableName="m_clerk_withdrawal_request" indexName="idx_cwr_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_clerk_withdrawal_request" indexName="idx_cwr_clerk_id">
|
||||
<column name="clerk_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_clerk_withdrawal_request" indexName="idx_cwr_submitted_at">
|
||||
<column name="submitted_at"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_clerk_withdrawal_request" indexName="idx_cwr_savings_account_id">
|
||||
<column name="savings_account_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?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.1.xsd">
|
||||
|
||||
<changeSet id="0322_add_tok_no_to_clerk_withdrawal_request" author="CBS">
|
||||
<addColumn tableName="m_clerk_withdrawal_request">
|
||||
<column name="tok_no" type="VARCHAR(50)"/>
|
||||
</addColumn>
|
||||
|
||||
<createIndex tableName="m_clerk_withdrawal_request" indexName="idx_cwr_tok_no">
|
||||
<column name="tok_no"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<?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.1.xsd">
|
||||
|
||||
<changeSet id="0323_add_manager_name_to_clerk_withdrawal_request" author="CBS">
|
||||
<addColumn tableName="m_clerk_withdrawal_request">
|
||||
<column name="manager_name" type="VARCHAR(150)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?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">
|
||||
|
||||
<changeSet id="0324_add_daily_token_counter" author="CBS">
|
||||
<createTable tableName="m_daily_token_counter">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="counter_date" type="DATE">
|
||||
<constraints nullable="false" unique="true"/>
|
||||
</column>
|
||||
<column name="last_token" type="INTEGER" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?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">
|
||||
|
||||
<changeSet id="0325_add_disbursement_fields" author="CBS">
|
||||
<addColumn tableName="m_clerk_withdrawal_request">
|
||||
<column name="cashier_id" type="BIGINT"/>
|
||||
<column name="disbursed_by" type="VARCHAR(150)"/>
|
||||
<column name="disbursed_at" type="TIMESTAMP"/>
|
||||
<column name="disbursement_denomination_data" type="TEXT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<?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">
|
||||
|
||||
<changeSet id="0326_drop_daily_token_counter" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_daily_token_counter"/>
|
||||
</preConditions>
|
||||
<dropTable tableName="m_daily_token_counter"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user