Merge remote-tracking branch 'origin/feature/switch-integration-backend' into feature/account-charges
This commit is contained in:
+5
@@ -146,6 +146,11 @@ public class SecurityConfig {
|
||||
auth.requestMatchers(API_MATCHER.matcher(HttpMethod.OPTIONS, "/api/**")).permitAll()
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/echo")).permitAll()
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/authentication")).permitAll()
|
||||
// IMPS receiver — Fineract acts as the bank-side endpoint under
|
||||
// IMPS_BANK_API_SPEC_CBS_1.0 §6.2.x. LincPay (the switch) calls these without
|
||||
// a Fineract Bearer token; security is enforced at the JWT envelope
|
||||
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll()
|
||||
// Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed,
|
||||
// no Fineract auth required.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll()
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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.infrastructure.imps.api;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.function.Function;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.imps.crypto.ImpsCryptoUtil;
|
||||
import org.apache.fineract.infrastructure.imps.crypto.ImpsEnvelope;
|
||||
import org.apache.fineract.infrastructure.imps.crypto.ImpsJwtUtil;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsRequestData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsResponseData;
|
||||
import org.apache.fineract.infrastructure.imps.service.ImpsCbsService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Receiver-side IMPS CBS endpoints — Fineract acting as the bank under
|
||||
* IMPS_BANK_API_SPEC_CBS_1.0 §6. Each endpoint:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Reads the {@link ImpsEnvelope} ({@code data}, {@code signature}).</li>
|
||||
* <li>Verifies the inbound JWT via {@link ImpsJwtUtil}.</li>
|
||||
* <li>Decrypts {@code data} via {@link ImpsCryptoUtil}.</li>
|
||||
* <li>Hands the typed {@link ImpsRequestData} to {@link ImpsCbsService}.</li>
|
||||
* <li>Encrypts the response, signs an outbound JWT, returns the envelope.</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Path("/v1/imps")
|
||||
@Component
|
||||
@Tag(name = "IMPS CBS (Bank Side)", description = "Receiver-side endpoints for IMPS_BANK_API_SPEC_CBS_1.0 §6.2.")
|
||||
@RequiredArgsConstructor
|
||||
public class ImpsCbsApiResource {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ImpsCbsApiResource.class);
|
||||
|
||||
private final ImpsCryptoUtil cryptoUtil;
|
||||
private final ImpsJwtUtil jwtUtil;
|
||||
private final ImpsCbsService cbsService;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@POST
|
||||
@Path("/ReqPay/v1/urn:txnid:{txnid}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "IMPS ReqPay (§6.2.1)")
|
||||
public Response reqPay(@PathParam("txnid") String txnid, ImpsEnvelope body) {
|
||||
return handle("ReqPay", txnid, body, cbsService::reqPay);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/ReqValAdd/v1/urn:txnid:{txnid}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "IMPS ReqValAdd (§6.2.3)")
|
||||
public Response reqValAdd(@PathParam("txnid") String txnid, ImpsEnvelope body) {
|
||||
return handle("ReqValAdd", txnid, body, cbsService::reqValAdd);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/ReqChkTxn/v1/urn:txnid:{txnid}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "IMPS ReqChkTxn (§6.2.2)")
|
||||
public Response reqChkTxn(@PathParam("txnid") String txnid, ImpsEnvelope body) {
|
||||
return handle("ReqChkTxn", txnid, body, cbsService::reqChkTxn);
|
||||
}
|
||||
|
||||
private Response handle(String api, String txnid, ImpsEnvelope body, Function<ImpsRequestData, ImpsResponseData> handler) {
|
||||
log.info("[ImpsCbs] Inbound {} txnid={}", api, txnid);
|
||||
try {
|
||||
if (!jwtUtil.verifyJwtSignature(body.getSignature())) {
|
||||
log.warn("[ImpsCbs] {} txnid={} — JWT verification FAILED", api, txnid);
|
||||
return Response.status(Response.Status.BAD_REQUEST).entity(new ImpsEnvelope("invalid-signature", "")).build();
|
||||
}
|
||||
|
||||
String decrypted = cryptoUtil.decryptJson(body.getData());
|
||||
if (decrypted == null) {
|
||||
log.warn("[ImpsCbs] {} txnid={} — decrypt FAILED", api, txnid);
|
||||
return Response.status(Response.Status.BAD_REQUEST).entity(new ImpsEnvelope("decrypt-failed", "")).build();
|
||||
}
|
||||
|
||||
ImpsRequestData request = objectMapper.readValue(decrypted, ImpsRequestData.class);
|
||||
ImpsResponseData response = handler.apply(request);
|
||||
String respJson = objectMapper.writeValueAsString(response);
|
||||
log.info("[ImpsCbs] {} txnid={} responding status={} responseCode={}", api, txnid, response.getStatus(),
|
||||
response.getResponseCode());
|
||||
|
||||
String encResp = cryptoUtil.encryptJson(respJson);
|
||||
String jwt = jwtUtil.signPayloadAsJwt(respJson);
|
||||
return Response.ok(new ImpsEnvelope(encResp, jwt)).build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[ImpsCbs] {} txnid={} unhandled error: {}", api, txnid, e.toString(), e);
|
||||
return Response.serverError().entity(new ImpsEnvelope("error: " + e.getMessage(), "")).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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.infrastructure.imps.client;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
import org.apache.fineract.infrastructure.imps.crypto.ImpsCryptoUtil;
|
||||
import org.apache.fineract.infrastructure.imps.crypto.ImpsEnvelope;
|
||||
import org.apache.fineract.infrastructure.imps.crypto.ImpsJwtUtil;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsRequestData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsResponseData;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Outbound IMPS calls — Fineract acting as the bank initiating ReqPay / ReqValAdd /
|
||||
* ReqChkTxn against the LincPay switch.
|
||||
*
|
||||
* <p>Each call: build {@link ImpsRequestData} → encrypt with LincPay public key →
|
||||
* sign JWT with CBS private key → POST envelope → verify response JWT → decrypt
|
||||
* response body → parse to {@link ImpsResponseData}.
|
||||
*
|
||||
* <p>The crypto utilities are symmetric across inbound and outbound: encryptJson
|
||||
* uses the peer's public key (LincPay), decryptJson uses our private key (CBS).
|
||||
* No additional key configuration is needed beyond what's already set for the
|
||||
* inbound receiver.
|
||||
*/
|
||||
@Service
|
||||
public class ImpsSwitchClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ImpsSwitchClient.class);
|
||||
private static final MediaType JSON = MediaType.get("application/json");
|
||||
|
||||
@Value("${fineract.imps.switch.base-url:}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${fineract.imps.switch.timeout-ms:10000}")
|
||||
private int timeoutMs;
|
||||
|
||||
private final ImpsCryptoUtil cryptoUtil;
|
||||
private final ImpsJwtUtil jwtUtil;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private OkHttpClient httpClient;
|
||||
|
||||
public ImpsSwitchClient(ImpsCryptoUtil cryptoUtil, ImpsJwtUtil jwtUtil) {
|
||||
this.cryptoUtil = cryptoUtil;
|
||||
this.jwtUtil = jwtUtil;
|
||||
}
|
||||
|
||||
private OkHttpClient client() {
|
||||
if (httpClient == null) {
|
||||
httpClient = new OkHttpClient.Builder().connectTimeout(Duration.ofMillis(timeoutMs))
|
||||
.readTimeout(timeoutMs, TimeUnit.MILLISECONDS).writeTimeout(timeoutMs, TimeUnit.MILLISECONDS).build();
|
||||
}
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
public ImpsResponseData reqPay(String txnId, ImpsRequestData payload) {
|
||||
return invoke("IMPS", "ReqPay", txnId, payload);
|
||||
}
|
||||
|
||||
public ImpsResponseData reqValAdd(String txnId, ImpsRequestData payload) {
|
||||
return invoke("IMPS", "ReqValAdd", txnId, payload);
|
||||
}
|
||||
|
||||
public ImpsResponseData reqChkTxn(String txnId, ImpsRequestData payload) {
|
||||
return invoke("IMPS", "ReqChkTxn", txnId, payload);
|
||||
}
|
||||
|
||||
public ImpsResponseData neftPay(String txnId, ImpsRequestData payload) {
|
||||
return invoke("NEFT", "ReqPay", txnId, payload);
|
||||
}
|
||||
|
||||
public ImpsResponseData neftChkTxn(String txnId, ImpsRequestData payload) {
|
||||
return invoke("NEFT", "ReqChkTxn", txnId, payload);
|
||||
}
|
||||
|
||||
public ImpsResponseData rtgsPay(String txnId, ImpsRequestData payload) {
|
||||
return invoke("RTGS", "ReqPay", txnId, payload);
|
||||
}
|
||||
|
||||
public ImpsResponseData rtgsChkTxn(String txnId, ImpsRequestData payload) {
|
||||
return invoke("RTGS", "ReqChkTxn", txnId, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort recall on the switch. Real-world semantics vary by channel:
|
||||
* IMPS uses a dispute / chargeback flow rather than a synchronous recall;
|
||||
* NEFT supports {@code ReqDirectRet} for return-on-no-credit; RTGS recall
|
||||
* is rare and usually manual. Callers should treat exceptions as soft
|
||||
* failures and proceed with local reversal.
|
||||
*/
|
||||
public ImpsResponseData recall(String channel, String txnId, ImpsRequestData payload) {
|
||||
return invoke(channel, "ReqRecall", txnId, payload);
|
||||
}
|
||||
|
||||
private ImpsResponseData invoke(String channel, String api, String txnId, ImpsRequestData payload) {
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
throw new IllegalStateException("fineract.imps.switch.base-url is not set");
|
||||
}
|
||||
try {
|
||||
String plainBody = objectMapper.writeValueAsString(payload);
|
||||
String encrypted = cryptoUtil.encryptJson(plainBody);
|
||||
String signature = jwtUtil.signPayloadAsJwt(plainBody);
|
||||
ImpsEnvelope env = new ImpsEnvelope(encrypted, signature);
|
||||
String envJson = objectMapper.writeValueAsString(env);
|
||||
|
||||
String url = baseUrl.replaceAll("/+$", "") + "/" + channel + "/" + api + "/v1/urn:txnid:" + txnId;
|
||||
log.info("[ImpsSwitchClient] {} {} txnId={} → POST {}", channel, api, txnId, url);
|
||||
|
||||
Request req = new Request.Builder().url(url).post(RequestBody.create(envJson, JSON)).build();
|
||||
try (Response resp = client().newCall(req).execute()) {
|
||||
String respBody = bodyAsString(resp.body());
|
||||
if (!resp.isSuccessful()) {
|
||||
log.warn("[ImpsSwitchClient] {} {} txnId={} HTTP {} body={}", channel, api, txnId, resp.code(), respBody);
|
||||
throw new IOException("Switch returned HTTP " + resp.code());
|
||||
}
|
||||
|
||||
ImpsEnvelope respEnv = objectMapper.readValue(respBody, ImpsEnvelope.class);
|
||||
if (!jwtUtil.verifyJwtSignature(respEnv.getSignature())) {
|
||||
throw new IllegalStateException("Response JWT signature verification failed");
|
||||
}
|
||||
String decrypted = cryptoUtil.decryptJson(respEnv.getData());
|
||||
if (decrypted == null) {
|
||||
throw new IllegalStateException("Response payload decrypt failed");
|
||||
}
|
||||
log.info("[ImpsSwitchClient] {} {} txnId={} ← decrypted response", channel, api, txnId);
|
||||
return objectMapper.readValue(decrypted, ImpsResponseData.class);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(channel + " " + api + " call failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String bodyAsString(ResponseBody body) throws IOException {
|
||||
return body == null ? "" : body.string();
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 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.infrastructure.imps.crypto;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyStore;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.util.Base64;
|
||||
import java.util.Enumeration;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Encryption / decryption matching IMPS_BANK_API_SPEC_CBS_1.0 §4.3.
|
||||
*
|
||||
* <p>Wire format: {@code <base64(wrappedAESKey)>:<base64(iv)>:<base64(cipherText)>}.
|
||||
* AES-256-GCM with a 12-byte IV and a 128-bit tag; the AES key is wrapped via
|
||||
* RSA-OAEP-SHA256.
|
||||
*
|
||||
* <h3>Key directions</h3>
|
||||
* <ul>
|
||||
* <li><b>Inbound</b>: {@link #decryptJson(String)} — unwrap the AES key with
|
||||
* the CBS private key loaded from {@code fineract.imps.cbs.private-key-path}.</li>
|
||||
* <li><b>Outbound</b>: {@link #encryptJson(String)} — wrap the AES key with
|
||||
* the LincPay public key loaded from
|
||||
* {@code fineract.imps.lincpay.public-key-path}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Keys are loaded once at startup in {@link #init()}.
|
||||
*/
|
||||
@Component
|
||||
public class ImpsCryptoUtil {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ImpsCryptoUtil.class);
|
||||
|
||||
private static final String AES = "AES";
|
||||
private static final String AES_GCM = "AES/GCM/NoPadding";
|
||||
private static final String RSA = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
|
||||
private static final int GCM_TAG_LENGTH = 128;
|
||||
private static final int AES_KEY_SIZE = 256;
|
||||
private static final int IV_SIZE = 12;
|
||||
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
@Value("${fineract.imps.cbs.private-key-path:}")
|
||||
private String cbsPrivateKeyPath;
|
||||
|
||||
@Value("${fineract.imps.cbs.private-key-password:}")
|
||||
private String cbsPrivateKeyPassword;
|
||||
|
||||
@Value("${fineract.imps.lincpay.public-key-path:}")
|
||||
private String lincpayPublicKeyPath;
|
||||
|
||||
private PrivateKey cbsPrivateKey;
|
||||
private PublicKey lincpayPublicKey;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
if (cbsPrivateKeyPath == null || cbsPrivateKeyPath.isBlank()) {
|
||||
log.info("[ImpsCryptoUtil] fineract.imps.cbs.private-key-path not set — CBS private key NOT loaded");
|
||||
} else {
|
||||
try {
|
||||
cbsPrivateKey = loadFirstPrivateKey(cbsPrivateKeyPath, cbsPrivateKeyPassword);
|
||||
log.info("[ImpsCryptoUtil] CBS private key loaded from {}", cbsPrivateKeyPath);
|
||||
} catch (Exception e) {
|
||||
log.error("[ImpsCryptoUtil] Failed to load CBS private key from {}: {}", cbsPrivateKeyPath, e.toString());
|
||||
}
|
||||
}
|
||||
if (lincpayPublicKeyPath == null || lincpayPublicKeyPath.isBlank()) {
|
||||
log.info("[ImpsCryptoUtil] fineract.imps.lincpay.public-key-path not set — LincPay public key NOT loaded");
|
||||
} else {
|
||||
try {
|
||||
lincpayPublicKey = loadPublicKey(lincpayPublicKeyPath);
|
||||
log.info("[ImpsCryptoUtil] LincPay public key loaded from {}", lincpayPublicKeyPath);
|
||||
} catch (Exception e) {
|
||||
log.error("[ImpsCryptoUtil] Failed to load LincPay public key from {}: {}", lincpayPublicKeyPath, e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt inbound payload using the CBS private key. */
|
||||
public String decryptJson(String encryptedPayload) {
|
||||
if (cbsPrivateKey == null) {
|
||||
throw new IllegalStateException("CBS private key not loaded — check fineract.imps.cbs.private-key-path");
|
||||
}
|
||||
try {
|
||||
String[] parts = encryptedPayload.split(":");
|
||||
if (parts.length != 3) {
|
||||
log.error("Invalid encrypted payload format — expected 3 colon-separated parts");
|
||||
return null;
|
||||
}
|
||||
byte[] iv = Base64.getDecoder().decode(parts[1]);
|
||||
SecretKey aesKey = unwrapAESKey(parts[0], cbsPrivateKey);
|
||||
if (aesKey == null) {
|
||||
return null;
|
||||
}
|
||||
return decryptData(parts[2], aesKey, iv);
|
||||
} catch (Exception e) {
|
||||
log.error("Error decrypting inbound payload", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Encrypt outbound payload using the LincPay public key. */
|
||||
public String encryptJson(String json) {
|
||||
if (lincpayPublicKey == null) {
|
||||
throw new IllegalStateException("LincPay public key not loaded — check fineract.imps.lincpay.public-key-path");
|
||||
}
|
||||
try {
|
||||
SecretKey aesKey = generateAESKey();
|
||||
if (aesKey == null) {
|
||||
return null;
|
||||
}
|
||||
byte[] iv = generateIV();
|
||||
String cipherText = encryptData(json, aesKey, iv);
|
||||
String wrappedKey = wrapAESKey(aesKey, lincpayPublicKey);
|
||||
if (cipherText == null || wrappedKey == null) {
|
||||
return null;
|
||||
}
|
||||
return wrappedKey + ":" + Base64.getEncoder().encodeToString(iv) + ":" + cipherText;
|
||||
} catch (Exception e) {
|
||||
log.error("Error encrypting outbound payload", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Exposed for {@link ImpsJwtUtil} — sign outbound JWT with the same CBS private key. */
|
||||
public PrivateKey getCbsPrivateKey() {
|
||||
return cbsPrivateKey;
|
||||
}
|
||||
|
||||
/** Exposed for {@link ImpsJwtUtil} — verify inbound JWT with the LincPay public key. */
|
||||
public PublicKey getLincpayPublicKey() {
|
||||
return lincpayPublicKey;
|
||||
}
|
||||
|
||||
private static SecretKey generateAESKey() throws Exception {
|
||||
KeyGenerator keyGen = KeyGenerator.getInstance(AES);
|
||||
keyGen.init(AES_KEY_SIZE);
|
||||
return keyGen.generateKey();
|
||||
}
|
||||
|
||||
private static byte[] generateIV() {
|
||||
byte[] iv = new byte[IV_SIZE];
|
||||
SECURE_RANDOM.nextBytes(iv);
|
||||
return iv;
|
||||
}
|
||||
|
||||
private static String wrapAESKey(SecretKey aesKey, PublicKey publicKey) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(RSA);
|
||||
cipher.init(Cipher.WRAP_MODE, publicKey);
|
||||
return Base64.getEncoder().encodeToString(cipher.wrap(aesKey));
|
||||
} catch (Exception e) {
|
||||
log.error("Error wrapping AES key", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static SecretKey unwrapAESKey(String wrappedBase64, PrivateKey privateKey) {
|
||||
try {
|
||||
byte[] wrapped = Base64.getDecoder().decode(wrappedBase64);
|
||||
Cipher cipher = Cipher.getInstance(RSA);
|
||||
cipher.init(Cipher.UNWRAP_MODE, privateKey);
|
||||
return (SecretKey) cipher.unwrap(wrapped, AES, Cipher.SECRET_KEY);
|
||||
} catch (Exception e) {
|
||||
log.error("Error unwrapping AES key", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String encryptData(String plain, SecretKey aesKey, byte[] iv) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(AES_GCM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||
byte[] enc = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(enc);
|
||||
} catch (Exception e) {
|
||||
log.error("Error encrypting data", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String decryptData(String encBase64, SecretKey aesKey, byte[] iv) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(AES_GCM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||
byte[] dec = cipher.doFinal(Base64.getDecoder().decode(encBase64));
|
||||
return new String(dec, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
log.error("Error decrypting data", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static PrivateKey loadFirstPrivateKey(String p12Path, String password) throws Exception {
|
||||
char[] pwd = password != null ? password.toCharArray() : null;
|
||||
try (FileInputStream fis = new FileInputStream(p12Path)) {
|
||||
KeyStore ks = KeyStore.getInstance("PKCS12");
|
||||
ks.load(fis, pwd);
|
||||
Enumeration<String> aliases = ks.aliases();
|
||||
while (aliases.hasMoreElements()) {
|
||||
String alias = aliases.nextElement();
|
||||
if (ks.isKeyEntry(alias)) {
|
||||
return (PrivateKey) ks.getKey(alias, pwd);
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("No private-key entry in keystore at " + p12Path);
|
||||
}
|
||||
}
|
||||
|
||||
private static PublicKey loadPublicKey(String crtPath) throws Exception {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
try (InputStream is = new BufferedInputStream(new FileInputStream(crtPath))) {
|
||||
Certificate cert = cf.generateCertificate(is);
|
||||
return cert.getPublicKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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.infrastructure.imps.crypto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Wire envelope shared with the LincPay IMPS gateway:
|
||||
* {@code { "data": "<encrypted JSON>", "signature": "<RS256 JWT>" } }.
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ImpsEnvelope {
|
||||
|
||||
private String data;
|
||||
private String signature;
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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.infrastructure.imps.crypto;
|
||||
|
||||
import com.nimbusds.jose.JOSEObjectType;
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.JWSHeader;
|
||||
import com.nimbusds.jose.JWSSigner;
|
||||
import com.nimbusds.jose.JWSVerifier;
|
||||
import com.nimbusds.jose.crypto.RSASSASigner;
|
||||
import com.nimbusds.jose.crypto.RSASSAVerifier;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.util.Date;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* JWT helpers for IMPS_BANK_API_SPEC_CBS_1.0 §4.2.
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Verify</b> inbound LincPay-signed JWT using the LincPay public key
|
||||
* loaded by {@link ImpsCryptoUtil}.</li>
|
||||
* <li><b>Sign</b> outbound JWT (RS256, 1h TTL, payload in {@code "data"}
|
||||
* claim) using the CBS private key loaded by {@link ImpsCryptoUtil}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@link #verifyJwtSignature(String)} returns {@code true} when the LincPay
|
||||
* public key is not configured — useful for smoke testing the encrypt/decrypt
|
||||
* path before both certs are wired up.
|
||||
*/
|
||||
@Component
|
||||
public class ImpsJwtUtil {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ImpsJwtUtil.class);
|
||||
|
||||
private static final long JWT_TTL_MILLIS = 3600_000L;
|
||||
|
||||
private final ImpsCryptoUtil cryptoUtil;
|
||||
|
||||
public ImpsJwtUtil(ImpsCryptoUtil cryptoUtil) {
|
||||
this.cryptoUtil = cryptoUtil;
|
||||
}
|
||||
|
||||
/** Sign a JSON payload — payload carried in the {@code "data"} claim. */
|
||||
public String signPayloadAsJwt(String jsonPayload) {
|
||||
PrivateKey privateKey = cryptoUtil.getCbsPrivateKey();
|
||||
if (privateKey == null) {
|
||||
log.warn("[ImpsJwtUtil] CBS private key not loaded — outbound JWT signing SKIPPED");
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
JWSSigner signer = new RSASSASigner(privateKey);
|
||||
JWTClaimsSet claims = new JWTClaimsSet.Builder().claim("data", jsonPayload).issueTime(new Date())
|
||||
.expirationTime(new Date(System.currentTimeMillis() + JWT_TTL_MILLIS)).build();
|
||||
SignedJWT signedJWT = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).type(JOSEObjectType.JWT).build(), claims);
|
||||
signedJWT.sign(signer);
|
||||
return signedJWT.serialize();
|
||||
} catch (Exception e) {
|
||||
log.error("[ImpsJwtUtil] Error signing outbound JWT: {}", e.toString(), e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify an inbound JWT against the LincPay public key. */
|
||||
public boolean verifyJwtSignature(String jwtToken) {
|
||||
if (jwtToken == null || jwtToken.isBlank()) {
|
||||
log.warn("[ImpsJwtUtil] Inbound JWT is blank — verification skipped (signature missing)");
|
||||
return true;
|
||||
}
|
||||
PublicKey pub = cryptoUtil.getLincpayPublicKey();
|
||||
if (pub == null) {
|
||||
log.warn("[ImpsJwtUtil] LincPay public key not loaded — verification skipped (DEV MODE)");
|
||||
return true;
|
||||
}
|
||||
if (!(pub instanceof RSAPublicKey rsaPub)) {
|
||||
log.error("[ImpsJwtUtil] LincPay public key is not RSA (type={}) — verification FAILED", pub.getAlgorithm());
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
SignedJWT signedJWT = SignedJWT.parse(jwtToken);
|
||||
JWSVerifier verifier = new RSASSAVerifier(rsaPub);
|
||||
return signedJWT.verify(verifier);
|
||||
} catch (Exception e) {
|
||||
log.error("[ImpsJwtUtil] Verification error: {}", e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.infrastructure.imps.data;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Account block under {@code payer.account} / {@code payee.account}. */
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class AccountData {
|
||||
|
||||
private String accountNumber;
|
||||
private String ifsc;
|
||||
private String accountType;
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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.infrastructure.imps.data;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Union of the decrypted inbound request fields across the three IMPS APIs
|
||||
* (ReqPay §6.2.1, ReqValAdd §6.2.3, ReqChkTxn §6.2.2). Fields not present in a
|
||||
* given API stay {@code null}.
|
||||
*
|
||||
* <p>Covers the fields the simulator extracted via {@code JsonNode} dotted paths.
|
||||
* Extend with additional spec fields (e.g. {@code remarks}, {@code merchantCode},
|
||||
* {@code signature}) as the IMPS_BANK_API_SPEC_CBS_1.0 §6 schemas are consulted.
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ImpsRequestData {
|
||||
|
||||
private String txnId;
|
||||
private String orgTxnId;
|
||||
private String refId;
|
||||
private String utr;
|
||||
private String amount;
|
||||
private PartyData payer;
|
||||
private PartyData payee;
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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.infrastructure.imps.data;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Union of CBS response fields across RespPay / RespValAdd / RespChkTxn per
|
||||
* IMPS_BANK_API_SPEC_CBS_1.0 §6.2.x. {@code @JsonInclude(NON_NULL)} so each API
|
||||
* only emits the fields its spec section requires.
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ImpsResponseData {
|
||||
|
||||
private String txnId;
|
||||
private String orgTxnId;
|
||||
private String refId;
|
||||
private String timeStamp;
|
||||
private String utr;
|
||||
private String amount;
|
||||
private String status;
|
||||
private String responseCode;
|
||||
private String accountNumber;
|
||||
private String ifsc;
|
||||
private String maskName;
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.infrastructure.imps.data;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Payer / payee block on an IMPS request. */
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class PartyData {
|
||||
|
||||
private String name;
|
||||
private String mobile;
|
||||
private AccountData account;
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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.infrastructure.imps.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Lob;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
|
||||
/**
|
||||
* One row per inbound IMPS request received by Fineract acting as the CBS.
|
||||
* Records audit + idempotency state — a retried {@code txnId} should resolve
|
||||
* back to the same {@code status} / {@code responseCode} via {@code unique(txn_id, api)}.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_imps_transaction")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class ImpsTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
/** Which spec endpoint produced this row — REQ_PAY / REQ_VAL_ADD / REQ_CHK_TXN. */
|
||||
@Column(name = "api", nullable = false, length = 32)
|
||||
private String api;
|
||||
|
||||
@Column(name = "txn_id", nullable = false, length = 64)
|
||||
private String txnId;
|
||||
|
||||
@Column(name = "org_txn_id", length = 64)
|
||||
private String orgTxnId;
|
||||
|
||||
@Column(name = "ref_id", length = 64)
|
||||
private String refId;
|
||||
|
||||
@Column(name = "utr", length = 64)
|
||||
private String utr;
|
||||
|
||||
@Column(name = "amount", length = 32)
|
||||
private String amount;
|
||||
|
||||
@Column(name = "payee_account_number", length = 64)
|
||||
private String payeeAccountNumber;
|
||||
|
||||
@Column(name = "payee_ifsc", length = 32)
|
||||
private String payeeIfsc;
|
||||
|
||||
/** Resolved scenario — SUCCESS / DEEMED / FAILED. */
|
||||
@Column(name = "status", length = 16)
|
||||
private String status;
|
||||
|
||||
/** IMPS response code — 00 / 91 / XC / 8 / etc. */
|
||||
@Column(name = "response_code", length = 8)
|
||||
private String responseCode;
|
||||
|
||||
/** Decrypted request JSON, kept for audit / replay. */
|
||||
@Lob
|
||||
@Column(name = "request_json")
|
||||
private String requestJson;
|
||||
|
||||
/** Outbound response JSON before encryption, kept for audit. */
|
||||
@Lob
|
||||
@Column(name = "response_json")
|
||||
private String responseJson;
|
||||
|
||||
public static ImpsTransaction newRow(String api, String txnId) {
|
||||
ImpsTransaction t = new ImpsTransaction();
|
||||
t.api = api;
|
||||
t.txnId = txnId;
|
||||
return t;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.imps.domain;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ImpsTransactionRepository extends JpaRepository<ImpsTransaction, Long> {
|
||||
|
||||
Optional<ImpsTransaction> findFirstByApiAndTxnId(String api, String txnId);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.infrastructure.imps.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsRequestData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsResponseData;
|
||||
|
||||
/**
|
||||
* Receiver-side IMPS handler. Each method takes the decrypted request and
|
||||
* returns the unencrypted response body — the controller layer is responsible
|
||||
* for envelope encryption / signing around these calls.
|
||||
*/
|
||||
public interface ImpsCbsService {
|
||||
|
||||
ImpsResponseData reqPay(ImpsRequestData request);
|
||||
|
||||
ImpsResponseData reqValAdd(ImpsRequestData request);
|
||||
|
||||
ImpsResponseData reqChkTxn(ImpsRequestData request);
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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.infrastructure.imps.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.imps.data.AccountData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsRequestData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsResponseData;
|
||||
import org.apache.fineract.infrastructure.imps.data.PartyData;
|
||||
import org.apache.fineract.infrastructure.imps.domain.ImpsTransaction;
|
||||
import org.apache.fineract.infrastructure.imps.domain.ImpsTransactionRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* STUB implementation — returns hard-coded SUCCESS for every request and
|
||||
* persists an audit row per call. Replace the {@link #buildResponse} body
|
||||
* with real Fineract account lookup / debit-credit logic when that scope is
|
||||
* agreed.
|
||||
*
|
||||
* <p>Idempotency: a retried {@code (api, txnId)} short-circuits to the
|
||||
* previously-recorded response instead of running the handler twice.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ImpsCbsServiceImpl implements ImpsCbsService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ImpsCbsServiceImpl.class);
|
||||
private static final DateTimeFormatter ISO_OFFSET = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
|
||||
|
||||
private final ImpsTransactionRepository repository;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ImpsResponseData reqPay(ImpsRequestData request) {
|
||||
return handle("REQ_PAY", request, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ImpsResponseData reqValAdd(ImpsRequestData request) {
|
||||
return handle("REQ_VAL_ADD", request, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ImpsResponseData reqChkTxn(ImpsRequestData request) {
|
||||
return handle("REQ_CHK_TXN", request, false);
|
||||
}
|
||||
|
||||
private ImpsResponseData handle(String api, ImpsRequestData request, boolean echoIfsc) {
|
||||
Optional<ImpsTransaction> prior = repository.findFirstByApiAndTxnId(api, request.getTxnId());
|
||||
if (prior.isPresent()) {
|
||||
log.info("[ImpsCbsService] {} txnId={} idempotent replay — returning prior response", api, request.getTxnId());
|
||||
return readResponse(prior.get());
|
||||
}
|
||||
|
||||
ImpsResponseData response = buildResponse(api, request, echoIfsc);
|
||||
|
||||
ImpsTransaction row = ImpsTransaction.newRow(api, request.getTxnId());
|
||||
row.setOrgTxnId(request.getOrgTxnId());
|
||||
row.setRefId(request.getRefId());
|
||||
row.setUtr(request.getUtr());
|
||||
row.setAmount(request.getAmount());
|
||||
AccountData payeeAcc = Optional.ofNullable(request.getPayee()).map(PartyData::getAccount).orElse(null);
|
||||
if (payeeAcc != null) {
|
||||
row.setPayeeAccountNumber(payeeAcc.getAccountNumber());
|
||||
row.setPayeeIfsc(payeeAcc.getIfsc());
|
||||
}
|
||||
row.setStatus(response.getStatus());
|
||||
row.setResponseCode(response.getResponseCode());
|
||||
row.setRequestJson(writeJson(request));
|
||||
row.setResponseJson(writeJson(response));
|
||||
repository.save(row);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/** STUB — replace with real account lookup + debit/credit. */
|
||||
private ImpsResponseData buildResponse(String api, ImpsRequestData request, boolean echoIfsc) {
|
||||
ImpsResponseData resp = new ImpsResponseData();
|
||||
resp.setTxnId(request.getTxnId());
|
||||
resp.setOrgTxnId(request.getOrgTxnId());
|
||||
resp.setRefId(request.getRefId());
|
||||
resp.setUtr(request.getUtr());
|
||||
resp.setAmount(request.getAmount());
|
||||
resp.setTimeStamp(OffsetDateTime.now().format(ISO_OFFSET));
|
||||
resp.setStatus("SUCCESS");
|
||||
resp.setResponseCode("00");
|
||||
AccountData payeeAcc = Optional.ofNullable(request.getPayee()).map(PartyData::getAccount).orElse(null);
|
||||
if (payeeAcc != null) {
|
||||
resp.setAccountNumber(payeeAcc.getAccountNumber());
|
||||
if (echoIfsc) {
|
||||
resp.setIfsc(payeeAcc.getIfsc());
|
||||
}
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
private String writeJson(Object o) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(o);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("[ImpsCbsService] failed to serialize {} for audit: {}", o.getClass().getSimpleName(), e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private ImpsResponseData readResponse(ImpsTransaction row) {
|
||||
if (row.getResponseJson() == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(row.getResponseJson(), ImpsResponseData.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("[ImpsCbsService] failed to deserialize stored response for txnId={}: {}", row.getTxnId(), e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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.fundtransfer.api;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.TransferRequestData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.VerifyRequestData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.VerifyResponseData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.FundTransferReadPlatformService;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.FundTransferWritePlatformService;
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.BulkTransferRowResult;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.BulkTransferService;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.ImpsDeemedReconciliationService;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.NeftBatchProcessorService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Cashier-facing fund transfer endpoints. Flow:
|
||||
*
|
||||
* <ol>
|
||||
* <li>{@code POST /v1/transfers/verify} — BANL, returns verificationToken</li>
|
||||
* <li>{@code POST /v1/transfers} — redeems token, executes (or queues for approval)</li>
|
||||
* <li>{@code GET /v1/transfers/{ref}} — status</li>
|
||||
* <li>{@code POST /v1/transfers/{ref}/approve} — manager approves a pending row</li>
|
||||
* <li>{@code POST /v1/transfers/{ref}/reject} — manager rejects a pending row</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Path("/v1/transfers")
|
||||
@Component
|
||||
@Tag(name = "Fund Transfers", description = "Cashier-initiated INTERNAL / IMPS / NEFT / RTGS transfers.")
|
||||
@RequiredArgsConstructor
|
||||
public class FundTransferApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final FundTransferWritePlatformService writeService;
|
||||
private final FundTransferReadPlatformService readService;
|
||||
private final ImpsDeemedReconciliationService reconciliationService;
|
||||
private final NeftBatchProcessorService neftBatchProcessor;
|
||||
private final BulkTransferService bulkTransferService;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@POST
|
||||
@Path("/verify")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "BANL pre-step",
|
||||
description = "Validates the transfer, runs the beneficiary name lookup, and returns a verificationToken "
|
||||
+ "that must be presented at POST /v1/transfers. Token TTL is 300 seconds.")
|
||||
public VerifyResponseData verify(final String body) throws Exception {
|
||||
context.authenticatedUser().validateHasPermissionTo("CREATE_FUNDTRANSFER");
|
||||
VerifyRequestData request = objectMapper.readValue(body, VerifyRequestData.class);
|
||||
return writeService.verify(request);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Initiate transfer",
|
||||
description = "Redeems a verificationToken issued by /verify. Returns immediately with final status "
|
||||
+ "(AUTO path) or PENDING_APPROVAL (above maker-checker threshold).")
|
||||
public TransferResponseData create(final String body) throws Exception {
|
||||
context.authenticatedUser().validateHasPermissionTo("CREATE_FUNDTRANSFER");
|
||||
TransferRequestData request = objectMapper.readValue(body, TransferRequestData.class);
|
||||
return writeService.createFromToken(request.getVerificationToken(), request.getRemarks());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{ref}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Get transfer status by ref")
|
||||
public TransferResponseData findByRef(@PathParam("ref") final String txnRef) {
|
||||
context.authenticatedUser().validateHasPermissionTo("READ_FUNDTRANSFER");
|
||||
return readService.findByRef(txnRef);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{ref}/approve")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Manager approves a pending transfer")
|
||||
public TransferResponseData approve(@PathParam("ref") final String txnRef) {
|
||||
context.authenticatedUser().validateHasPermissionTo("APPROVE_FUNDTRANSFER");
|
||||
return writeService.approve(txnRef);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{ref}/reject")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Manager rejects a pending transfer")
|
||||
public TransferResponseData reject(@PathParam("ref") final String txnRef, final String body) throws Exception {
|
||||
context.authenticatedUser().validateHasPermissionTo("REJECT_FUNDTRANSFER");
|
||||
String reason = null;
|
||||
if (body != null && !body.isBlank()) {
|
||||
var node = objectMapper.readTree(body);
|
||||
if (node.hasNonNull("reason")) {
|
||||
reason = node.get("reason").asText();
|
||||
}
|
||||
}
|
||||
return writeService.reject(txnRef, reason);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{ref}/recall")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Recall an OFF_US transfer",
|
||||
description = "Mark the original transfer REVERSED and reverse the suspense parking. "
|
||||
+ "Only valid on OFF_US transfers in SUCCESS / DEEMED / QUEUED state. "
|
||||
+ "Production may also need to notify the beneficiary bank/switch — that's a phase 4 enhancement.")
|
||||
public TransferResponseData recall(@PathParam("ref") final String txnRef, final String body) throws Exception {
|
||||
context.authenticatedUser().validateHasPermissionTo("RECALL_FUNDTRANSFER");
|
||||
String reason = null;
|
||||
if (body != null && !body.isBlank()) {
|
||||
var node = objectMapper.readTree(body);
|
||||
if (node.hasNonNull("reason")) {
|
||||
reason = node.get("reason").asText();
|
||||
}
|
||||
}
|
||||
return writeService.recall(txnRef, reason);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/reconcile-deemed")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Trigger DEEMED reconciliation (admin / cron)",
|
||||
description = "Polls the IMPS switch via ReqChkTxn for every DEEMED transfer in the "
|
||||
+ "[24h ago, 30s ago] window and resolves to SUCCESS/FAILED. Call from ops "
|
||||
+ "cron until phase 2 wires this into the Quartz scheduler.")
|
||||
public String reconcileDeemed() {
|
||||
context.authenticatedUser().validateHasPermissionTo("APPROVE_FUNDTRANSFER");
|
||||
int touched = reconciliationService.reconcileAll();
|
||||
return "{\"reconciled\":" + touched + "}";
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/bulk")
|
||||
@Consumes("text/csv")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Bulk upload transfers via CSV",
|
||||
description = "Accepts a CSV body with header row "
|
||||
+ "'channel,payerAccountId,payeeAccountNumber,payeeIfsc,payeeName,amount,remarks'. "
|
||||
+ "Each row is verified and created independently; one bad row does not roll back the others.")
|
||||
public List<BulkTransferRowResult> bulk(final String csvBody) {
|
||||
context.authenticatedUser().validateHasPermissionTo("CREATE_FUNDTRANSFER");
|
||||
return bulkTransferService.process(csvBody);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/process-neft-batch")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Trigger NEFT batch cycle (admin / cron)",
|
||||
description = "Picks up every QUEUED NEFT transfer that has cleared maker-checker and "
|
||||
+ "sends each to the switch's /NEFT/ReqPay endpoint. Call from ops cron on the "
|
||||
+ "NEFT half-hourly schedule until phase 2.5 wires this into Quartz.")
|
||||
public NeftBatchProcessorService.BatchResult processNeftBatch() {
|
||||
context.authenticatedUser().validateHasPermissionTo("APPROVE_FUNDTRANSFER");
|
||||
return neftBatchProcessor.runBatch();
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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.fundtransfer.data;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** One row's outcome inside a bulk CSV upload. */
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class BulkTransferRowResult {
|
||||
|
||||
private int rowNumber;
|
||||
private String txnRef;
|
||||
private String status;
|
||||
private String approvalStatus;
|
||||
private String errorMessage;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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.fundtransfer.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Per-channel, per-customer effective limits — output of
|
||||
* {@code TransferLimitResolver.resolve(...)}. Null fields mean unbounded.
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class ResolvedLimits {
|
||||
|
||||
private BigDecimal perTxnMin;
|
||||
private BigDecimal perTxnMax;
|
||||
private BigDecimal perDayMax;
|
||||
private BigDecimal makerCheckerAbove;
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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.fundtransfer.data;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* POST /v1/transfers body — debit step. The verificationToken locks all
|
||||
* transfer params; only remarks can be set here.
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class TransferRequestData {
|
||||
|
||||
private String verificationToken;
|
||||
private String remarks;
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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.fundtransfer.data;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Response for POST /v1/transfers + GET /v1/transfers/{ref}. */
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class TransferResponseData {
|
||||
|
||||
private String txnRef;
|
||||
private String channel;
|
||||
private String direction;
|
||||
private String status;
|
||||
private String approvalStatus;
|
||||
private String responseCode;
|
||||
private String responseMessage;
|
||||
private String payeeAccountNumber;
|
||||
private String payeeIfsc;
|
||||
private String payeeMaskName;
|
||||
private BigDecimal amount;
|
||||
private String utr;
|
||||
private String switchTxnId;
|
||||
private LocalDateTime initiatedAt;
|
||||
private LocalDateTime settledAt;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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.fundtransfer.data;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** POST /v1/transfers/verify body — BANL step. */
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class VerifyRequestData {
|
||||
|
||||
private String channel;
|
||||
private Long payerAccountId;
|
||||
private String payeeAccountNumber;
|
||||
private String payeeIfsc;
|
||||
private String payeeName;
|
||||
private BigDecimal amount;
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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.fundtransfer.data;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Response from /v1/transfers/verify — masked name + token to present at debit. */
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class VerifyResponseData {
|
||||
|
||||
private String verificationToken;
|
||||
private long ttlSeconds;
|
||||
private String direction;
|
||||
private String payeeMaskName;
|
||||
private boolean makerCheckerRequired;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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.fundtransfer.domain;
|
||||
|
||||
public enum ApprovalStatus {
|
||||
AUTO,
|
||||
PENDING_APPROVAL,
|
||||
APPROVED,
|
||||
REJECTED
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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.fundtransfer.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
|
||||
/** Row in our bank's IFSC list — presence here means a payee.ifsc resolves ON-US. */
|
||||
@Entity
|
||||
@Table(name = "m_bank_ifsc")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class BankIfsc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
@Column(name = "ifsc_code", nullable = false, length = 11, unique = true)
|
||||
private String ifscCode;
|
||||
|
||||
@Column(name = "branch_code", length = 16)
|
||||
private String branchCode;
|
||||
|
||||
@Column(name = "branch_name")
|
||||
private String branchName;
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active = true;
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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.fundtransfer.domain;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface BankIfscRepository extends JpaRepository<BankIfsc, Long> {
|
||||
|
||||
Optional<BankIfsc> findByIfscCodeIgnoreCaseAndActiveTrue(String ifscCode);
|
||||
|
||||
boolean existsByIfscCodeIgnoreCaseAndActiveTrue(String ifscCode);
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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.fundtransfer.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
|
||||
/**
|
||||
* Per-customer override of per_txn_max / per_day_max for a channel. Null on
|
||||
* either column inherits the global default from {@link TransferLimitConfig}.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_customer_transfer_limit")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class CustomerTransferLimit extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
@Column(name = "client_id", nullable = false)
|
||||
private Long clientId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "channel", nullable = false, length = 16)
|
||||
private TransferChannel channel;
|
||||
|
||||
@Column(name = "per_txn_max", precision = 19, scale = 4)
|
||||
private BigDecimal perTxnMax;
|
||||
|
||||
@Column(name = "per_day_max", precision = 19, scale = 4)
|
||||
private BigDecimal perDayMax;
|
||||
|
||||
@Column(name = "effective_from", nullable = false)
|
||||
private LocalDate effectiveFrom;
|
||||
|
||||
@Column(name = "effective_to")
|
||||
private LocalDate effectiveTo;
|
||||
}
|
||||
+39
@@ -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.fundtransfer.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface CustomerTransferLimitRepository extends JpaRepository<CustomerTransferLimit, Long> {
|
||||
|
||||
@Query("""
|
||||
SELECT l FROM CustomerTransferLimit l
|
||||
WHERE l.clientId = :clientId
|
||||
AND l.channel = :channel
|
||||
AND l.effectiveFrom <= :asOf
|
||||
AND (l.effectiveTo IS NULL OR l.effectiveTo >= :asOf)
|
||||
ORDER BY l.effectiveFrom DESC
|
||||
""")
|
||||
Optional<CustomerTransferLimit> findEffective(@Param("clientId") Long clientId,
|
||||
@Param("channel") TransferChannel channel, @Param("asOf") LocalDate asOf);
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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.fundtransfer.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
|
||||
/** One row per outward fund transfer (INTERNAL / IMPS / NEFT / RTGS). */
|
||||
@Entity
|
||||
@Table(name = "m_fund_transfer")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class FundTransfer extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
@Column(name = "txn_ref", nullable = false, length = 64, unique = true)
|
||||
private String txnRef;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "channel", nullable = false, length = 16)
|
||||
private TransferChannel channel;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "direction", nullable = false, length = 8)
|
||||
private TransferDirection direction;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 24)
|
||||
private FundTransferStatus status;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "approval_status", nullable = false, length = 16)
|
||||
private ApprovalStatus approvalStatus = ApprovalStatus.AUTO;
|
||||
|
||||
@Column(name = "response_code", length = 8)
|
||||
private String responseCode;
|
||||
|
||||
@Column(name = "response_message")
|
||||
private String responseMessage;
|
||||
|
||||
@Column(name = "cashier_user_id", nullable = false)
|
||||
private Long cashierUserId;
|
||||
|
||||
@Column(name = "client_id", nullable = false)
|
||||
private Long clientId;
|
||||
|
||||
@Column(name = "payer_account_id", nullable = false)
|
||||
private Long payerAccountId;
|
||||
|
||||
@Column(name = "payer_account_number", nullable = false, length = 64)
|
||||
private String payerAccountNumber;
|
||||
|
||||
@Column(name = "payee_account_number", nullable = false, length = 64)
|
||||
private String payeeAccountNumber;
|
||||
|
||||
@Column(name = "payee_ifsc", length = 11)
|
||||
private String payeeIfsc;
|
||||
|
||||
@Column(name = "payee_name")
|
||||
private String payeeName;
|
||||
|
||||
@Column(name = "payee_mask_name")
|
||||
private String payeeMaskName;
|
||||
|
||||
@Column(name = "amount", nullable = false, precision = 19, scale = 4)
|
||||
private BigDecimal amount;
|
||||
|
||||
@Column(name = "remarks")
|
||||
private String remarks;
|
||||
|
||||
@Column(name = "internal_txn_id")
|
||||
private Long internalTxnId;
|
||||
|
||||
@Column(name = "switch_txn_id", length = 64)
|
||||
private String switchTxnId;
|
||||
|
||||
@Column(name = "utr", length = 64)
|
||||
private String utr;
|
||||
|
||||
@Column(name = "command_source_id")
|
||||
private Long commandSourceId;
|
||||
|
||||
@Column(name = "initiated_at", nullable = false)
|
||||
private LocalDateTime initiatedAt;
|
||||
|
||||
@Column(name = "settled_at")
|
||||
private LocalDateTime settledAt;
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.fundtransfer.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Lob;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Append-only audit row per state transition on a {@link FundTransfer}. */
|
||||
@Entity
|
||||
@Table(name = "m_fund_transfer_event")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class FundTransferEvent {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "fund_transfer_id", nullable = false)
|
||||
private Long fundTransferId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "from_status", length = 24)
|
||||
private FundTransferStatus fromStatus;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "to_status", nullable = false, length = 24)
|
||||
private FundTransferStatus toStatus;
|
||||
|
||||
@Column(name = "event", nullable = false, length = 48)
|
||||
private String event;
|
||||
|
||||
@Lob
|
||||
@Column(name = "payload_json")
|
||||
private String payloadJson;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "created_by")
|
||||
private Long createdBy;
|
||||
|
||||
public static FundTransferEvent of(Long fundTransferId, FundTransferStatus from, FundTransferStatus to,
|
||||
String event, String payloadJson, Long createdBy) {
|
||||
FundTransferEvent e = new FundTransferEvent();
|
||||
e.fundTransferId = fundTransferId;
|
||||
e.fromStatus = from;
|
||||
e.toStatus = to;
|
||||
e.event = event;
|
||||
e.payloadJson = payloadJson;
|
||||
e.createdAt = LocalDateTime.now();
|
||||
e.createdBy = createdBy;
|
||||
return e;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.fundtransfer.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface FundTransferEventRepository extends JpaRepository<FundTransferEvent, Long> {
|
||||
|
||||
List<FundTransferEvent> findByFundTransferIdOrderByCreatedAtAsc(Long fundTransferId);
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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.fundtransfer.domain;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface FundTransferRepository extends JpaRepository<FundTransfer, Long> {
|
||||
|
||||
Optional<FundTransfer> findByTxnRef(String txnRef);
|
||||
|
||||
/**
|
||||
* Sum of completed/in-flight transfer amounts for a payer on a channel
|
||||
* since the given timestamp. Used to enforce per_day_max.
|
||||
* Excludes FAILED and REJECTED rows.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT COALESCE(SUM(t.amount), 0) FROM FundTransfer t
|
||||
WHERE t.payerAccountId = :payerAccountId
|
||||
AND t.channel = :channel
|
||||
AND t.initiatedAt >= :since
|
||||
AND t.status NOT IN (org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus.FAILED,
|
||||
org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus.REVERSED)
|
||||
AND t.approvalStatus <> org.apache.fineract.portfolio.fundtransfer.domain.ApprovalStatus.REJECTED
|
||||
""")
|
||||
BigDecimal sumActiveOnChannelSince(@Param("payerAccountId") Long payerAccountId,
|
||||
@Param("channel") TransferChannel channel, @Param("since") LocalDateTime since);
|
||||
|
||||
/**
|
||||
* DEEMED transfers eligible for reconciliation — initiated between the floor (24h ago)
|
||||
* and ceiling (30s ago) so we don't poll a transfer that just landed or one that's
|
||||
* already aged out.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT t FROM FundTransfer t
|
||||
WHERE t.status = org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus.DEEMED
|
||||
AND t.initiatedAt BETWEEN :floor AND :ceiling
|
||||
ORDER BY t.initiatedAt ASC
|
||||
""")
|
||||
List<FundTransfer> findDeemedNeedingReconciliation(@Param("floor") LocalDateTime floor,
|
||||
@Param("ceiling") LocalDateTime ceiling);
|
||||
|
||||
/**
|
||||
* QUEUED NEFT transfers that have cleared maker-checker and are ready for the next
|
||||
* batch cycle. Excludes rows still PENDING_APPROVAL.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT t FROM FundTransfer t
|
||||
WHERE t.status = org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus.QUEUED
|
||||
AND t.channel = org.apache.fineract.portfolio.fundtransfer.domain.TransferChannel.NEFT
|
||||
AND t.approvalStatus IN (org.apache.fineract.portfolio.fundtransfer.domain.ApprovalStatus.AUTO,
|
||||
org.apache.fineract.portfolio.fundtransfer.domain.ApprovalStatus.APPROVED)
|
||||
ORDER BY t.initiatedAt ASC
|
||||
""")
|
||||
List<FundTransfer> findQueuedNeftReadyForBatch();
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.fundtransfer.domain;
|
||||
|
||||
public enum FundTransferStatus {
|
||||
INITIATED,
|
||||
VALIDATED,
|
||||
VERIFIED,
|
||||
/** NEFT-only: row created and awaiting the next batch cycle. */
|
||||
QUEUED,
|
||||
IN_PROGRESS,
|
||||
SUCCESS,
|
||||
DEEMED,
|
||||
FAILED,
|
||||
REVERSED
|
||||
}
|
||||
+33
@@ -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.fundtransfer.domain;
|
||||
|
||||
public enum TransferChannel {
|
||||
INTERNAL,
|
||||
IMPS,
|
||||
NEFT,
|
||||
RTGS;
|
||||
|
||||
public static TransferChannel from(String s) {
|
||||
if (s == null) {
|
||||
throw new IllegalArgumentException("channel is required");
|
||||
}
|
||||
return TransferChannel.valueOf(s.trim().toUpperCase());
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 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.fundtransfer.domain;
|
||||
|
||||
public enum TransferDirection {
|
||||
ON_US,
|
||||
OFF_US
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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.fundtransfer.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
|
||||
/** Global per-channel transfer limits + maker-checker threshold. */
|
||||
@Entity
|
||||
@Table(name = "m_transfer_limit_config")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class TransferLimitConfig extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "channel", nullable = false, length = 16)
|
||||
private TransferChannel channel;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "direction", length = 8)
|
||||
private TransferDirection direction;
|
||||
|
||||
@Column(name = "per_txn_min", precision = 19, scale = 4)
|
||||
private BigDecimal perTxnMin;
|
||||
|
||||
@Column(name = "per_txn_max", precision = 19, scale = 4)
|
||||
private BigDecimal perTxnMax;
|
||||
|
||||
@Column(name = "per_day_max", precision = 19, scale = 4)
|
||||
private BigDecimal perDayMax;
|
||||
|
||||
@Column(name = "maker_checker_above", precision = 19, scale = 4)
|
||||
private BigDecimal makerCheckerAbove;
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active = true;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.fundtransfer.domain;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface TransferLimitConfigRepository extends JpaRepository<TransferLimitConfig, Long> {
|
||||
|
||||
Optional<TransferLimitConfig> findByChannelAndActiveTrue(TransferChannel channel);
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.fundtransfer.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Short-lived token issued at /v1/transfers/verify and redeemed at
|
||||
* /v1/transfers. Locks the params so they can't change between confirm and
|
||||
* debit, and doubles as the idempotency key.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_verification_token")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class VerificationToken {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "token", nullable = false, length = 64, unique = true)
|
||||
private String token;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "channel", nullable = false, length = 16)
|
||||
private TransferChannel channel;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "direction", nullable = false, length = 8)
|
||||
private TransferDirection direction;
|
||||
|
||||
@Column(name = "cashier_user_id", nullable = false)
|
||||
private Long cashierUserId;
|
||||
|
||||
@Column(name = "client_id", nullable = false)
|
||||
private Long clientId;
|
||||
|
||||
@Column(name = "payer_account_id", nullable = false)
|
||||
private Long payerAccountId;
|
||||
|
||||
@Column(name = "payee_account_number", nullable = false, length = 64)
|
||||
private String payeeAccountNumber;
|
||||
|
||||
@Column(name = "payee_ifsc", length = 11)
|
||||
private String payeeIfsc;
|
||||
|
||||
@Column(name = "payee_mask_name")
|
||||
private String payeeMaskName;
|
||||
|
||||
@Column(name = "amount", nullable = false, precision = 19, scale = 4)
|
||||
private BigDecimal amount;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
private VerificationTokenStatus status = VerificationTokenStatus.ISSUED;
|
||||
|
||||
@Column(name = "issued_at", nullable = false)
|
||||
private LocalDateTime issuedAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Column(name = "redeemed_at")
|
||||
private LocalDateTime redeemedAt;
|
||||
|
||||
@Column(name = "redeemed_fund_transfer_id")
|
||||
private Long redeemedFundTransferId;
|
||||
|
||||
public boolean isExpired() {
|
||||
return LocalDateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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.fundtransfer.domain;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface VerificationTokenRepository extends JpaRepository<VerificationToken, Long> {
|
||||
|
||||
Optional<VerificationToken> findByToken(String token);
|
||||
|
||||
/**
|
||||
* Deletes verification tokens whose lifecycle has already terminated and that are
|
||||
* older than the cutoff. Spares ISSUED tokens regardless of age — those are still
|
||||
* potentially redeemable until they tick over to EXPIRED on next load.
|
||||
*/
|
||||
@Modifying
|
||||
@Query("""
|
||||
DELETE FROM VerificationToken t
|
||||
WHERE t.status <> org.apache.fineract.portfolio.fundtransfer.domain.VerificationTokenStatus.ISSUED
|
||||
AND t.issuedAt < :cutoff
|
||||
""")
|
||||
int deleteTerminalOlderThan(@Param("cutoff") LocalDateTime cutoff);
|
||||
|
||||
/**
|
||||
* Flips ISSUED → EXPIRED for tokens whose expiry has passed. Called on the same
|
||||
* cleanup cycle so the next prune sweep can remove them.
|
||||
*/
|
||||
@Modifying
|
||||
@Query("""
|
||||
UPDATE VerificationToken t
|
||||
SET t.status = org.apache.fineract.portfolio.fundtransfer.domain.VerificationTokenStatus.EXPIRED
|
||||
WHERE t.status = org.apache.fineract.portfolio.fundtransfer.domain.VerificationTokenStatus.ISSUED
|
||||
AND t.expiresAt < :now
|
||||
""")
|
||||
int markExpired(@Param("now") LocalDateTime now);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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.fundtransfer.domain;
|
||||
|
||||
public enum VerificationTokenStatus {
|
||||
ISSUED,
|
||||
REDEEMED,
|
||||
EXPIRED
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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.fundtransfer.job;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.ImpsDeemedReconciliationService;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@Configuration
|
||||
public class ImpsDeemedReconcileJobConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Autowired
|
||||
private ImpsDeemedReconciliationService service;
|
||||
|
||||
@Bean
|
||||
public ImpsDeemedReconcileTasklet impsDeemedReconcileTasklet() {
|
||||
return new ImpsDeemedReconcileTasklet(service);
|
||||
}
|
||||
|
||||
@Bean
|
||||
protected Step impsDeemedReconcileStep() {
|
||||
return new StepBuilder(JobName.IMPS_DEEMED_RECONCILE.name(), jobRepository)
|
||||
.tasklet(impsDeemedReconcileTasklet(), transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job impsDeemedReconcileJob() {
|
||||
return new JobBuilder(JobName.IMPS_DEEMED_RECONCILE.name(), jobRepository).start(impsDeemedReconcileStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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.fundtransfer.job;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.ImpsDeemedReconciliationService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ImpsDeemedReconcileTasklet implements Tasklet {
|
||||
|
||||
private final ImpsDeemedReconciliationService service;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
|
||||
int touched = service.reconcileAll();
|
||||
log.info("[Job:IMPS_DEEMED_RECONCILE] reconciled {} transfer(s)", touched);
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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.fundtransfer.job;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.NeftBatchProcessorService;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@Configuration
|
||||
public class NeftBatchProcessJobConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Autowired
|
||||
private NeftBatchProcessorService service;
|
||||
|
||||
@Bean
|
||||
public NeftBatchProcessTasklet neftBatchProcessTasklet() {
|
||||
return new NeftBatchProcessTasklet(service);
|
||||
}
|
||||
|
||||
@Bean
|
||||
protected Step neftBatchProcessStep() {
|
||||
return new StepBuilder(JobName.NEFT_BATCH_PROCESS.name(), jobRepository)
|
||||
.tasklet(neftBatchProcessTasklet(), transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job neftBatchProcessJob() {
|
||||
return new JobBuilder(JobName.NEFT_BATCH_PROCESS.name(), jobRepository).start(neftBatchProcessStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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.fundtransfer.job;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.NeftBatchProcessorService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class NeftBatchProcessTasklet implements Tasklet {
|
||||
|
||||
private final NeftBatchProcessorService service;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
|
||||
NeftBatchProcessorService.BatchResult r = service.runBatch();
|
||||
log.info("[Job:NEFT_BATCH_PROCESS] picked={} success={} deemed={} failed={}",
|
||||
r.picked(), r.success(), r.deemed(), r.failed());
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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.fundtransfer.job;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.VerificationTokenCleanupService;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@Configuration
|
||||
public class VerificationTokenCleanupJobConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Autowired
|
||||
private VerificationTokenCleanupService service;
|
||||
|
||||
@Bean
|
||||
public VerificationTokenCleanupTasklet verificationTokenCleanupTasklet() {
|
||||
return new VerificationTokenCleanupTasklet(service);
|
||||
}
|
||||
|
||||
@Bean
|
||||
protected Step verificationTokenCleanupStep() {
|
||||
return new StepBuilder(JobName.VERIFICATION_TOKEN_CLEANUP.name(), jobRepository)
|
||||
.tasklet(verificationTokenCleanupTasklet(), transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job verificationTokenCleanupJob() {
|
||||
return new JobBuilder(JobName.VERIFICATION_TOKEN_CLEANUP.name(), jobRepository).start(verificationTokenCleanupStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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.fundtransfer.job;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.VerificationTokenCleanupService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class VerificationTokenCleanupTasklet implements Tasklet {
|
||||
|
||||
private final VerificationTokenCleanupService service;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
|
||||
VerificationTokenCleanupService.CleanupResult r = service.cleanup();
|
||||
log.info("[Job:VERIFICATION_TOKEN_CLEANUP] expired={} deleted={}", r.expired(), r.deleted());
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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.fundtransfer.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.BankIfscRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Decides ON_US vs OFF_US by checking the payee IFSC against m_bank_ifsc. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BankIfscLookupService {
|
||||
|
||||
private final BankIfscRepository repository;
|
||||
|
||||
public boolean isOurIfsc(String ifscCode) {
|
||||
if (ifscCode == null || ifscCode.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
return repository.existsByIfscCodeIgnoreCaseAndActiveTrue(ifscCode.trim());
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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.fundtransfer.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVParser;
|
||||
import org.apache.commons.csv.CSVRecord;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.BulkTransferRowResult;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.VerifyRequestData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.VerifyResponseData;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Processes a CSV upload of transfers. Each row goes through the standard
|
||||
* verify + create flow, isolated in its own transaction so one bad row doesn't
|
||||
* roll back the rest.
|
||||
*
|
||||
* <p>Expected CSV header: {@code channel,payerAccountId,payeeAccountNumber,
|
||||
* payeeIfsc,payeeName,amount,remarks}
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BulkTransferService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BulkTransferService.class);
|
||||
|
||||
private final FundTransferWritePlatformService writeService;
|
||||
|
||||
/** Hard cap on rows per upload — keeps a JAX-RS worker from being tied up for minutes. */
|
||||
@Value("${fineract.transfer.bulk.max-rows:1000}")
|
||||
private int maxRows;
|
||||
|
||||
public List<BulkTransferRowResult> process(String csvBody) {
|
||||
List<BulkTransferRowResult> results = new ArrayList<>();
|
||||
CSVFormat format = CSVFormat.DEFAULT.builder().setHeader().setSkipHeaderRecord(true).setTrim(true).build();
|
||||
try (CSVParser parser = new CSVParser(new StringReader(csvBody), format)) {
|
||||
int rowNumber = 0;
|
||||
for (CSVRecord rec : parser) {
|
||||
rowNumber++;
|
||||
if (rowNumber > maxRows) {
|
||||
throw new IllegalArgumentException("CSV exceeds bulk row limit of " + maxRows
|
||||
+ " — split the upload or raise fineract.transfer.bulk.max-rows");
|
||||
}
|
||||
BulkTransferRowResult row = new BulkTransferRowResult();
|
||||
row.setRowNumber(rowNumber);
|
||||
try {
|
||||
VerifyRequestData verifyReq = parseRow(rec);
|
||||
VerifyResponseData verifyResp = writeService.verify(verifyReq);
|
||||
String remarks = column(rec, "remarks");
|
||||
TransferResponseData txnResp = writeService.createFromToken(verifyResp.getVerificationToken(), remarks);
|
||||
row.setTxnRef(txnResp.getTxnRef());
|
||||
row.setStatus(txnResp.getStatus());
|
||||
row.setApprovalStatus(txnResp.getApprovalStatus());
|
||||
} catch (RuntimeException ex) {
|
||||
row.setStatus("ERROR");
|
||||
row.setErrorMessage(ex.getMessage());
|
||||
log.warn("[BulkTransfer] row {} failed: {}", rowNumber, ex.toString());
|
||||
}
|
||||
results.add(row);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Failed to parse CSV: " + e.getMessage(), e);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static VerifyRequestData parseRow(CSVRecord rec) {
|
||||
VerifyRequestData req = new VerifyRequestData();
|
||||
req.setChannel(column(rec, "channel"));
|
||||
String payerId = column(rec, "payerAccountId");
|
||||
if (payerId == null) {
|
||||
throw new IllegalArgumentException("payerAccountId is required");
|
||||
}
|
||||
req.setPayerAccountId(Long.valueOf(payerId));
|
||||
req.setPayeeAccountNumber(column(rec, "payeeAccountNumber"));
|
||||
req.setPayeeIfsc(column(rec, "payeeIfsc"));
|
||||
req.setPayeeName(column(rec, "payeeName"));
|
||||
String amount = column(rec, "amount");
|
||||
if (amount == null) {
|
||||
throw new IllegalArgumentException("amount is required");
|
||||
}
|
||||
req.setAmount(new BigDecimal(amount));
|
||||
return req;
|
||||
}
|
||||
|
||||
private static String column(CSVRecord rec, String name) {
|
||||
if (!rec.isMapped(name)) {
|
||||
return null;
|
||||
}
|
||||
String v = rec.get(name);
|
||||
return v == null || v.isBlank() ? null : v.trim();
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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.fundtransfer.service;
|
||||
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData;
|
||||
|
||||
public interface FundTransferReadPlatformService {
|
||||
|
||||
TransferResponseData findByRef(String txnRef);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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.fundtransfer.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FundTransferReadPlatformServiceImpl implements FundTransferReadPlatformService {
|
||||
|
||||
private final FundTransferRepository repository;
|
||||
|
||||
@Override
|
||||
public TransferResponseData findByRef(String txnRef) {
|
||||
FundTransfer t = repository.findByTxnRef(txnRef)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Unknown txn_ref: " + txnRef));
|
||||
return toData(t);
|
||||
}
|
||||
|
||||
static TransferResponseData toData(FundTransfer t) {
|
||||
return new TransferResponseData(t.getTxnRef(), t.getChannel().name(), t.getDirection().name(), t.getStatus().name(),
|
||||
t.getApprovalStatus().name(), t.getResponseCode(), t.getResponseMessage(), t.getPayeeAccountNumber(),
|
||||
t.getPayeeIfsc(), t.getPayeeMaskName(), t.getAmount(), t.getUtr(), t.getSwitchTxnId(), t.getInitiatedAt(),
|
||||
t.getSettledAt());
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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.fundtransfer.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.TransferChannel;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.TransferDirection;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.strategy.ImpsOutwardStrategy;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.strategy.InternalTransferStrategy;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.strategy.NeftOutwardStrategy;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.strategy.RtgsOutwardStrategy;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.strategy.TransferStrategy;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Selects the right {@link TransferStrategy} for a transfer. ON_US always goes
|
||||
* internal regardless of channel; OFF_US dispatches by channel.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class FundTransferRouter {
|
||||
|
||||
private final InternalTransferStrategy internal;
|
||||
private final ImpsOutwardStrategy impsOutward;
|
||||
private final NeftOutwardStrategy neftOutward;
|
||||
private final RtgsOutwardStrategy rtgsOutward;
|
||||
|
||||
public TransferStrategy strategyFor(FundTransfer transfer) {
|
||||
if (transfer.getDirection() == TransferDirection.ON_US) {
|
||||
return internal;
|
||||
}
|
||||
TransferChannel channel = transfer.getChannel();
|
||||
return switch (channel) {
|
||||
case IMPS -> impsOutward;
|
||||
case NEFT -> neftOutward;
|
||||
case RTGS -> rtgsOutward;
|
||||
case INTERNAL -> internal;
|
||||
};
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.fundtransfer.service;
|
||||
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.VerifyRequestData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.VerifyResponseData;
|
||||
|
||||
public interface FundTransferWritePlatformService {
|
||||
|
||||
VerifyResponseData verify(VerifyRequestData request);
|
||||
|
||||
TransferResponseData createFromToken(String token, String remarks);
|
||||
|
||||
TransferResponseData approve(String txnRef);
|
||||
|
||||
TransferResponseData reject(String txnRef, String reason);
|
||||
|
||||
TransferResponseData recall(String txnRef, String reason);
|
||||
}
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* 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.fundtransfer.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.imps.client.ImpsSwitchClient;
|
||||
import org.apache.fineract.infrastructure.imps.data.AccountData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsRequestData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsResponseData;
|
||||
import org.apache.fineract.infrastructure.imps.data.PartyData;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.ResolvedLimits;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.VerifyRequestData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.VerifyResponseData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.ApprovalStatus;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEvent;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEventRepository;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferRepository;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.TransferChannel;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.TransferDirection;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.VerificationToken;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.strategy.TransferStrategy;
|
||||
import org.apache.fineract.portfolio.sanctions.data.SanctionsScreeningResult;
|
||||
import org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FundTransferWritePlatformServiceImpl implements FundTransferWritePlatformService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FundTransferWritePlatformServiceImpl.class);
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final FundTransferRepository fundTransferRepository;
|
||||
private final FundTransferEventRepository eventRepository;
|
||||
private final SavingsAccountRepositoryWrapper savingsRepository;
|
||||
private final BankIfscLookupService ifscLookup;
|
||||
private final PayeeAccountLookupService payeeLookup;
|
||||
private final TransferLimitResolver limitResolver;
|
||||
private final VerificationTokenService tokenService;
|
||||
private final FundTransferRouter router;
|
||||
private final ClientReadPlatformService clientReadPlatformService;
|
||||
private final ImpsSwitchClient impsSwitchClient;
|
||||
private final SanctionsScreeningService sanctionsScreeningService;
|
||||
private final TransferChannelWindowValidator windowValidator;
|
||||
private final SuspenseGlService suspenseGl;
|
||||
|
||||
// ─── /verify ──────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public VerifyResponseData verify(VerifyRequestData request) {
|
||||
if (request.getAmount() == null || request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new IllegalArgumentException("amount must be > 0");
|
||||
}
|
||||
TransferChannel channel = TransferChannel.from(request.getChannel());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
windowValidator.requireOpen(channel, LocalTime.from(now), now.getDayOfWeek());
|
||||
|
||||
AppUser cashier = context.authenticatedUser();
|
||||
SavingsAccount payer = savingsRepository.findOneWithNotFoundDetection(request.getPayerAccountId());
|
||||
Long clientId = payer.clientId();
|
||||
if (clientId == null) {
|
||||
throw new IllegalArgumentException("payer account is not owned by a client");
|
||||
}
|
||||
|
||||
ResolvedLimits limits = limitResolver.resolve(clientId, channel);
|
||||
validateAmountAgainstLimits(request.getAmount(), limits);
|
||||
validateDailyLimit(payer.getId(), channel, request.getAmount(), limits);
|
||||
|
||||
TransferDirection direction = resolveDirection(channel, request.getPayeeIfsc());
|
||||
if (direction == TransferDirection.ON_US) {
|
||||
payeeLookup.findByAccountNumber(request.getPayeeAccountNumber())
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"INVALID_PAYEE — IFSC is ours but account " + request.getPayeeAccountNumber() + " not found"));
|
||||
} else {
|
||||
screenOffUsPayee(request.getPayeeName(), clientId);
|
||||
}
|
||||
|
||||
String maskName = resolvePayeeMaskName(direction, request.getPayeeAccountNumber(), request.getPayeeIfsc(),
|
||||
request.getAmount());
|
||||
|
||||
VerificationToken token = tokenService.issue(channel, direction, cashier.getId(), clientId, payer.getId(),
|
||||
request.getPayeeAccountNumber(), request.getPayeeIfsc(), maskName, request.getAmount());
|
||||
|
||||
boolean makerCheckerRequired = isMakerCheckerRequired(request.getAmount(), limits);
|
||||
|
||||
log.info("[FundTransfer] verify OK channel={} direction={} amount={} makerChecker={} token={}",
|
||||
channel, direction, request.getAmount(), makerCheckerRequired, token.getToken());
|
||||
|
||||
return new VerifyResponseData(token.getToken(), VerificationTokenService.TTL_SECONDS, direction.name(), maskName,
|
||||
makerCheckerRequired);
|
||||
}
|
||||
|
||||
// ─── POST /v1/transfers ───────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public TransferResponseData createFromToken(String tokenString, String remarks) {
|
||||
if (tokenString == null || tokenString.isBlank()) {
|
||||
throw new IllegalArgumentException("verificationToken is required");
|
||||
}
|
||||
VerificationToken token = tokenService.loadActive(tokenString);
|
||||
AppUser cashier = context.authenticatedUser();
|
||||
if (!token.getCashierUserId().equals(cashier.getId())) {
|
||||
throw new IllegalStateException("Verification token was issued to a different cashier");
|
||||
}
|
||||
|
||||
ResolvedLimits limits = limitResolver.resolve(token.getClientId(), token.getChannel());
|
||||
boolean needsApproval = isMakerCheckerRequired(token.getAmount(), limits);
|
||||
|
||||
FundTransfer transfer = new FundTransfer();
|
||||
transfer.setTxnRef(generateRef());
|
||||
transfer.setChannel(token.getChannel());
|
||||
transfer.setDirection(token.getDirection());
|
||||
transfer.setStatus(FundTransferStatus.VERIFIED);
|
||||
transfer.setApprovalStatus(needsApproval ? ApprovalStatus.PENDING_APPROVAL : ApprovalStatus.AUTO);
|
||||
transfer.setCashierUserId(cashier.getId());
|
||||
transfer.setClientId(token.getClientId());
|
||||
|
||||
SavingsAccount payer = savingsRepository.findOneWithNotFoundDetection(token.getPayerAccountId());
|
||||
transfer.setPayerAccountId(payer.getId());
|
||||
transfer.setPayerAccountNumber(payer.getAccountNumber());
|
||||
transfer.setPayeeAccountNumber(token.getPayeeAccountNumber());
|
||||
transfer.setPayeeIfsc(token.getPayeeIfsc());
|
||||
transfer.setPayeeMaskName(token.getPayeeMaskName());
|
||||
transfer.setAmount(token.getAmount());
|
||||
transfer.setRemarks(remarks);
|
||||
transfer.setInitiatedAt(LocalDateTime.now());
|
||||
|
||||
if (!needsApproval) {
|
||||
executeStrategy(transfer);
|
||||
}
|
||||
FundTransfer saved = fundTransferRepository.save(transfer);
|
||||
tokenService.markRedeemed(token, saved.getId());
|
||||
|
||||
eventRepository.save(FundTransferEvent.of(saved.getId(), null, saved.getStatus(),
|
||||
needsApproval ? "INITIATED_PENDING_APPROVAL" : "INITIATED_AUTO_EXECUTE", null, cashier.getId()));
|
||||
|
||||
return FundTransferReadPlatformServiceImpl.toData(saved);
|
||||
}
|
||||
|
||||
// ─── POST /v1/transfers/{ref}/approve ─────────────────────────────────
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public TransferResponseData approve(String txnRef) {
|
||||
FundTransfer t = loadByRef(txnRef);
|
||||
if (t.getApprovalStatus() != ApprovalStatus.PENDING_APPROVAL) {
|
||||
throw new IllegalStateException("Transfer is not pending approval");
|
||||
}
|
||||
AppUser approver = context.authenticatedUser();
|
||||
if (approver.getId().equals(t.getCashierUserId())) {
|
||||
throw new IllegalStateException("Maker cannot approve own transfer");
|
||||
}
|
||||
FundTransferStatus prev = t.getStatus();
|
||||
t.setApprovalStatus(ApprovalStatus.APPROVED);
|
||||
executeStrategy(t);
|
||||
FundTransfer saved = fundTransferRepository.save(t);
|
||||
eventRepository.save(FundTransferEvent.of(saved.getId(), prev, saved.getStatus(), "APPROVED", null, approver.getId()));
|
||||
return FundTransferReadPlatformServiceImpl.toData(saved);
|
||||
}
|
||||
|
||||
// ─── POST /v1/transfers/{ref}/reject ──────────────────────────────────
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public TransferResponseData reject(String txnRef, String reason) {
|
||||
FundTransfer t = loadByRef(txnRef);
|
||||
if (t.getApprovalStatus() != ApprovalStatus.PENDING_APPROVAL) {
|
||||
throw new IllegalStateException("Transfer is not pending approval");
|
||||
}
|
||||
AppUser approver = context.authenticatedUser();
|
||||
if (approver.getId().equals(t.getCashierUserId())) {
|
||||
throw new IllegalStateException("Maker cannot reject own transfer");
|
||||
}
|
||||
FundTransferStatus prev = t.getStatus();
|
||||
t.setApprovalStatus(ApprovalStatus.REJECTED);
|
||||
t.setStatus(FundTransferStatus.FAILED);
|
||||
t.setResponseCode("REJ");
|
||||
t.setResponseMessage(reason);
|
||||
t.setSettledAt(LocalDateTime.now());
|
||||
FundTransfer saved = fundTransferRepository.save(t);
|
||||
eventRepository.save(FundTransferEvent.of(saved.getId(), prev, saved.getStatus(), "REJECTED", reason, approver.getId()));
|
||||
return FundTransferReadPlatformServiceImpl.toData(saved);
|
||||
}
|
||||
|
||||
// ─── POST /v1/transfers/{ref}/recall ─────────────────────────────────
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public TransferResponseData recall(String txnRef, String reason) {
|
||||
FundTransfer t = loadByRef(txnRef);
|
||||
if (t.getDirection() == TransferDirection.ON_US) {
|
||||
throw new IllegalStateException("Recall only applies to OFF_US transfers");
|
||||
}
|
||||
FundTransferStatus prev = t.getStatus();
|
||||
if (prev != FundTransferStatus.SUCCESS && prev != FundTransferStatus.DEEMED && prev != FundTransferStatus.QUEUED) {
|
||||
throw new IllegalStateException("Cannot recall a transfer in state " + prev);
|
||||
}
|
||||
AppUser actor = context.authenticatedUser();
|
||||
String switchOutcome = notifySwitchOfRecall(t);
|
||||
suspenseGl.reverseFromSuspense(t);
|
||||
t.setStatus(FundTransferStatus.REVERSED);
|
||||
t.setResponseCode("RCL");
|
||||
t.setResponseMessage(reason == null ? "Recall initiated by ops" : reason);
|
||||
t.setSettledAt(LocalDateTime.now());
|
||||
FundTransfer saved = fundTransferRepository.save(t);
|
||||
String payload = "reason=" + reason + ", switch=" + switchOutcome;
|
||||
eventRepository.save(FundTransferEvent.of(saved.getId(), prev, saved.getStatus(), "RECALLED", payload, actor.getId()));
|
||||
log.info("[FundTransfer] txnRef={} recalled from state {} by user {} (switch outcome: {})",
|
||||
txnRef, prev, actor.getId(), switchOutcome);
|
||||
return FundTransferReadPlatformServiceImpl.toData(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort recall notification to the switch. Real-world recall protocols vary
|
||||
* by channel (IMPS = dispute flow; NEFT = ReqDirectRet; RTGS = manual). We send a
|
||||
* uniform {@code ReqRecall} envelope and swallow any error so the local reversal
|
||||
* still proceeds.
|
||||
*/
|
||||
private String notifySwitchOfRecall(FundTransfer t) {
|
||||
ImpsRequestData req = new ImpsRequestData();
|
||||
String recallId = "RCL" + UUID.randomUUID().toString().replace("-", "").substring(0, 14).toUpperCase();
|
||||
req.setTxnId(recallId);
|
||||
req.setOrgTxnId(t.getSwitchTxnId() == null ? t.getTxnRef() : t.getSwitchTxnId());
|
||||
req.setRefId(t.getTxnRef());
|
||||
req.setUtr(t.getUtr());
|
||||
req.setAmount(t.getAmount() == null ? null : t.getAmount().toPlainString());
|
||||
try {
|
||||
ImpsResponseData resp = impsSwitchClient.recall(t.getChannel().name(), recallId, req);
|
||||
String code = resp == null ? null : resp.getResponseCode();
|
||||
return "NOTIFIED code=" + code;
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("[FundTransfer] txnRef={} switch-side recall failed (proceeding with local reversal): {}",
|
||||
t.getTxnRef(), ex.toString());
|
||||
return "FAILED " + ex.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── internals ────────────────────────────────────────────────────────
|
||||
|
||||
private void executeStrategy(FundTransfer transfer) {
|
||||
transfer.setStatus(FundTransferStatus.IN_PROGRESS);
|
||||
|
||||
Long suspenseLegId = null;
|
||||
if (transfer.getDirection() == TransferDirection.OFF_US) {
|
||||
suspenseLegId = suspenseGl.parkInSuspense(transfer);
|
||||
if (suspenseLegId != null) {
|
||||
transfer.setInternalTxnId(suspenseLegId);
|
||||
}
|
||||
}
|
||||
|
||||
TransferStrategy strategy = router.strategyFor(transfer);
|
||||
try {
|
||||
strategy.execute(transfer);
|
||||
} catch (RuntimeException ex) {
|
||||
log.error("[FundTransfer] strategy failed for txnRef={}: {}", transfer.getTxnRef(), ex.toString(), ex);
|
||||
transfer.setStatus(FundTransferStatus.FAILED);
|
||||
transfer.setResponseCode("ERR");
|
||||
transfer.setResponseMessage(ex.getMessage());
|
||||
transfer.setSettledAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
if (transfer.getDirection() == TransferDirection.OFF_US && suspenseLegId != null
|
||||
&& transfer.getStatus() == FundTransferStatus.FAILED) {
|
||||
suspenseGl.reverseFromSuspense(transfer);
|
||||
}
|
||||
}
|
||||
|
||||
private FundTransfer loadByRef(String txnRef) {
|
||||
return fundTransferRepository.findByTxnRef(txnRef)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Unknown txn_ref: " + txnRef));
|
||||
}
|
||||
|
||||
private String resolvePayeeMaskName(TransferDirection direction, String payeeAccountNumber, String payeeIfsc,
|
||||
BigDecimal amount) {
|
||||
if (direction == TransferDirection.ON_US) {
|
||||
return payeeLookup.findByAccountNumber(payeeAccountNumber).map(SavingsAccount::clientId)
|
||||
.map(this::maskClientName).orElse(null);
|
||||
}
|
||||
return callSwitchForBanl(payeeAccountNumber, payeeIfsc, amount);
|
||||
}
|
||||
|
||||
private void screenOffUsPayee(String payeeName, Long initiatorClientId) {
|
||||
if (payeeName == null || payeeName.isBlank()) {
|
||||
return;
|
||||
}
|
||||
SanctionsScreeningResult result = sanctionsScreeningService.screenName(payeeName, "FUND_TRANSFER",
|
||||
initiatorClientId, null);
|
||||
if (result.isMatchFound()) {
|
||||
log.warn("[FundTransfer] payee '{}' FLAGGED by sanctions screening — {} match(es), screeningLogId={}",
|
||||
payeeName, result.getMatchCount(), result.getScreeningLogId());
|
||||
throw new IllegalStateException("SCREENING_FAILED — payee matches sanctions list (review screeningLog id "
|
||||
+ result.getScreeningLogId() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private String callSwitchForBanl(String payeeAccountNumber, String payeeIfsc, BigDecimal amount) {
|
||||
String banlTxnId = "BANL" + UUID.randomUUID().toString().replace("-", "").substring(0, 14).toUpperCase();
|
||||
ImpsRequestData req = new ImpsRequestData();
|
||||
req.setTxnId(banlTxnId);
|
||||
req.setRefId(banlTxnId);
|
||||
req.setAmount(amount == null ? null : amount.toPlainString());
|
||||
PartyData payee = new PartyData();
|
||||
AccountData payeeAcc = new AccountData();
|
||||
payeeAcc.setAccountNumber(payeeAccountNumber);
|
||||
payeeAcc.setIfsc(payeeIfsc);
|
||||
payee.setAccount(payeeAcc);
|
||||
req.setPayee(payee);
|
||||
try {
|
||||
ImpsResponseData resp = impsSwitchClient.reqValAdd(banlTxnId, req);
|
||||
return resp == null ? null : resp.getMaskName();
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("[FundTransfer] OFF_US BANL call failed: {} — proceeding without maskName", ex.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String maskClientName(Long clientId) {
|
||||
try {
|
||||
var client = clientReadPlatformService.retrieveOne(clientId);
|
||||
return maskName(client == null ? null : client.getDisplayName());
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("[FundTransfer] client {} display-name lookup failed: {}", clientId, ex.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks a display name to the IMPS RespValAdd convention: first letter of each
|
||||
* word kept, rest replaced with up to 5 asterisks. "John Doe" → "J*** D**".
|
||||
*/
|
||||
static String maskName(String displayName) {
|
||||
if (displayName == null || displayName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = displayName.trim().split("\\s+");
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
if (i > 0) {
|
||||
sb.append(' ');
|
||||
}
|
||||
String p = parts[i];
|
||||
if (p.length() <= 1) {
|
||||
sb.append(Character.toUpperCase(p.charAt(0)));
|
||||
} else {
|
||||
sb.append(Character.toUpperCase(p.charAt(0)));
|
||||
int stars = Math.min(p.length() - 1, 5);
|
||||
for (int j = 0; j < stars; j++) {
|
||||
sb.append('*');
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private TransferDirection resolveDirection(TransferChannel channel, String payeeIfsc) {
|
||||
if (channel == TransferChannel.INTERNAL) {
|
||||
return TransferDirection.ON_US;
|
||||
}
|
||||
if (payeeIfsc == null || payeeIfsc.isBlank()) {
|
||||
throw new IllegalArgumentException("payeeIfsc is required for channel " + channel);
|
||||
}
|
||||
return ifscLookup.isOurIfsc(payeeIfsc) ? TransferDirection.ON_US : TransferDirection.OFF_US;
|
||||
}
|
||||
|
||||
private void validateAmountAgainstLimits(BigDecimal amount, ResolvedLimits limits) {
|
||||
if (limits.getPerTxnMin() != null && amount.compareTo(limits.getPerTxnMin()) < 0) {
|
||||
throw new IllegalArgumentException("amount below per-txn minimum " + limits.getPerTxnMin());
|
||||
}
|
||||
if (limits.getPerTxnMax() != null && amount.compareTo(limits.getPerTxnMax()) > 0) {
|
||||
throw new IllegalArgumentException("amount exceeds per-txn maximum " + limits.getPerTxnMax());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDailyLimit(Long payerAccountId, TransferChannel channel, BigDecimal amount, ResolvedLimits limits) {
|
||||
if (limits.getPerDayMax() == null) {
|
||||
return;
|
||||
}
|
||||
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
|
||||
BigDecimal already = fundTransferRepository.sumActiveOnChannelSince(payerAccountId, channel, startOfDay);
|
||||
if (already.add(amount).compareTo(limits.getPerDayMax()) > 0) {
|
||||
throw new IllegalStateException("daily limit exhausted for channel " + channel + " — already " + already
|
||||
+ " of " + limits.getPerDayMax());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isMakerCheckerRequired(BigDecimal amount, ResolvedLimits limits) {
|
||||
if (limits.getMakerCheckerAbove() == null) {
|
||||
return true;
|
||||
}
|
||||
return amount.compareTo(limits.getMakerCheckerAbove()) >= 0;
|
||||
}
|
||||
|
||||
private static String generateRef() {
|
||||
return "FT" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase();
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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.fundtransfer.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.imps.client.ImpsSwitchClient;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsRequestData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsResponseData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEvent;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEventRepository;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferRepository;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Polls the IMPS switch for DEEMED transfers (responseCode=91) to resolve them
|
||||
* to SUCCESS / FAILED. Reconciliation window: initiated between 24h ago and 30s
|
||||
* ago — too young = noisy, too old = ops responsibility.
|
||||
*
|
||||
* <p>Phase 1.5 wiring: callable directly (manual trigger) or via an admin REST
|
||||
* endpoint. Phase 2 should register this with Fineract's Quartz scheduler
|
||||
* ({@code JobName} enum + a step/tasklet) for a proper 30-second cron.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ImpsDeemedReconciliationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ImpsDeemedReconciliationService.class);
|
||||
|
||||
/** Minimum age — give the switch ~30s before chasing it. */
|
||||
static final long MIN_AGE_SECONDS = 30;
|
||||
/** Maximum age — past this, ops owns the row. */
|
||||
static final long MAX_AGE_HOURS = 24;
|
||||
|
||||
private final FundTransferRepository fundTransferRepository;
|
||||
private final FundTransferEventRepository eventRepository;
|
||||
private final ImpsSwitchClient switchClient;
|
||||
private final SuspenseGlService suspenseGl;
|
||||
|
||||
/** Picks all DEEMED rows in the window and tries to resolve each. Returns count touched. */
|
||||
public int reconcileAll() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime floor = now.minusHours(MAX_AGE_HOURS);
|
||||
LocalDateTime ceiling = now.minusSeconds(MIN_AGE_SECONDS);
|
||||
List<FundTransfer> deemed = fundTransferRepository.findDeemedNeedingReconciliation(floor, ceiling);
|
||||
log.info("[ImpsDeemedReconciliation] {} DEEMED transfer(s) in window {} .. {}", deemed.size(), floor, ceiling);
|
||||
int touched = 0;
|
||||
for (FundTransfer t : deemed) {
|
||||
try {
|
||||
if (reconcileOne(t)) {
|
||||
touched++;
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("[ImpsDeemedReconciliation] txnRef={} reconciliation error: {}", t.getTxnRef(), ex.toString());
|
||||
}
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean reconcileOne(FundTransfer transfer) {
|
||||
String chkTxnId = "CHK" + UUID.randomUUID().toString().replace("-", "").substring(0, 14).toUpperCase();
|
||||
ImpsRequestData req = new ImpsRequestData();
|
||||
req.setTxnId(chkTxnId);
|
||||
req.setOrgTxnId(transfer.getSwitchTxnId() == null ? transfer.getTxnRef() : transfer.getSwitchTxnId());
|
||||
req.setRefId(transfer.getTxnRef());
|
||||
|
||||
ImpsResponseData resp = switchClient.reqChkTxn(chkTxnId, req);
|
||||
String code = resp.getResponseCode() == null ? "" : resp.getResponseCode().trim();
|
||||
FundTransferStatus newStatus = switch (code) {
|
||||
case "00" -> FundTransferStatus.SUCCESS;
|
||||
case "91" -> FundTransferStatus.DEEMED;
|
||||
default -> FundTransferStatus.FAILED;
|
||||
};
|
||||
|
||||
if (newStatus == FundTransferStatus.DEEMED) {
|
||||
log.info("[ImpsDeemedReconciliation] txnRef={} still DEEMED (code={})", transfer.getTxnRef(), code);
|
||||
return false;
|
||||
}
|
||||
FundTransferStatus prev = transfer.getStatus();
|
||||
transfer.setStatus(newStatus);
|
||||
transfer.setResponseCode(code.isEmpty() ? null : code);
|
||||
transfer.setResponseMessage(resp.getStatus());
|
||||
if (resp.getUtr() != null) {
|
||||
transfer.setUtr(resp.getUtr());
|
||||
}
|
||||
transfer.setSettledAt(LocalDateTime.now());
|
||||
if (newStatus == FundTransferStatus.FAILED) {
|
||||
suspenseGl.reverseFromSuspense(transfer);
|
||||
}
|
||||
fundTransferRepository.save(transfer);
|
||||
eventRepository.save(FundTransferEvent.of(transfer.getId(), prev, newStatus, "RECONCILED",
|
||||
"code=" + code + " status=" + resp.getStatus(), null));
|
||||
log.info("[ImpsDeemedReconciliation] txnRef={} resolved {} → {}", transfer.getTxnRef(), prev, newStatus);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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.fundtransfer.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.imps.client.ImpsSwitchClient;
|
||||
import org.apache.fineract.infrastructure.imps.data.AccountData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsRequestData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsResponseData;
|
||||
import org.apache.fineract.infrastructure.imps.data.PartyData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEvent;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEventRepository;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferRepository;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Processes a NEFT batch cycle: picks all rows in QUEUED, sends each to the
|
||||
* switch's {@code /NEFT/ReqPay} endpoint, and writes the final status. Driven
|
||||
* by an admin endpoint today; phase 2.5 should wire this to Quartz with a
|
||||
* 30-minute cron matching the NEFT settlement schedule.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NeftBatchProcessorService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NeftBatchProcessorService.class);
|
||||
|
||||
private final FundTransferRepository fundTransferRepository;
|
||||
private final FundTransferEventRepository eventRepository;
|
||||
private final ImpsSwitchClient switchClient;
|
||||
private final SuspenseGlService suspenseGl;
|
||||
|
||||
@Value("${fineract.imps.our-default-ifsc:BANK0000001}")
|
||||
private String ourDefaultIfsc;
|
||||
|
||||
public BatchResult runBatch() {
|
||||
List<FundTransfer> queued = fundTransferRepository.findQueuedNeftReadyForBatch();
|
||||
log.info("[NeftBatchProcessor] picking up {} queued NEFT transfer(s)", queued.size());
|
||||
int success = 0;
|
||||
int failed = 0;
|
||||
int deemed = 0;
|
||||
for (FundTransfer t : queued) {
|
||||
try {
|
||||
FundTransferStatus terminal = processOne(t);
|
||||
if (terminal == FundTransferStatus.SUCCESS) {
|
||||
success++;
|
||||
} else if (terminal == FundTransferStatus.DEEMED) {
|
||||
deemed++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("[NeftBatchProcessor] txnRef={} unhandled error: {}", t.getTxnRef(), ex.toString());
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
log.info("[NeftBatchProcessor] cycle done — success={} deemed={} failed={}", success, deemed, failed);
|
||||
return new BatchResult(queued.size(), success, deemed, failed);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FundTransferStatus processOne(FundTransfer t) {
|
||||
ImpsRequestData payload = buildPayload(t);
|
||||
FundTransferStatus prev = t.getStatus();
|
||||
try {
|
||||
ImpsResponseData resp = switchClient.neftPay(t.getTxnRef(), payload);
|
||||
String code = resp.getResponseCode() == null ? "" : resp.getResponseCode().trim();
|
||||
FundTransferStatus mapped = switch (code) {
|
||||
case "00" -> FundTransferStatus.SUCCESS;
|
||||
case "91" -> FundTransferStatus.DEEMED;
|
||||
default -> FundTransferStatus.FAILED;
|
||||
};
|
||||
t.setStatus(mapped);
|
||||
t.setResponseCode(code.isEmpty() ? null : code);
|
||||
t.setResponseMessage(resp.getStatus());
|
||||
t.setSwitchTxnId(resp.getTxnId() == null ? t.getTxnRef() : resp.getTxnId());
|
||||
t.setUtr(resp.getUtr());
|
||||
if (mapped != FundTransferStatus.DEEMED) {
|
||||
t.setSettledAt(LocalDateTime.now());
|
||||
}
|
||||
if (mapped == FundTransferStatus.FAILED) {
|
||||
suspenseGl.reverseFromSuspense(t);
|
||||
}
|
||||
fundTransferRepository.save(t);
|
||||
eventRepository.save(FundTransferEvent.of(t.getId(), prev, mapped, "NEFT_BATCH_PROCESSED",
|
||||
"code=" + code, null));
|
||||
log.info("[NeftBatchProcessor] txnRef={} {} → {} utr={}", t.getTxnRef(), prev, mapped, t.getUtr());
|
||||
return mapped;
|
||||
} catch (RuntimeException ex) {
|
||||
t.setStatus(FundTransferStatus.FAILED);
|
||||
t.setResponseCode("ERR");
|
||||
t.setResponseMessage(ex.getMessage());
|
||||
t.setSettledAt(LocalDateTime.now());
|
||||
suspenseGl.reverseFromSuspense(t);
|
||||
fundTransferRepository.save(t);
|
||||
eventRepository.save(FundTransferEvent.of(t.getId(), prev, FundTransferStatus.FAILED,
|
||||
"NEFT_BATCH_ERROR", ex.toString(), null));
|
||||
log.warn("[NeftBatchProcessor] txnRef={} switch call failed: {}", t.getTxnRef(), ex.toString());
|
||||
return FundTransferStatus.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
private ImpsRequestData buildPayload(FundTransfer t) {
|
||||
ImpsRequestData req = new ImpsRequestData();
|
||||
req.setTxnId(t.getTxnRef());
|
||||
req.setRefId(t.getTxnRef());
|
||||
req.setAmount(t.getAmount() == null ? null : t.getAmount().toPlainString());
|
||||
PartyData payer = new PartyData();
|
||||
AccountData payerAcc = new AccountData();
|
||||
payerAcc.setAccountNumber(t.getPayerAccountNumber());
|
||||
payerAcc.setIfsc(ourDefaultIfsc);
|
||||
payer.setAccount(payerAcc);
|
||||
req.setPayer(payer);
|
||||
PartyData payee = new PartyData();
|
||||
payee.setName(t.getPayeeName());
|
||||
AccountData payeeAcc = new AccountData();
|
||||
payeeAcc.setAccountNumber(t.getPayeeAccountNumber());
|
||||
payeeAcc.setIfsc(t.getPayeeIfsc());
|
||||
payee.setAccount(payeeAcc);
|
||||
req.setPayee(payee);
|
||||
return req;
|
||||
}
|
||||
|
||||
public record BatchResult(int picked, int success, int deemed, int failed) {}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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.fundtransfer.service;
|
||||
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Resolves a payee account number to a {@link SavingsAccount} for the on-us path. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PayeeAccountLookupService {
|
||||
|
||||
private final SavingsAccountRepositoryWrapper savingsRepository;
|
||||
|
||||
public Optional<SavingsAccount> findByAccountNumber(String accountNumber) {
|
||||
if (accountNumber == null || accountNumber.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(savingsRepository.findNonClosedAccountByAccountNumber(accountNumber.trim()));
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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.fundtransfer.service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||
import org.apache.fineract.portfolio.account.PortfolioAccountType;
|
||||
import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
||||
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Moves money between the customer's savings account and the bank's "in transit"
|
||||
* suspense savings account around OFF_US transfers. The suspense account is
|
||||
* seeded by Liquibase 0311 with {@code external_id = 'FUNDTRANSFER_SUSPENSE'} —
|
||||
* resolved at runtime, so the auto-generated id never has to be threaded
|
||||
* through application.properties.
|
||||
*
|
||||
* <p>Override via {@code fineract.transfer.suspense-account-external-id} if a
|
||||
* deployment uses a different external id (legacy installs, multi-tenant
|
||||
* splits, etc.).
|
||||
*
|
||||
* <h3>Flow</h3>
|
||||
* <ul>
|
||||
* <li>Initiate OFF_US → debit customer, credit suspense</li>
|
||||
* <li>Switch SUCCESS → leave it; end-of-day reconciliation clears suspense</li>
|
||||
* <li>Switch FAILED → reverse: debit suspense, credit customer</li>
|
||||
* <li>DEEMED → FAILED → reverse (via {@code ImpsDeemedReconciliationService})</li>
|
||||
* <li>NEFT batch FAILED → reverse (via {@code NeftBatchProcessorService})</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>When the suspense account can't be resolved, both methods are no-ops with a
|
||||
* warning log — keeps dev environments without the GL set up still bootable.
|
||||
*/
|
||||
@Service
|
||||
public class SuspenseGlService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SuspenseGlService.class);
|
||||
|
||||
private final AccountTransfersWritePlatformService transfersService;
|
||||
private final SavingsAccountRepository savingsAccountRepository;
|
||||
|
||||
@Value("${fineract.transfer.suspense-account-external-id:FUNDTRANSFER_SUSPENSE}")
|
||||
private String suspenseExternalId;
|
||||
|
||||
/** Resolved on first lookup and cached for the JVM lifetime — the suspense account doesn't change. */
|
||||
private volatile Long resolvedSuspenseAccountId;
|
||||
|
||||
public SuspenseGlService(AccountTransfersWritePlatformService transfersService,
|
||||
SavingsAccountRepository savingsAccountRepository) {
|
||||
this.transfersService = transfersService;
|
||||
this.savingsAccountRepository = savingsAccountRepository;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void warnIfUnresolved() {
|
||||
Long id = tryResolve();
|
||||
if (id == null) {
|
||||
log.warn("[SuspenseGl] suspense external_id '{}' did not resolve — OFF_US debits will be no-ops "
|
||||
+ "until the suspense GL row is created (see Liquibase 0311)", suspenseExternalId);
|
||||
} else {
|
||||
log.info("[SuspenseGl] resolved suspense external_id '{}' → savings account id {}", suspenseExternalId, id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Debit customer, credit suspense. Returns the transfer transaction id, or null when GL not configured. */
|
||||
public Long parkInSuspense(FundTransfer transfer) {
|
||||
Long suspenseId = tryResolve();
|
||||
if (suspenseId == null) {
|
||||
log.warn("[SuspenseGl] suspense GL unresolved — skipping debit-to-suspense for txnRef={}", transfer.getTxnRef());
|
||||
return null;
|
||||
}
|
||||
return move(transfer.getPayerAccountId(), suspenseId, transfer, "OFF_US park: " + transfer.getTxnRef());
|
||||
}
|
||||
|
||||
/** Reverse a prior park — debit suspense, credit customer. */
|
||||
public Long reverseFromSuspense(FundTransfer transfer) {
|
||||
Long suspenseId = tryResolve();
|
||||
if (suspenseId == null) {
|
||||
log.warn("[SuspenseGl] suspense GL unresolved — skipping reversal for txnRef={}", transfer.getTxnRef());
|
||||
return null;
|
||||
}
|
||||
return move(suspenseId, transfer.getPayerAccountId(), transfer, "OFF_US reverse: " + transfer.getTxnRef());
|
||||
}
|
||||
|
||||
private Long tryResolve() {
|
||||
Long cached = resolvedSuspenseAccountId;
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
if (suspenseExternalId == null || suspenseExternalId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
SavingsAccount account = savingsAccountRepository.findByExternalId(new ExternalId(suspenseExternalId));
|
||||
if (account == null) {
|
||||
return null;
|
||||
}
|
||||
resolvedSuspenseAccountId = account.getId();
|
||||
return resolvedSuspenseAccountId;
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("[SuspenseGl] lookup by external_id '{}' failed: {}", suspenseExternalId, ex.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Long move(Long fromAccountId, Long toAccountId, FundTransfer t, String description) {
|
||||
AccountTransferDTO dto = new AccountTransferDTO(LocalDate.now(), t.getAmount(), PortfolioAccountType.SAVINGS,
|
||||
PortfolioAccountType.SAVINGS, fromAccountId, toAccountId, description, null, null, null, null, null, null, null,
|
||||
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, null, null,
|
||||
Boolean.TRUE, Boolean.FALSE);
|
||||
Long txnId = transfersService.transferFunds(dto);
|
||||
log.info("[SuspenseGl] txnRef={} moved {} units {} → {} (internalTxnId={})",
|
||||
t.getTxnRef(), t.getAmount(), fromAccountId, toAccountId, txnId);
|
||||
return txnId;
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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.fundtransfer.service;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalTime;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.TransferChannel;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Optional channel-operating-window guard. RBI made NEFT and RTGS 24×7 in 2019
|
||||
* and 2020 respectively, so the defaults are open; but banks often still enforce
|
||||
* business-hour windows internally for ops reasons. Property format:
|
||||
*
|
||||
* <pre>fineract.transfer.<channel>.window-hours=09-16</pre>
|
||||
*
|
||||
* Empty / unset = always open. Days-of-week format:
|
||||
*
|
||||
* <pre>fineract.transfer.<channel>.window-days=MON,TUE,WED,THU,FRI,SAT</pre>
|
||||
*
|
||||
* Empty / unset = all days.
|
||||
*/
|
||||
@Component
|
||||
public class TransferChannelWindowValidator {
|
||||
|
||||
@Value("${fineract.transfer.imps.window-hours:}")
|
||||
private String impsHours;
|
||||
@Value("${fineract.transfer.imps.window-days:}")
|
||||
private String impsDays;
|
||||
|
||||
@Value("${fineract.transfer.neft.window-hours:}")
|
||||
private String neftHours;
|
||||
@Value("${fineract.transfer.neft.window-days:}")
|
||||
private String neftDays;
|
||||
|
||||
@Value("${fineract.transfer.rtgs.window-hours:}")
|
||||
private String rtgsHours;
|
||||
@Value("${fineract.transfer.rtgs.window-days:}")
|
||||
private String rtgsDays;
|
||||
|
||||
public void requireOpen(TransferChannel channel, LocalTime nowTime, DayOfWeek nowDay) {
|
||||
String hours;
|
||||
String days;
|
||||
switch (channel) {
|
||||
case IMPS -> { hours = impsHours; days = impsDays; }
|
||||
case NEFT -> { hours = neftHours; days = neftDays; }
|
||||
case RTGS -> { hours = rtgsHours; days = rtgsDays; }
|
||||
default -> { return; }
|
||||
}
|
||||
if (!isHourInWindow(nowTime, hours)) {
|
||||
throw new IllegalStateException("OUTSIDE_" + channel + "_WINDOW — current hour outside configured window " + hours);
|
||||
}
|
||||
if (!isDayInWindow(nowDay, days)) {
|
||||
throw new IllegalStateException("OUTSIDE_" + channel + "_WINDOW — current day not in configured days " + days);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isHourInWindow(LocalTime now, String spec) {
|
||||
if (spec == null || spec.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
String[] parts = spec.split("-");
|
||||
if (parts.length != 2) {
|
||||
throw new IllegalStateException("Invalid window-hours format: " + spec + " (expected HH-HH)");
|
||||
}
|
||||
int start = Integer.parseInt(parts[0].trim());
|
||||
int end = Integer.parseInt(parts[1].trim());
|
||||
int hour = now.getHour();
|
||||
return hour >= start && hour < end;
|
||||
}
|
||||
|
||||
private static boolean isDayInWindow(DayOfWeek now, String spec) {
|
||||
if (spec == null || spec.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
for (String tok : spec.split(",")) {
|
||||
if (DayOfWeek.valueOf(tok.trim().toUpperCase()) == now) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.fundtransfer.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.ResolvedLimits;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.CustomerTransferLimit;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.CustomerTransferLimitRepository;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.TransferChannel;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.TransferLimitConfig;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.TransferLimitConfigRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Resolves effective per-customer-per-channel limits by overlaying any
|
||||
* {@link CustomerTransferLimit} row on top of the global {@link TransferLimitConfig}
|
||||
* defaults. Null fields on the customer override inherit the global value.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TransferLimitResolver {
|
||||
|
||||
private final TransferLimitConfigRepository globalRepository;
|
||||
private final CustomerTransferLimitRepository customerRepository;
|
||||
|
||||
public ResolvedLimits resolve(Long clientId, TransferChannel channel) {
|
||||
TransferLimitConfig global = globalRepository.findByChannelAndActiveTrue(channel)
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"No active m_transfer_limit_config for channel " + channel + " — seed missing?"));
|
||||
|
||||
Optional<CustomerTransferLimit> override = customerRepository.findEffective(clientId, channel, LocalDate.now());
|
||||
|
||||
BigDecimal perTxnMax = override.map(CustomerTransferLimit::getPerTxnMax).orElse(null);
|
||||
if (perTxnMax == null) {
|
||||
perTxnMax = global.getPerTxnMax();
|
||||
}
|
||||
BigDecimal perDayMax = override.map(CustomerTransferLimit::getPerDayMax).orElse(null);
|
||||
if (perDayMax == null) {
|
||||
perDayMax = global.getPerDayMax();
|
||||
}
|
||||
|
||||
return new ResolvedLimits(global.getPerTxnMin(), perTxnMax, perDayMax, global.getMakerCheckerAbove());
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.fundtransfer.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.VerificationTokenRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Two-phase cleanup of {@code m_verification_token}:
|
||||
* <ol>
|
||||
* <li>Flip ISSUED tokens past their {@code expires_at} to EXPIRED.</li>
|
||||
* <li>Delete REDEEMED + EXPIRED rows older than the retention cutoff.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Default retention: 24 hours. ISSUED rows are never deleted directly; they
|
||||
* have to pass through EXPIRED first, so a clock skew never silently drops a
|
||||
* token that's still potentially redeemable.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VerificationTokenCleanupService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(VerificationTokenCleanupService.class);
|
||||
|
||||
private final VerificationTokenRepository repository;
|
||||
|
||||
@Value("${fineract.transfer.verification-token.retention-hours:24}")
|
||||
private long retentionHours;
|
||||
|
||||
@Transactional
|
||||
public CleanupResult cleanup() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
int expired = repository.markExpired(now);
|
||||
int deleted = repository.deleteTerminalOlderThan(now.minusHours(retentionHours));
|
||||
log.info("[VerificationTokenCleanup] flipped {} → EXPIRED, deleted {} terminal token(s) older than {}h",
|
||||
expired, deleted, retentionHours);
|
||||
return new CleanupResult(expired, deleted);
|
||||
}
|
||||
|
||||
public record CleanupResult(int expired, int deleted) {}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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.fundtransfer.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.TransferChannel;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.TransferDirection;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.VerificationToken;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.VerificationTokenRepository;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.VerificationTokenStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Issues + redeems verification tokens (the BANL → debit bridge). */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VerificationTokenService {
|
||||
|
||||
/** Five minutes is long enough for a cashier interaction, short enough that stale tokens don't pile up. */
|
||||
public static final long TTL_SECONDS = 300L;
|
||||
|
||||
private final VerificationTokenRepository repository;
|
||||
|
||||
@Transactional
|
||||
public VerificationToken issue(TransferChannel channel, TransferDirection direction, Long cashierUserId, Long clientId,
|
||||
Long payerAccountId, String payeeAccountNumber, String payeeIfsc, String payeeMaskName, BigDecimal amount) {
|
||||
|
||||
VerificationToken t = new VerificationToken();
|
||||
t.setToken(UUID.randomUUID().toString());
|
||||
t.setChannel(channel);
|
||||
t.setDirection(direction);
|
||||
t.setCashierUserId(cashierUserId);
|
||||
t.setClientId(clientId);
|
||||
t.setPayerAccountId(payerAccountId);
|
||||
t.setPayeeAccountNumber(payeeAccountNumber);
|
||||
t.setPayeeIfsc(payeeIfsc);
|
||||
t.setPayeeMaskName(payeeMaskName);
|
||||
t.setAmount(amount);
|
||||
t.setStatus(VerificationTokenStatus.ISSUED);
|
||||
t.setIssuedAt(LocalDateTime.now());
|
||||
t.setExpiresAt(t.getIssuedAt().plusSeconds(TTL_SECONDS));
|
||||
return repository.save(t);
|
||||
}
|
||||
|
||||
public VerificationToken loadActive(String token) {
|
||||
VerificationToken t = repository.findByToken(token)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Unknown verification token"));
|
||||
if (t.getStatus() == VerificationTokenStatus.REDEEMED) {
|
||||
throw new IllegalStateException("Token already redeemed — txn ref already issued");
|
||||
}
|
||||
if (t.isExpired()) {
|
||||
t.setStatus(VerificationTokenStatus.EXPIRED);
|
||||
repository.save(t);
|
||||
throw new IllegalStateException("Verification token expired — re-run /verify");
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markRedeemed(VerificationToken t, Long fundTransferId) {
|
||||
t.setStatus(VerificationTokenStatus.REDEEMED);
|
||||
t.setRedeemedAt(LocalDateTime.now());
|
||||
t.setRedeemedFundTransferId(fundTransferId);
|
||||
repository.save(t);
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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.fundtransfer.service.strategy;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.fineract.infrastructure.imps.client.ImpsSwitchClient;
|
||||
import org.apache.fineract.infrastructure.imps.data.AccountData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsRequestData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsResponseData;
|
||||
import org.apache.fineract.infrastructure.imps.data.PartyData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* IMPS outward via LincPay's ReqPay endpoint. Maps the IMPS response code to
|
||||
* the {@link FundTransferStatus} lifecycle: 00 → SUCCESS, 91 → DEEMED (pending
|
||||
* reconciliation), anything else → FAILED.
|
||||
*
|
||||
* <p>Note: payer IFSC is read from {@code fineract.imps.our-default-ifsc} for
|
||||
* phase 1.5. Production should derive it from the payer account's branch office.
|
||||
*/
|
||||
@Component
|
||||
public class ImpsOutwardStrategy implements TransferStrategy {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ImpsOutwardStrategy.class);
|
||||
|
||||
private final ImpsSwitchClient switchClient;
|
||||
|
||||
@Value("${fineract.imps.our-default-ifsc:BANK0000001}")
|
||||
private String ourDefaultIfsc;
|
||||
|
||||
public ImpsOutwardStrategy(ImpsSwitchClient switchClient) {
|
||||
this.switchClient = switchClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(FundTransfer transfer) {
|
||||
ImpsRequestData payload = buildPayload(transfer);
|
||||
try {
|
||||
ImpsResponseData resp = switchClient.reqPay(transfer.getTxnRef(), payload);
|
||||
applyResponse(transfer, resp);
|
||||
} catch (RuntimeException ex) {
|
||||
log.error("[ImpsOutwardStrategy] txnRef={} switch call failed: {}", transfer.getTxnRef(), ex.toString());
|
||||
transfer.setStatus(FundTransferStatus.FAILED);
|
||||
transfer.setResponseCode("ERR");
|
||||
transfer.setResponseMessage(ex.getMessage());
|
||||
transfer.setSettledAt(LocalDateTime.now());
|
||||
}
|
||||
}
|
||||
|
||||
private ImpsRequestData buildPayload(FundTransfer t) {
|
||||
ImpsRequestData req = new ImpsRequestData();
|
||||
req.setTxnId(t.getTxnRef());
|
||||
req.setRefId(t.getTxnRef());
|
||||
req.setAmount(t.getAmount() == null ? null : t.getAmount().toPlainString());
|
||||
|
||||
PartyData payer = new PartyData();
|
||||
AccountData payerAcc = new AccountData();
|
||||
payerAcc.setAccountNumber(t.getPayerAccountNumber());
|
||||
payerAcc.setIfsc(ourDefaultIfsc);
|
||||
payer.setAccount(payerAcc);
|
||||
req.setPayer(payer);
|
||||
|
||||
PartyData payee = new PartyData();
|
||||
payee.setName(t.getPayeeName());
|
||||
AccountData payeeAcc = new AccountData();
|
||||
payeeAcc.setAccountNumber(t.getPayeeAccountNumber());
|
||||
payeeAcc.setIfsc(t.getPayeeIfsc());
|
||||
payee.setAccount(payeeAcc);
|
||||
req.setPayee(payee);
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
private void applyResponse(FundTransfer transfer, ImpsResponseData resp) {
|
||||
String code = resp.getResponseCode() == null ? "" : resp.getResponseCode().trim();
|
||||
transfer.setResponseCode(code.isEmpty() ? null : code);
|
||||
transfer.setResponseMessage(resp.getStatus());
|
||||
transfer.setSwitchTxnId(resp.getTxnId() == null ? transfer.getTxnRef() : resp.getTxnId());
|
||||
transfer.setUtr(resp.getUtr());
|
||||
|
||||
FundTransferStatus mapped = switch (code) {
|
||||
case "00" -> FundTransferStatus.SUCCESS;
|
||||
case "91" -> FundTransferStatus.DEEMED;
|
||||
default -> FundTransferStatus.FAILED;
|
||||
};
|
||||
transfer.setStatus(mapped);
|
||||
if (mapped != FundTransferStatus.DEEMED) {
|
||||
transfer.setSettledAt(LocalDateTime.now());
|
||||
}
|
||||
log.info("[ImpsOutwardStrategy] txnRef={} responseCode={} → status={} utr={}",
|
||||
transfer.getTxnRef(), code, mapped, transfer.getUtr());
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.fundtransfer.service.strategy;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||
import org.apache.fineract.portfolio.account.PortfolioAccountType;
|
||||
import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
||||
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
||||
import org.apache.fineract.portfolio.fundtransfer.service.PayeeAccountLookupService;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* On-us settlement — debits payer + credits payee atomically via Fineract's
|
||||
* existing {@link AccountTransfersWritePlatformService#transferFunds}. Runs
|
||||
* inside the caller's JPA transaction so balance check, debit, credit, and
|
||||
* the FundTransfer row commit (or roll back) together.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class InternalTransferStrategy implements TransferStrategy {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(InternalTransferStrategy.class);
|
||||
|
||||
private final AccountTransfersWritePlatformService transfersService;
|
||||
private final PayeeAccountLookupService payeeLookup;
|
||||
|
||||
@Override
|
||||
public void execute(FundTransfer transfer) {
|
||||
SavingsAccount payee = payeeLookup.findByAccountNumber(transfer.getPayeeAccountNumber()).orElseThrow(
|
||||
() -> new IllegalStateException("Payee account vanished between verify and debit: " + transfer.getPayeeAccountNumber()));
|
||||
|
||||
AccountTransferDTO dto = new AccountTransferDTO(LocalDate.now(), transfer.getAmount(), PortfolioAccountType.SAVINGS,
|
||||
PortfolioAccountType.SAVINGS, transfer.getPayerAccountId(), payee.getId(),
|
||||
buildDescription(transfer), null, null, null, null, null, null, null,
|
||||
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, null, null,
|
||||
Boolean.TRUE, Boolean.FALSE);
|
||||
|
||||
Long transactionId = transfersService.transferFunds(dto);
|
||||
log.info("[InternalTransferStrategy] txnRef={} debited {} → credited {} ({} units) — internalTxnId={}",
|
||||
transfer.getTxnRef(), transfer.getPayerAccountId(), payee.getId(), transfer.getAmount(), transactionId);
|
||||
|
||||
transfer.setInternalTxnId(transactionId);
|
||||
transfer.setStatus(FundTransferStatus.SUCCESS);
|
||||
transfer.setResponseCode("00");
|
||||
transfer.setResponseMessage("Internal transfer settled");
|
||||
transfer.setSettledAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
private static String buildDescription(FundTransfer t) {
|
||||
String r = t.getRemarks();
|
||||
return r == null || r.isBlank() ? "Fund transfer " + t.getTxnRef() : (t.getTxnRef() + " — " + r);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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.fundtransfer.service.strategy;
|
||||
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* NEFT outward — enqueue only. NEFT in India is batched (currently half-hourly,
|
||||
* 24×7 since Dec 2019). The actual switch call happens in
|
||||
* {@code NeftBatchProcessorService}, which an ops cron / Quartz job triggers
|
||||
* once per cycle.
|
||||
*
|
||||
* <p>Status transition here: VERIFIED → QUEUED. Final status (SUCCESS / FAILED
|
||||
* / DEEMED) is written by the batch processor.
|
||||
*/
|
||||
@Component
|
||||
public class NeftOutwardStrategy implements TransferStrategy {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NeftOutwardStrategy.class);
|
||||
|
||||
@Override
|
||||
public void execute(FundTransfer transfer) {
|
||||
transfer.setStatus(FundTransferStatus.QUEUED);
|
||||
transfer.setResponseCode("QUEUED");
|
||||
transfer.setResponseMessage("Awaiting next NEFT batch cycle");
|
||||
log.info("[NeftOutwardStrategy] txnRef={} enqueued for next NEFT batch", transfer.getTxnRef());
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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.fundtransfer.service.strategy;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.fineract.infrastructure.imps.client.ImpsSwitchClient;
|
||||
import org.apache.fineract.infrastructure.imps.data.AccountData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsRequestData;
|
||||
import org.apache.fineract.infrastructure.imps.data.ImpsResponseData;
|
||||
import org.apache.fineract.infrastructure.imps.data.PartyData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* RTGS outward — realtime, same shape as IMPS but routed to {@code /RTGS/ReqPay}.
|
||||
* RTGS in India: min ₹2 lakh (enforced by the limit config), 24×7 since Dec 2020.
|
||||
* Maker-checker is mandatory per the seeded config.
|
||||
*/
|
||||
@Component
|
||||
public class RtgsOutwardStrategy implements TransferStrategy {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RtgsOutwardStrategy.class);
|
||||
|
||||
private final ImpsSwitchClient switchClient;
|
||||
|
||||
@Value("${fineract.imps.our-default-ifsc:BANK0000001}")
|
||||
private String ourDefaultIfsc;
|
||||
|
||||
public RtgsOutwardStrategy(ImpsSwitchClient switchClient) {
|
||||
this.switchClient = switchClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(FundTransfer transfer) {
|
||||
ImpsRequestData payload = buildPayload(transfer);
|
||||
try {
|
||||
ImpsResponseData resp = switchClient.rtgsPay(transfer.getTxnRef(), payload);
|
||||
applyResponse(transfer, resp);
|
||||
} catch (RuntimeException ex) {
|
||||
log.error("[RtgsOutwardStrategy] txnRef={} switch call failed: {}", transfer.getTxnRef(), ex.toString());
|
||||
transfer.setStatus(FundTransferStatus.FAILED);
|
||||
transfer.setResponseCode("ERR");
|
||||
transfer.setResponseMessage(ex.getMessage());
|
||||
transfer.setSettledAt(LocalDateTime.now());
|
||||
}
|
||||
}
|
||||
|
||||
private ImpsRequestData buildPayload(FundTransfer t) {
|
||||
ImpsRequestData req = new ImpsRequestData();
|
||||
req.setTxnId(t.getTxnRef());
|
||||
req.setRefId(t.getTxnRef());
|
||||
req.setAmount(t.getAmount() == null ? null : t.getAmount().toPlainString());
|
||||
|
||||
PartyData payer = new PartyData();
|
||||
AccountData payerAcc = new AccountData();
|
||||
payerAcc.setAccountNumber(t.getPayerAccountNumber());
|
||||
payerAcc.setIfsc(ourDefaultIfsc);
|
||||
payer.setAccount(payerAcc);
|
||||
req.setPayer(payer);
|
||||
|
||||
PartyData payee = new PartyData();
|
||||
payee.setName(t.getPayeeName());
|
||||
AccountData payeeAcc = new AccountData();
|
||||
payeeAcc.setAccountNumber(t.getPayeeAccountNumber());
|
||||
payeeAcc.setIfsc(t.getPayeeIfsc());
|
||||
payee.setAccount(payeeAcc);
|
||||
req.setPayee(payee);
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
private void applyResponse(FundTransfer transfer, ImpsResponseData resp) {
|
||||
String code = resp.getResponseCode() == null ? "" : resp.getResponseCode().trim();
|
||||
transfer.setResponseCode(code.isEmpty() ? null : code);
|
||||
transfer.setResponseMessage(resp.getStatus());
|
||||
transfer.setSwitchTxnId(resp.getTxnId() == null ? transfer.getTxnRef() : resp.getTxnId());
|
||||
transfer.setUtr(resp.getUtr());
|
||||
|
||||
FundTransferStatus mapped = switch (code) {
|
||||
case "00" -> FundTransferStatus.SUCCESS;
|
||||
case "91" -> FundTransferStatus.DEEMED;
|
||||
default -> FundTransferStatus.FAILED;
|
||||
};
|
||||
transfer.setStatus(mapped);
|
||||
if (mapped != FundTransferStatus.DEEMED) {
|
||||
transfer.setSettledAt(LocalDateTime.now());
|
||||
}
|
||||
log.info("[RtgsOutwardStrategy] txnRef={} responseCode={} → status={} utr={}",
|
||||
transfer.getTxnRef(), code, mapped, transfer.getUtr());
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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.fundtransfer.service.strategy;
|
||||
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||
|
||||
/**
|
||||
* Executes a single, validated, approved transfer. Implementations must update the
|
||||
* {@link FundTransfer} row in place — final status, response code, switch_txn_id,
|
||||
* utr, settled_at — and persist via the caller's transaction.
|
||||
*/
|
||||
public interface TransferStrategy {
|
||||
|
||||
void execute(FundTransfer transfer);
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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.useradministration.mfa.log.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.mfa.log.data.MfaLogPage;
|
||||
import org.apache.fineract.useradministration.mfa.log.data.MfaLogSearchCriteria;
|
||||
import org.apache.fineract.useradministration.mfa.log.service.MfaLogReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Admin viewer for MFA audit, scoped to two tabs the UI renders side by side:
|
||||
*
|
||||
* <ul>
|
||||
* <li>GET /v1/mfa/logs?tab=enrollment — device mapping / first-time verification events
|
||||
* (SMS / EMAIL / TOTP / DSC). Sourced from {@code m_user_mfa_factor} with related
|
||||
* enrolment-OTP / DSC-challenge context joined in.</li>
|
||||
* <li>GET /v1/mfa/logs?tab=login — login 2FA challenges. Sourced from {@code m_otp_log}
|
||||
* (purpose LIKE LOGIN_2FA*) excluding enrolment OTPs, UNION'd with {@code m_dsc_challenge}
|
||||
* rows of purpose LOGIN_2FA.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Both tabs share filters: {@code factorType}, {@code status}, {@code userId},
|
||||
* {@code username}, {@code search}, {@code dateFrom}, {@code dateTo}, plus {@code page} /
|
||||
* {@code pageSize} for paging.
|
||||
*
|
||||
* <p>Permission: {@code READ_OTP_LOG} — same audit audience as the OTP log viewer.
|
||||
*/
|
||||
@Path("/v1/mfa/logs")
|
||||
@Component
|
||||
@Tag(name = "MFA Logs (Admin)", description = "Two-tab MFA audit: device mapping + login 2FA events")
|
||||
@RequiredArgsConstructor
|
||||
public class MfaLogApiResource {
|
||||
|
||||
private static final String RESOURCE = "OTP_LOG";
|
||||
private static final int DEFAULT_PAGE_SIZE = 25;
|
||||
private static final int MAX_PAGE_SIZE = 500;
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final MfaLogReadPlatformService readService;
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Paged MFA audit. Pass tab=enrollment for device-mapping events or "
|
||||
+ "tab=login for 2FA login events. Filters apply to both tabs.")
|
||||
public MfaLogPage list(
|
||||
@QueryParam("tab") @DefaultValue("enrollment") final String tab,
|
||||
@QueryParam("page") @DefaultValue("0") final int page,
|
||||
@QueryParam("pageSize") @DefaultValue("25") final int pageSize,
|
||||
@QueryParam("factorType") final String factorType,
|
||||
@QueryParam("status") final String status,
|
||||
@QueryParam("userId") final Long userId,
|
||||
@QueryParam("username") final String username,
|
||||
@QueryParam("search") final String search,
|
||||
@QueryParam("dateFrom") final String dateFrom,
|
||||
@QueryParam("dateTo") final String dateTo) {
|
||||
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
|
||||
final int sanitizedPageSize = (pageSize <= 0 || pageSize > MAX_PAGE_SIZE) ? DEFAULT_PAGE_SIZE : pageSize;
|
||||
final int sanitizedPage = Math.max(0, page);
|
||||
|
||||
final MfaLogSearchCriteria criteria = MfaLogSearchCriteria.builder()
|
||||
.userId(userId)
|
||||
.username(blankToNull(username))
|
||||
.factorType(blankToNull(factorType))
|
||||
.status(blankToNull(status))
|
||||
.search(blankToNull(search))
|
||||
.fromDate(parseFromDate(dateFrom))
|
||||
.toDate(parseToDate(dateTo))
|
||||
.offset(sanitizedPage * sanitizedPageSize)
|
||||
.limit(sanitizedPageSize)
|
||||
.build();
|
||||
|
||||
return "login".equalsIgnoreCase(tab)
|
||||
? readService.retrieveLoginLogs(criteria)
|
||||
: readService.retrieveEnrollmentLogs(criteria);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static String blankToNull(final String s) {
|
||||
return s == null || s.isBlank() ? null : s;
|
||||
}
|
||||
|
||||
private static LocalDateTime parseFromDate(final String s) {
|
||||
if (s == null || s.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(s).atStartOfDay();
|
||||
} catch (final Exception ignored) {
|
||||
try {
|
||||
return LocalDateTime.parse(s);
|
||||
} catch (final Exception ignored2) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDateTime parseToDate(final String s) {
|
||||
if (s == null || s.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(s).atTime(23, 59, 59, 999_000_000);
|
||||
} catch (final Exception ignored) {
|
||||
try {
|
||||
return LocalDateTime.parse(s);
|
||||
} catch (final Exception ignored2) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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.useradministration.mfa.log.data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* One row for either tab of the MFA audit grid. Shape is intentionally unified so the UI can
|
||||
* render both tabs from the same renderer / column set — the {@code tab} field tells it which
|
||||
* source the row came from in case the column ordering needs to differ.
|
||||
*
|
||||
* <p>Tab values:
|
||||
* <ul>
|
||||
* <li>{@code ENROLLMENT} — device-mapping event (first-time SMS / EMAIL / TOTP / DSC pairing).
|
||||
* Sourced from {@code m_user_mfa_factor} with related context joined from
|
||||
* {@code m_otp_log} (SMS/EMAIL) and {@code m_dsc_challenge} (DSC).</li>
|
||||
* <li>{@code LOGIN} — a 2FA challenge during sign-in. Sourced from {@code m_otp_log} rows
|
||||
* with purpose {@code LOGIN_2FA*} (excluding the OTPs that were used for enrolment) and
|
||||
* {@code m_dsc_challenge} rows with purpose {@code LOGIN_2FA}.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
public class MfaLogData {
|
||||
|
||||
/** ENROLLMENT or LOGIN — which tab this row belongs to. */
|
||||
private final String tab;
|
||||
|
||||
/** Primary key of the underlying audit row. NOT globally unique across sources — composite with {@code source}. */
|
||||
private final Long id;
|
||||
|
||||
/** Source table: USER_MFA_FACTOR / OTP_LOG / DSC_CHALLENGE. */
|
||||
private final String source;
|
||||
|
||||
private final Long userId;
|
||||
private final String username;
|
||||
/** Composed full name from m_appuser (firstname + lastname). */
|
||||
private final String userFullName;
|
||||
|
||||
/** Normalised factor type — SMS / EMAIL / TOTP / DSC. */
|
||||
private final String factorType;
|
||||
|
||||
/** Masked recipient — "+91 XXXXXX2076" for SMS, "adXXXX@gmail.com" for email, common name for DSC, "Google Authenticator" for TOTP. */
|
||||
private final String destination;
|
||||
|
||||
/**
|
||||
* Status — for ENROLLMENT: ACTIVE / DISABLED / PENDING_VERIFICATION (factor row's verified+disabled state).
|
||||
* For LOGIN: PENDING / SENT / VERIFIED / FAILED / EXPIRED / LOCKED (carried verbatim from m_otp_log or m_dsc_challenge.status).
|
||||
*/
|
||||
private final String status;
|
||||
|
||||
/** When the event occurred (enrolment verified_on, or login OTP/challenge created_at). */
|
||||
private final LocalDateTime eventOn;
|
||||
|
||||
/** Verified timestamp when applicable (enrolment verified_on, or login verified_at). */
|
||||
private final OffsetDateTime verifiedOn;
|
||||
|
||||
/** Number of attempts (login OTP/challenge) — null for enrolment rows. */
|
||||
private final Integer attempts;
|
||||
|
||||
private final String ipAddress;
|
||||
private final String userAgent;
|
||||
|
||||
/** Human-readable summary — error_message on failure, synthesised text otherwise. */
|
||||
private final String message;
|
||||
|
||||
/** Provider — LINCPAY / EMAIL / TOTP / DSC. */
|
||||
private final String provider;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.useradministration.mfa.log.data;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/** Paged result envelope — matches the shape used by {@code OtpLogPage}. */
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public class MfaLogPage {
|
||||
|
||||
private final Long totalFilteredRecords;
|
||||
private final List<MfaLogData> pageItems;
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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.useradministration.mfa.log.data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Filter knobs for the MFA log search. All fields optional except {@code limit}/{@code offset}
|
||||
* (paging is always applied). {@code tab} drives which underlying query is used — service-level
|
||||
* concern, not exposed to the caller as part of the filter set.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class MfaLogSearchCriteria {
|
||||
|
||||
private final Long userId;
|
||||
private final String username;
|
||||
/** SMS / EMAIL / TOTP / DSC — null means all factor types. */
|
||||
private final String factorType;
|
||||
/** Status filter (ACTIVE/DISABLED for enrolment, VERIFIED/FAILED/EXPIRED for login). */
|
||||
private final String status;
|
||||
/** LIKE-match across username, destination (mobile/email). */
|
||||
private final String search;
|
||||
private final LocalDateTime fromDate;
|
||||
private final LocalDateTime toDate;
|
||||
private final int offset;
|
||||
private final int limit;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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.useradministration.mfa.log.service;
|
||||
|
||||
import org.apache.fineract.useradministration.mfa.log.data.MfaLogPage;
|
||||
import org.apache.fineract.useradministration.mfa.log.data.MfaLogSearchCriteria;
|
||||
|
||||
public interface MfaLogReadPlatformService {
|
||||
|
||||
/** Tab 1 — device-mapping / first-time verification events. */
|
||||
MfaLogPage retrieveEnrollmentLogs(MfaLogSearchCriteria criteria);
|
||||
|
||||
/** Tab 2 — login 2FA challenges (SMS / EMAIL / TOTP / DSC). */
|
||||
MfaLogPage retrieveLoginLogs(MfaLogSearchCriteria criteria);
|
||||
}
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* 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.useradministration.mfa.log.service;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.useradministration.mfa.log.data.MfaLogData;
|
||||
import org.apache.fineract.useradministration.mfa.log.data.MfaLogPage;
|
||||
import org.apache.fineract.useradministration.mfa.log.data.MfaLogSearchCriteria;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Tab 1 (enrolment) is sourced from {@code m_user_mfa_factor} — the canonical "which devices is
|
||||
* this user paired with" table — with related context joined in from {@code m_otp_log} so SMS /
|
||||
* EMAIL enrolments carry the IP / provider / OTP reference that proved possession.
|
||||
*
|
||||
* <p>Tab 2 (login) is a UNION ALL of {@code m_otp_log} (filtered to login OTPs, excluding the
|
||||
* OTP rows whose ids are referenced by a factor — those were the one-time enrolment OTPs) and
|
||||
* {@code m_dsc_challenge} (purpose=LOGIN_2FA). Filters and paging are applied to the merged set.
|
||||
*
|
||||
* <p>TOTP login attempts also write a row into {@code m_otp_log} with provider=TOTP from
|
||||
* {@code MfaFactorBackedTwoFactorServiceImpl}, so they show up in Tab 2 alongside SMS / EMAIL /
|
||||
* DSC.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class MfaLogReadPlatformServiceImpl implements MfaLogReadPlatformService {
|
||||
|
||||
private static final String TAB_ENROLLMENT = "ENROLLMENT";
|
||||
private static final String TAB_LOGIN = "LOGIN";
|
||||
|
||||
private static final String SOURCE_FACTOR = "USER_MFA_FACTOR";
|
||||
private static final String SOURCE_OTP = "OTP_LOG";
|
||||
private static final String SOURCE_DSC = "DSC_CHALLENGE";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// ── Tab 1: ENROLLMENT ────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public MfaLogPage retrieveEnrollmentLogs(final MfaLogSearchCriteria criteria) {
|
||||
final List<Object> params = new ArrayList<>();
|
||||
// verified=true → only completed device-mapping events. Unverified rows are pending TOTP
|
||||
// QR setup that hasn't been confirmed with a code yet; we surface those separately or
|
||||
// simply hide them from the audit grid.
|
||||
final StringBuilder where = new StringBuilder(" WHERE uf.verified = true ");
|
||||
|
||||
if (criteria.getUserId() != null) {
|
||||
where.append(" AND uf.appuser_id = ? ");
|
||||
params.add(criteria.getUserId());
|
||||
}
|
||||
if (notBlank(criteria.getUsername())) {
|
||||
where.append(" AND u.username = ? ");
|
||||
params.add(criteria.getUsername());
|
||||
}
|
||||
if (notBlank(criteria.getFactorType())) {
|
||||
where.append(" AND uf.factor_type = ? ");
|
||||
params.add(criteria.getFactorType().toUpperCase());
|
||||
}
|
||||
if (notBlank(criteria.getStatus())) {
|
||||
// ENROLLMENT-side status synthesised from disabled_on. Translate the input.
|
||||
final String s = criteria.getStatus().toUpperCase();
|
||||
if ("ACTIVE".equals(s)) {
|
||||
where.append(" AND uf.disabled_on IS NULL ");
|
||||
} else if ("DISABLED".equals(s)) {
|
||||
where.append(" AND uf.disabled_on IS NOT NULL ");
|
||||
}
|
||||
}
|
||||
if (notBlank(criteria.getSearch())) {
|
||||
where.append(" AND (u.username LIKE ? OR uf.destination LIKE ?) ");
|
||||
final String like = "%" + criteria.getSearch() + "%";
|
||||
params.add(like);
|
||||
params.add(like);
|
||||
}
|
||||
if (criteria.getFromDate() != null) {
|
||||
where.append(" AND COALESCE(uf.verified_on, uf.created_on) >= ? ");
|
||||
params.add(Timestamp.valueOf(criteria.getFromDate()));
|
||||
}
|
||||
if (criteria.getToDate() != null) {
|
||||
where.append(" AND COALESCE(uf.verified_on, uf.created_on) <= ? ");
|
||||
params.add(Timestamp.valueOf(criteria.getToDate()));
|
||||
}
|
||||
|
||||
final String baseFrom = " FROM m_user_mfa_factor uf "
|
||||
+ " LEFT JOIN m_appuser u ON u.id = uf.appuser_id "
|
||||
+ " LEFT JOIN m_otp_log ol ON ol.id = uf.otp_log_id ";
|
||||
|
||||
final String countSql = "SELECT COUNT(*) " + baseFrom + where;
|
||||
final Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); // NOSONAR
|
||||
if (total == null || total == 0L) {
|
||||
return new MfaLogPage(0L, Collections.emptyList());
|
||||
}
|
||||
|
||||
final String dataSql = "SELECT "
|
||||
+ " uf.id AS row_id, uf.factor_type, uf.destination, uf.verified, uf.verified_on, "
|
||||
+ " uf.disabled_on, uf.created_on, "
|
||||
+ " u.id AS user_id, u.username, u.firstname, u.lastname, "
|
||||
+ " ol.provider AS otp_provider, ol.ip_address AS otp_ip, ol.user_agent AS otp_ua, "
|
||||
+ " ol.otp_reference, ol.error_message AS otp_err, ol.attempts AS otp_attempts "
|
||||
+ baseFrom + where
|
||||
+ " ORDER BY COALESCE(uf.verified_on, uf.created_on) DESC "
|
||||
+ " LIMIT ? OFFSET ? ";
|
||||
final List<Object> dataParams = new ArrayList<>(params);
|
||||
dataParams.add(criteria.getLimit() <= 0 ? 25 : criteria.getLimit());
|
||||
dataParams.add(Math.max(0, criteria.getOffset()));
|
||||
|
||||
final List<MfaLogData> rows = jdbcTemplate.query(dataSql, enrollmentRowMapper(), dataParams.toArray()); // NOSONAR
|
||||
return new MfaLogPage(total, rows);
|
||||
}
|
||||
|
||||
private RowMapper<MfaLogData> enrollmentRowMapper() {
|
||||
return (final ResultSet rs, final int rowNum) -> {
|
||||
final String factorType = upper(rs.getString("factor_type"));
|
||||
final String storedDestination = rs.getString("destination");
|
||||
final String destination = maskDestination(factorType, storedDestination);
|
||||
final boolean disabled = rs.getTimestamp("disabled_on") != null;
|
||||
final String status = disabled ? "DISABLED" : "ACTIVE";
|
||||
final String provider = providerForEnrolment(factorType, rs.getString("otp_provider"));
|
||||
final String message = composeEnrolmentMessage(factorType, rs.getString("otp_reference"), rs.getString("otp_err"));
|
||||
|
||||
return MfaLogData.builder()
|
||||
.tab(TAB_ENROLLMENT)
|
||||
.id(rs.getLong("row_id"))
|
||||
.source(SOURCE_FACTOR)
|
||||
.userId(getNullableLong(rs, "user_id"))
|
||||
.username(rs.getString("username"))
|
||||
.userFullName(composeFullName(rs.getString("firstname"), rs.getString("lastname")))
|
||||
.factorType(factorType)
|
||||
.destination(destination)
|
||||
.status(status)
|
||||
.eventOn(toLocalDateTime(rs.getTimestamp("verified_on") != null ? rs.getTimestamp("verified_on") : rs.getTimestamp("created_on")))
|
||||
.verifiedOn(toOffsetDateTime(rs.getTimestamp("verified_on")))
|
||||
.attempts(getNullableInt(rs, "otp_attempts"))
|
||||
.ipAddress(rs.getString("otp_ip"))
|
||||
.userAgent(rs.getString("otp_ua"))
|
||||
.message(message)
|
||||
.provider(provider)
|
||||
.build();
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tab 2: LOGIN ─────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public MfaLogPage retrieveLoginLogs(final MfaLogSearchCriteria criteria) {
|
||||
// UNION ALL of OTP login rows (excluding the enrolment OTPs referenced by a factor) and
|
||||
// DSC login challenges. Both halves project to the same column shape so the outer SELECT
|
||||
// can paginate, sort, and apply filters uniformly.
|
||||
final String unionInner = "("
|
||||
// OTP half — login OTPs only (LOGIN_2FA / LOGIN_2FA_EMAIL) and TOTP login rows.
|
||||
// NOT IN excludes the OTP id that was used to enrol an SMS/EMAIL factor.
|
||||
+ " SELECT "
|
||||
+ " o.id AS row_id, 'OTP_LOG' AS source, "
|
||||
+ " CASE WHEN o.provider = 'LINCPAY' THEN 'SMS' "
|
||||
+ " WHEN o.provider = 'EMAIL' THEN 'EMAIL' "
|
||||
+ " WHEN o.provider = 'TOTP' THEN 'TOTP' "
|
||||
+ " ELSE o.provider END AS factor_type, "
|
||||
+ " CASE WHEN o.provider = 'LINCPAY' THEN o.mobile_number "
|
||||
+ " WHEN o.provider = 'EMAIL' THEN o.email_address "
|
||||
+ " WHEN o.provider = 'TOTP' THEN 'Google Authenticator' "
|
||||
+ " ELSE COALESCE(o.mobile_number, o.email_address) END AS destination, "
|
||||
+ " o.user_id, o.username, "
|
||||
+ " u.firstname, u.lastname, "
|
||||
+ " o.status, o.attempts, "
|
||||
+ " o.created_at AS event_on, o.verified_at AS verified_on, "
|
||||
+ " o.ip_address, o.user_agent, o.error_message, "
|
||||
+ " o.otp_reference, o.provider "
|
||||
+ " FROM m_otp_log o "
|
||||
+ " LEFT JOIN m_appuser u ON u.id = o.user_id "
|
||||
+ " WHERE o.purpose LIKE 'LOGIN_2FA%' "
|
||||
+ " AND o.id NOT IN (SELECT otp_log_id FROM m_user_mfa_factor WHERE otp_log_id IS NOT NULL) "
|
||||
|
||||
+ " UNION ALL "
|
||||
|
||||
// DSC half — login challenges only.
|
||||
+ " SELECT "
|
||||
+ " c.id AS row_id, 'DSC_CHALLENGE' AS source, "
|
||||
+ " 'DSC' AS factor_type, "
|
||||
+ " c.cert_serial_returned AS destination, "
|
||||
+ " c.appuser_id AS user_id, u.username, "
|
||||
+ " u.firstname, u.lastname, "
|
||||
+ " c.status, c.attempts, "
|
||||
+ " c.created_at AS event_on, c.verified_at AS verified_on, "
|
||||
+ " c.ip_address, c.user_agent, c.error_message, "
|
||||
+ " NULL AS otp_reference, 'DSC' AS provider "
|
||||
+ " FROM m_dsc_challenge c "
|
||||
+ " LEFT JOIN m_appuser u ON u.id = c.appuser_id "
|
||||
+ " WHERE c.purpose = 'LOGIN_2FA' "
|
||||
+ ") merged ";
|
||||
|
||||
final List<Object> params = new ArrayList<>();
|
||||
final StringBuilder where = new StringBuilder(" WHERE 1=1 ");
|
||||
if (criteria.getUserId() != null) {
|
||||
where.append(" AND merged.user_id = ? ");
|
||||
params.add(criteria.getUserId());
|
||||
}
|
||||
if (notBlank(criteria.getUsername())) {
|
||||
where.append(" AND merged.username = ? ");
|
||||
params.add(criteria.getUsername());
|
||||
}
|
||||
if (notBlank(criteria.getFactorType())) {
|
||||
where.append(" AND merged.factor_type = ? ");
|
||||
params.add(criteria.getFactorType().toUpperCase());
|
||||
}
|
||||
if (notBlank(criteria.getStatus())) {
|
||||
where.append(" AND merged.status = ? ");
|
||||
params.add(criteria.getStatus().toUpperCase());
|
||||
}
|
||||
if (notBlank(criteria.getSearch())) {
|
||||
where.append(" AND (merged.username LIKE ? OR merged.destination LIKE ? OR merged.otp_reference LIKE ?) ");
|
||||
final String like = "%" + criteria.getSearch() + "%";
|
||||
params.add(like);
|
||||
params.add(like);
|
||||
params.add(like);
|
||||
}
|
||||
if (criteria.getFromDate() != null) {
|
||||
where.append(" AND merged.event_on >= ? ");
|
||||
params.add(Timestamp.valueOf(criteria.getFromDate()));
|
||||
}
|
||||
if (criteria.getToDate() != null) {
|
||||
where.append(" AND merged.event_on <= ? ");
|
||||
params.add(Timestamp.valueOf(criteria.getToDate()));
|
||||
}
|
||||
|
||||
final String countSql = "SELECT COUNT(*) FROM " + unionInner + where;
|
||||
final Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); // NOSONAR
|
||||
if (total == null || total == 0L) {
|
||||
return new MfaLogPage(0L, Collections.emptyList());
|
||||
}
|
||||
|
||||
final String dataSql = "SELECT * FROM " + unionInner + where
|
||||
+ " ORDER BY merged.event_on DESC, merged.row_id DESC "
|
||||
+ " LIMIT ? OFFSET ? ";
|
||||
final List<Object> dataParams = new ArrayList<>(params);
|
||||
dataParams.add(criteria.getLimit() <= 0 ? 25 : criteria.getLimit());
|
||||
dataParams.add(Math.max(0, criteria.getOffset()));
|
||||
|
||||
final List<MfaLogData> rows = jdbcTemplate.query(dataSql, loginRowMapper(), dataParams.toArray()); // NOSONAR
|
||||
return new MfaLogPage(total, rows);
|
||||
}
|
||||
|
||||
private RowMapper<MfaLogData> loginRowMapper() {
|
||||
return (final ResultSet rs, final int rowNum) -> {
|
||||
final String factorType = upper(rs.getString("factor_type"));
|
||||
final String storedDestination = rs.getString("destination");
|
||||
// Mask the destination for the login grid the same way the existing OTP log does.
|
||||
final String destination = maskDestination(factorType, storedDestination);
|
||||
final String source = rs.getString("source");
|
||||
final String status = upper(rs.getString("status"));
|
||||
final String message = composeLoginMessage(status, rs.getString("error_message"), rs.getString("otp_reference"));
|
||||
|
||||
return MfaLogData.builder()
|
||||
.tab(TAB_LOGIN)
|
||||
.id(rs.getLong("row_id"))
|
||||
.source(source)
|
||||
.userId(getNullableLong(rs, "user_id"))
|
||||
.username(rs.getString("username"))
|
||||
.userFullName(composeFullName(rs.getString("firstname"), rs.getString("lastname")))
|
||||
.factorType(factorType)
|
||||
.destination(destination)
|
||||
.status(status)
|
||||
.eventOn(toLocalDateTime(rs.getTimestamp("event_on")))
|
||||
.verifiedOn(toOffsetDateTime(rs.getTimestamp("verified_on")))
|
||||
.attempts(getNullableInt(rs, "attempts"))
|
||||
.ipAddress(rs.getString("ip_address"))
|
||||
.userAgent(rs.getString("user_agent"))
|
||||
.message(message)
|
||||
.provider(rs.getString("provider"))
|
||||
.build();
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static String composeEnrolmentMessage(final String factorType, final String otpRef, final String otpErr) {
|
||||
if (notBlank(otpErr)) {
|
||||
return otpErr;
|
||||
}
|
||||
if ("SMS".equalsIgnoreCase(factorType) || "EMAIL".equalsIgnoreCase(factorType)) {
|
||||
if (notBlank(otpRef)) {
|
||||
return factorType.toUpperCase() + " OTP verified (req#" + otpRef + ")";
|
||||
}
|
||||
return factorType.toUpperCase() + " OTP verified";
|
||||
}
|
||||
if ("TOTP".equalsIgnoreCase(factorType)) {
|
||||
return "Google Authenticator device paired";
|
||||
}
|
||||
if ("DSC".equalsIgnoreCase(factorType)) {
|
||||
return "Digital Signature Certificate mapped";
|
||||
}
|
||||
return factorType + " factor enrolled";
|
||||
}
|
||||
|
||||
private static String composeLoginMessage(final String status, final String errorMessage, final String otpReference) {
|
||||
if (notBlank(errorMessage)) {
|
||||
return errorMessage;
|
||||
}
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
switch (status) {
|
||||
case "VERIFIED":
|
||||
return "2FA verified";
|
||||
case "SENT":
|
||||
return notBlank(otpReference) ? "OTP delivered (req#" + otpReference + ")" : "OTP delivered";
|
||||
case "EXPIRED":
|
||||
return "Challenge expired";
|
||||
case "FAILED":
|
||||
return "Verification failed";
|
||||
case "PENDING":
|
||||
return "Pending verification";
|
||||
case "LOCKED":
|
||||
return "Locked — too many attempts";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
private static String providerForEnrolment(final String factorType, final String otpProvider) {
|
||||
if ("DSC".equalsIgnoreCase(factorType)) {
|
||||
return "DSC";
|
||||
}
|
||||
if ("TOTP".equalsIgnoreCase(factorType)) {
|
||||
return "TOTP";
|
||||
}
|
||||
return otpProvider;
|
||||
}
|
||||
|
||||
/** Mask SMS / email destinations; pass DSC / TOTP labels through unchanged. */
|
||||
private static String maskDestination(final String factorType, final String stored) {
|
||||
if (stored == null || stored.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if ("SMS".equalsIgnoreCase(factorType)) {
|
||||
return maskMobile(stored);
|
||||
}
|
||||
if ("EMAIL".equalsIgnoreCase(factorType)) {
|
||||
return maskEmail(stored);
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
private static String maskMobile(final String mobile) {
|
||||
if (mobile == null || mobile.length() <= 4) {
|
||||
return mobile;
|
||||
}
|
||||
String prefix = "";
|
||||
String digits = mobile;
|
||||
if (mobile.startsWith("+") && mobile.length() > 3) {
|
||||
prefix = mobile.substring(0, 3) + " ";
|
||||
digits = mobile.substring(3);
|
||||
}
|
||||
if (digits.length() <= 4) {
|
||||
return prefix + digits;
|
||||
}
|
||||
final String last4 = digits.substring(digits.length() - 4);
|
||||
final int hidden = digits.length() - 4;
|
||||
return prefix + "X".repeat(hidden) + last4;
|
||||
}
|
||||
|
||||
private static String maskEmail(final String email) {
|
||||
if (email == null) {
|
||||
return null;
|
||||
}
|
||||
final int at = email.indexOf('@');
|
||||
if (at < 0) {
|
||||
return email;
|
||||
}
|
||||
final String local = email.substring(0, at);
|
||||
final String domain = email.substring(at);
|
||||
if (local.length() <= 2) {
|
||||
return local + "XXXX" + domain;
|
||||
}
|
||||
return local.substring(0, 2) + "XXXX" + domain;
|
||||
}
|
||||
|
||||
private static String composeFullName(final String firstname, final String lastname) {
|
||||
if (firstname == null && lastname == null) {
|
||||
return null;
|
||||
}
|
||||
if (firstname == null) {
|
||||
return lastname;
|
||||
}
|
||||
if (lastname == null) {
|
||||
return firstname;
|
||||
}
|
||||
return (firstname + " " + lastname).trim();
|
||||
}
|
||||
|
||||
private static String upper(final String s) {
|
||||
return s == null ? null : s.toUpperCase();
|
||||
}
|
||||
|
||||
private static boolean notBlank(final String s) {
|
||||
return s != null && !s.isBlank();
|
||||
}
|
||||
|
||||
private static Long getNullableLong(final ResultSet rs, final String col) throws SQLException {
|
||||
final long v = rs.getLong(col);
|
||||
return rs.wasNull() ? null : v;
|
||||
}
|
||||
|
||||
private static Integer getNullableInt(final ResultSet rs, final String col) throws SQLException {
|
||||
final int v = rs.getInt(col);
|
||||
return rs.wasNull() ? null : v;
|
||||
}
|
||||
|
||||
private static LocalDateTime toLocalDateTime(final Timestamp ts) {
|
||||
return ts == null ? null : ts.toLocalDateTime();
|
||||
}
|
||||
|
||||
private static OffsetDateTime toOffsetDateTime(final Timestamp ts) {
|
||||
return ts == null ? null : ts.toLocalDateTime().atOffset(ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
|
||||
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
||||
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:root}
|
||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||
|
||||
@@ -327,4 +327,16 @@
|
||||
<include file="parts/0018_cash_reconciliation.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0019_cash_reversal_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0020_cash_permissions.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0300_create_m_imps_transaction.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0301_create_m_bank_ifsc.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0302_create_m_transfer_limit_config.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0303_create_m_customer_transfer_limit.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0304_create_m_fund_transfer.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0305_create_m_fund_transfer_event.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0306_create_m_verification_token.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0307_seed_fund_transfer_permissions.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0308_grant_fund_transfer_permissions_to_roles.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0309_phase3_jobs_and_recall_permission.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0310_fund_transfer_roles_and_token_cleanup_job.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0311_create_fund_transfer_suspense_account.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<?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
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
One row per inbound IMPS request received by Fineract acting as the CBS
|
||||
under IMPS_BANK_API_SPEC_CBS_1.0 §6. Used for audit + idempotency: a
|
||||
retried (api, txn_id) replays the prior recorded response.
|
||||
-->
|
||||
<changeSet id="0300_create_m_imps_transaction" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_imps_transaction"/></not></preConditions>
|
||||
<createTable tableName="m_imps_transaction">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints nullable="false" primaryKey="true"/>
|
||||
</column>
|
||||
|
||||
<!-- REQ_PAY / REQ_VAL_ADD / REQ_CHK_TXN -->
|
||||
<column name="api" type="VARCHAR(32)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="txn_id" type="VARCHAR(64)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="org_txn_id" type="VARCHAR(64)"/>
|
||||
<column name="ref_id" type="VARCHAR(64)"/>
|
||||
<column name="utr" type="VARCHAR(64)"/>
|
||||
<column name="amount" type="VARCHAR(32)"/>
|
||||
|
||||
<column name="payee_account_number" type="VARCHAR(64)"/>
|
||||
<column name="payee_ifsc" type="VARCHAR(32)"/>
|
||||
|
||||
<!-- SUCCESS / DEEMED / FAILED -->
|
||||
<column name="status" type="VARCHAR(16)"/>
|
||||
<!-- 00 / 91 / XC / 8 / etc. -->
|
||||
<column name="response_code" type="VARCHAR(8)"/>
|
||||
|
||||
<column name="request_json" type="CLOB"/>
|
||||
<column name="response_json" type="CLOB"/>
|
||||
|
||||
<!-- Standard Fineract audit columns (AbstractAuditableWithUTCDateTimeCustom). -->
|
||||
<column name="created_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="version" type="BIGINT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<!-- Idempotency: a retried (api, txn_id) resolves to the prior row. -->
|
||||
<addUniqueConstraint tableName="m_imps_transaction" columnNames="api, txn_id"
|
||||
constraintName="uq_imps_txn_api_txnid"/>
|
||||
|
||||
<!-- Common audit queries: latest activity per payee account. -->
|
||||
<createIndex tableName="m_imps_transaction" indexName="idx_imps_txn_payee_account">
|
||||
<column name="payee_account_number"/>
|
||||
</createIndex>
|
||||
|
||||
<!-- Reconciliation by UTR. -->
|
||||
<createIndex tableName="m_imps_transaction" indexName="idx_imps_txn_utr">
|
||||
<column name="utr"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<?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
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
OUR bank's IFSC codes. Used to classify a transfer as ON_US (payee.ifsc matches
|
||||
a row here) vs OFF_US (route via switch). The real RBI/sponsor-bank list is
|
||||
bulk-loaded at deployment time via tenant data-import — the placeholder row
|
||||
below just keeps a non-empty set for dev.
|
||||
-->
|
||||
<changeSet id="0301_create_m_bank_ifsc" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_bank_ifsc"/></not></preConditions>
|
||||
<createTable tableName="m_bank_ifsc">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints nullable="false" primaryKey="true"/>
|
||||
</column>
|
||||
<column name="ifsc_code" type="VARCHAR(11)">
|
||||
<constraints nullable="false" unique="true" uniqueConstraintName="uq_bank_ifsc_code"/>
|
||||
</column>
|
||||
<column name="branch_code" type="VARCHAR(16)"/>
|
||||
<column name="branch_name" type="VARCHAR(255)"/>
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<column name="created_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="version" type="BIGINT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0301_seed_placeholder_bank_ifsc" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_bank_ifsc</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_bank_ifsc">
|
||||
<column name="ifsc_code" value="BANK0000001"/>
|
||||
<column name="branch_code" value="MAIN"/>
|
||||
<column name="branch_name" value="Head Office (placeholder — replace with sponsor-bank IFSC list before go-live)"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_by" valueNumeric="1"/>
|
||||
<column name="created_on_utc" valueComputed="CURRENT_TIMESTAMP"/>
|
||||
<column name="last_modified_by" valueNumeric="1"/>
|
||||
<column name="last_modified_on_utc" valueComputed="CURRENT_TIMESTAMP"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<?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
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
Global per-channel transfer limits + maker-checker thresholds. Per-customer
|
||||
overrides live in m_customer_transfer_limit. Resolution = customer override
|
||||
first, fall back to this table.
|
||||
|
||||
maker_checker_above semantics:
|
||||
NULL or 0 → every transfer needs maker-checker
|
||||
huge val → never (auto-execute)
|
||||
else → maker-checker required when amount >= threshold
|
||||
-->
|
||||
<changeSet id="0302_create_m_transfer_limit_config" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_transfer_limit_config"/></not></preConditions>
|
||||
<createTable tableName="m_transfer_limit_config">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints nullable="false" primaryKey="true"/>
|
||||
</column>
|
||||
<column name="channel" type="VARCHAR(16)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="direction" type="VARCHAR(8)"/>
|
||||
<column name="per_txn_min" type="DECIMAL(19,4)" defaultValueNumeric="1.00"/>
|
||||
<column name="per_txn_max" type="DECIMAL(19,4)"/>
|
||||
<column name="per_day_max" type="DECIMAL(19,4)"/>
|
||||
<column name="maker_checker_above" type="DECIMAL(19,4)"/>
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<column name="created_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="version" type="BIGINT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<addUniqueConstraint tableName="m_transfer_limit_config" columnNames="channel"
|
||||
constraintName="uq_transfer_limit_config_channel"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0302_seed_transfer_limit_config_defaults" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_transfer_limit_config</sqlCheck>
|
||||
</preConditions>
|
||||
<!-- INTERNAL (on-us): no upper cap, maker-checker at ₹50,000 -->
|
||||
<insert tableName="m_transfer_limit_config">
|
||||
<column name="channel" value="INTERNAL"/>
|
||||
<column name="per_txn_min" valueNumeric="1.00"/>
|
||||
<column name="maker_checker_above" valueNumeric="50000.00"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_by" valueNumeric="1"/>
|
||||
<column name="created_on_utc" valueComputed="CURRENT_TIMESTAMP"/>
|
||||
<column name="last_modified_by" valueNumeric="1"/>
|
||||
<column name="last_modified_on_utc" valueComputed="CURRENT_TIMESTAMP"/>
|
||||
</insert>
|
||||
<!-- IMPS: per RBI, ₹5 lakh per-txn cap; maker-checker at ₹1 lakh -->
|
||||
<insert tableName="m_transfer_limit_config">
|
||||
<column name="channel" value="IMPS"/>
|
||||
<column name="per_txn_min" valueNumeric="1.00"/>
|
||||
<column name="per_txn_max" valueNumeric="500000.00"/>
|
||||
<column name="per_day_max" valueNumeric="1000000.00"/>
|
||||
<column name="maker_checker_above" valueNumeric="100000.00"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_by" valueNumeric="1"/>
|
||||
<column name="created_on_utc" valueComputed="CURRENT_TIMESTAMP"/>
|
||||
<column name="last_modified_by" valueNumeric="1"/>
|
||||
<column name="last_modified_on_utc" valueComputed="CURRENT_TIMESTAMP"/>
|
||||
</insert>
|
||||
<!-- NEFT: no cap, maker-checker at ₹2 lakh -->
|
||||
<insert tableName="m_transfer_limit_config">
|
||||
<column name="channel" value="NEFT"/>
|
||||
<column name="per_txn_min" valueNumeric="1.00"/>
|
||||
<column name="maker_checker_above" valueNumeric="200000.00"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_by" valueNumeric="1"/>
|
||||
<column name="created_on_utc" valueComputed="CURRENT_TIMESTAMP"/>
|
||||
<column name="last_modified_by" valueNumeric="1"/>
|
||||
<column name="last_modified_on_utc" valueComputed="CURRENT_TIMESTAMP"/>
|
||||
</insert>
|
||||
<!-- RTGS: min ₹2 lakh, maker-checker always -->
|
||||
<insert tableName="m_transfer_limit_config">
|
||||
<column name="channel" value="RTGS"/>
|
||||
<column name="per_txn_min" valueNumeric="200000.00"/>
|
||||
<column name="maker_checker_above" valueNumeric="0.00"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="created_by" valueNumeric="1"/>
|
||||
<column name="created_on_utc" valueComputed="CURRENT_TIMESTAMP"/>
|
||||
<column name="last_modified_by" valueNumeric="1"/>
|
||||
<column name="last_modified_on_utc" valueComputed="CURRENT_TIMESTAMP"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
<?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
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
Per-customer override of (per_txn_max, per_day_max) for a given channel.
|
||||
NULL on either column inherits the global default from m_transfer_limit_config.
|
||||
Effective dating lets us raise/lower a customer's limit at KYC re-review without
|
||||
deleting history.
|
||||
-->
|
||||
<changeSet id="0303_create_m_customer_transfer_limit" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_customer_transfer_limit"/></not></preConditions>
|
||||
<createTable tableName="m_customer_transfer_limit">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints nullable="false" primaryKey="true"/>
|
||||
</column>
|
||||
<column name="client_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="channel" type="VARCHAR(16)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="per_txn_max" type="DECIMAL(19,4)"/>
|
||||
<column name="per_day_max" type="DECIMAL(19,4)"/>
|
||||
<column name="effective_from" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="effective_to" type="DATE"/>
|
||||
|
||||
<column name="created_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="version" type="BIGINT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_customer_transfer_limit" baseColumnNames="client_id"
|
||||
constraintName="fk_cust_transfer_limit_client"
|
||||
referencedTableName="m_client" referencedColumnNames="id"/>
|
||||
|
||||
<addUniqueConstraint tableName="m_customer_transfer_limit"
|
||||
columnNames="client_id, channel, effective_from"
|
||||
constraintName="uq_cust_transfer_limit_scope"/>
|
||||
|
||||
<createIndex tableName="m_customer_transfer_limit" indexName="idx_cust_transfer_limit_lookup">
|
||||
<column name="client_id"/>
|
||||
<column name="channel"/>
|
||||
<column name="effective_from"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
<?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
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
One row per customer-initiated outward fund transfer (INTERNAL/IMPS/NEFT/RTGS).
|
||||
Distinct from m_imps_transaction which tracks INBOUND IMPS calls Fineract
|
||||
receives as the bank under §6.2.x.
|
||||
|
||||
Lifecycle:
|
||||
INITIATED → VALIDATED → VERIFIED → IN_PROGRESS → SUCCESS / DEEMED / FAILED / REVERSED
|
||||
Plus an approval axis: AUTO / PENDING_APPROVAL / APPROVED / REJECTED
|
||||
-->
|
||||
<changeSet id="0304_create_m_fund_transfer" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_fund_transfer"/></not></preConditions>
|
||||
<createTable tableName="m_fund_transfer">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints nullable="false" primaryKey="true"/>
|
||||
</column>
|
||||
<column name="txn_ref" type="VARCHAR(64)">
|
||||
<constraints nullable="false" unique="true" uniqueConstraintName="uq_fund_transfer_ref"/>
|
||||
</column>
|
||||
<column name="channel" type="VARCHAR(16)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="direction" type="VARCHAR(8)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="status" type="VARCHAR(24)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="approval_status" type="VARCHAR(16)" defaultValue="AUTO">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="response_code" type="VARCHAR(8)"/>
|
||||
<column name="response_message" type="VARCHAR(255)"/>
|
||||
|
||||
<!-- Cashier (initiator) + customer (owner of payer account) -->
|
||||
<column name="cashier_user_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="client_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- Payer side -->
|
||||
<column name="payer_account_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="payer_account_number" type="VARCHAR(64)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- Payee side -->
|
||||
<column name="payee_account_number" type="VARCHAR(64)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="payee_ifsc" type="VARCHAR(11)"/>
|
||||
<column name="payee_name" type="VARCHAR(255)"/>
|
||||
<column name="payee_mask_name" type="VARCHAR(255)"/>
|
||||
|
||||
<column name="amount" type="DECIMAL(19,4)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="remarks" type="VARCHAR(255)"/>
|
||||
|
||||
<!-- External handles -->
|
||||
<column name="internal_txn_id" type="BIGINT"/>
|
||||
<column name="switch_txn_id" type="VARCHAR(64)"/>
|
||||
<column name="utr" type="VARCHAR(64)"/>
|
||||
|
||||
<!-- Maker-checker linkage -->
|
||||
<column name="command_source_id" type="BIGINT"/>
|
||||
|
||||
<column name="initiated_at" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="settled_at" type="DATETIME"/>
|
||||
|
||||
<column name="created_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="version" type="BIGINT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_fund_transfer" baseColumnNames="cashier_user_id"
|
||||
constraintName="fk_fund_transfer_cashier"
|
||||
referencedTableName="m_appuser" referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_fund_transfer" baseColumnNames="client_id"
|
||||
constraintName="fk_fund_transfer_client"
|
||||
referencedTableName="m_client" referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_fund_transfer" baseColumnNames="payer_account_id"
|
||||
constraintName="fk_fund_transfer_payer_acc"
|
||||
referencedTableName="m_savings_account" referencedColumnNames="id"/>
|
||||
|
||||
<!-- Cashier's pipeline + daily-limit summation -->
|
||||
<createIndex tableName="m_fund_transfer" indexName="idx_fund_transfer_payer_initiated">
|
||||
<column name="payer_account_id"/>
|
||||
<column name="initiated_at"/>
|
||||
</createIndex>
|
||||
|
||||
<!-- Operations queues -->
|
||||
<createIndex tableName="m_fund_transfer" indexName="idx_fund_transfer_channel_status">
|
||||
<column name="channel"/>
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
|
||||
<!-- Reconciliation by switch handles -->
|
||||
<createIndex tableName="m_fund_transfer" indexName="idx_fund_transfer_utr">
|
||||
<column name="utr"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_fund_transfer" indexName="idx_fund_transfer_switch_txn">
|
||||
<column name="switch_txn_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<?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
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
Append-only audit log of every state change on a m_fund_transfer row. Mandatory
|
||||
for fraud investigations and RBI reconciliation queries.
|
||||
-->
|
||||
<changeSet id="0305_create_m_fund_transfer_event" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_fund_transfer_event"/></not></preConditions>
|
||||
<createTable tableName="m_fund_transfer_event">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints nullable="false" primaryKey="true"/>
|
||||
</column>
|
||||
<column name="fund_transfer_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="from_status" type="VARCHAR(24)"/>
|
||||
<column name="to_status" type="VARCHAR(24)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="event" type="VARCHAR(48)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="payload_json" type="CLOB"/>
|
||||
<column name="created_at" type="DATETIME" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_by" type="BIGINT"/>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_fund_transfer_event" baseColumnNames="fund_transfer_id"
|
||||
constraintName="fk_fund_transfer_event_parent"
|
||||
referencedTableName="m_fund_transfer" referencedColumnNames="id"/>
|
||||
|
||||
<createIndex tableName="m_fund_transfer_event" indexName="idx_fund_transfer_event_parent_created">
|
||||
<column name="fund_transfer_id"/>
|
||||
<column name="created_at"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<?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
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
Short-lived token issued at /v1/transfers/verify (BANL step) and redeemed at
|
||||
/v1/transfers (actual debit). Carries the locked transfer params so the
|
||||
customer can't change account number / amount between confirming the masked
|
||||
name and the debit landing. Also doubles as the idempotency key — a token
|
||||
already in REDEEMED state returns the cached transfer ref.
|
||||
-->
|
||||
<changeSet id="0306_create_m_verification_token" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_verification_token"/></not></preConditions>
|
||||
<createTable tableName="m_verification_token">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints nullable="false" primaryKey="true"/>
|
||||
</column>
|
||||
<column name="token" type="VARCHAR(64)">
|
||||
<constraints nullable="false" unique="true" uniqueConstraintName="uq_verification_token"/>
|
||||
</column>
|
||||
|
||||
<!-- Locked params (cashier cannot vary these between verify and debit) -->
|
||||
<column name="channel" type="VARCHAR(16)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="direction" type="VARCHAR(8)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="cashier_user_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="client_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="payer_account_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="payee_account_number" type="VARCHAR(64)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="payee_ifsc" type="VARCHAR(11)"/>
|
||||
<column name="payee_mask_name" type="VARCHAR(255)"/>
|
||||
<column name="amount" type="DECIMAL(19,4)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- Lifecycle -->
|
||||
<column name="status" type="VARCHAR(16)" defaultValue="ISSUED">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="issued_at" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="expires_at" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="redeemed_at" type="DATETIME"/>
|
||||
<column name="redeemed_fund_transfer_id" type="BIGINT"/>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_verification_token" baseColumnNames="cashier_user_id"
|
||||
constraintName="fk_verification_token_cashier"
|
||||
referencedTableName="m_appuser" referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_verification_token" baseColumnNames="client_id"
|
||||
constraintName="fk_verification_token_client"
|
||||
referencedTableName="m_client" referencedColumnNames="id"/>
|
||||
|
||||
<createIndex tableName="m_verification_token" indexName="idx_verification_token_expires">
|
||||
<column name="status"/>
|
||||
<column name="expires_at"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
<?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
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
Permissions for fund transfer (FUNDTRANSFER entity) and its sibling admin
|
||||
entities (BANKIFSC, CUSTOMERTRANSFERLIMIT). can_maker_checker=true on the
|
||||
sensitive ones drives Fineract's command-pattern approval queue.
|
||||
-->
|
||||
<changeSet id="0307_seed_fund_transfer_permissions" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_FUNDTRANSFER'</sqlCheck>
|
||||
</preConditions>
|
||||
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="transaction_savings"/>
|
||||
<column name="code" value="CREATE_FUNDTRANSFER"/>
|
||||
<column name="entity_name" value="FUNDTRANSFER"/>
|
||||
<column name="action_name" value="CREATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="true"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="transaction_savings"/>
|
||||
<column name="code" value="APPROVE_FUNDTRANSFER"/>
|
||||
<column name="entity_name" value="FUNDTRANSFER"/>
|
||||
<column name="action_name" value="APPROVE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="transaction_savings"/>
|
||||
<column name="code" value="REJECT_FUNDTRANSFER"/>
|
||||
<column name="entity_name" value="FUNDTRANSFER"/>
|
||||
<column name="action_name" value="REJECT"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="transaction_savings"/>
|
||||
<column name="code" value="READ_FUNDTRANSFER"/>
|
||||
<column name="entity_name" value="FUNDTRANSFER"/>
|
||||
<column name="action_name" value="READ"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="configuration"/>
|
||||
<column name="code" value="CREATE_BANKIFSC"/>
|
||||
<column name="entity_name" value="BANKIFSC"/>
|
||||
<column name="action_name" value="CREATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="configuration"/>
|
||||
<column name="code" value="UPDATE_BANKIFSC"/>
|
||||
<column name="entity_name" value="BANKIFSC"/>
|
||||
<column name="action_name" value="UPDATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="configuration"/>
|
||||
<column name="code" value="READ_BANKIFSC"/>
|
||||
<column name="entity_name" value="BANKIFSC"/>
|
||||
<column name="action_name" value="READ"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="configuration"/>
|
||||
<column name="code" value="CREATE_CUSTOMERTRANSFERLIMIT"/>
|
||||
<column name="entity_name" value="CUSTOMERTRANSFERLIMIT"/>
|
||||
<column name="action_name" value="CREATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="true"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="configuration"/>
|
||||
<column name="code" value="UPDATE_CUSTOMERTRANSFERLIMIT"/>
|
||||
<column name="entity_name" value="CUSTOMERTRANSFERLIMIT"/>
|
||||
<column name="action_name" value="UPDATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="true"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="configuration"/>
|
||||
<column name="code" value="READ_CUSTOMERTRANSFERLIMIT"/>
|
||||
<column name="entity_name" value="CUSTOMERTRANSFERLIMIT"/>
|
||||
<column name="action_name" value="READ"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<?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
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
Bootstrap permission grants for the fund-transfer module.
|
||||
|
||||
Two role buckets:
|
||||
- 'Super user' gets EVERYTHING (initiate + read + approve + reject + admin tables).
|
||||
- Every role attached to 'bankadmin' gets the cashier subset (initiate + read).
|
||||
Matches the pattern used in 0291. Bank-side roles (Bank Cashier, Branch Manager)
|
||||
can be created in Manage Roles UI later; this migration unblocks the immediate
|
||||
"log in as bankadmin and use the new endpoints" path.
|
||||
|
||||
Idempotent via NOT EXISTS. No-op on installs missing the target users/roles.
|
||||
-->
|
||||
<changeSet id="0308_grant_fund_transfer_to_superuser" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'Super user'</sqlCheck>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_FUNDTRANSFER'</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r
|
||||
CROSS JOIN m_permission p
|
||||
WHERE r.name = 'Super user'
|
||||
AND p.code IN (
|
||||
'CREATE_FUNDTRANSFER', 'READ_FUNDTRANSFER',
|
||||
'APPROVE_FUNDTRANSFER', 'REJECT_FUNDTRANSFER',
|
||||
'CREATE_BANKIFSC', 'UPDATE_BANKIFSC', 'READ_BANKIFSC',
|
||||
'CREATE_CUSTOMERTRANSFERLIMIT', 'UPDATE_CUSTOMERTRANSFERLIMIT', 'READ_CUSTOMERTRANSFERLIMIT'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||
)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0308_grant_fund_transfer_to_bankadmin_roles" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_appuser WHERE username = 'bankadmin'</sqlCheck>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_FUNDTRANSFER'</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT ur.role_id, p.id
|
||||
FROM m_appuser u
|
||||
JOIN m_appuser_role ur ON ur.appuser_id = u.id
|
||||
CROSS JOIN m_permission p
|
||||
WHERE u.username = 'bankadmin'
|
||||
AND p.code IN ('CREATE_FUNDTRANSFER', 'READ_FUNDTRANSFER', 'READ_BANKIFSC', 'READ_CUSTOMERTRANSFERLIMIT')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
|
||||
)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
<?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
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!-- Phase 3 wiring: two Quartz jobs + RECALL_FUNDTRANSFER permission. -->
|
||||
|
||||
<changeSet author="fineract" id="0309_imps_deemed_reconcile_job">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'IMPS DEEMED Reconciliation'</sqlCheck>
|
||||
</preConditions>
|
||||
<!--
|
||||
Polls the IMPS switch (ReqChkTxn) for every DEEMED transfer in the [24h ago, 30s ago]
|
||||
window. Cron: every 30 seconds. Adjust via Manage Jobs in the UI if too aggressive.
|
||||
-->
|
||||
<insert tableName="job">
|
||||
<column name="name" value="IMPS DEEMED Reconciliation"/>
|
||||
<column name="short_name" value="IMPS_REC"/>
|
||||
<column name="display_name" value="IMPS DEEMED Reconciliation"/>
|
||||
<column name="cron_expression" value="0/30 * * * * ?"/>
|
||||
<column name="create_time" valueDate="${current_datetime}"/>
|
||||
<column name="task_priority" valueNumeric="5"/>
|
||||
<column name="group_name"/>
|
||||
<column name="previous_run_start_time"/>
|
||||
<column name="job_key" value="IMPS DEEMED ReconciliationDetail1 _ DEFAULT"/>
|
||||
<column name="initializing_errorlog"/>
|
||||
<column name="is_active" valueBoolean="false"/>
|
||||
<column name="currently_running" valueBoolean="false"/>
|
||||
<column name="updates_allowed" valueBoolean="true"/>
|
||||
<column name="scheduler_group" valueNumeric="0"/>
|
||||
<column name="is_misfired" valueBoolean="false"/>
|
||||
<column name="node_id" valueNumeric="1"/>
|
||||
<column name="is_mismatched_job" valueBoolean="true"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0309_neft_batch_process_job">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'NEFT Batch Process'</sqlCheck>
|
||||
</preConditions>
|
||||
<!--
|
||||
Processes QUEUED NEFT rows through the switch. Cron: top and bottom of every hour,
|
||||
matching the NEFT half-hourly settlement schedule.
|
||||
-->
|
||||
<insert tableName="job">
|
||||
<column name="name" value="NEFT Batch Process"/>
|
||||
<column name="short_name" value="NEFT_BAT"/>
|
||||
<column name="display_name" value="NEFT Batch Process"/>
|
||||
<column name="cron_expression" value="0 0,30 * * * ?"/>
|
||||
<column name="create_time" valueDate="${current_datetime}"/>
|
||||
<column name="task_priority" valueNumeric="5"/>
|
||||
<column name="group_name"/>
|
||||
<column name="previous_run_start_time"/>
|
||||
<column name="job_key" value="NEFT Batch ProcessDetail1 _ DEFAULT"/>
|
||||
<column name="initializing_errorlog"/>
|
||||
<column name="is_active" valueBoolean="false"/>
|
||||
<column name="currently_running" valueBoolean="false"/>
|
||||
<column name="updates_allowed" valueBoolean="true"/>
|
||||
<column name="scheduler_group" valueNumeric="0"/>
|
||||
<column name="is_misfired" valueBoolean="false"/>
|
||||
<column name="node_id" valueNumeric="1"/>
|
||||
<column name="is_mismatched_job" valueBoolean="true"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0309_seed_recall_fund_transfer_permission" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'RECALL_FUNDTRANSFER'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="transaction_savings"/>
|
||||
<column name="code" value="RECALL_FUNDTRANSFER"/>
|
||||
<column name="entity_name" value="FUNDTRANSFER"/>
|
||||
<column name="action_name" value="RECALL"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0309_grant_recall_to_superuser" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'Super user'</sqlCheck>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_permission WHERE code = 'RECALL_FUNDTRANSFER'</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r
|
||||
CROSS JOIN m_permission p
|
||||
WHERE r.name = 'Super user'
|
||||
AND p.code = 'RECALL_FUNDTRANSFER'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||
)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
<?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
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
Creates two purpose-built roles for the fund-transfer module so admins don't have to
|
||||
wire permissions by hand in Manage Roles UI. Existing 0308 grants to Super user +
|
||||
bankadmin stay; these add the canonical pair.
|
||||
|
||||
Bank Cashier: initiates transfers, reads bank IFSC list, reads customer limit view
|
||||
Branch Manager: everything the cashier can do, plus approve / reject / recall, plus
|
||||
create / update / read per-customer limit overrides
|
||||
-->
|
||||
|
||||
<changeSet id="0310_create_bank_cashier_role" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_role WHERE name = 'Bank Cashier'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_role">
|
||||
<column name="name" value="Bank Cashier"/>
|
||||
<column name="description" value="Teller-side fund-transfer role. Initiate IMPS / NEFT / RTGS / INTERNAL transfers via /v1/transfers. No approval / recall rights."/>
|
||||
<column name="is_disabled" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0310_grant_bank_cashier_permissions" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'Bank Cashier'</sqlCheck>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_FUNDTRANSFER'</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r
|
||||
CROSS JOIN m_permission p
|
||||
WHERE r.name = 'Bank Cashier'
|
||||
AND p.code IN ('CREATE_FUNDTRANSFER', 'READ_FUNDTRANSFER', 'READ_BANKIFSC', 'READ_CUSTOMERTRANSFERLIMIT')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||
)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0310_create_branch_manager_role" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_role WHERE name = 'Branch Manager'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_role">
|
||||
<column name="name" value="Branch Manager"/>
|
||||
<column name="description" value="Approves / rejects / recalls fund transfers above maker-checker threshold. Can also create and update per-customer limit overrides. Includes all Bank Cashier rights."/>
|
||||
<column name="is_disabled" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0310_grant_branch_manager_permissions" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'Branch Manager'</sqlCheck>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_permission WHERE code = 'APPROVE_FUNDTRANSFER'</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r
|
||||
CROSS JOIN m_permission p
|
||||
WHERE r.name = 'Branch Manager'
|
||||
AND p.code IN (
|
||||
'CREATE_FUNDTRANSFER', 'READ_FUNDTRANSFER',
|
||||
'APPROVE_FUNDTRANSFER', 'REJECT_FUNDTRANSFER', 'RECALL_FUNDTRANSFER',
|
||||
'READ_BANKIFSC',
|
||||
'CREATE_CUSTOMERTRANSFERLIMIT', 'UPDATE_CUSTOMERTRANSFERLIMIT', 'READ_CUSTOMERTRANSFERLIMIT'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||
)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Register the verification-token cleanup Quartz job. Inactive by default; ops should
|
||||
flip is_active to true once they're happy with the retention window
|
||||
(fineract.transfer.verification-token.retention-hours, default 24h).
|
||||
Cron: every 15 minutes — light query, no concurrency concerns.
|
||||
-->
|
||||
<changeSet author="fineract" id="0310_verification_token_cleanup_job">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'Verification Token Cleanup'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="job">
|
||||
<column name="name" value="Verification Token Cleanup"/>
|
||||
<column name="short_name" value="VTOK_CLN"/>
|
||||
<column name="display_name" value="Verification Token Cleanup"/>
|
||||
<column name="cron_expression" value="0 0/15 * * * ?"/>
|
||||
<column name="create_time" valueDate="${current_datetime}"/>
|
||||
<column name="task_priority" valueNumeric="5"/>
|
||||
<column name="group_name"/>
|
||||
<column name="previous_run_start_time"/>
|
||||
<column name="job_key" value="Verification Token CleanupDetail1 _ DEFAULT"/>
|
||||
<column name="initializing_errorlog"/>
|
||||
<column name="is_active" valueBoolean="false"/>
|
||||
<column name="currently_running" valueBoolean="false"/>
|
||||
<column name="updates_allowed" valueBoolean="true"/>
|
||||
<column name="scheduler_group" valueNumeric="0"/>
|
||||
<column name="is_misfired" valueBoolean="false"/>
|
||||
<column name="node_id" valueNumeric="1"/>
|
||||
<column name="is_mismatched_job" valueBoolean="true"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<?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
|
||||
-->
|
||||
<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">
|
||||
|
||||
<!--
|
||||
Bootstraps the suspense GL account that OFF_US transfers park money in while in flight.
|
||||
|
||||
Topology:
|
||||
House client (m_client, external_id='FUNDTRANSFER_SUSPENSE_HOUSE')
|
||||
└── Suspense savings account (m_savings_account, external_id='FUNDTRANSFER_SUSPENSE')
|
||||
|
||||
The savings account inherits its currency / interest / overdraft fields from the first
|
||||
active savings product so we don't have to hardcode env-specific defaults. If no savings
|
||||
product exists yet, both changesets MARK_RAN — admin must create a product first then
|
||||
let ops re-run the changelog (or do the inserts by hand).
|
||||
|
||||
SuspenseGlService resolves these at runtime via external_id, so the auto-generated id
|
||||
doesn't need to be threaded through application.properties.
|
||||
-->
|
||||
|
||||
<changeSet id="0311_create_house_client" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_client WHERE external_id = 'FUNDTRANSFER_SUSPENSE_HOUSE'</sqlCheck>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_office WHERE id = 1</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_client (
|
||||
account_no, external_id, status_enum, office_id, display_name,
|
||||
fullname, is_staff, activation_date, office_joining_date,
|
||||
submittedon_date, legal_form_enum
|
||||
)
|
||||
VALUES (
|
||||
'HOUSE0000001', 'FUNDTRANSFER_SUSPENSE_HOUSE', 300, 1, 'House (Fund Transfer Suspense)',
|
||||
'House (Fund Transfer Suspense)', false, CURRENT_DATE, CURRENT_DATE,
|
||||
CURRENT_DATE, 2
|
||||
)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0311_create_suspense_savings_account" author="fineract">
|
||||
<preConditions onFail="MARK_RAN" onFailMessage="At least one savings product required to bootstrap the fund-transfer suspense account">
|
||||
<and>
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_savings_account WHERE external_id = 'FUNDTRANSFER_SUSPENSE'</sqlCheck>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_client WHERE external_id = 'FUNDTRANSFER_SUSPENSE_HOUSE'</sqlCheck>
|
||||
<sqlCheck expectedResult="1">
|
||||
SELECT CASE WHEN COUNT(*) >= 1 THEN 1 ELSE 0 END FROM m_savings_product
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_savings_account (
|
||||
account_no, external_id, client_id, product_id,
|
||||
status_enum, sub_status_enum, account_type_enum, deposit_type_enum,
|
||||
submittedon_date, activatedon_date,
|
||||
currency_code, currency_digits, currency_multiplesof,
|
||||
nominal_annual_interest_rate,
|
||||
interest_compounding_period_enum, interest_posting_period_enum,
|
||||
interest_calculation_type_enum, interest_calculation_days_in_year_type_enum,
|
||||
withdrawal_fee_for_transfer, allow_overdraft, overdraft_limit,
|
||||
nominal_annual_interest_rate_overdraft, min_overdraft_for_interest_calculation,
|
||||
enforce_min_required_balance, account_balance_derived,
|
||||
version, withhold_tax
|
||||
)
|
||||
SELECT
|
||||
'SUSPENSE01', 'FUNDTRANSFER_SUSPENSE',
|
||||
(SELECT id FROM m_client WHERE external_id = 'FUNDTRANSFER_SUSPENSE_HOUSE'),
|
||||
sp.id,
|
||||
300, 0, 1, 100,
|
||||
CURRENT_DATE, CURRENT_DATE,
|
||||
sp.currency_code, sp.currency_digits, sp.currency_multiplesof,
|
||||
sp.nominal_annual_interest_rate,
|
||||
sp.interest_compounding_period_enum, sp.interest_posting_period_enum,
|
||||
sp.interest_calculation_type_enum, sp.interest_calculation_days_in_year_type_enum,
|
||||
false, false, NULL,
|
||||
0.000000, 0.000000,
|
||||
false, 0.000000,
|
||||
1, false
|
||||
FROM m_savings_product sp
|
||||
WHERE sp.id = (SELECT MIN(id) FROM m_savings_product)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user