Compare commits

...
Author SHA1 Message Date
anshulandClaude Opus 5.5 2569e0f836 feat(investment): interest schedule API for Interest Entry
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Add GET /v1/investment/purchases/{id}/interest-schedule. For a holding it
lists every half-yearly coupon and every 31 Mar / 30 Sep closing provision
from purchase to maturity, worked out on the face value still held:
provision = interest earned since the last coupon (30/360), and the coupon
clears what is already booked (purchased accrued interest or the earlier
provision). Lines up to the as-on date are marked due. Calculation only;
nothing is posted.

The day-count and interest arithmetic move into InvestmentInterestCalculator,
shared with Purchase Entry, and are covered by unit tests using Jitendra
Sir's worked example. Duplicate-code errors on the masters now keep the
original exception as the cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:45:41 +05:30
anshulandClaude Opus 5.5 81b5850b62 feat(investment): Bank/Party Master, Script Master and Purchase Entry backend
Treasury Investment module, first slice, following the bank's existing CBS
Investment screens:

- Bank / Party Master (m_investment_party): auto party code PTY-nnn, address,
  bank type, GL A/c, RBI code, exposure limit, LEI, counterparty bank details.
- Script Master (m_investment_script): auto script code by bond type
  (G00001, B00001, ...), SLR type (GOI / STATE_GOVT / TREASURY / NON_SLR),
  GL A/c, ISIN, interest rate, issue / maturity dates, issuer details.
- Purchase Entry (m_investment_purchase): one deal = one holding. FY deal
  number (e.g. 202627000001), auto cert no, settlement date from T+n.
  Units, book value, premium/discount, accrued interest (30/360 up to the
  deal date), settlement amount and YTM are computed by the backend.

APIs: /v1/investment/parties, /v1/investment/scripts, /v1/investment/purchases,
all through the command pipeline. Permissions READ/CREATE/UPDATE/DELETE plus
_CHECKER rows are seeded with maker-checker off and granted to roles that
hold MENU_INVESTMENT. Mapped GLs must be enabled ASSET detail accounts.
No GL entry is posted by a purchase yet.

Migrations: 0593_investment_masters, 0594_investment_purchase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:35:43 +05:30
niteshandClaude Sonnet 5 88157ee9d6 Revert "Enforce same-user maker-checker restriction"
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Reverts commits 2afd4aaf8 and 11a0d502b (both by anshul, same title).
Restores the CHECKER_SUPER_USER self-approval bypass in CommandSourceService
and the six *EditWritePlatformServiceJpaRepositoryImpl classes, and restores
the original isSameMakerCheckerEnabled()/isCheckerSuperUser()-gated same-user
check in PortfolioCommandSourceWritePlatformServiceImpl. Removes the
0588_persist_manually_enabled_maker_checker_permissions.xml migration added
alongside the restriction.

The unconditional same-user block these commits introduced was blocking
legitimate single-admin operation/testing (e.g. creating and then activating
a client as the same CHECKER_SUPER_USER), with no way to resolve it from
inside the app.

11a0d502b's only change (0012_seed_default_platform_api_client.xml comment
wording) is a no-op here: that exact text was independently reworded again by
82336cbe9 after 11a0d502b landed, so there is nothing left of 11a0d502b's
change to revert in that file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 10:02:34 +05:30
niteshandClaude Sonnet 5 760fff8d3e fix(permissions): persist manually-enabled Teller/Cashier maker-checker settings
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Same pattern as 0588_persist_manually_enabled_maker_checker_permissions:
converts a manually-applied Configure Maker Checker Tasks change into a
repeatable migration, this time for the Teller/Cashier module. Found by
diffing can_maker_checker across every permission code common to
fineract_lincpay and a reference environment -- ALLOCATECASHIER_TELLER,
ALLOCATECASHTOCASHIER_TELLER, SETTLECASHFROMCASHIER_TELLER, and
UPDATECASHIERALLOCATION_TELLER were true there but still seeded false here,
confirming they were toggled on manually and never captured for any other
environment (including UAT). The _CHECKER permissions already exist and are
already granted to Clerk/Teller/Branch Manager, so approval works as soon
as this migration runs -- no role-grant changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 16:29:26 +05:30
keshav d7ff4e4a7b Merge remote-tracking branch 'origin/dev-latest' into dev-latest
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-10-05 12:43:38 +05:30
keshav 7068d65422 feat: teller uniqueness, cashier allocation office check and interest posting report updates 2026-10-05 12:42:44 +05:30
66 changed files with 3935 additions and 267 deletions
@@ -33,4 +33,6 @@ public interface TellerRepository extends JpaRepository<Teller, Long>, JpaSpecif
Collection<Teller> findTellerByOfficeId(Long officeId);
long countByDeletedFalse();
long countByOfficeIdAndDeletedFalse(Long officeId);
}
@@ -58,4 +58,9 @@ public class TellerRepositoryWrapper {
public boolean existsAny() {
return this.repository.countByDeletedFalse() > 0;
}
/** Whether the branch already has a (not deleted) teller — each branch keeps one vault of its own. */
public boolean existsInOffice(final Long officeId) {
return this.repository.countByOfficeIdAndDeletedFalse(officeId) > 0;
}
}
@@ -28,4 +28,9 @@ public class TellerAlreadyExistsException extends AbstractPlatformDomainRuleExce
public TellerAlreadyExistsException() {
super(ERROR_MESSAGE_CODE, DEFAULT_ERROR_MESSAGE);
}
public TellerAlreadyExistsException(final String officeName) {
super(ERROR_MESSAGE_CODE, "Cannot create teller: " + officeName + " already has a teller. Each branch has one teller.",
officeName);
}
}
@@ -137,16 +137,7 @@ public class CommandSourceService {
String permission = commandSource.getPermissionCode();
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
if (isMakerChecker || result.isRollbackTransaction()) {
// isApprovedByChecker is only ever true here for a genuine checker approval
// (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run
// validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user
// editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker
// genuinely does not apply there). On the ORIGINAL submission this is always false and `user`
// here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any
// other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's
// request without needing the specific <TASK>_CHECKER permission, it must never let a maker
// auto-approve their own submission just by holding it.
if (isApprovedByChecker) {
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
commandSource.markAsChecked(user);
} else {
if (commandSource.isSanitized()) {
@@ -160,6 +151,25 @@ public class CommandSourceService {
return result;
}
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
if (!user.isCheckerSuperUser()) {
return false;
}
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
return false;
}
return true;
}
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
return;
@@ -95,6 +95,62 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createInvestmentParty() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTPARTY";
this.entityId = null;
this.href = "/investment/parties";
return this;
}
public CommandWrapperBuilder updateInvestmentParty(final Long partyId) {
this.actionName = "UPDATE";
this.entityName = "INVESTMENTPARTY";
this.entityId = partyId;
this.href = "/investment/parties/" + partyId;
return this;
}
public CommandWrapperBuilder deleteInvestmentParty(final Long partyId) {
this.actionName = "DELETE";
this.entityName = "INVESTMENTPARTY";
this.entityId = partyId;
this.href = "/investment/parties/" + partyId;
return this;
}
public CommandWrapperBuilder createInvestmentScript() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTSCRIPT";
this.entityId = null;
this.href = "/investment/scripts";
return this;
}
public CommandWrapperBuilder updateInvestmentScript(final Long scriptId) {
this.actionName = "UPDATE";
this.entityName = "INVESTMENTSCRIPT";
this.entityId = scriptId;
this.href = "/investment/scripts/" + scriptId;
return this;
}
public CommandWrapperBuilder createInvestmentPurchase() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTPURCHASE";
this.entityId = null;
this.href = "/investment/purchases";
return this;
}
public CommandWrapperBuilder deleteInvestmentPurchase(final Long purchaseId) {
this.actionName = "DELETE";
this.entityName = "INVESTMENTPURCHASE";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId;
return this;
}
public CommandWrapperBuilder createChequeStockLot() {
this.actionName = "CREATE";
this.entityName = "CHEQUESTOCKLOT";
@@ -48,12 +48,6 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
private final FromJsonHelper fromApiJsonHelper;
private final CommandProcessingService processAndLogCommandService;
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
// No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user
// can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no
// longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a
// live, independently-configurable global switch (System -> Configurations); deleting this field,
// and by extension deciding that switch should stop existing altogether, is a separate decision
// from closing this specific self-approval loophole.
private final ConfigurationDomainService configurationService;
@Override
@@ -129,20 +123,14 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
AppUser appUser = this.context.authenticatedUser();
String permissionCode = commandSource.getPermissionCode();
appUser.validateHasCheckerPermissionTo(permissionCode);
// Maker-identity rule: the user who submitted a command may never be the one who approves
// (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that
// permission still lets its holder action a DIFFERENT user's request without needing the
// specific <TASK>_CHECKER permission - see validateHasCheckerPermissionTo() above - it just no
// longer lets them action their own) and not overridable by the enable-same-maker-checker
// global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that
// configuration is left in place and unused for this decision rather than removed, since
// deleting a still-configurable global setting is a separate decision from this one).
AppUser maker = commandSource.getMaker();
if (maker == null) {
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
}
if (Objects.equals(appUser.getId(), maker.getId())) {
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
AppUser maker = commandSource.getMaker();
if (maker == null) {
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
}
if (Objects.equals(appUser.getId(), maker.getId())) {
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
}
}
return commandSource;
}
@@ -130,7 +130,12 @@ public class TellerManagementReadPlatformServiceImpl implements TellerManagement
@Override
public Collection<TellerData> getTellers(Long officeId) {
return retrieveAllTellers(false);
if (officeId == null) {
return retrieveAllTellers(false);
}
// The tellers of that one branch. The officeId used to be ignored, so the head office, whose
// hierarchy covers every branch, counted each branch's vault and cashiers as its own at day close.
return retrieveAllTellers(false).stream().filter(t -> officeId.equals(t.getOfficeId())).toList();
}
@Override
@@ -70,10 +70,6 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
try {
this.context.authenticatedUser();
if (this.tellerRepositoryWrapper.existsAny()) {
throw new TellerAlreadyExistsException();
}
final Long officeId = command.longValueOfParameterNamed("officeId");
this.fromApiJsonDeserializer.validateForCreateAndUpdateTeller(command.json());
@@ -81,6 +77,12 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
// final Office parent =
// validateUserPriviledgeOnOfficeAndRetrieve(currentUser, officeId);
final Office tellerOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
// One teller (vault) per branch, not one for the whole bank: each branch holds and settles
// its own cash, so a second branch needs a vault of its own.
if (this.tellerRepositoryWrapper.existsInOffice(officeId)) {
throw new TellerAlreadyExistsException(tellerOffice.getName());
}
final Teller teller = Teller.fromJson(tellerOffice, command);
// pre save to generate id for use in office hierarchy
@@ -71,6 +71,20 @@ import org.springframework.transaction.annotation.Transactional;
@Produces({ MediaType.APPLICATION_JSON })
public class BranchDayRegisterApiResource {
/**
* The branch whose counter took a breakdown {@code std}: the teller office of its officer's cashier
* allocation if that allocation already existed when the cash was counted, otherwise the officer's
* own office. A person moved to another branch's teller keeps what they took at the old branch
* there.
*/
private static final String COUNTER_OFFICE_OF_STD = """
COALESCE((SELECT t.office_id FROM m_appuser au JOIN m_cashiers mc ON mc.staff_id = au.staff_id
JOIN m_tellers t ON t.id = mc.teller_id
WHERE au.id = std.officer_id""" + CountedCashLedger.SINCE_ALLOCATION + """
LIMIT 1),
(SELECT au.office_id FROM m_appuser au WHERE au.id = std.officer_id))
""";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
@@ -195,8 +209,8 @@ public class BranchDayRegisterApiResource {
// Scoped to this office through the officer who took the cash (appuser -> staff -> cashier ->
// teller -> office). Unscoped, a branch's own "does the physical count agree with the books"
// cross-check was being compared against every branch's counted cash at once, which makes it
// wrong at every office but the first. A row whose officer maps to no cashier is still
// counted, for the same reason as the withdrawal legs in bookBreakdown.
// wrong at every office but the first. A row whose officer has no cashier allocation counts
// at the officer's own office (see COUNTER_OFFICE_OF_STD), not at every office.
//
// Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the
// note there on why a manual GL entry's decoded reference is kept and a stamped millisecond
@@ -205,23 +219,15 @@ public class BranchDayRegisterApiResource {
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id)
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
JOIN m_tellers t ON t.id = c.teller_id
WHERE au.id = std.officer_id AND t.office_id = ?))
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
) = ?)
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
AND """ + CountedCashLedger.COUNTED_ROW + """
AND (std.officer_id IS NULL
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
WHERE au.id = std.officer_id)
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
JOIN m_tellers t ON t.id = c.teller_id
WHERE au.id = std.officer_id AND t.office_id = ?))
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
) = ?)
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
result.put("denominationIn", countedIn);
result.put("denominationOut", countedOut);
@@ -609,11 +615,9 @@ public class BranchDayRegisterApiResource {
// never opened is not waited for.
final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId);
if (isHeadOffice) {
final List<String> stillOpen = jdbcTemplate.queryForList("""
SELECT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id
WHERE r.business_date = ? AND r.closed_at IS NULL AND r.office_id <> ?
ORDER BY o.name
""", String.class, Date.valueOf(businessDate), officeId);
// Any day still open counts, not only today's: a branch whose earlier day was never closed is
// still working and has not posted its interest.
final List<String> stillOpen = interestPostingReportService.branchesStillOpen(businessDate, officeId);
if (!stillOpen.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open",
"Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen)
@@ -1058,6 +1062,9 @@ public class BranchDayRegisterApiResource {
final Map<String, Object> r = rows.get(0);
final Map<String, Object> m = new LinkedHashMap<>();
m.put("id", r.get("id"));
// The close screen fetches this office's tellers with it; left out, it fetched every teller
// the user can see, and the head office counted each branch's vault as its own.
m.put("officeId", r.get("office_id"));
m.put("businessDate", str(r.get("business_date")));
m.put("openedByName", r.get("opened_by_name"));
m.put("openedAt", str(r.get("opened_at")));
@@ -1111,11 +1118,13 @@ public class BranchDayRegisterApiResource {
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
// By the counter that took the cash. A savings deposit carries no office_id, and the old
// "OR office_id IS NULL" counted every such deposit in the bank at every branch.
final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
WHERE status = 'APPROVED'
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
AND (office_id = ? OR office_id IS NULL)
AND COALESCE(counter_office_id, office_id) = ?
""", BigDecimal.class, date, officeId));
// Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
@@ -166,8 +166,8 @@ public class CashierDepositRequestApiResource {
client_id, client_name, amount,
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at,
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id, counter_office_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
@@ -179,7 +179,7 @@ public class CashierDepositRequestApiResource {
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
denominationData, activityLog, Timestamp.from(Instant.now()),
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId, counterOfficeId(currentUser)
);
// Globally unique voucher number from the central m_voucher register
@@ -691,6 +691,20 @@ public class CashierDepositRequestApiResource {
.orElse(Map.of());
}
/**
* The branch whose counter took the cash: the office of the teller the user is allocated to as a
* cashier, otherwise the user's own office. office_id cannot say this -- a savings deposit carries
* none, and a GL deposit's is the GL's branch -- and the day close totals each branch's cash by it.
*/
private Long counterOfficeId(final AppUser user) {
final List<Long> tellerOffice = user.getStaff() == null ? List.of()
: jdbcTemplate.queryForList("""
SELECT t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
WHERE c.staff_id = ? LIMIT 1
""", Long.class, user.getStaff().getId());
return tellerOffice.isEmpty() ? user.getOffice().getId() : tellerOffice.get(0);
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, voucher_no, account_no, account_type, savings_account_id,
@@ -93,7 +93,7 @@ public class CashReconciliationApiResource {
+ "AND " + CountedCashLedger.COUNTED_ROW
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Out: settlements to vault
@@ -123,7 +123,7 @@ public class CashReconciliationApiResource {
+ "AND " + CountedCashLedger.COUNTED_ROW
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// In: cash exchange — customer gave denomination TO cashier (RECEIVED direction)
@@ -671,25 +671,25 @@ public class CashReportApiResource {
// day itself: taken from the vault, in over the counter, out over the counter, settled
// back. What is left is what should physically be in the drawer.
result.put("todayVaultIn", dayLegTotal(
"SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
"SELECT COALESCE(SUM(CAST(ctd.count AS BIGINT) * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
+ " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
+ " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ct.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todaySettledOut", dayLegTotal(
"SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
"SELECT COALESCE(SUM(CAST(csd.count AS BIGINT) * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
+ " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
+ " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)"
+ " AND COALESCE(cs.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todayInterCashierIn", dayLegTotal(
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ic.is_reversed, false) = false",
scopedCashierId, txnDate));
result.put("todayInterCashierOut", dayLegTotal(
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
+ " AND COALESCE(ic.is_reversed, false) = false",
@@ -721,7 +721,7 @@ public class CashReportApiResource {
final Long scopedCashierId) {
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " : "";
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
@@ -1171,7 +1171,7 @@ public class CashReportApiResource {
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Settlements back to vault
@@ -1197,7 +1197,7 @@ public class CashReportApiResource {
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
@@ -1315,13 +1315,13 @@ public class CashReportApiResource {
if (cashierId != null) {
officerFilter = "AND std.officer_id IN ("
+ "SELECT au.id FROM m_appuser au "
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)";
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
baseParams = new Object[] { txnDate, cashierId };
} else if (tellerId != null) {
officerFilter = "AND std.officer_id IN ("
+ "SELECT au.id FROM m_appuser au "
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id "
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?)";
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
baseParams = new Object[] { txnDate, tellerId };
} else {
officerFilter = "AND std.officer_id IN ("
@@ -130,9 +130,19 @@ public final class CountedCashLedger {
+ " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))"
+ " AND" + REAL_TXN_ID + ") ";
/**
* Over the aliases {@code std} and {@code mc} (the cashier a breakdown's officer maps to): only what
* the login counted once this allocation existed. A breakdown is tied to a cashier through its
* officer's login, so without this a person moved from one branch's teller to another's carried
* every rupee they had ever taken -- at the old branch too -- into the new drawer. An allocation
* with no recorded creation time keeps its whole history.
*/
public static final String SINCE_ALLOCATION = " AND (mc.created_at IS NULL OR std.created_at IS NULL"
+ " OR std.created_at >= mc.created_at)";
/** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */
private static String breakdownDiffersFrom(final String amountColumn) {
return "ABS(" + amountColumn + " - (SELECT SUM(sd.count * sd.denomination_value)"
return "ABS(" + amountColumn + " - (SELECT SUM(CAST(sd.count AS BIGINT) * sd.denomination_value)"
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id"
+ " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1";
}
@@ -288,7 +288,7 @@ public class DenominationBreakdownWriter {
+ " WHERE std.txn_type = 'DEPOSIT'"
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
+ " GROUP BY std.denomination_id"
+ " UNION ALL"
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
@@ -312,7 +312,7 @@ public class DenominationBreakdownWriter {
+ " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
+ " GROUP BY std.denomination_id"
+ " UNION ALL"
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
@@ -409,7 +409,7 @@ public class DenominationBreakdownWriter {
+ " WHERE std.txn_type = 'DEPOSIT'"
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
+ " GROUP BY std.denomination_id"
+ " UNION ALL"
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
@@ -433,7 +433,7 @@ public class DenominationBreakdownWriter {
+ " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND " + CountedCashLedger.COUNTED_ROW
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
+ " GROUP BY std.denomination_id"
+ " UNION ALL"
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
@@ -0,0 +1,95 @@
/**
* 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.investment.api;
import java.util.Map;
import java.util.Set;
/**
* Constants for the Treasury Investment masters (Bank / Party Master and Script Master), modelled on the bank's existing
* CBS Investment screens.
*/
public final class InvestmentApiConstants {
private InvestmentApiConstants() {}
public static final String ENTITY_PARTY = "INVESTMENTPARTY";
public static final String ENTITY_SCRIPT = "INVESTMENTSCRIPT";
public static final String RESOURCE_PARTY = "investmentparty";
public static final String RESOURCE_SCRIPT = "investmentscript";
/** Instrument ("Bond Type") an investment party / script belongs to. */
public static final Set<String> BOND_TYPES = Set.of("GOVT_SECURITY", "NON_SLR", "BOND", "MUTUAL_FUND", "FD", "SHARE");
/** Bond types that carry coupon terms (ISIN, interest rate, issue date are mandatory for these). */
public static final Set<String> DEBT_BOND_TYPES = Set.of("GOVT_SECURITY", "NON_SLR", "BOND");
/** SLR classification on the Script Master ("GOI" = Central Government). */
public static final Set<String> SLR_TYPES = Set.of("GOI", "STATE_GOVT", "TREASURY", "NON_SLR");
/** Script Code prefix per bond type, e.g. G00001 for the first Govt Security. */
public static final Map<String, String> SCRIPT_CODE_PREFIX = Map.of("GOVT_SECURITY", "G", "NON_SLR", "N", "BOND", "B", "MUTUAL_FUND",
"M", "FD", "F", "SHARE", "S");
public static final String PARTY_CODE_PREFIX = "PTY-";
// Common parameters
public static final String BOND_TYPE = "bondType";
public static final String GL_ACCOUNT_ID = "glAccountId";
public static final String ADDRESS1 = "address1";
public static final String ADDRESS2 = "address2";
public static final String ADDRESS3 = "address3";
public static final String CITY = "city";
public static final String PINCODE = "pincode";
public static final String PHONE = "phone";
public static final String FAX = "fax";
public static final String ACTIVE = "active";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
// Party parameters
public static final String PARTY_NAME = "partyName";
public static final String BANK_TYPE = "bankType";
public static final String RBI_CODE = "rbiCode";
public static final String EXPOSURE_LIMIT = "limit";
public static final String LEI_NO = "leiNo";
public static final String CP_BANK_NAME = "cpBankName";
public static final String CP_BRANCH_NAME = "cpBranchName";
public static final String CP_ACCOUNT_NO = "cpAccountNo";
public static final String CP_IFSC = "cpIfsc";
// Script parameters
public static final String SLR_TYPE = "slrType";
public static final String SCRIPT_DESC = "scriptDesc";
public static final String ISIN = "isin";
public static final String FACE_VALUE_PER_UNIT = "faceValuePerUnit";
public static final String INT_RATE = "intRate";
public static final String ISSUE_DATE = "issueDate";
public static final String MATURITY_DATE = "maturityDate";
public static final String COMPANY_NAME = "companyName";
public static final String CORP_CODE = "corpCode";
public static final Set<String> PARTY_PARAMS = Set.of(BOND_TYPE, PARTY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX,
BANK_TYPE, GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE,
LOCALE);
public static final Set<String> SCRIPT_PARAMS = Set.of(BOND_TYPE, SLR_TYPE, SCRIPT_DESC, GL_ACCOUNT_ID, ISIN, FACE_VALUE_PER_UNIT,
INT_RATE, ISSUE_DATE, MATURITY_DATE, COMPANY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, CORP_CODE,
ACTIVE, LOCALE, DATE_FORMAT);
}
@@ -0,0 +1,99 @@
/**
* 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.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentPartyData;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/parties")
@Component
@Tag(name = "Investment Bank / Party Master", description = "Counterparties an investment is bought from or placed with.")
@RequiredArgsConstructor
public class InvestmentPartyApiResource {
private final PlatformSecurityContext context;
private final InvestmentMasterReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List investment parties")
public List<InvestmentPartyData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_PARTY);
return this.readPlatformService.retrieveAllParties();
}
@GET
@Path("{partyId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve an investment party")
public InvestmentPartyData retrieveOne(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_PARTY);
return this.readPlatformService.retrieveParty(partyId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create an investment party")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentParty().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@PUT
@Path("{partyId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update an investment party")
public CommandProcessingResult update(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().updateInvestmentParty(partyId).withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@DELETE
@Path("{partyId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Drop an investment party")
public CommandProcessingResult delete(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentParty(partyId).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -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.investment.api;
import java.util.Set;
/** Constants for the Investment Purchase Entry (a deal that creates a holding). */
public final class InvestmentPurchaseApiConstants {
private InvestmentPurchaseApiConstants() {}
public static final String ENTITY = "INVESTMENTPURCHASE";
public static final String RESOURCE = "investmentpurchase";
public static final Set<String> SETTLEMENT_TYPES = Set.of("T+0", "T+1", "T+2");
public static final Set<String> CATEGORIES = Set.of("HTM (Held To Maturity)", "AFS (Available For Sale)", "HFT (Held For Trading)");
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String BOND_TYPE = "bondType";
public static final String SCRIPT_ID = "scriptId";
public static final String PARTY_ID = "partyId";
public static final String DEAL_DATE = "dealDate";
public static final String DEAL_TIME = "dealTime";
public static final String SETTLEMENT_TYPE = "settlementType";
public static final String FACE_VALUE = "faceValue";
public static final String PURCHASE_RATE = "purchaseRate";
public static final String CONSIDER_AMT = "considerAmt";
public static final String NAV = "nav";
public static final String STAMP_DUTY = "stampDuty";
public static final String BROKERAGE = "brokerage";
public static final String CATEGORY = "category";
public static final String LAST_INT_DATE = "lastIntDate";
public static final String CR_GL_ACCOUNT_ID = "crGlAccountId";
public static final String SUB_TYPE = "subType";
/** Optional legacy fields (trade no, authorities, lien, delivery mode, …) kept as a JSON object. */
public static final String OTHER_DETAILS = "otherDetails";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
public static final Set<String> PARAMS = Set.of(BOND_TYPE, SCRIPT_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, FACE_VALUE,
PURCHASE_RATE, CONSIDER_AMT, NAV, STAMP_DUTY, BROKERAGE, CATEGORY, LAST_INT_DATE, CR_GL_ACCOUNT_ID, SUB_TYPE, OTHER_DETAILS,
LOCALE, DATE_FORMAT);
}
@@ -0,0 +1,106 @@
/**
* 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.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
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.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData;
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/purchases")
@Component
@Tag(name = "Investment Purchase Entry", description = "Investment deals (Govt Security, Bond, Mutual Fund, …) — each becomes a holding.")
@RequiredArgsConstructor
public class InvestmentPurchaseApiResource {
private final PlatformSecurityContext context;
private final InvestmentPurchaseReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List investment purchases (holdings)")
public List<InvestmentPurchaseData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
return this.readPlatformService.retrieveAll();
}
@GET
@Path("{purchaseId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve an investment purchase")
public InvestmentPurchaseData retrieveOne(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
return this.readPlatformService.retrieveOne(purchaseId);
}
@GET
@Path("{purchaseId}/interest-schedule")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Interest schedule of a holding", description = "Half-yearly coupons and 31 Mar / 30 Sep closing provisions "
+ "from purchase to maturity. Calculated only — nothing is posted. asOnDate (dd-MM-yyyy) defaults to the business date.")
public InvestmentInterestScheduleData retrieveInterestSchedule(
@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@QueryParam("asOnDate") @Parameter(description = "dd-MM-yyyy") final String asOnDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate asOn = StringUtils.isBlank(asOnDate) ? null
: LocalDate.parse(asOnDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy"));
return this.readPlatformService.retrieveInterestSchedule(purchaseId, asOn);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Save a purchase entry")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentPurchase().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@DELETE
@Path("{purchaseId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Drop a purchase entry")
public CommandProcessingResult delete(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentPurchase(purchaseId).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,89 @@
/**
* 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.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
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.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentScriptData;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/scripts")
@Component
@Tag(name = "Investment Script Master", description = "Securities / instruments the bank invests in (Govt Security, Bond, Mutual Fund, …).")
@RequiredArgsConstructor
public class InvestmentScriptApiResource {
private final PlatformSecurityContext context;
private final InvestmentMasterReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List investment scripts")
public List<InvestmentScriptData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_SCRIPT);
return this.readPlatformService.retrieveAllScripts();
}
@GET
@Path("{scriptId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve an investment script")
public InvestmentScriptData retrieveOne(@PathParam("scriptId") @Parameter(description = "scriptId") final Long scriptId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_SCRIPT);
return this.readPlatformService.retrieveScript(scriptId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create an investment script")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentScript().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@PUT
@Path("{scriptId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update an investment script")
public CommandProcessingResult update(@PathParam("scriptId") @Parameter(description = "scriptId") final Long scriptId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().updateInvestmentScript(scriptId).withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,75 @@
/**
* 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.investment.data;
import java.math.BigDecimal;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
/**
* Interest schedule of one holding: every half-yearly coupon and every 31 March / 30 September closing provision from
* purchase to maturity. Calculated, not yet posted.
*/
@Getter
@Builder
public class InvestmentInterestScheduleData {
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String scriptCode;
private final String scriptDesc;
private final BigDecimal faceValue;
private final BigDecimal unitValue;
private final BigDecimal balanceUnits;
private final BigDecimal purchaseRate;
private final BigDecimal bookValue;
private final BigDecimal intRate;
private final String dealDate;
private final String lastIntDate;
private final String maturityDate;
/** Interest paid to the seller at purchase — already in Interest Receivable. */
private final BigDecimal purchasedAccruedInterest;
private final BigDecimal couponPerHalfYear;
private final String asOnDate;
private final String interestGlCode;
private final String interestGlName;
private final List<Row> rows;
@Getter
@Builder
public static class Row {
/** COUPON (cash received) or PROVISION (closing accrual). */
private final String type;
private final String intDate;
private final String fromDate;
private final String toDate;
private final int days;
/** Coupon received, or interest earned since the last coupon up to the closing. */
private final BigDecimal grossInterest;
/** Interest Receivable balance before this line (purchased accrued interest / earlier provision). */
private final BigDecimal alreadyBooked;
/** Provision to book now, or the part of the coupon that is new income. */
private final BigDecimal amount;
/** True when the date is on or before the as-on date. */
private final boolean due;
}
}
@@ -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.investment.data;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
@Getter
@Builder
public class InvestmentPartyData {
private final Long id;
private final String partyCode;
private final String bondType;
private final String partyName;
private final String address1;
private final String address2;
private final String address3;
private final String city;
private final String pincode;
private final String phone;
private final String fax;
private final String bankType;
private final Long glAccountId;
private final String glCode;
private final String glName;
private final String rbiCode;
private final BigDecimal limit;
private final String leiNo;
private final String cpBankName;
private final String cpBranchName;
private final String cpAccountNo;
private final String cpIfsc;
private final boolean active;
public static InvestmentPartyData from(final InvestmentParty p) {
return InvestmentPartyData.builder().id(p.getId()).partyCode(p.getPartyCode()).bondType(p.getBondType())
.partyName(p.getPartyName()).address1(p.getAddress1()).address2(p.getAddress2()).address3(p.getAddress3())
.city(p.getCity()).pincode(p.getPincode()).phone(p.getPhone()).fax(p.getFax()).bankType(p.getBankType())
.glAccountId(p.getGlAccount().getId()).glCode(p.getGlAccount().getGlCode()).glName(p.getGlAccount().getName())
.rbiCode(p.getRbiCode()).limit(p.getExposureLimit()).leiNo(p.getLeiNo()).cpBankName(p.getCpBankName())
.cpBranchName(p.getCpBranchName()).cpAccountNo(p.getCpAccountNo()).cpIfsc(p.getCpIfsc()).active(p.isActive()).build();
}
}
@@ -0,0 +1,96 @@
/**
* 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.investment.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
@Getter
@Builder
public class InvestmentPurchaseData {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final String dealNo;
private final String certNo;
private final String bondType;
private final Long scriptId;
private final String scriptCode;
private final String scriptDesc;
private final String isin;
private final Long partyId;
private final String partyCode;
private final String partyName;
private final String dealDate;
private final String dealTime;
private final String settlementType;
private final String settlementDate;
private final BigDecimal faceValue;
private final BigDecimal purchaseRate;
private final BigDecimal units;
private final BigDecimal balanceUnits;
private final BigDecimal brokerage;
private final BigDecimal stampDuty;
private final BigDecimal bookValue;
private final BigDecimal premiumDiscount;
private final BigDecimal intRate;
private final String issueDate;
private final String maturityDate;
private final String lastIntDate;
private final Integer accruedDays;
private final BigDecimal accruedInterest;
private final BigDecimal settlementAmount;
private final BigDecimal ytm;
private final String category;
private final String subType;
private final Long investmentGlAccountId;
private final String investmentGlCode;
private final String investmentGlName;
private final Long crGlAccountId;
private final String crGlCode;
private final String crGlName;
private final String otherDetails;
private final String status;
public static InvestmentPurchaseData from(final InvestmentPurchase p) {
return InvestmentPurchaseData.builder().id(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo()).bondType(p.getBondType())
.scriptId(p.getScript().getId()).scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc())
.isin(p.getScript().getIsin()).partyId(p.getParty().getId()).partyCode(p.getParty().getPartyCode())
.partyName(p.getParty().getPartyName()).dealDate(format(p.getDealDate())).dealTime(p.getDealTime())
.settlementType(p.getSettlementType()).settlementDate(format(p.getSettlementDate())).faceValue(p.getFaceValue())
.purchaseRate(p.getPurchaseRate()).units(p.getUnits()).balanceUnits(p.getBalanceUnits()).brokerage(p.getBrokerage())
.stampDuty(p.getStampDuty()).bookValue(p.getBookValue()).premiumDiscount(p.getPremiumDiscount()).intRate(p.getIntRate())
.issueDate(format(p.getIssueDate())).maturityDate(format(p.getMaturityDate())).lastIntDate(format(p.getLastIntDate()))
.accruedDays(p.getAccruedDays()).accruedInterest(p.getAccruedInterest()).settlementAmount(p.getSettlementAmount())
.ytm(p.getYtm()).category(p.getCategory()).subType(p.getSubType())
.investmentGlAccountId(p.getInvestmentGlAccount().getId()).investmentGlCode(p.getInvestmentGlAccount().getGlCode())
.investmentGlName(p.getInvestmentGlAccount().getName()).crGlAccountId(p.getCrGlAccount().getId())
.crGlCode(p.getCrGlAccount().getGlCode()).crGlName(p.getCrGlAccount().getName()).otherDetails(p.getOtherDetails())
.status(p.getStatus()).build();
}
private static String format(final LocalDate date) {
return date == null ? null : date.format(DATE_FORMAT);
}
}
@@ -0,0 +1,72 @@
/**
* 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.investment.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
@Getter
@Builder
public class InvestmentScriptData {
/** Dates go out as DD-MM-YYYY, the format the Investment screens use throughout. */
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final String scriptCode;
private final String bondType;
private final String slrType;
private final String scriptDesc;
private final Long glAccountId;
private final String glCode;
private final String glName;
private final String isin;
private final BigDecimal faceValuePerUnit;
private final BigDecimal intRate;
private final String issueDate;
private final String maturityDate;
private final String companyName;
private final String address1;
private final String address2;
private final String address3;
private final String city;
private final String pincode;
private final String phone;
private final String fax;
private final String corpCode;
private final boolean active;
public static InvestmentScriptData from(final InvestmentScript s) {
return InvestmentScriptData.builder().id(s.getId()).scriptCode(s.getScriptCode()).bondType(s.getBondType())
.slrType(s.getSlrType()).scriptDesc(s.getScriptDesc()).glAccountId(s.getGlAccount().getId())
.glCode(s.getGlAccount().getGlCode()).glName(s.getGlAccount().getName()).isin(s.getIsin())
.faceValuePerUnit(s.getFaceValuePerUnit()).intRate(s.getIntRate()).issueDate(format(s.getIssueDate()))
.maturityDate(format(s.getMaturityDate())).companyName(s.getCompanyName()).address1(s.getAddress1())
.address2(s.getAddress2()).address3(s.getAddress3()).city(s.getCity()).pincode(s.getPincode()).phone(s.getPhone())
.fax(s.getFax()).corpCode(s.getCorpCode()).active(s.isActive()).build();
}
private static String format(final LocalDate date) {
return date == null ? null : date.format(DATE_FORMAT);
}
}
@@ -0,0 +1,105 @@
/**
* 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.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Bank / Party Master — the counterparty an investment is bought from or placed with (bank, RBI, issuer, AMC, broker),
* following the bank's existing CBS Bank / Party Master.
*/
@Entity
@Table(name = "m_investment_party")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentParty extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "party_code", nullable = false, length = 20, unique = true)
private String partyCode;
@Column(name = "bond_type", nullable = false, length = 20)
private String bondType;
@Column(name = "party_name", nullable = false, length = 150)
private String partyName;
@Column(name = "address1", length = 150)
private String address1;
@Column(name = "address2", length = 150)
private String address2;
@Column(name = "address3", length = 150)
private String address3;
@Column(name = "city", length = 100)
private String city;
@Column(name = "pincode", length = 10)
private String pincode;
@Column(name = "phone", length = 20)
private String phone;
@Column(name = "fax", length = 20)
private String fax;
@Column(name = "bank_type", length = 60)
private String bankType;
@ManyToOne
@JoinColumn(name = "gl_account_id", nullable = false)
private GLAccount glAccount;
@Column(name = "rbi_code", length = 30)
private String rbiCode;
@Column(name = "exposure_limit", scale = 6, precision = 19)
private BigDecimal exposureLimit;
@Column(name = "lei_no", length = 30)
private String leiNo;
@Column(name = "cp_bank_name", length = 150)
private String cpBankName;
@Column(name = "cp_branch_name", length = 150)
private String cpBranchName;
@Column(name = "cp_account_no", length = 40)
private String cpAccountNo;
@Column(name = "cp_ifsc", length = 11)
private String cpIfsc;
/** An inactive party is kept for history but not offered for new deals. */
@Column(name = "is_active", nullable = false)
private boolean active = true;
}
@@ -0,0 +1,23 @@
/**
* 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.investment.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentPartyRepository extends JpaRepository<InvestmentParty, Long> {}
@@ -0,0 +1,148 @@
/**
* 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.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
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.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Investment Purchase Entry — one deal (as in the bank's existing CBS "Govt.Security/Bond Entry"). Once saved it is the
* holding that interest, sale and redemption later work against; {@link #balanceUnits} tracks what is still held.
* Every derived figure (units, book value, accrued interest, settlement amount) is computed by the backend.
*/
@Entity
@Table(name = "m_investment_purchase")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentPurchase extends AbstractAuditableWithUTCDateTimeCustom<Long> {
/** Financial-year deal number, e.g. 202627000001 for the first deal of FY 2026-27. */
@Column(name = "deal_no", nullable = false, length = 20, unique = true)
private String dealNo;
/** Certificate number — system-generated running number. */
@Column(name = "cert_no", nullable = false, length = 20, unique = true)
private String certNo;
@Column(name = "bond_type", nullable = false, length = 20)
private String bondType;
@ManyToOne
@JoinColumn(name = "script_id", nullable = false)
private InvestmentScript script;
@ManyToOne
@JoinColumn(name = "party_id", nullable = false)
private InvestmentParty party;
@Column(name = "deal_date", nullable = false)
private LocalDate dealDate;
@Column(name = "deal_time", length = 8)
private String dealTime;
@Column(name = "settlement_type", nullable = false, length = 5)
private String settlementType;
@Column(name = "settlement_date", nullable = false)
private LocalDate settlementDate;
@Column(name = "face_value", scale = 6, precision = 19, nullable = false)
private BigDecimal faceValue;
/** Price per ₹100 face value (Govt Security / Bond); NAV for a mutual fund. */
@Column(name = "purchase_rate", scale = 6, precision = 19, nullable = false)
private BigDecimal purchaseRate;
@Column(name = "units", scale = 6, precision = 19, nullable = false)
private BigDecimal units;
@Column(name = "balance_units", scale = 6, precision = 19, nullable = false)
private BigDecimal balanceUnits;
@Column(name = "brokerage", scale = 6, precision = 19)
private BigDecimal brokerage;
@Column(name = "stamp_duty", scale = 6, precision = 19)
private BigDecimal stampDuty;
@Column(name = "book_value", scale = 6, precision = 19, nullable = false)
private BigDecimal bookValue;
@Column(name = "premium_discount", scale = 6, precision = 19)
private BigDecimal premiumDiscount;
@Column(name = "int_rate", scale = 4, precision = 9)
private BigDecimal intRate;
@Column(name = "issue_date")
private LocalDate issueDate;
@Column(name = "maturity_date")
private LocalDate maturityDate;
@Column(name = "last_int_date")
private LocalDate lastIntDate;
@Column(name = "accrued_days")
private Integer accruedDays;
/** Interest paid to the seller from the last coupon date up to the deal date (30/360). */
@Column(name = "accrued_interest", scale = 6, precision = 19)
private BigDecimal accruedInterest;
@Column(name = "settlement_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal settlementAmount;
@Column(name = "ytm", scale = 4, precision = 9)
private BigDecimal ytm;
@Column(name = "category", nullable = false, length = 40)
private String category;
@Column(name = "sub_type", length = 40)
private String subType;
/** Investment GL the holding is booked to (from the script). */
@ManyToOne
@JoinColumn(name = "investment_gl_account_id", nullable = false)
private GLAccount investmentGlAccount;
/** Bank GL the purchase is paid from ("CR. To A/c"). */
@ManyToOne
@JoinColumn(name = "cr_gl_account_id", nullable = false)
private GLAccount crGlAccount;
@Column(name = "other_details", length = 4000)
private String otherDetails;
@Column(name = "status", nullable = false, length = 20)
private String status;
}
@@ -0,0 +1,23 @@
/**
* 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.investment.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentPurchaseRepository extends JpaRepository<InvestmentPurchase, Long> {}
@@ -0,0 +1,107 @@
/**
* 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.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
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.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Script Master — what is being bought (a Govt Security, bond, mutual fund scheme, share, …), following the bank's
* existing CBS Script Master. A purchase picks these terms up instead of the dealer re-typing them.
*/
@Entity
@Table(name = "m_investment_script")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentScript extends AbstractAuditableWithUTCDateTimeCustom<Long> {
/** System-generated: bond-type letter + running number, e.g. G00001. */
@Column(name = "script_code", nullable = false, length = 20, unique = true)
private String scriptCode;
@Column(name = "bond_type", nullable = false, length = 20)
private String bondType;
/** GOI / STATE_GOVT / TREASURY / NON_SLR. */
@Column(name = "slr_type", length = 20)
private String slrType;
@Column(name = "script_desc", nullable = false, length = 150)
private String scriptDesc;
@ManyToOne
@JoinColumn(name = "gl_account_id", nullable = false)
private GLAccount glAccount;
@Column(name = "isin", length = 20)
private String isin;
@Column(name = "face_value_per_unit", scale = 6, precision = 19, nullable = false)
private BigDecimal faceValuePerUnit;
@Column(name = "int_rate", scale = 4, precision = 9)
private BigDecimal intRate;
@Column(name = "issue_date")
private LocalDate issueDate;
@Column(name = "maturity_date")
private LocalDate maturityDate;
@Column(name = "company_name", length = 150)
private String companyName;
@Column(name = "address1", length = 150)
private String address1;
@Column(name = "address2", length = 150)
private String address2;
@Column(name = "address3", length = 150)
private String address3;
@Column(name = "city", length = 100)
private String city;
@Column(name = "pincode", length = 10)
private String pincode;
@Column(name = "phone", length = 20)
private String phone;
@Column(name = "fax", length = 20)
private String fax;
@Column(name = "corp_code", length = 30)
private String corpCode;
@Column(name = "is_active", nullable = false)
private boolean active = true;
}
@@ -0,0 +1,23 @@
/**
* 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.investment.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentScriptRepository extends JpaRepository<InvestmentScript, Long> {}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class InvestmentPartyNotFoundException extends AbstractPlatformResourceNotFoundException {
public InvestmentPartyNotFoundException(final Long id) {
super("error.msg.investmentparty.id.invalid", "Investment party with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class InvestmentPurchaseNotFoundException extends AbstractPlatformResourceNotFoundException {
public InvestmentPurchaseNotFoundException(final Long id) {
super("error.msg.investmentpurchase.id.invalid", "Investment purchase with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class InvestmentScriptNotFoundException extends AbstractPlatformResourceNotFoundException {
public InvestmentScriptNotFoundException(final Long id) {
super("error.msg.investmentscript.id.invalid", "Investment script with identifier " + id + " does not exist", id);
}
}
@@ -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.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTPARTY", action = "CREATE")
@RequiredArgsConstructor
public class CreateInvestmentPartyCommandHandler implements NewCommandSourceHandler {
private final InvestmentMasterWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createParty(command);
}
}
@@ -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.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTPURCHASE", action = "CREATE")
@RequiredArgsConstructor
public class CreateInvestmentPurchaseCommandHandler implements NewCommandSourceHandler {
private final InvestmentPurchaseWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createPurchase(command);
}
}
@@ -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.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTSCRIPT", action = "CREATE")
@RequiredArgsConstructor
public class CreateInvestmentScriptCommandHandler implements NewCommandSourceHandler {
private final InvestmentMasterWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createScript(command);
}
}
@@ -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.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTPARTY", action = "DELETE")
@RequiredArgsConstructor
public class DeleteInvestmentPartyCommandHandler implements NewCommandSourceHandler {
private final InvestmentMasterWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.deleteParty(command.entityId());
}
}
@@ -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.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTPURCHASE", action = "DELETE")
@RequiredArgsConstructor
public class DeleteInvestmentPurchaseCommandHandler implements NewCommandSourceHandler {
private final InvestmentPurchaseWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.deletePurchase(command.entityId());
}
}
@@ -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.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTPARTY", action = "UPDATE")
@RequiredArgsConstructor
public class UpdateInvestmentPartyCommandHandler implements NewCommandSourceHandler {
private final InvestmentMasterWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.updateParty(command.entityId(), command);
}
}
@@ -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.investment.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTSCRIPT", action = "UPDATE")
@RequiredArgsConstructor
public class UpdateInvestmentScriptCommandHandler implements NewCommandSourceHandler {
private final InvestmentMasterWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.updateScript(command.entityId(), command);
}
}
@@ -0,0 +1,254 @@
/**
* 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.investment.serialization;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS1;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS2;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS3;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BANK_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BOND_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BOND_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CITY;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.COMPANY_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CORP_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_ACCOUNT_NO;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_BANK_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_BRANCH_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_IFSC;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.DEBT_BOND_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.EXPOSURE_LIMIT;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FACE_VALUE_PER_UNIT;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FAX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INT_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.MATURITY_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PHONE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PINCODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RBI_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RESOURCE_PARTY;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RESOURCE_SCRIPT;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_DESC;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPES;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.springframework.stereotype.Component;
/** Validates Bank / Party Master and Script Master requests before anything is written. */
@Component
@RequiredArgsConstructor
public final class InvestmentMasterCommandFromApiJsonDeserializer {
private static final String IFSC_PATTERN = "^[A-Z]{4}0[A-Z0-9]{6}$";
private static final String PINCODE_PATTERN = "^[0-9]{6}$";
private final FromJsonHelper fromApiJsonHelper;
// -------------------- Bank / Party Master --------------------
public void validateForCreateParty(final String json) {
validateParty(json, true);
}
public void validateForUpdateParty(final String json) {
validateParty(json, false);
}
private void validateParty(final String json, final boolean create) {
final JsonElement element = parse(json, PARTY_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE_PARTY);
validateBondType(element, v, create);
validateRequiredString(element, v, PARTY_NAME, 150, create);
validateGlAccountId(element, v, create);
validateAddress(element, v);
validateOptionalString(element, v, BANK_TYPE, 60);
validateOptionalString(element, v, RBI_CODE, 30);
validateOptionalString(element, v, LEI_NO, 30);
validateOptionalString(element, v, CP_BANK_NAME, 150);
validateOptionalString(element, v, CP_BRANCH_NAME, 150);
validateOptionalString(element, v, CP_ACCOUNT_NO, 40);
final String ifsc = this.fromApiJsonHelper.extractStringNamed(CP_IFSC, element);
if (StringUtils.isNotBlank(ifsc)) {
v.reset().parameter(CP_IFSC).value(ifsc.trim().toUpperCase()).matchesRegularExpression(IFSC_PATTERN,
"IFSC must be 11 characters: 4 letters, 0, then 6 letters/digits");
}
if (this.fromApiJsonHelper.parameterExists(EXPOSURE_LIMIT, element)) {
final BigDecimal limit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(EXPOSURE_LIMIT, element);
v.reset().parameter(EXPOSURE_LIMIT).value(limit).ignoreIfNull().zeroOrPositiveAmount();
}
throwIfErrors(errors);
}
// -------------------- Script Master --------------------
public void validateForCreateScript(final String json) {
validateScript(json, true);
}
public void validateForUpdateScript(final String json, final String existingBondType) {
validateScript(json, false, existingBondType);
}
private void validateScript(final String json, final boolean create) {
validateScript(json, create, null);
}
private void validateScript(final String json, final boolean create, final String existingBondType) {
final JsonElement element = parse(json, SCRIPT_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE_SCRIPT);
validateBondType(element, v, create);
validateRequiredString(element, v, SCRIPT_DESC, 150, create);
validateGlAccountId(element, v, create);
final String slrType = this.fromApiJsonHelper.extractStringNamed(SLR_TYPE, element);
if (StringUtils.isNotBlank(slrType)) {
v.reset().parameter(SLR_TYPE).value(slrType).isOneOfTheseValues(SLR_TYPES.toArray());
}
final String bondType = create ? this.fromApiJsonHelper.extractStringNamed(BOND_TYPE, element) : existingBondType;
final boolean debt = bondType != null && DEBT_BOND_TYPES.contains(bondType);
final String isin = this.fromApiJsonHelper.extractStringNamed(ISIN, element);
if (create && debt) {
v.reset().parameter(ISIN).value(isin).notBlank();
}
v.reset().parameter(ISIN).value(isin).ignoreIfNull().notExceedingLengthOf(20);
if (this.fromApiJsonHelper.parameterExists(INT_RATE, element) || (create && debt)) {
final BigDecimal intRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(INT_RATE, element);
final DataValidatorBuilder rate = v.reset().parameter(INT_RATE).value(intRate);
if (create && debt) {
rate.notNull();
}
rate.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(FACE_VALUE_PER_UNIT, element)) {
final BigDecimal fv = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(FACE_VALUE_PER_UNIT, element);
v.reset().parameter(FACE_VALUE_PER_UNIT).value(fv).ignoreIfNull().positiveAmount();
}
LocalDate issueDate = null;
if (this.fromApiJsonHelper.parameterExists(ISSUE_DATE, element) || (create && debt)) {
issueDate = this.fromApiJsonHelper.extractLocalDateNamed(ISSUE_DATE, element);
if (create && debt) {
v.reset().parameter(ISSUE_DATE).value(issueDate).notNull();
}
}
if (this.fromApiJsonHelper.parameterExists(MATURITY_DATE, element)) {
final LocalDate maturityDate = this.fromApiJsonHelper.extractLocalDateNamed(MATURITY_DATE, element);
v.reset().parameter(MATURITY_DATE).value(maturityDate).ignoreIfNull().validateDateAfter(issueDate);
}
validateOptionalString(element, v, COMPANY_NAME, 150);
validateOptionalString(element, v, CORP_CODE, 30);
validateAddress(element, v);
throwIfErrors(errors);
}
// -------------------- shared --------------------
private void validateBondType(final JsonElement element, final DataValidatorBuilder v, final boolean required) {
final String bondType = this.fromApiJsonHelper.extractStringNamed(BOND_TYPE, element);
if (required) {
v.reset().parameter(BOND_TYPE).value(bondType).notBlank();
}
if (StringUtils.isNotBlank(bondType)) {
v.reset().parameter(BOND_TYPE).value(bondType).isOneOfTheseValues(BOND_TYPES.toArray());
}
}
private void validateGlAccountId(final JsonElement element, final DataValidatorBuilder v, final boolean required) {
if (!required && !this.fromApiJsonHelper.parameterExists(GL_ACCOUNT_ID, element)) {
return;
}
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(GL_ACCOUNT_ID, element);
v.reset().parameter(GL_ACCOUNT_ID).value(glAccountId).notNull().longGreaterThanZero();
}
private void validateRequiredString(final JsonElement element, final DataValidatorBuilder v, final String param, final int maxLength,
final boolean required) {
if (!required && !this.fromApiJsonHelper.parameterExists(param, element)) {
return;
}
final String value = this.fromApiJsonHelper.extractStringNamed(param, element);
v.reset().parameter(param).value(value).notBlank().notExceedingLengthOf(maxLength);
}
private void validateOptionalString(final JsonElement element, final DataValidatorBuilder v, final String param,
final int maxLength) {
final String value = this.fromApiJsonHelper.extractStringNamed(param, element);
v.reset().parameter(param).value(value).ignoreIfNull().notExceedingLengthOf(maxLength);
}
private void validateAddress(final JsonElement element, final DataValidatorBuilder v) {
validateOptionalString(element, v, ADDRESS1, 150);
validateOptionalString(element, v, ADDRESS2, 150);
validateOptionalString(element, v, ADDRESS3, 150);
validateOptionalString(element, v, CITY, 100);
validateOptionalString(element, v, PHONE, 20);
validateOptionalString(element, v, FAX, 20);
final String pincode = this.fromApiJsonHelper.extractStringNamed(PINCODE, element);
if (StringUtils.isNotBlank(pincode)) {
v.reset().parameter(PINCODE).value(pincode.trim()).matchesRegularExpression(PINCODE_PATTERN, "Pincode must be 6 digits");
}
}
private JsonElement parse(final String json, final Set<String> supportedParams) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, supportedParams);
return this.fromApiJsonHelper.parse(json);
}
private void throwIfErrors(final List<ApiParameterError> errors) {
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
}
@@ -0,0 +1,140 @@
/**
* 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.investment.serialization;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BOND_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKERAGE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CATEGORIES;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CATEGORY;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CONSIDER_AMT;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CR_GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DEAL_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DEAL_TIME;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.FACE_VALUE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.LAST_INT_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.NAV;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.OTHER_DETAILS;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PARTY_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PURCHASE_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.RESOURCE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SCRIPT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SETTLEMENT_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SETTLEMENT_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STAMP_DUTY;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SUB_TYPE;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.springframework.stereotype.Component;
/** Validates a Purchase Entry before anything is computed or written. */
@Component
@RequiredArgsConstructor
public final class InvestmentPurchaseCommandFromApiJsonDeserializer {
private static final String TIME_PATTERN = "^([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$";
private final FromJsonHelper fromApiJsonHelper;
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, PARAMS);
final JsonElement element = this.fromApiJsonHelper.parse(json);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE);
final String bondType = this.fromApiJsonHelper.extractStringNamed(BOND_TYPE, element);
v.reset().parameter(BOND_TYPE).value(bondType).notBlank();
if (StringUtils.isNotBlank(bondType)) {
v.reset().parameter(BOND_TYPE).value(bondType).isOneOfTheseValues(InvestmentApiConstants.BOND_TYPES.toArray());
}
v.reset().parameter(SCRIPT_ID).value(this.fromApiJsonHelper.extractLongNamed(SCRIPT_ID, element)).notNull().longGreaterThanZero();
v.reset().parameter(PARTY_ID).value(this.fromApiJsonHelper.extractLongNamed(PARTY_ID, element)).notNull().longGreaterThanZero();
v.reset().parameter(CR_GL_ACCOUNT_ID).value(this.fromApiJsonHelper.extractLongNamed(CR_GL_ACCOUNT_ID, element)).notNull()
.longGreaterThanZero();
v.reset().parameter(DEAL_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(DEAL_DATE, element)).notNull();
final String settlementType = this.fromApiJsonHelper.extractStringNamed(SETTLEMENT_TYPE, element);
v.reset().parameter(SETTLEMENT_TYPE).value(settlementType).notBlank();
if (StringUtils.isNotBlank(settlementType)) {
v.reset().parameter(SETTLEMENT_TYPE).value(settlementType).isOneOfTheseValues(SETTLEMENT_TYPES.toArray());
}
final String category = this.fromApiJsonHelper.extractStringNamed(CATEGORY, element);
v.reset().parameter(CATEGORY).value(category).notBlank();
if (StringUtils.isNotBlank(category)) {
v.reset().parameter(CATEGORY).value(category).isOneOfTheseValues(CATEGORIES.toArray());
}
if ("MUTUAL_FUND".equals(bondType)) {
v.reset().parameter(CONSIDER_AMT).value(amount(CONSIDER_AMT, element)).notNull().positiveAmount();
v.reset().parameter(NAV).value(amount(NAV, element)).notNull().positiveAmount();
} else {
v.reset().parameter(FACE_VALUE).value(amount(FACE_VALUE, element)).notNull().positiveAmount();
v.reset().parameter(PURCHASE_RATE).value(amount(PURCHASE_RATE, element)).notNull().positiveAmount();
v.reset().parameter(LAST_INT_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(LAST_INT_DATE, element)).notNull();
}
v.reset().parameter(BROKERAGE).value(amount(BROKERAGE, element)).ignoreIfNull().zeroOrPositiveAmount();
v.reset().parameter(STAMP_DUTY).value(amount(STAMP_DUTY, element)).ignoreIfNull().zeroOrPositiveAmount();
final String dealTime = this.fromApiJsonHelper.extractStringNamed(DEAL_TIME, element);
if (StringUtils.isNotBlank(dealTime)) {
v.reset().parameter(DEAL_TIME).value(dealTime).matchesRegularExpression(TIME_PATTERN, "Deal Time must be HH:mm:ss (24 hr)");
}
v.reset().parameter(SUB_TYPE).value(this.fromApiJsonHelper.extractStringNamed(SUB_TYPE, element)).ignoreIfNull()
.notExceedingLengthOf(40);
if (this.fromApiJsonHelper.parameterExists(OTHER_DETAILS, element)) {
final JsonElement other = element.getAsJsonObject().get(OTHER_DETAILS);
if (!other.isJsonNull() && !other.isJsonObject()) {
v.reset().parameter(OTHER_DETAILS).failWithCode("must.be.an.object");
} else if (other.isJsonObject() && other.toString().length() > 4000) {
v.reset().parameter(OTHER_DETAILS).failWithCode("too.long");
}
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
private BigDecimal amount(final String param, final JsonElement element) {
return this.fromApiJsonHelper.parameterExists(param, element)
? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(param, element)
: null;
}
}
@@ -0,0 +1,169 @@
/**
* 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.investment.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.Month;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;
/**
* Interest arithmetic for half-yearly coupon securities, as the bank's existing CBS does it (Jitendra Sir):
* <ul>
* <li>days are counted 30/360 — every month is 30 days, a half-year is 180, a year 360;</li>
* <li>the coupon is fixed: face value × rate ÷ 2, paid on the maturity day/month and six months either side;</li>
* <li>at each closing (31 March, 30 September) the interest earned since the last coupon is booked as a provision (Dr
* Interest Receivable / Cr Interest Income), even though the cash arrives at the next coupon;</li>
* <li>when the coupon arrives, the provision already booked is cleared and only the rest is new income.</li>
* </ul>
*/
public final class InvestmentInterestCalculator {
private static final BigDecimal THIRTY_SIX_THOUSAND = BigDecimal.valueOf(36000);
private static final BigDecimal TWO_HUNDRED = BigDecimal.valueOf(200);
public static final String TYPE_PROVISION = "PROVISION";
public static final String TYPE_COUPON = "COUPON";
private InvestmentInterestCalculator() {}
/** One line of the interest schedule. */
public record ScheduleRow(String type, LocalDate date, LocalDate fromDate, LocalDate toDate, int days, BigDecimal grossInterest,
BigDecimal alreadyBooked, BigDecimal amount) {
}
/**
* Days between two dates on a 30/360 basis. Counting runs from the day after {@code from} up to and including
* {@code to}: 10-08 → 30-09 is 50 days, 10-08 → 08-10 is 58 days, 30-06 → 30-09 is 90 days.
*/
public static int days360(final LocalDate from, final LocalDate to) {
if (from == null || to == null || !to.isAfter(from)) {
return 0;
}
final int d1 = Math.min(from.getDayOfMonth(), 30);
final int d2 = Math.min(to.getDayOfMonth(), 30);
return (to.getYear() - from.getYear()) * 360 + (to.getMonthValue() - from.getMonthValue()) * 30 + (d2 - d1);
}
/** Interest on {@code faceValue} at {@code ratePercent} p.a. for {@code days} (30/360), rounded to the rupee. */
public static BigDecimal interestForDays(final BigDecimal faceValue, final BigDecimal ratePercent, final int days) {
return faceValue.multiply(ratePercent).multiply(BigDecimal.valueOf(days)).divide(THIRTY_SIX_THOUSAND, 0, RoundingMode.HALF_UP);
}
/** The fixed half-yearly coupon: face value × rate ÷ 2. */
public static BigDecimal halfYearCoupon(final BigDecimal faceValue, final BigDecimal ratePercent) {
return faceValue.multiply(ratePercent).divide(TWO_HUNDRED, 0, RoundingMode.HALF_UP);
}
/** k-th coupon date counting back from maturity — measured from maturity each time so month-end days survive. */
private static LocalDate couponBack(final LocalDate maturity, final int k) {
return maturity.minusMonths(6L * k);
}
/** The most recent coupon date on or before {@code on}. */
public static LocalDate lastCouponOnOrBefore(final LocalDate maturity, final LocalDate on) {
int k = 0;
LocalDate d = maturity;
while (d.isAfter(on)) {
k++;
d = couponBack(maturity, k);
}
return d;
}
/** Coupon dates strictly after {@code from}, up to and including maturity, in date order. */
public static List<LocalDate> couponDatesAfter(final LocalDate maturity, final LocalDate from) {
final List<LocalDate> dates = new ArrayList<>();
int k = 0;
LocalDate d = maturity;
while (d.isAfter(from)) {
dates.add(0, d);
k++;
d = couponBack(maturity, k);
}
return dates;
}
/** Closing dates (31 March, 30 September) from {@code from} up to {@code to}, inclusive. */
public static List<LocalDate> closingDates(final LocalDate from, final LocalDate to) {
final List<LocalDate> dates = new ArrayList<>();
for (int year = from.getYear(); year <= to.getYear(); year++) {
for (final LocalDate closing : List.of(LocalDate.of(year, Month.MARCH, 31), LocalDate.of(year, Month.SEPTEMBER, 30))) {
if (!closing.isBefore(from) && !closing.isAfter(to)) {
dates.add(closing);
}
}
}
return dates;
}
/**
* The full interest schedule of a holding, from purchase to maturity.
*
* @param faceValue
* face value currently held
* @param ratePercent
* coupon rate % p.a.
* @param maturity
* maturity date (coupons fall on its day/month and six months either side)
* @param lastIntDate
* coupon date immediately before the purchase
* @param dealDate
* purchase deal date — closings before it belong to the seller
* @param purchasedAccrued
* interest paid to the seller at purchase; it already sits in Interest Receivable
*/
public static List<ScheduleRow> schedule(final BigDecimal faceValue, final BigDecimal ratePercent, final LocalDate maturity,
final LocalDate lastIntDate, final LocalDate dealDate, final BigDecimal purchasedAccrued) {
final List<ScheduleRow> rows = new ArrayList<>();
if (faceValue == null || ratePercent == null || ratePercent.signum() <= 0 || maturity == null || lastIntDate == null
|| dealDate == null || !maturity.isAfter(lastIntDate)) {
return rows;
}
final List<LocalDate> coupons = couponDatesAfter(maturity, lastIntDate);
final TreeSet<LocalDate> events = new TreeSet<>(coupons);
for (final LocalDate closing : closingDates(dealDate, maturity)) {
if (closing.isAfter(lastIntDate) && !events.contains(closing)) {
events.add(closing);
}
}
final BigDecimal coupon = halfYearCoupon(faceValue, ratePercent);
BigDecimal receivable = purchasedAccrued == null ? BigDecimal.ZERO : purchasedAccrued;
LocalDate previousCoupon = lastIntDate;
for (final LocalDate date : events) {
final int days = days360(previousCoupon, date);
if (coupons.contains(date)) {
rows.add(new ScheduleRow(TYPE_COUPON, date, previousCoupon.plusDays(1), date, days, coupon, receivable,
coupon.subtract(receivable)));
receivable = BigDecimal.ZERO;
previousCoupon = date;
} else {
final BigDecimal earned = interestForDays(faceValue, ratePercent, days);
rows.add(new ScheduleRow(TYPE_PROVISION, date, previousCoupon.plusDays(1), date, days, earned, receivable,
earned.subtract(receivable)));
receivable = earned;
}
}
return rows;
}
}
@@ -0,0 +1,34 @@
/**
* 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.investment.service;
import java.util.List;
import org.apache.fineract.portfolio.investment.data.InvestmentPartyData;
import org.apache.fineract.portfolio.investment.data.InvestmentScriptData;
public interface InvestmentMasterReadPlatformService {
List<InvestmentPartyData> retrieveAllParties();
InvestmentPartyData retrieveParty(Long partyId);
List<InvestmentScriptData> retrieveAllScripts();
InvestmentScriptData retrieveScript(Long scriptId);
}
@@ -0,0 +1,65 @@
/**
* 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.investment.service;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.investment.data.InvestmentPartyData;
import org.apache.fineract.portfolio.investment.data.InvestmentScriptData;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException;
import org.apache.fineract.portfolio.investment.exception.InvestmentScriptNotFoundException;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Masters are small (tens of rows), so they are listed whole, newest first, the way the screens show them. */
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class InvestmentMasterReadPlatformServiceImpl implements InvestmentMasterReadPlatformService {
private static final Sort NEWEST_FIRST = Sort.by(Sort.Direction.DESC, "id");
private final InvestmentPartyRepository partyRepository;
private final InvestmentScriptRepository scriptRepository;
@Override
public List<InvestmentPartyData> retrieveAllParties() {
return this.partyRepository.findAll(NEWEST_FIRST).stream().map(InvestmentPartyData::from).toList();
}
@Override
public InvestmentPartyData retrieveParty(final Long partyId) {
return this.partyRepository.findById(partyId).map(InvestmentPartyData::from)
.orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
}
@Override
public List<InvestmentScriptData> retrieveAllScripts() {
return this.scriptRepository.findAll(NEWEST_FIRST).stream().map(InvestmentScriptData::from).toList();
}
@Override
public InvestmentScriptData retrieveScript(final Long scriptId) {
return this.scriptRepository.findById(scriptId).map(InvestmentScriptData::from)
.orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
}
}
@@ -0,0 +1,35 @@
/**
* 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.investment.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface InvestmentMasterWritePlatformService {
CommandProcessingResult createParty(JsonCommand command);
CommandProcessingResult updateParty(Long partyId, JsonCommand command);
CommandProcessingResult deleteParty(Long partyId);
CommandProcessingResult createScript(JsonCommand command);
CommandProcessingResult updateScript(Long scriptId, JsonCommand command);
}
@@ -0,0 +1,298 @@
/**
* 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.investment.service;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ACTIVE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS1;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS2;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ADDRESS3;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BANK_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.BOND_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CITY;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.COMPANY_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CORP_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_ACCOUNT_NO;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_BANK_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_BRANCH_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.CP_IFSC;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.EXPOSURE_LIMIT;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FACE_VALUE_PER_UNIT;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FAX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INT_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.MATURITY_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_CODE_PREFIX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PHONE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PINCODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RBI_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_CODE_PREFIX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_DESC;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPE;
import java.math.BigDecimal;
import java.util.function.Consumer;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException;
import org.apache.fineract.portfolio.investment.exception.InvestmentScriptNotFoundException;
import org.apache.fineract.portfolio.investment.serialization.InvestmentMasterCommandFromApiJsonDeserializer;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMasterWritePlatformService {
private final InvestmentMasterCommandFromApiJsonDeserializer validator;
private final InvestmentPartyRepository partyRepository;
private final InvestmentScriptRepository scriptRepository;
private final GLAccountRepositoryWrapper glAccountRepository;
private final JdbcTemplate jdbcTemplate;
// -------------------- Bank / Party Master --------------------
@Override
@Transactional
public CommandProcessingResult createParty(final JsonCommand command) {
this.validator.validateForCreateParty(command.json());
final InvestmentParty party = new InvestmentParty();
party.setPartyCode(nextPartyCode());
applyParty(party, command);
if (command.parameterExists(ACTIVE)) {
party.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
}
final InvestmentParty saved = saveParty(party);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
.withResourceIdAsString(saved.getPartyCode()).build();
}
@Override
@Transactional
public CommandProcessingResult updateParty(final Long partyId, final JsonCommand command) {
this.validator.validateForUpdateParty(command.json());
final InvestmentParty party = this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
applyParty(party, command);
if (command.parameterExists(ACTIVE)) {
party.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
}
saveParty(party);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId)
.withResourceIdAsString(party.getPartyCode()).build();
}
@Override
@Transactional
public CommandProcessingResult deleteParty(final Long partyId) {
final InvestmentParty party = this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
this.partyRepository.delete(party);
this.partyRepository.flush();
return new CommandProcessingResultBuilder().withEntityId(partyId).withResourceIdAsString(party.getPartyCode()).build();
}
/** Copies every party field present in the request onto the entity (absent fields are left unchanged). */
private void applyParty(final InvestmentParty p, final JsonCommand command) {
setIfPresent(command, BOND_TYPE, p::setBondType);
setIfPresent(command, PARTY_NAME, p::setPartyName);
setIfPresent(command, ADDRESS1, p::setAddress1);
setIfPresent(command, ADDRESS2, p::setAddress2);
setIfPresent(command, ADDRESS3, p::setAddress3);
setIfPresent(command, CITY, p::setCity);
setIfPresent(command, PINCODE, p::setPincode);
setIfPresent(command, PHONE, p::setPhone);
setIfPresent(command, FAX, p::setFax);
setIfPresent(command, BANK_TYPE, p::setBankType);
setIfPresent(command, RBI_CODE, p::setRbiCode);
setIfPresent(command, LEI_NO, p::setLeiNo);
setIfPresent(command, CP_BANK_NAME, p::setCpBankName);
setIfPresent(command, CP_BRANCH_NAME, p::setCpBranchName);
setIfPresent(command, CP_ACCOUNT_NO, p::setCpAccountNo);
if (command.parameterExists(CP_IFSC)) {
final String ifsc = command.stringValueOfParameterNamedAllowingNull(CP_IFSC);
p.setCpIfsc(StringUtils.isBlank(ifsc) ? null : ifsc.trim().toUpperCase());
}
if (command.parameterExists(EXPOSURE_LIMIT)) {
p.setExposureLimit(command.bigDecimalValueOfParameterNamed(EXPOSURE_LIMIT));
}
if (command.parameterExists(GL_ACCOUNT_ID)) {
p.setGlAccount(postableAssetGl(command.longValueOfParameterNamed(GL_ACCOUNT_ID)));
}
}
private InvestmentParty saveParty(final InvestmentParty party) {
try {
return this.partyRepository.saveAndFlush(party);
} catch (final DataIntegrityViolationException e) {
throw new PlatformDataIntegrityException("error.msg.investmentparty.duplicate", "A party with this code already exists",
"partyCode", party.getPartyCode(), e);
}
}
/** Next code in the PTY-001, PTY-002 … series. */
private String nextPartyCode() {
final Integer max = this.jdbcTemplate.queryForObject(
"SELECT MAX(CAST(SUBSTRING(party_code FROM 5) AS INTEGER)) FROM m_investment_party WHERE party_code ~ '^PTY-[0-9]+$'",
Integer.class);
return PARTY_CODE_PREFIX + String.format("%03d", (max == null ? 0 : max) + 1);
}
// -------------------- Script Master --------------------
@Override
@Transactional
public CommandProcessingResult createScript(final JsonCommand command) {
this.validator.validateForCreateScript(command.json());
final InvestmentScript script = new InvestmentScript();
script.setBondType(command.stringValueOfParameterNamed(BOND_TYPE));
script.setScriptCode(nextScriptCode(script.getBondType()));
script.setFaceValuePerUnit(defaultFaceValue(script.getBondType()));
applyScript(script, command);
if (command.parameterExists(ACTIVE)) {
script.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
}
final InvestmentScript saved = saveScript(script);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
.withResourceIdAsString(saved.getScriptCode()).build();
}
@Override
@Transactional
public CommandProcessingResult updateScript(final Long scriptId, final JsonCommand command) {
final InvestmentScript script = this.scriptRepository.findById(scriptId)
.orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
this.validator.validateForUpdateScript(command.json(), script.getBondType());
if (command.parameterExists(BOND_TYPE) && !script.getBondType().equals(command.stringValueOfParameterNamed(BOND_TYPE))) {
// The script code carries the bond-type letter (G00001), so the type cannot change once created.
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.bondtype.immutable",
"Bond Type of an existing script cannot be changed", script.getScriptCode());
}
applyScript(script, command);
if (command.parameterExists(ACTIVE)) {
script.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
}
saveScript(script);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(scriptId)
.withResourceIdAsString(script.getScriptCode()).build();
}
private void applyScript(final InvestmentScript s, final JsonCommand command) {
setIfPresent(command, SLR_TYPE, s::setSlrType);
setIfPresent(command, SCRIPT_DESC, s::setScriptDesc);
setIfPresent(command, ISIN, s::setIsin);
setIfPresent(command, COMPANY_NAME, s::setCompanyName);
setIfPresent(command, ADDRESS1, s::setAddress1);
setIfPresent(command, ADDRESS2, s::setAddress2);
setIfPresent(command, ADDRESS3, s::setAddress3);
setIfPresent(command, CITY, s::setCity);
setIfPresent(command, PINCODE, s::setPincode);
setIfPresent(command, PHONE, s::setPhone);
setIfPresent(command, FAX, s::setFax);
setIfPresent(command, CORP_CODE, s::setCorpCode);
if (command.parameterExists(FACE_VALUE_PER_UNIT) && command.bigDecimalValueOfParameterNamed(FACE_VALUE_PER_UNIT) != null) {
s.setFaceValuePerUnit(command.bigDecimalValueOfParameterNamed(FACE_VALUE_PER_UNIT));
}
if (command.parameterExists(INT_RATE)) {
s.setIntRate(command.bigDecimalValueOfParameterNamed(INT_RATE));
}
if (command.parameterExists(ISSUE_DATE)) {
s.setIssueDate(command.localDateValueOfParameterNamed(ISSUE_DATE));
}
if (command.parameterExists(MATURITY_DATE)) {
s.setMaturityDate(command.localDateValueOfParameterNamed(MATURITY_DATE));
}
if (s.getIssueDate() != null && s.getMaturityDate() != null && s.getMaturityDate().isBefore(s.getIssueDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.maturity.before.issue",
"Maturity date cannot be before the issue date");
}
if (command.parameterExists(GL_ACCOUNT_ID)) {
s.setGlAccount(postableAssetGl(command.longValueOfParameterNamed(GL_ACCOUNT_ID)));
}
}
private InvestmentScript saveScript(final InvestmentScript script) {
try {
return this.scriptRepository.saveAndFlush(script);
} catch (final DataIntegrityViolationException e) {
throw new PlatformDataIntegrityException("error.msg.investmentscript.duplicate", "A script with this code already exists",
"scriptCode", script.getScriptCode(), e);
}
}
/** Next code for the bond type — its letter plus a five-digit running number, e.g. G00001. */
private String nextScriptCode(final String bondType) {
final String prefix = SCRIPT_CODE_PREFIX.get(bondType);
final Integer max = this.jdbcTemplate.queryForObject(
"SELECT MAX(CAST(SUBSTRING(script_code FROM 2) AS INTEGER)) FROM m_investment_script WHERE script_code ~ ?",
Integer.class, "^" + prefix + "[0-9]{5}$");
return prefix + String.format("%05d", (max == null ? 0 : max) + 1);
}
/** Face value of one unit: ₹100 for securities and bonds, as the existing CBS defaults it. */
private BigDecimal defaultFaceValue(final String bondType) {
return switch (bondType) {
case "MUTUAL_FUND" -> BigDecimal.valueOf(1000);
case "SHARE", "FD" -> BigDecimal.ONE;
default -> BigDecimal.valueOf(100);
};
}
// -------------------- shared --------------------
/** An investment is an asset, so the mapped GL must be an enabled ASSET detail account. */
private GLAccount postableAssetGl(final Long glAccountId) {
final GLAccount gl = this.glAccountRepository.findOneWithNotFoundDetection(glAccountId);
if (gl.isHeaderAccount()) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.glaccount.header",
"GL " + gl.getGlCode() + " is a header account; choose a detail account", glAccountId);
}
if (gl.isDisabled()) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.glaccount.disabled", "GL " + gl.getGlCode() + " is disabled",
glAccountId);
}
if (!GLAccountType.ASSET.getValue().equals(gl.getType())) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.glaccount.not.asset",
"GL " + gl.getGlCode() + " is not an Asset account", glAccountId);
}
return gl;
}
private static void setIfPresent(final JsonCommand command, final String param, final Consumer<String> setter) {
if (command.parameterExists(param)) {
final String value = command.stringValueOfParameterNamedAllowingNull(param);
setter.accept(StringUtils.isBlank(value) ? null : value.trim());
}
}
}
@@ -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.portfolio.investment.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class InvestmentPurchaseReadPlatformService {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
/** Income head for coupon and provision interest (GL 400401 under 400400 INCOME FROM INVESTMENTS). */
private static final String INTEREST_GL_CODE = "400401";
private static final String INTEREST_GL_NAME = "INTEREST ON GOVT SECURITIES";
private final InvestmentPurchaseRepository purchaseRepository;
/** All deals, newest first — the Holdings register. */
public List<InvestmentPurchaseData> retrieveAll() {
return this.purchaseRepository.findAll(Sort.by(Sort.Direction.DESC, "id")).stream().map(InvestmentPurchaseData::from).toList();
}
public InvestmentPurchaseData retrieveOne(final Long purchaseId) {
return InvestmentPurchaseData.from(find(purchaseId));
}
/**
* Coupon and closing-provision lines of a holding from purchase to maturity, worked out on the face value still
* held. Lines on or before {@code asOnDate} (default: business date) are marked due.
*/
public InvestmentInterestScheduleData retrieveInterestSchedule(final Long purchaseId, final LocalDate asOnDate) {
final InvestmentPurchase p = find(purchaseId);
final LocalDate asOn = asOnDate == null ? DateUtils.getBusinessLocalDate() : asOnDate;
final BigDecimal unitValue = p.getScript().getFaceValuePerUnit();
final BigDecimal faceHeld = p.getBalanceUnits().multiply(unitValue);
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
final List<InvestmentInterestScheduleData.Row> rows = InvestmentInterestCalculator
.schedule(faceHeld, rate, p.getMaturityDate(), p.getLastIntDate(), p.getDealDate(), p.getAccruedInterest()).stream()
.map(r -> InvestmentInterestScheduleData.Row.builder().type(r.type()).intDate(format(r.date()))
.fromDate(format(r.fromDate())).toDate(format(r.toDate())).days(r.days()).grossInterest(r.grossInterest())
.alreadyBooked(r.alreadyBooked()).amount(r.amount()).due(!r.date().isAfter(asOn)).build())
.toList();
return InvestmentInterestScheduleData.builder().purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo())
.scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()).faceValue(faceHeld)
.unitValue(unitValue).balanceUnits(p.getBalanceUnits()).purchaseRate(p.getPurchaseRate()).bookValue(p.getBookValue())
.intRate(p.getIntRate()).dealDate(format(p.getDealDate())).lastIntDate(format(p.getLastIntDate()))
.maturityDate(format(p.getMaturityDate())).purchasedAccruedInterest(p.getAccruedInterest())
.couponPerHalfYear(rate.signum() > 0 ? InvestmentInterestCalculator.halfYearCoupon(faceHeld, rate) : BigDecimal.ZERO)
.asOnDate(format(asOn)).interestGlCode(INTEREST_GL_CODE).interestGlName(INTEREST_GL_NAME).rows(rows).build();
}
private InvestmentPurchase find(final Long purchaseId) {
return this.purchaseRepository.findById(purchaseId).orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
}
private static String format(final LocalDate date) {
return date == null ? null : date.format(DATE_FORMAT);
}
}
@@ -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.investment.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface InvestmentPurchaseWritePlatformService {
CommandProcessingResult createPurchase(JsonCommand command);
CommandProcessingResult deletePurchase(Long purchaseId);
}
@@ -0,0 +1,262 @@
/**
* 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.investment.service;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BOND_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKERAGE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CATEGORY;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CONSIDER_AMT;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CR_GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DEAL_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DEAL_TIME;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.FACE_VALUE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.LAST_INT_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.NAV;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.OTHER_DETAILS;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PARTY_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PURCHASE_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SCRIPT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SETTLEMENT_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STAMP_DUTY;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_ACTIVE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SUB_TYPE;
import com.google.gson.JsonElement;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException;
import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
import org.apache.fineract.portfolio.investment.exception.InvestmentScriptNotFoundException;
import org.apache.fineract.portfolio.investment.serialization.InvestmentPurchaseCommandFromApiJsonDeserializer;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Saves a Purchase Entry as a holding. Every derived figure is computed here from the script master and the deal
* inputs, so the stored deal does not depend on what the screen calculated. No GL entry is posted yet.
*/
@Service
@RequiredArgsConstructor
public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPurchaseWritePlatformService {
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
private final InvestmentPurchaseCommandFromApiJsonDeserializer validator;
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentScriptRepository scriptRepository;
private final InvestmentPartyRepository partyRepository;
private final GLAccountRepositoryWrapper glAccountRepository;
private final JdbcTemplate jdbcTemplate;
@Override
@Transactional
public CommandProcessingResult createPurchase(final JsonCommand command) {
this.validator.validateForCreate(command.json());
final String bondType = command.stringValueOfParameterNamed(BOND_TYPE);
final Long scriptId = command.longValueOfParameterNamed(SCRIPT_ID);
final InvestmentScript script = this.scriptRepository.findById(scriptId)
.orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
final Long partyId = command.longValueOfParameterNamed(PARTY_ID);
final InvestmentParty party = this.partyRepository.findById(partyId)
.orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
if (!script.isActive()) {
throw rule("script.inactive", "Script " + script.getScriptCode() + " is inactive");
}
if (!script.getBondType().equals(bondType)) {
throw rule("script.bondtype.mismatch",
"Script " + script.getScriptCode() + " is a " + script.getBondType() + ", not " + bondType);
}
if (!party.isActive()) {
throw rule("party.inactive", "Party " + party.getPartyCode() + " is inactive");
}
final LocalDate dealDate = command.localDateValueOfParameterNamed(DEAL_DATE);
if (script.getMaturityDate() != null && dealDate.isAfter(script.getMaturityDate())) {
throw rule("deal.after.maturity", "Deal date is after the security's maturity date " + script.getMaturityDate());
}
final String settlementType = command.stringValueOfParameterNamed(SETTLEMENT_TYPE);
final InvestmentPurchase p = new InvestmentPurchase();
p.setDealNo(nextDealNo(dealDate));
p.setCertNo(nextCertNo());
p.setBondType(bondType);
p.setScript(script);
p.setParty(party);
p.setDealDate(dealDate);
p.setDealTime(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(DEAL_TIME)));
p.setSettlementType(settlementType);
p.setSettlementDate(dealDate.plusDays(Long.parseLong(settlementType.substring(2))));
p.setCategory(command.stringValueOfParameterNamed(CATEGORY));
p.setSubType(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(SUB_TYPE)));
p.setIntRate(script.getIntRate());
p.setIssueDate(script.getIssueDate());
p.setMaturityDate(script.getMaturityDate());
p.setInvestmentGlAccount(script.getGlAccount());
p.setCrGlAccount(postableAssetGl(command.longValueOfParameterNamed(CR_GL_ACCOUNT_ID)));
p.setOtherDetails(otherDetails(command));
p.setStatus(STATUS_ACTIVE);
final BigDecimal brokerage = zeroIfNull(command.bigDecimalValueOfParameterNamed(BROKERAGE));
p.setBrokerage(brokerage);
if ("MUTUAL_FUND".equals(bondType)) {
computeMutualFund(p, command, brokerage);
} else {
computeSecurity(p, command, script, brokerage);
}
final InvestmentPurchase saved = this.purchaseRepository.saveAndFlush(p);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
.withResourceIdAsString(saved.getDealNo()).build();
}
@Override
@Transactional
public CommandProcessingResult deletePurchase(final Long purchaseId) {
final InvestmentPurchase p = this.purchaseRepository.findById(purchaseId)
.orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
if (p.getBalanceUnits().compareTo(p.getUnits()) != 0) {
throw rule("purchase.partly.sold", "Deal " + p.getDealNo() + " has been partly sold and cannot be dropped");
}
this.purchaseRepository.delete(p);
this.purchaseRepository.flush();
return new CommandProcessingResultBuilder().withEntityId(purchaseId).withResourceIdAsString(p.getDealNo()).build();
}
/** Govt Security / Bond: face value at a price per ₹100, plus interest owed to the seller since the last coupon. */
private void computeSecurity(final InvestmentPurchase p, final JsonCommand command, final InvestmentScript script,
final BigDecimal brokerage) {
final BigDecimal faceValue = command.bigDecimalValueOfParameterNamed(FACE_VALUE);
final BigDecimal rate = command.bigDecimalValueOfParameterNamed(PURCHASE_RATE);
final LocalDate lastIntDate = command.localDateValueOfParameterNamed(LAST_INT_DATE);
if (lastIntDate.isAfter(p.getDealDate())) {
throw rule("lastint.after.deal", "Last interest date cannot be after the deal date");
}
final BigDecimal units = faceValue.divide(script.getFaceValuePerUnit(), 6, RoundingMode.HALF_UP);
final BigDecimal cost = faceValue.multiply(rate).divide(HUNDRED, 2, RoundingMode.HALF_UP);
final int days = InvestmentInterestCalculator.days360(lastIntDate, p.getDealDate());
final BigDecimal intRate = zeroIfNull(script.getIntRate());
// Interest is counted up to the deal date (the event), not the settlement date — Jitendra Sir.
final BigDecimal accrued = InvestmentInterestCalculator.interestForDays(faceValue, intRate, days);
p.setFaceValue(faceValue);
p.setPurchaseRate(rate);
p.setUnits(units);
p.setBalanceUnits(units);
p.setBookValue(cost.add(brokerage));
p.setPremiumDiscount(faceValue.multiply(rate.subtract(HUNDRED)).divide(HUNDRED, 2, RoundingMode.HALF_UP));
p.setLastIntDate(lastIntDate);
p.setAccruedDays(days);
p.setAccruedInterest(accrued);
p.setSettlementAmount(p.getBookValue().add(accrued));
p.setYtm(approxYtm(intRate, rate, script.getIssueDate(), script.getMaturityDate()));
}
/** Mutual fund: the amount invested buys units at the NAV; stamp duty and brokerage add to the cost. */
private void computeMutualFund(final InvestmentPurchase p, final JsonCommand command, final BigDecimal brokerage) {
final BigDecimal amount = command.bigDecimalValueOfParameterNamed(CONSIDER_AMT);
final BigDecimal nav = command.bigDecimalValueOfParameterNamed(NAV);
final BigDecimal stampDuty = zeroIfNull(command.bigDecimalValueOfParameterNamed(STAMP_DUTY));
final BigDecimal units = amount.divide(nav, 3, RoundingMode.HALF_UP);
p.setFaceValue(amount);
p.setPurchaseRate(nav);
p.setUnits(units);
p.setBalanceUnits(units);
p.setStampDuty(stampDuty);
p.setBookValue(amount.add(stampDuty).add(brokerage));
p.setAccruedInterest(BigDecimal.ZERO);
p.setAccruedDays(0);
p.setSettlementAmount(p.getBookValue());
}
/** Approximate yield: coupon adjusted for the discount/premium spread over the security's life, over the price. */
static BigDecimal approxYtm(final BigDecimal coupon, final BigDecimal price, final LocalDate issue, final LocalDate maturity) {
if (coupon.signum() == 0 || issue == null || maturity == null || !maturity.isAfter(issue)) {
return coupon;
}
final double years = ChronoUnit.DAYS.between(issue, maturity) / 365.25;
final double ytm = (coupon.doubleValue() + (100 - price.doubleValue()) / years) / price.doubleValue() * 100;
return BigDecimal.valueOf(ytm).setScale(2, RoundingMode.HALF_UP);
}
/** Financial-year deal number: FY start year + FY end (2 digits) + 6-digit running number, e.g. 202627000001. */
private String nextDealNo(final LocalDate dealDate) {
final String prefix = dealNoPrefix(dealDate);
final Integer max = this.jdbcTemplate.queryForObject(
"SELECT MAX(CAST(SUBSTRING(deal_no FROM 7) AS INTEGER)) FROM m_investment_purchase WHERE deal_no LIKE ?", Integer.class,
prefix + "%");
return prefix + String.format("%06d", (max == null ? 0 : max) + 1);
}
/** Financial year (April–March) of the deal date: 2026-04-01 … 2027-03-31 → "202627". */
static String dealNoPrefix(final LocalDate dealDate) {
final int fyStart = dealDate.getMonthValue() >= 4 ? dealDate.getYear() : dealDate.getYear() - 1;
return fyStart + String.format("%02d", (fyStart + 1) % 100);
}
private String nextCertNo() {
final Integer max = this.jdbcTemplate.queryForObject(
"SELECT MAX(CAST(cert_no AS INTEGER)) FROM m_investment_purchase WHERE cert_no ~ '^[0-9]+$'", Integer.class);
return String.valueOf((max == null ? 0 : max) + 1);
}
private static String otherDetails(final JsonCommand command) {
if (!command.parameterExists(OTHER_DETAILS)) {
return null;
}
final JsonElement other = command.parsedJson().getAsJsonObject().get(OTHER_DETAILS);
return other == null || other.isJsonNull() ? null : other.toString();
}
/** The paying bank account must be an enabled ASSET detail GL. */
private GLAccount postableAssetGl(final Long glAccountId) {
final GLAccount gl = this.glAccountRepository.findOneWithNotFoundDetection(glAccountId);
if (gl.isHeaderAccount() || gl.isDisabled() || !GLAccountType.ASSET.getValue().equals(gl.getType())) {
throw rule("cr.glaccount.invalid", "CR. To A/c " + gl.getGlCode() + " must be an enabled Asset detail account");
}
return gl;
}
private static BigDecimal zeroIfNull(final BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
private static GeneralPlatformDomainRuleException rule(final String code, final String message) {
return new GeneralPlatformDomainRuleException("error.msg.investmentpurchase." + code, message);
}
}
@@ -222,9 +222,10 @@ public class SavingsInterestPostingReportService {
return jdbcTemplate.queryForList("""
SELECT r.id FROM m_savings_interest_posting_report r
WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ?
AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id AND b.business_date = ?)
AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id
AND (b.business_date = ? OR (b.closed_at IS NULL AND b.business_date <= ?)))
ORDER BY r.posting_date, r.office_id, r.savings_product_id
""", Long.class, businessDate, headOfficeId, businessDate);
""", Long.class, businessDate, headOfficeId, businessDate, businessDate);
}
/** Every approved report still waiting, for the nightly safety net. */
@@ -245,15 +246,15 @@ public class SavingsInterestPostingReportService {
// ── reading ──────────────────────────────────────────────────────────────────────────────────
/**
* Reports for the screen. A branch user sees only their branch; a head office user sees every
* branch, so it can act for one that did not open. canAct says whether this user may regenerate
* or approve the report.
* Reports for the screen. {@code scope} "own" (the default) is the user's own branch; "others", for a
* head office user, is every other branch (or one, with officeId). canAct says whether this user
* may regenerate or approve the report, and blockedReason why not.
*/
public List<Map<String, Object>> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId,
final AppUser user) {
final String scope, final AppUser user) {
final Long userOffice = officeOf(user);
final boolean headOffice = isHeadOffice(userOffice);
final Long branch = headOffice ? officeId : userOffice;
final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice);
final Long branch = others ? officeId : userOffice;
final StringBuilder sql = new StringBuilder("""
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
@@ -287,6 +288,10 @@ public class SavingsInterestPostingReportService {
sql.append(" AND r.office_id = ?");
params.add(branch);
}
if (others) {
sql.append(" AND r.office_id <> ?");
params.add(userOffice);
}
sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC");
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
@@ -294,7 +299,9 @@ public class SavingsInterestPostingReportService {
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
final Map<String, Object> out = new LinkedHashMap<>(row);
final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue();
out.put("canAct", reportOffice != null && mayAct(userOffice, reportOffice, businessDate));
final String blocked = reportOffice == null ? "No branch on this report." : actBlockedReason(userOffice, reportOffice, businessDate);
out.put("canAct", blocked == null);
out.put("blockedReason", blocked);
rows.add(out);
}
return rows;
@@ -339,6 +346,45 @@ public class SavingsInterestPostingReportService {
return report;
}
/**
* What each savings account has earned so far in its current cycle — what it would be paid if
* interest were posted today. Read-only: nothing is saved, approved or posted. The ledger is first
* brought up to the business date (which moves no money) so the figure includes today.
*
* <p>
* A branch user sees their own branch; a head office user every branch, or one with officeId.
*/
public List<Map<String, Object>> preview(final Long officeId, final AppUser user) {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate));
final Long userOffice = officeOf(user);
final Long branch = isHeadOffice(userOffice) ? officeId : userOffice;
final StringBuilder sql = new StringBuilder("""
SELECT COALESCE(c.office_id, g.office_id) AS "officeId", o.name AS "officeName",
p.id AS "productId", p.name AS "productName", sa.id AS "savingsAccountId", sa.account_no AS "accountNo",
COALESCE(c.display_name, g.display_name) AS "clientName", d.closing_balance AS "balance",
d.interest_rate AS "rate", d.cycle_start_date AS "cycleStart", d.posting_date AS "postingDate",
d.balance_date AS "asOf", ROUND(d.cycle_cumulative_interest, 2) AS "interest"
FROM m_savings_daily_interest d
JOIN m_savings_account sa ON sa.id = d.savings_account_id
JOIN m_savings_product p ON p.id = d.savings_product_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
LEFT JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE p.deposit_type_enum = 100
AND sa.status_enum = ?
AND d.savings_transaction_id IS NULL
""");
final List<Object> params = new ArrayList<>(List.of(ACTIVE));
if (branch != null) {
sql.append(" AND COALESCE(c.office_id, g.office_id) = ?");
params.add(branch);
}
sql.append(" ORDER BY o.name, p.name, sa.account_no");
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
}
/**
* Branch and product interest for the head office: per report, how many customers and how much
* interest, and where it stands. Superseded reports are left out — each line is the report that
@@ -375,9 +421,13 @@ public class SavingsInterestPostingReportService {
/**
* Branch and product pairs with interest due by the date that have no report yet — the noon job ran
* before they had anything due, or failed. Limited to what the user may act on.
* before they had anything due, or failed.
*
* <p>
* {@code scope} "own" (the default) lists the user's own branch. "others", for a head office user,
* lists the other branches — each with canAct, and where the head office may not act for it, why.
*/
public List<Map<String, Object>> dueWithoutReport(final LocalDate postingDate, final AppUser user) {
public List<Map<String, Object>> dueWithoutReport(final LocalDate postingDate, final AppUser user, final String scope) {
final List<Map<String, Object>> out = new ArrayList<>();
// A future date has nothing due yet, and offering to generate for it would label this cycle's
// part-earned interest with a date it has not reached.
@@ -386,15 +436,21 @@ public class SavingsInterestPostingReportService {
return out;
}
final Long userOffice = officeOf(user);
for (final DueKey due : dueKeys(postingDate, isHeadOffice(userOffice) ? null : userOffice)) {
if (activeReportId(due.productId(), due.officeId(), postingDate) == null
&& mayAct(userOffice, due.officeId(), businessDate)) {
final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice);
for (final DueKey due : dueKeys(postingDate, others ? null : userOffice)) {
if (others == due.officeId().equals(userOffice)) {
continue;
}
if (activeReportId(due.productId(), due.officeId(), postingDate) == null) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("productId", due.productId());
row.put("productName", productName(due.productId()));
row.put("officeId", due.officeId());
row.put("officeName", jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, due.officeId()));
row.put("officeName", officeName(due.officeId()));
row.put("postingDate", postingDate.toString());
final String blocked = actBlockedReason(userOffice, due.officeId(), businessDate);
row.put("canAct", blocked == null);
row.put("blockedReason", blocked);
out.add(row);
}
}
@@ -408,36 +464,79 @@ public class SavingsInterestPostingReportService {
return !parent.isEmpty() && parent.get(0) == null;
}
/** Whether the branch opened its day on the date — the head office may act only for one that did not. */
/**
* Whether the branch is working on the date: it opened that day, or a day it opened earlier is still
* not closed. A branch with yesterday's day still open is open — it closes that day and goes on.
*/
public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) {
final Long rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
Long.class, officeId, businessDate);
final Long rows = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_branch_day_register
WHERE office_id = ? AND (business_date = ? OR (closed_at IS NULL AND business_date <= ?))
""", Long.class, officeId, businessDate, businessDate);
return rows != null && rows > 0;
}
private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
/** The branches, other than those given, that still have a day open on or before the date. */
public List<String> branchesStillOpen(final LocalDate businessDate, final Long... excludeOfficeIds) {
final StringBuilder sql = new StringBuilder("""
SELECT DISTINCT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id
WHERE r.closed_at IS NULL AND r.business_date <= ?
""");
final List<Object> params = new ArrayList<>(List.of(businessDate));
for (final Long exclude : excludeOfficeIds) {
sql.append(" AND r.office_id <> ?");
params.add(exclude);
}
sql.append(" ORDER BY o.name");
return jdbcTemplate.queryForList(sql.toString(), String.class, params.toArray());
}
/**
* Null where the user may generate or approve the branch's report, otherwise why not.
*
* <p>
* A branch always acts for itself. The head office acts for another branch only when that branch
* has not opened at all, and only once every branch that did open has closed — the head office's
* own close being the last of the day, so nothing is taken out of a branch that may still open.
*/
private String actBlockedReason(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
if (userOffice == null) {
return false;
return "No branch is set for this user.";
}
if (userOffice.equals(reportOffice)) {
return true;
return null;
}
return isHeadOffice(userOffice) && !branchOpenedOn(reportOffice, businessDate);
final String branch = officeName(reportOffice);
if (!isHeadOffice(userOffice)) {
return "Only " + branch + " can generate or approve its own interest posting report.";
}
if (branchOpenedOn(reportOffice, businessDate)) {
return branch + " is open — it generates and approves its own report.";
}
final List<String> stillOpen = branchesStillOpen(businessDate, userOffice, reportOffice);
if (!stillOpen.isEmpty()) {
return "The head office can act for " + branch + " only after every open branch has closed. Still open: "
+ String.join(", ", stillOpen) + ".";
}
return null;
}
private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
return actBlockedReason(userOffice, reportOffice, businessDate) == null;
}
/** @return true where the head office is acting on a branch's behalf */
private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) {
final Long userOffice = officeOf(user);
if (userOffice != null && userOffice.equals(reportOffice)) {
return false;
final String blocked = actBlockedReason(userOffice, reportOffice, businessDate);
if (blocked != null) {
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch", blocked);
}
if (mayAct(userOffice, reportOffice, businessDate)) {
return true;
}
final String branch = jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, reportOffice);
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch",
"Only " + branch + " can generate or approve its own interest posting report. The head office may do so only on a day "
+ branch + " has not opened.");
return !reportOffice.equals(userOffice);
}
private String officeName(final Long officeId) {
return jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, officeId);
}
private static Long officeOf(final AppUser user) {
@@ -65,20 +65,34 @@ public class SavingsInterestPostingReportApiResource {
/** A branch user sees their own branch; a head office user every branch, or one with officeId. */
@GET
public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status,
@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId) {
@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId,
@QueryParam("scope") final String scope) {
final AppUser user = context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, user).stream().map(this::plain).toList());
return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream().map(this::plain).toList());
}
/** Who is looking: their branch, and whether it is the head office (which gets the Other branches tab). */
@GET
@Path("/context")
public String userContext() {
final AppUser user = context.authenticatedUser();
final Long officeId = user.getOffice().getId();
final Map<String, Object> result = new LinkedHashMap<>();
result.put("officeId", officeId);
result.put("officeName", user.getOffice().getName());
result.put("headOffice", reportService.isHeadOffice(officeId));
return gson.toJson(result);
}
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
@GET
@Path("/due-without-report")
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate) {
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) {
final AppUser user = context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
return gson.toJson(reportService.dueWithoutReport(date, user));
return gson.toJson(reportService.dueWithoutReport(date, user, scope));
}
/**
@@ -99,6 +113,15 @@ public class SavingsInterestPostingReportApiResource {
return gson.toJson(result);
}
/** Interest each account has earned so far this cycle, as if posted today. View only — nothing is saved. */
@GET
@Path("/preview")
public String preview(@QueryParam("officeId") final Long officeId) {
final AppUser user = context.authenticatedUser();
user.validateHasReadPermission(RESOURCE);
return gson.toJson(reportService.preview(officeId, user).stream().map(this::plain).toList());
}
/** The head office's view: per branch and product, customers paid and interest, over a date range. */
@GET
@Path("/summary")
@@ -250,11 +250,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement elsewhere.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
}
@@ -310,11 +306,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement elsewhere.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
}
@@ -260,11 +260,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -354,11 +350,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -264,11 +264,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -361,11 +357,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -203,11 +203,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
}
@@ -269,11 +265,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
}
@@ -420,11 +420,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -528,11 +524,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -172,12 +172,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
// it just no longer lets them bypass the hold on their own (matches
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
// equivalent rule for the classic command pipeline).
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
}
@@ -219,12 +214,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
// it just no longer lets them bypass the hold on their own (matches
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
// equivalent rule for the classic command pipeline).
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
}
@@ -882,11 +882,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new ShareProductSelfApprovalNotAllowedException(productId);
}
@@ -972,11 +968,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
throw new ShareProductSelfApprovalNotAllowedException(productId);
}
@@ -163,5 +163,8 @@
<include file="parts/0588_add_premature_closure_reason.xml" relativeToChangelogFile="true"/>
<include file="parts/0589_savings_interest_posting_report.xml" relativeToChangelogFile="true"/>
<include file="parts/0590_savings_interest_posting_report_by_branch.xml" relativeToChangelogFile="true"/>
<include file="parts/0588_persist_manually_enabled_maker_checker_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0591_cashier_allocation_time_and_deposit_counter_office.xml" relativeToChangelogFile="true"/>
<include file="parts/0592_enable_teller_cashier_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0593_investment_masters.xml" relativeToChangelogFile="true"/>
<include file="parts/0594_investment_purchase.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -1,73 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Converts manually-applied Configure Maker Checker Tasks changes into a repeatable migration.
During Admin -> Organization and Admin -> System testing, can_maker_checker was switched on
(via System -> Configure Maker Checker Tasks) for the 28 permissions below, directly in the
dev database - none of that was ever captured by a migration, so a fresh/other environment
restored from this changelog history would come up with every one of them still at its
originally-seeded false value. Every code here was independently confirmed, via read-only
inspection of both its origin changeset and the live database, to be seeded can_maker_checker
= false and currently true in the running dev DB with no existing changeset already covering
the flip (the one precedent for this pattern, DELETE_OFFICE, was flipped by changeset
0538_enable_maker_checker_for_delete_office and is deliberately left untouched here).
This changeset only ever moves can_maker_checker from false to true for these exact codes -
entity_name, action_name, grouping and every other column are left exactly as their origin
changeset defined them (including the pre-existing DELETE_ACCOUNTNUMBERFORMAT entity_name
drift, which is out of scope for this change and is not corrected here). No _CHECKER row is
touched: those already exist for every code below (added earlier by 0583/0586, or by base
Fineract for the stock codes), and can_maker_checker is not a column on a _CHECKER row.
Grouped by the screen each permission belongs to:
- Currency Configuration: UPDATE_CURRENCY
- Manage Funds: CREATE_FUND
- Working Days: UPDATE_WORKINGDAYS
- Payment Type: CREATE_PAYMENTTYPE, UPDATE_PAYMENTTYPE, DELETE_PAYMENTTYPE
- Sponsor Banks: CREATE_SPONSORBANK, UPDATE_SPONSORBANK
- Branch Profile: UPDATE_BRANCHPROFILE
- Manage Data Tables: CREATE_DATATABLE, UPDATE_DATATABLE, DELETE_DATATABLE
- Manage Codes: CREATE_CODE, UPDATE_CODE, DELETE_CODE,
CREATE_CODEVALUE, UPDATE_CODEVALUE, DELETE_CODEVALUE
- Manage Reports: CREATE_REPORT, UPDATE_REPORT, DELETE_REPORT
- Manage Hooks: CREATE_HOOK, UPDATE_HOOK, DELETE_HOOK
- Account Number Preferences: CREATE_ACCOUNTNUMBERFORMAT, DELETE_ACCOUNTNUMBERFORMAT
- Manage User Agreement: CREATE_USERAGREEMENT
- Manage Jobs (COB Workflow Step Reorder): UPDATE_BATCH_BUSINESS_STEP
Deliberately NOT included (confirmed still false, or out of scope, and left untouched):
- UPDATE_PERMISSION, UPDATE_CONFIGURATION (the Configure Maker Checker Tasks self-lockout
exclusion added by PermissionReadPlatformServiceImpl stays in effect; these remain false)
- UPDATE_SPONSORDDGLMAPPING, all Entity-to-Entity Mapping, External Services and Manage
Survey permissions (confirmed still false - never actually toggled on)
- CREATE_ROLE/UPDATE_ROLE/DELETE_ROLE/etc., CREATE_GLACCOUNT, CREATE_CHARGE,
CREATE_LOANPRODUCT, CREATE_SAVINGSPRODUCT and other permissions observed to already be
true in the live DB but belonging to Accounting/Products/Roles modules, not Admin ->
Organization or Admin -> System - out of scope for this change and left as pre-existing
drift for a separate decision. -->
<changeSet id="0588_persist_manually_enabled_maker_checker_permissions" author="cbs-anshul">
<update tableName="m_permission">
<column name="can_maker_checker" valueBoolean="true"/>
<where>code IN (
'UPDATE_CURRENCY',
'CREATE_FUND',
'UPDATE_WORKINGDAYS',
'CREATE_PAYMENTTYPE', 'UPDATE_PAYMENTTYPE', 'DELETE_PAYMENTTYPE',
'CREATE_SPONSORBANK', 'UPDATE_SPONSORBANK',
'UPDATE_BRANCHPROFILE',
'CREATE_DATATABLE', 'UPDATE_DATATABLE', 'DELETE_DATATABLE',
'CREATE_CODE', 'UPDATE_CODE', 'DELETE_CODE',
'CREATE_CODEVALUE', 'UPDATE_CODEVALUE', 'DELETE_CODEVALUE',
'CREATE_REPORT', 'UPDATE_REPORT', 'DELETE_REPORT',
'CREATE_HOOK', 'UPDATE_HOOK', 'DELETE_HOOK',
'CREATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT',
'CREATE_USERAGREEMENT',
'UPDATE_BATCH_BUSINESS_STEP'
)</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Cash taken over a counter belongs to the drawer and the branch that took it, not to whoever the
same login is allocated to today.
m_cashiers.created_at: a cashier position is built from the counter deposits and withdrawals of
the cashier's login, so a person moved from one branch's teller to another's carried every
rupee they had ever taken into the new drawer. The position now counts only what was taken
once the allocation existed. Existing rows take the time their ALLOCATECASHIER command ran;
a row with no such command stays NULL and keeps its whole history, as before.
m_cashier_deposit_request.counter_office_id: savings deposits are submitted with no office_id,
and the day close counted every such deposit at every branch. The counter's office is now
stored at submit. Existing rows take the teller office of the submitter's allocation if it
already existed when the deposit was submitted, otherwise the submitter's own office.
-->
<changeSet id="0591_cashier_created_at" author="fineract">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_cashiers" columnName="created_at"/>
</not>
</preConditions>
<addColumn tableName="m_cashiers">
<column name="created_at" type="TIMESTAMP"/>
</addColumn>
<sql>
UPDATE m_cashiers mc
SET created_at = (SELECT CAST(MIN(cs.made_on_date_utc) AS TIMESTAMP)
FROM m_portfolio_command_source cs
WHERE cs.entity_name = 'TELLER' AND cs.action_name = 'ALLOCATECASHIER'
AND cs.subresource_id = mc.id)
</sql>
<addDefaultValue tableName="m_cashiers" columnName="created_at" columnDataType="TIMESTAMP"
defaultValueComputed="CURRENT_TIMESTAMP"/>
</changeSet>
<changeSet id="0591_deposit_request_counter_office" author="fineract">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_cashier_deposit_request" columnName="counter_office_id"/>
</not>
</preConditions>
<addColumn tableName="m_cashier_deposit_request">
<column name="counter_office_id" type="BIGINT"/>
</addColumn>
<sql>
UPDATE m_cashier_deposit_request r
SET counter_office_id = COALESCE(
(SELECT t.office_id FROM m_appuser au
JOIN m_cashiers c ON c.staff_id = au.staff_id
JOIN m_tellers t ON t.id = c.teller_id
WHERE au.id = r.cashier_id
AND (c.created_at IS NULL OR r.submitted_at IS NULL OR r.submitted_at >= c.created_at)
LIMIT 1),
(SELECT au.office_id FROM m_appuser au WHERE au.id = r.cashier_id),
r.office_id)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Same pattern as 0588_persist_manually_enabled_maker_checker_permissions: converts a
manually-applied Configure Maker Checker Tasks change into a repeatable migration, this time
for the Teller/Cashier module. Found by diffing can_maker_checker across every permission code
common to fineract_lincpay and a reference environment (fineract_default) - these 4 were true
in the reference but still seeded false here, confirming they were toggled on manually there
and never captured for any other environment (including this one and UAT).
Grouped by the screen each permission belongs to (Organization -> Manage Tellers -> allocate/
settle cash for a cashier):
ALLOCATECASHIER_TELLER, ALLOCATECASHTOCASHIER_TELLER,
SETTLECASHFROMCASHIER_TELLER, UPDATECASHIERALLOCATION_TELLER
No _CHECKER row is touched: those already exist for every code below (seeded by base
Fineract), and can_maker_checker is not a column on a _CHECKER row. -->
<changeSet id="0592_enable_teller_cashier_maker_checker" author="cbs-nitesh">
<update tableName="m_permission">
<column name="can_maker_checker" valueBoolean="true"/>
<where>code IN (
'ALLOCATECASHIER_TELLER',
'ALLOCATECASHTOCASHIER_TELLER',
'SETTLECASHFROMCASHIER_TELLER',
'UPDATECASHIERALLOCATION_TELLER'
)</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,216 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
Treasury Investment masters, following the bank's existing CBS screens:
- m_investment_party : Bank / Party Master (whom an investment is bought from / placed with)
- m_investment_script : Script Master (what is bought - Govt Security, Bond, Mutual Fund, ...)
Party and script codes are generated by the application (PTY-001, G00001). Both rows are
tied to the investment (asset) GL they are booked to.
-->
<changeSet author="cbs-anshul" id="0593-1-create-investment-party">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_investment_party"/></not>
</preConditions>
<createTable tableName="m_investment_party">
<column name="id" type="BIGINT" autoIncrement="true"><constraints nullable="false" primaryKey="true"/></column>
<column name="party_code" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="bond_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="party_name" type="VARCHAR(150)"><constraints nullable="false"/></column>
<column name="address1" type="VARCHAR(150)"/>
<column name="address2" type="VARCHAR(150)"/>
<column name="address3" type="VARCHAR(150)"/>
<column name="city" type="VARCHAR(100)"/>
<column name="pincode" type="VARCHAR(10)"/>
<column name="phone" type="VARCHAR(20)"/>
<column name="fax" type="VARCHAR(20)"/>
<column name="bank_type" type="VARCHAR(60)"/>
<column name="gl_account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="rbi_code" type="VARCHAR(30)"/>
<column name="exposure_limit" type="DECIMAL(19,6)"/>
<column name="lei_no" type="VARCHAR(30)"/>
<column name="cp_bank_name" type="VARCHAR(150)"/>
<column name="cp_branch_name" type="VARCHAR(150)"/>
<column name="cp_account_no" type="VARCHAR(40)"/>
<column name="cp_ifsc" type="VARCHAR(11)"/>
<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>
</createTable>
<addUniqueConstraint tableName="m_investment_party" columnNames="party_code" constraintName="uq_investment_party_code"/>
<addForeignKeyConstraint baseTableName="m_investment_party" baseColumnNames="gl_account_id" constraintName="fk_investment_party_gl_account"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<createIndex tableName="m_investment_party" indexName="idx_investment_party_bond_type">
<column name="bond_type"/>
</createIndex>
</changeSet>
<changeSet author="cbs-anshul" id="0593-2-create-investment-script">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_investment_script"/></not>
</preConditions>
<createTable tableName="m_investment_script">
<column name="id" type="BIGINT" autoIncrement="true"><constraints nullable="false" primaryKey="true"/></column>
<column name="script_code" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="bond_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="slr_type" type="VARCHAR(20)"/>
<column name="script_desc" type="VARCHAR(150)"><constraints nullable="false"/></column>
<column name="gl_account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="isin" type="VARCHAR(20)"/>
<column name="face_value_per_unit" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="int_rate" type="DECIMAL(9,4)"/>
<column name="issue_date" type="DATE"/>
<column name="maturity_date" type="DATE"/>
<column name="company_name" type="VARCHAR(150)"/>
<column name="address1" type="VARCHAR(150)"/>
<column name="address2" type="VARCHAR(150)"/>
<column name="address3" type="VARCHAR(150)"/>
<column name="city" type="VARCHAR(100)"/>
<column name="pincode" type="VARCHAR(10)"/>
<column name="phone" type="VARCHAR(20)"/>
<column name="fax" type="VARCHAR(20)"/>
<column name="corp_code" type="VARCHAR(30)"/>
<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>
</createTable>
<addUniqueConstraint tableName="m_investment_script" columnNames="script_code" constraintName="uq_investment_script_code"/>
<addForeignKeyConstraint baseTableName="m_investment_script" baseColumnNames="gl_account_id" constraintName="fk_investment_script_gl_account"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<createIndex tableName="m_investment_script" indexName="idx_investment_script_bond_type">
<column name="bond_type"/>
</createIndex>
</changeSet>
<changeSet author="cbs-anshul" id="0593-3-investment-master-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_INVESTMENTPARTY', 'CREATE_INVESTMENTPARTY', 'UPDATE_INVESTMENTPARTY', 'DELETE_INVESTMENTPARTY', 'READ_INVESTMENTSCRIPT', 'CREATE_INVESTMENTSCRIPT', 'UPDATE_INVESTMENTSCRIPT', 'CREATE_INVESTMENTPARTY_CHECKER', 'UPDATE_INVESTMENTPARTY_CHECKER', 'DELETE_INVESTMENTPARTY_CHECKER', 'CREATE_INVESTMENTSCRIPT_CHECKER', 'UPDATE_INVESTMENTSCRIPT_CHECKER')</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="READ_INVESTMENTPARTY"/>
<column name="entity_name" value="INVESTMENTPARTY"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="CREATE_INVESTMENTPARTY"/>
<column name="entity_name" value="INVESTMENTPARTY"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="UPDATE_INVESTMENTPARTY"/>
<column name="entity_name" value="INVESTMENTPARTY"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="DELETE_INVESTMENTPARTY"/>
<column name="entity_name" value="INVESTMENTPARTY"/>
<column name="action_name" value="DELETE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="READ_INVESTMENTSCRIPT"/>
<column name="entity_name" value="INVESTMENTSCRIPT"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="CREATE_INVESTMENTSCRIPT"/>
<column name="entity_name" value="INVESTMENTSCRIPT"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="UPDATE_INVESTMENTSCRIPT"/>
<column name="entity_name" value="INVESTMENTSCRIPT"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="CREATE_INVESTMENTPARTY_CHECKER"/>
<column name="entity_name" value="INVESTMENTPARTY"/>
<column name="action_name" value="CREATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="UPDATE_INVESTMENTPARTY_CHECKER"/>
<column name="entity_name" value="INVESTMENTPARTY"/>
<column name="action_name" value="UPDATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="DELETE_INVESTMENTPARTY_CHECKER"/>
<column name="entity_name" value="INVESTMENTPARTY"/>
<column name="action_name" value="DELETE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="CREATE_INVESTMENTSCRIPT_CHECKER"/>
<column name="entity_name" value="INVESTMENTSCRIPT"/>
<column name="action_name" value="CREATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="UPDATE_INVESTMENTSCRIPT_CHECKER"/>
<column name="entity_name" value="INVESTMENTSCRIPT"/>
<column name="action_name" value="UPDATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Whoever already sees the Investment menu gets to read and maintain its masters. Maker-checker
is left off (can_maker_checker=false); it can be switched on per action from
Configure Maker Checker Tasks, and the _CHECKER rows above let a checker role approve. -->
<changeSet author="cbs-anshul" id="0593-4-grant-investment-master-permissions">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT rp.role_id, p.id
FROM m_role_permission rp
JOIN m_permission menu ON menu.id = rp.permission_id AND menu.code = 'MENU_INVESTMENT'
CROSS JOIN m_permission p
WHERE p.code IN ('READ_INVESTMENTPARTY', 'CREATE_INVESTMENTPARTY', 'UPDATE_INVESTMENTPARTY', 'DELETE_INVESTMENTPARTY', 'READ_INVESTMENTSCRIPT', 'CREATE_INVESTMENTSCRIPT', 'UPDATE_INVESTMENTSCRIPT')
AND NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p.id)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,149 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
Investment Purchase Entry: one row per deal, which is the holding that interest, sale and
redemption later work against (balance_units tracks what is still held). Units, book value,
accrued interest and settlement amount are computed by the application. The optional legacy
deal fields (trade no, authorities, lien, delivery mode, ...) are kept in other_details as JSON.
No GL entry is posted by this table yet.
-->
<changeSet author="cbs-anshul" id="0594-1-create-investment-purchase">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_investment_purchase"/></not>
</preConditions>
<createTable tableName="m_investment_purchase">
<column name="id" type="BIGINT" autoIncrement="true"><constraints nullable="false" primaryKey="true"/></column>
<column name="deal_no" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="cert_no" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="bond_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="script_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="party_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="deal_date" type="DATE"><constraints nullable="false"/></column>
<column name="deal_time" type="VARCHAR(8)"/>
<column name="settlement_type" type="VARCHAR(5)"><constraints nullable="false"/></column>
<column name="settlement_date" type="DATE"><constraints nullable="false"/></column>
<column name="face_value" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="purchase_rate" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="units" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="balance_units" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="brokerage" type="DECIMAL(19,6)"/>
<column name="stamp_duty" type="DECIMAL(19,6)"/>
<column name="book_value" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="premium_discount" type="DECIMAL(19,6)"/>
<column name="int_rate" type="DECIMAL(9,4)"/>
<column name="issue_date" type="DATE"/>
<column name="maturity_date" type="DATE"/>
<column name="last_int_date" type="DATE"/>
<column name="accrued_days" type="INT"/>
<column name="accrued_interest" type="DECIMAL(19,6)"/>
<column name="settlement_amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="ytm" type="DECIMAL(9,4)"/>
<column name="category" type="VARCHAR(40)"><constraints nullable="false"/></column>
<column name="sub_type" type="VARCHAR(40)"/>
<column name="investment_gl_account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="cr_gl_account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="other_details" type="VARCHAR(4000)"/>
<column name="status" type="VARCHAR(20)"><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>
</createTable>
<addUniqueConstraint tableName="m_investment_purchase" columnNames="deal_no" constraintName="uq_investment_purchase_deal_no"/>
<addUniqueConstraint tableName="m_investment_purchase" columnNames="cert_no" constraintName="uq_investment_purchase_cert_no"/>
<addForeignKeyConstraint baseTableName="m_investment_purchase" baseColumnNames="script_id" constraintName="fk_investment_purchase_script"
referencedTableName="m_investment_script" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_investment_purchase" baseColumnNames="party_id" constraintName="fk_investment_purchase_party"
referencedTableName="m_investment_party" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_investment_purchase" baseColumnNames="investment_gl_account_id" constraintName="fk_investment_purchase_inv_gl"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_investment_purchase" baseColumnNames="cr_gl_account_id" constraintName="fk_investment_purchase_cr_gl"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<createIndex tableName="m_investment_purchase" indexName="idx_investment_purchase_script">
<column name="script_id"/>
</createIndex>
<createIndex tableName="m_investment_purchase" indexName="idx_investment_purchase_bond_type_status">
<column name="bond_type"/>
<column name="status"/>
</createIndex>
</changeSet>
<changeSet author="cbs-anshul" id="0594-2-investment-purchase-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_INVESTMENTPURCHASE', 'CREATE_INVESTMENTPURCHASE', 'DELETE_INVESTMENTPURCHASE', 'CREATE_INVESTMENTPURCHASE_CHECKER', 'DELETE_INVESTMENTPURCHASE_CHECKER')</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="READ_INVESTMENTPURCHASE"/>
<column name="entity_name" value="INVESTMENTPURCHASE"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="CREATE_INVESTMENTPURCHASE"/>
<column name="entity_name" value="INVESTMENTPURCHASE"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="DELETE_INVESTMENTPURCHASE"/>
<column name="entity_name" value="INVESTMENTPURCHASE"/>
<column name="action_name" value="DELETE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="CREATE_INVESTMENTPURCHASE_CHECKER"/>
<column name="entity_name" value="INVESTMENTPURCHASE"/>
<column name="action_name" value="CREATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="DELETE_INVESTMENTPURCHASE_CHECKER"/>
<column name="entity_name" value="INVESTMENTPURCHASE"/>
<column name="action_name" value="DELETE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Same roles as the masters: whoever sees the Investment menu. Maker-checker stays off until
switched on from Configure Maker Checker Tasks. -->
<changeSet author="cbs-anshul" id="0594-3-grant-investment-purchase-permissions">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT rp.role_id, p.id
FROM m_role_permission rp
JOIN m_permission menu ON menu.id = rp.permission_id AND menu.code = 'MENU_INVESTMENT'
CROSS JOIN m_permission p
WHERE p.code IN ('READ_INVESTMENTPURCHASE', 'CREATE_INVESTMENTPURCHASE', 'DELETE_INVESTMENTPURCHASE')
AND NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p.id)
</sql>
</changeSet>
</databaseChangeLog>
@@ -116,11 +116,7 @@ public class CommandSourceServiceTest {
}
@Test
public void testProcessCommand_NonGLAccount_SuperUser_OwnSubmission_DoesNotBypassMakerChecker() {
// A maker who happens to hold CHECKER_SUPER_USER must not auto-approve their own submission -
// CHECKER_SUPER_USER only ever lets its holder approve a DIFFERENT user's request (see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction()). This
// replaces the old bypass this test used to assert.
public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
@@ -129,8 +125,6 @@ public class CommandSourceServiceTest {
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW");
Mockito.when(commandSource.getId()).thenReturn(300L);
Mockito.when(commandSource.getResourceId()).thenReturn(400L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
@@ -138,12 +132,12 @@ public class CommandSourceServiceTest {
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, superUser, false);
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(superUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
}
@Test
@@ -0,0 +1,173 @@
/**
* 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.investment.service;
import static org.apache.fineract.portfolio.investment.service.InvestmentInterestCalculator.TYPE_COUPON;
import static org.apache.fineract.portfolio.investment.service.InvestmentInterestCalculator.TYPE_PROVISION;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.portfolio.investment.service.InvestmentInterestCalculator.ScheduleRow;
import org.junit.jupiter.api.Test;
/**
* Figures from Jitendra Sir's worked example: 7.50% GOI 2034, face value 1 crore, coupons 10 Feb / 10 Aug, bought on
* 08-10-2026.
*/
class InvestmentInterestCalculatorTest {
private static final BigDecimal ONE_CRORE = new BigDecimal("10000000");
private static final BigDecimal RATE = new BigDecimal("7.50");
private static final LocalDate MATURITY = LocalDate.of(2034, 8, 10);
private static final LocalDate LAST_COUPON = LocalDate.of(2026, 8, 10);
private static final LocalDate DEAL = LocalDate.of(2026, 10, 8);
@Test
void days360CountsEveryMonthAsThirtyDays() {
assertEquals(50, InvestmentInterestCalculator.days360(LAST_COUPON, LocalDate.of(2026, 9, 30)));
assertEquals(58, InvestmentInterestCalculator.days360(LAST_COUPON, DEAL));
assertEquals(90, InvestmentInterestCalculator.days360(LocalDate.of(2026, 6, 30), LocalDate.of(2026, 9, 30)));
assertEquals(50, InvestmentInterestCalculator.days360(LocalDate.of(2027, 2, 10), LocalDate.of(2027, 3, 31)));
assertEquals(180, InvestmentInterestCalculator.days360(LAST_COUPON, LocalDate.of(2027, 2, 10)));
assertEquals(360, InvestmentInterestCalculator.days360(LAST_COUPON, LocalDate.of(2027, 8, 10)));
}
@Test
void days360IsZeroWhenDatesAreMissingOrReversed() {
assertEquals(0, InvestmentInterestCalculator.days360(null, DEAL));
assertEquals(0, InvestmentInterestCalculator.days360(DEAL, null));
assertEquals(0, InvestmentInterestCalculator.days360(DEAL, DEAL));
assertEquals(0, InvestmentInterestCalculator.days360(DEAL, LAST_COUPON));
}
@Test
void interestForDaysRoundsToTheRupee() {
assertEquals(new BigDecimal("120833"), InvestmentInterestCalculator.interestForDays(ONE_CRORE, RATE, 58));
assertEquals(new BigDecimal("104167"), InvestmentInterestCalculator.interestForDays(ONE_CRORE, RATE, 50));
assertEquals(new BigDecimal("375000"), InvestmentInterestCalculator.interestForDays(ONE_CRORE, RATE, 180));
}
@Test
void halfYearCouponIsFaceValueTimesRateOverTwo() {
assertEquals(new BigDecimal("375000"), InvestmentInterestCalculator.halfYearCoupon(ONE_CRORE, RATE));
assertEquals(new BigDecimal("362500"), InvestmentInterestCalculator.halfYearCoupon(ONE_CRORE, new BigDecimal("7.25")));
}
@Test
void couponDatesRunSixMonthlyFromMaturity() {
assertEquals(LAST_COUPON, InvestmentInterestCalculator.lastCouponOnOrBefore(MATURITY, DEAL));
assertEquals(LAST_COUPON, InvestmentInterestCalculator.lastCouponOnOrBefore(MATURITY, LAST_COUPON));
final List<LocalDate> coupons = InvestmentInterestCalculator.couponDatesAfter(MATURITY, LAST_COUPON);
assertEquals(16, coupons.size());
assertEquals(LocalDate.of(2027, 2, 10), coupons.get(0));
assertEquals(LocalDate.of(2027, 8, 10), coupons.get(1));
assertEquals(MATURITY, coupons.get(15));
}
@Test
void monthEndMaturityKeepsMonthEndCoupons() {
final LocalDate maturity = LocalDate.of(2030, 8, 31);
final List<LocalDate> coupons = InvestmentInterestCalculator.couponDatesAfter(maturity, LocalDate.of(2029, 8, 31));
assertEquals(List.of(LocalDate.of(2030, 2, 28), maturity), coupons);
}
@Test
void closingDatesAreMarchAndSeptemberEnds() {
assertEquals(List.of(LocalDate.of(2027, 3, 31), LocalDate.of(2027, 9, 30)),
InvestmentInterestCalculator.closingDates(DEAL, LocalDate.of(2028, 3, 30)));
assertEquals(List.of(LocalDate.of(2026, 9, 30)),
InvestmentInterestCalculator.closingDates(LocalDate.of(2026, 9, 30), LocalDate.of(2026, 9, 30)));
}
@Test
void scheduleClearsPurchasedInterestAndProvisionsAtEachClosing() {
final List<ScheduleRow> rows = InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, MATURITY, LAST_COUPON, DEAL,
new BigDecimal("120833"));
// 16 coupons + 15 closings (Mar 2027 … Mar 2034); 30-09-2026 is before the deal, so it is the seller's.
assertEquals(31, rows.size());
// First coupon: 1,20,833 paid to the seller is recovered, only the rest is our income.
assertRow(rows.get(0), TYPE_COUPON, LocalDate.of(2027, 2, 10), LocalDate.of(2026, 8, 11), 180, "375000", "120833", "254167");
// 31 March closing: 50 days earned since 10 Feb, nothing booked yet.
assertRow(rows.get(1), TYPE_PROVISION, LocalDate.of(2027, 3, 31), LocalDate.of(2027, 2, 11), 50, "104167", "0", "104167");
// August coupon clears the March provision.
assertRow(rows.get(2), TYPE_COUPON, LocalDate.of(2027, 8, 10), LocalDate.of(2027, 2, 11), 180, "375000", "104167", "270833");
assertRow(rows.get(3), TYPE_PROVISION, LocalDate.of(2027, 9, 30), LocalDate.of(2027, 8, 11), 50, "104167", "0", "104167");
final ScheduleRow last = rows.get(rows.size() - 1);
assertEquals(TYPE_COUPON, last.type());
assertEquals(MATURITY, last.date());
// Over the life, income booked = all coupons − interest bought from the seller.
final BigDecimal totalIncome = rows.stream().map(ScheduleRow::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
assertEquals(new BigDecimal("375000").multiply(BigDecimal.valueOf(16)).subtract(new BigDecimal("120833")), totalIncome);
}
@Test
void closingOnACouponDateIsNotProvisionedTwice() {
// Coupons on 31 Mar / 30 Sep: every closing is a coupon day, so there are no provision lines.
final List<ScheduleRow> rows = InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, LocalDate.of(2028, 3, 31),
LocalDate.of(2026, 9, 30), DEAL, BigDecimal.ZERO);
assertEquals(3, rows.size());
assertTrue(rows.stream().allMatch(r -> TYPE_COUPON.equals(r.type())));
}
@Test
void scheduleIsEmptyWithoutRateOrDates() {
assertTrue(InvestmentInterestCalculator.schedule(ONE_CRORE, BigDecimal.ZERO, MATURITY, LAST_COUPON, DEAL, null).isEmpty());
assertTrue(InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, null, LAST_COUPON, DEAL, null).isEmpty());
assertTrue(InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, MATURITY, null, DEAL, null).isEmpty());
}
@Test
void dealNumberFollowsTheAprilToMarchFinancialYear() {
assertEquals("202627", InvestmentPurchaseWritePlatformServiceImpl.dealNoPrefix(LocalDate.of(2026, 4, 1)));
assertEquals("202627", InvestmentPurchaseWritePlatformServiceImpl.dealNoPrefix(DEAL));
assertEquals("202627", InvestmentPurchaseWritePlatformServiceImpl.dealNoPrefix(LocalDate.of(2027, 3, 31)));
assertEquals("202728", InvestmentPurchaseWritePlatformServiceImpl.dealNoPrefix(LocalDate.of(2027, 4, 1)));
assertEquals("209900", InvestmentPurchaseWritePlatformServiceImpl.dealNoPrefix(LocalDate.of(2099, 12, 1)));
}
@Test
void approxYtmRisesWhenBoughtAtADiscount() {
final LocalDate issue = LocalDate.of(2024, 8, 10);
assertEquals(new BigDecimal("7.59"),
InvestmentPurchaseWritePlatformServiceImpl.approxYtm(RATE, new BigDecimal("99.50"), issue, MATURITY));
assertEquals(new BigDecimal("7.50"),
InvestmentPurchaseWritePlatformServiceImpl.approxYtm(RATE, new BigDecimal("100"), issue, MATURITY));
assertEquals(RATE, InvestmentPurchaseWritePlatformServiceImpl.approxYtm(RATE, new BigDecimal("99.50"), null, MATURITY));
}
private static void assertRow(final ScheduleRow row, final String type, final LocalDate date, final LocalDate from, final int days,
final String gross, final String alreadyBooked, final String amount) {
assertEquals(type, row.type());
assertEquals(date, row.date());
assertEquals(from, row.fromDate());
assertEquals(date, row.toDate());
assertEquals(days, row.days());
assertEquals(new BigDecimal(gross), row.grossInterest());
assertEquals(new BigDecimal(alreadyBooked), row.alreadyBooked());
assertEquals(new BigDecimal(amount), row.amount());
}
}