diff --git a/docs/teller-cash-management/issues.md b/docs/teller-cash-management/issues.md new file mode 100644 index 00000000000..9556ae4c21e --- /dev/null +++ b/docs/teller-cash-management/issues.md @@ -0,0 +1,348 @@ +# Teller Cash Management Redesign — Planned Issues + +This document lists all 10 planned issues for the Teller Cash Management redesign. Issues are ordered from highest to lowest priority and in dependency order (bugs first, then enhancements). + +--- + +## Summary Table + +| # | Title | Type | Priority | Depends On | +|---|-------|------|----------|------------| +| 1 | Bug: PUT /tellers drops debitAccountId and creditAccountId | Bug | High | — | +| 2 | Bug: fromDate/toDate query parameters are ignored on cashier transactions endpoints | Bug | High | — | +| 3 | Bug: m_cashiers unique constraint (staff_id, teller_id) prevents cashier re-assignment to the same teller | Bug | High | — | +| 4 | Enhancement: Add m_cashier_sessions table and JPA entity | Enhancement | High | #3 | +| 5 | Enhancement: Add cashier_session_id FK to m_loan_transactions and m_savings_account_transaction | Enhancement | High | #4 | +| 6 | Enhancement: Implement Cashier Session lifecycle API endpoints | Enhancement | High | #4, #5 | +| 7 | Bug/Enhancement: Route cash GL through Teller Cash (11140) when a cashier session is open | Bug + Enhancement | High | #4, #5, #6 | +| 8 | Enhancement: Auto-post GL variance journal entry on unbalanced settlement | Enhancement | Medium | #6, #7 | +| 9 | Bug: Cashier assignment expiry causes silent failure with no notification | Bug | Medium | — | +| 10 | Enhancement: Multi-teller concurrent session support | Enhancement | Medium | #4, #5, #6, #7 | + +--- + +## Issue 1 — Bug: PUT /tellers drops debitAccountId and creditAccountId + +**Labels:** `bug` +**Spec Reference:** §2.5 — API Bugs Discovered, Phase 2 + +### Problem + +The `Teller.update()` method in `Teller.java` does not handle updating `debitAccountId` and `creditAccountId` from a PUT payload. Requests to update these fields via `PUT /tellers/{id}` are silently ignored, even though the fields exist in the database (`debit_account_id`, `credit_account_id` on `m_tellers`) and on the JPA entity. + +### Code Reference +- File: `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Teller.java` +- Method: `update()` — lines 94–153 +- The method handles: `officeId`, `name`, `description`, `startDate`, `endDate`, `status` +- ❌ Never reads or persists `debitAccountId` or `creditAccountId` + +### Workaround +Direct SQL only: +```sql +UPDATE m_tellers SET debit_account_id = ?, credit_account_id = ? WHERE id = ?; +``` + +### Fix Required +- Update `Teller.update()` to read and persist `debitAccountId` and `creditAccountId` (look up via `GLAccountRepository`) +- Update `TellerCommandFromApiJsonDeserializer` to allow and validate these fields +- Update the PUT request DTO to include these fields + +--- + +## Issue 2 — Bug: fromDate/toDate query parameters are ignored on cashier transactions endpoints + +**Labels:** `bug` +**Spec Reference:** §2.5 — API Bugs Discovered, Phase 2 + +### Problem + +The endpoints `/tellers/{id}/cashiers/{cashierId}/transactions` and `/tellers/{id}/cashiers/{cashierId}/summaryandtransactions` accept `currencyCode` but completely ignore `fromDate` and `toDate` — they are not even declared as `@QueryParam` in the API resource. The SQL always uses the cashier assignment dates (`c.start_date` / `c.end_date`) regardless of what is passed in the URL. + +### Code References + +- File: `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/TellerApiResource.java` + - Method: `getTransactionsForCashier()` — `fromDate`/`toDate` are absent from the method signature +- File: `fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java` + - Method: `retrieveCashierTransactions()` — lines 314–354 + - `fromDate` and `toDate` exist in the method signature but are **never referenced** in the SQL string or params array + +### Impact +It is impossible to narrow a cashier's transactions to a sub-range of their assignment period. All queries always return the full assignment window. + +### Fix Required +- Add `@QueryParam("fromDate")` and `@QueryParam("toDate")` (with `@QueryParam("dateFormat")` and `@QueryParam("locale")`) to `getTransactionsForCashier()` and `getTransactionsWithSummaryForCashier()` in `TellerApiResource.java` +- Pass the parsed dates to the service method +- Bind them into the SQL WHERE clauses, replacing `c.start_date`/`c.end_date` when provided + +--- + +## Issue 3 — Bug: m_cashiers unique constraint (staff_id, teller_id) prevents cashier re-assignment to the same teller + +**Labels:** `bug` +**Spec Reference:** §2.1 — Data Model Limitations, Phase 2 + +### Problem + +The `m_cashiers` table enforces a unique constraint on `(staff_id, teller_id)`, meaning a staff member can only ever be assigned to a given teller **once in the database's lifetime**. Re-assigning the same cashier to the same teller on a new date period is impossible without deleting the old record first. + +### Code Reference +- File: `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Cashier.java` +- Lines 48–51 + +### Real-world Impact +If a cashier is assigned to Teller 1 for March, they cannot be assigned to Teller 1 again in April without deleting the March assignment. This blocks normal shift rotation in any real MFI environment. + +### Fix Required +- Drop the unique constraint `ux_cashiers_staff_teller` from `m_cashiers` +- Provide a Liquibase migration to remove the constraint +- Rely on the existing date-overlap check in `CashierTransactionDataValidator.validateCashierAllowedDateAndTime()` as the only guard against duplicate active assignments + +--- + +## Issue 4 — Enhancement: Add m_cashier_sessions table and JPA entity + +**Labels:** `enhancement` +**Spec Reference:** §3.2.1, §3.3 — New Data Model, Phase 3 +**Depends On:** #3 + +### Feature: Cashier Session as a First-Class Entity + +Introduce a `m_cashier_sessions` table and corresponding JPA entity. This is the **foundational change** for the entire session-aware teller redesign. All subsequent issues (GL routing, transaction isolation, session API endpoints) depend on this. + +### Schema +```sql +CREATE TABLE m_cashier_sessions ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + cashier_id BIGINT NOT NULL, + teller_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + office_id BIGINT NOT NULL, + session_date DATE NOT NULL, + opened_at TIMESTAMP NOT NULL, + closed_at TIMESTAMP NULL, + opening_allocation DECIMAL(19,6) NOT NULL DEFAULT 0, + total_settled DECIMAL(19,6) NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL, -- OPEN, SETTLED, CLOSED + opening_txn_id BIGINT NULL, + closing_txn_id BIGINT NULL, + currency_code VARCHAR(3) NOT NULL, + created_by BIGINT NOT NULL, + created_date TIMESTAMP NOT NULL +); +``` + +### Deliverables +- Liquibase changeset for `m_cashier_sessions` +- `CashierSession.java` JPA entity +- `CashierSessionRepository.java` +- `CashierSessionStatus` enum: `OPEN`, `SETTLED`, `CLOSED` + +### Business Rules (from Spec §3.5.1) +- A cashier can only have ONE open session per day per teller +- A user cannot open a new session if they have an unsettled session from a previous day + +--- + +## Issue 5 — Enhancement: Add cashier_session_id FK to m_loan_transactions and m_savings_account_transaction + +**Labels:** `enhancement` +**Spec Reference:** §3.2.2 — Alter existing tables, Phase 3 +**Depends On:** #4 + +### Feature: Link Transactions to Cashier Sessions + +Add a `cashier_session_id` nullable foreign key column to both `m_loan_transactions` and `m_savings_account_transaction`. This is what enables true per-session transaction isolation and accurate session-level reconciliation. + +### Schema +```sql +ALTER TABLE m_loan_transactions + ADD COLUMN cashier_session_id BIGINT NULL, + ADD CONSTRAINT fk_loan_txn_cashier_session + FOREIGN KEY (cashier_session_id) REFERENCES m_cashier_sessions(id); + +ALTER TABLE m_savings_account_transaction + ADD COLUMN cashier_session_id BIGINT NULL, + ADD CONSTRAINT fk_sav_txn_cashier_session + FOREIGN KEY (cashier_session_id) REFERENCES m_cashier_sessions(id); +``` + +### Deliverables +- Liquibase changeset for both ALTER statements +- Update `LoanTransaction.java` JPA entity to include `@ManyToOne CashierSession cashierSession` +- Update `SavingsAccountTransaction.java` JPA entity similarly + +--- + +## Issue 6 — Enhancement: Implement Cashier Session lifecycle API endpoints + +**Labels:** `enhancement` +**Spec Reference:** §3.3, §4 — Session Lifecycle + API Endpoints, Phase 4 +**Depends On:** #4, #5 + +### New Endpoints Required + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/tellers/{id}/cashiers/{cId}/sessions` | Open a new cashier session | +| `GET` | `/tellers/{id}/cashiers/{cId}/sessions/active` | Get the currently open session | +| `GET` | `/tellers/{id}/cashiers/{cId}/sessions` | List all sessions (paginated) | +| `POST` | `/tellers/{id}/cashiers/{cId}/sessions/{sId}/close` | Close session, post variance entry if applicable | +| `GET` | `/tellers/{id}/cashiers/{cId}/sessions/{sId}/summary` | Full session summary with GL reconciliation | +| `GET` | `/users/{id}/session/active` | Resolve active session for logged-in user | +| `GET` | `/tellers/branch/{officeId}/dashboard` | Supervisor view — all open sessions and positions | + +### Session Lifecycle (Spec §3.3) + +| Step | Action | GL Entry | +|------|--------|----------| +| 1 | Open Session | None — record created | +| 2 | Allocate Cash | DR 11140 Teller Cash / CR 11130 Vault | +| 3 | Process Transactions | Auto-linked to open session | +| 4 | Settle Cash | DR 11130 Vault / CR 11140 Teller Cash | +| 5 | Close Session | Variance journal if applicable | + +### Deliverables +- `CashierSessionApiResource.java` +- `CashierSessionWritePlatformService.java` + implementation +- `CashierSessionReadPlatformService.java` + implementation +- Command wrapper additions in `CommandWrapperBuilder` +- `CashierSessionData.java` DTO +- `CashierSessionSummaryData.java` DTO + +--- + +## Issue 7 — Bug/Enhancement: Route cash GL through Teller Cash (11140) when a cashier session is open + +**Labels:** `bug`, `enhancement` +**Spec Reference:** §2.3, §3.4 — GL Routing Fix, Phase 5 +**Depends On:** #4, #5, #6 + +### Problem + Feature: GL Routing for Cash Transactions + +Currently, all cash repayments and disbursements post to `11130 Cash on Hand (Vault)` regardless of whether a cashier session is active. They should route through `11140 Teller Cash` when an active session exists. + +### Current Behaviour (Wrong) +``` +-- Cash repayment with active cashier session: +DEBIT 11130 Cash on Hand (Vault) ← wrong, bypasses teller +CREDIT 11210 Gross Loan Portfolio +``` + +### Expected Behaviour +``` +-- Cash repayment with active cashier session: +DEBIT 11140 Teller Cash ← correct, routes through cashier drawer +CREDIT 11210 Gross Loan Portfolio +``` + +### Files to Update +- `fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorForLoan.java` +- `fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorForSavings.java` + +### Financial Activity Mapping Required +| ID | Activity | GL Code | Account Name | +|----|----------|---------|--------------| +| 101 | cashAtMainVault | 11130 | Cash on Hand (Vault) | +| 102 | cashAtTeller | 11140 | Teller Cash — Head Office | + +--- + +## Issue 8 — Enhancement: Auto-post GL variance journal entry on unbalanced settlement + +**Labels:** `enhancement` +**Spec Reference:** §3.5.3 — Variance Handling, Phase 7 +**Depends On:** #6, #7 + +### Feature: Variance Journal Entry on Settlement + +When a cashier settles and the amount returned does not equal the expected cash on hand, the system must automatically calculate and post a correcting GL journal entry. + +### Variance Formula (Spec §3.5.3) +``` +Expected Cash = Opening Allocation + Cash In − Cash Out +Variance = Settled Amount − Expected Cash +``` + +### GL Entries + +**Short settlement (cashier returns less than expected):** +```sql +DEBIT 53920 Cash Shortage — Teller [variance amount] +CREDIT 11140 Teller Cash — Head Office [variance amount] +``` + +**Over settlement (cashier returns more than expected):** +```sql +DEBIT 11140 Teller Cash — Head Office [variance amount] +CREDIT 43210 Miscellaneous Income [variance amount] +``` + +### GL Accounts Required +| GL Code | Account Name | Type | +|---------|-------------|------| +| 53920 | Cash Shortage — Teller | Expense — Detail | +| 43210 | Miscellaneous Income | Income — Detail | + +### Business Rules +- If variance ≠ 0, a mandatory supervisor note is required before settlement is accepted +- The correcting journal must be auto-posted as part of the session close flow + +### Implementation Location +- `TellerWritePlatformServiceJpaImpl.java` — `settleCashFromCashier()` method +- Or new `CashierSessionWritePlatformServiceImpl.java` session close handler + +--- + +## Issue 9 — Bug: Cashier assignment expiry causes silent failure with no notification + +**Labels:** `bug` +**Spec Reference:** §7 — Known Limitations, Phase 9 + +### Problem + +When a cashier's `end_date` on `m_cashiers` is reached, operations fail silently. No notification is sent, no warning is shown, and errors returned to users are unclear. + +### Current Behaviour +- Cashier assignment expires silently at midnight on `end_date` +- Subsequent allocation, repayment, or settlement calls fail without a helpful error +- No API response distinguishes "cashier assignment expired" from other errors +- No advance warning when expiry is approaching + +### Files Involved +- `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Cashier.java` +- `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java` +- `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/TellerApiResource.java` + +### Fix Required +- On any teller operation, check if `cashier.getEndDate()` is in the past and return `422 Unprocessable Entity` with a clear message +- On cashier retrieval, include an `expiryWarning` flag in the response if `end_date` is within 7 days + +--- + +## Issue 10 — Enhancement: Multi-teller concurrent session support + +**Labels:** `enhancement` +**Spec Reference:** §8 — Implementation Plan Phase 10 +**Depends On:** #4, #5, #6, #7 + +### Feature: Allow Cashier to Hold Concurrent Sessions on Multiple Tellers + +The proposed session model (Issue #6) assumes one active session per cashier per day. To support cashiers rotating between multiple teller stations within the same day, the session model needs to permit concurrent sessions on different tellers. + +### Required Changes + +**Session uniqueness rule change:** +- Current proposed rule: one open session per cashier per day +- New rule: one open session per cashier **per teller** per day + +**Session lookup change:** +```java +// Required (this issue): +cashierSessionRepository.findOpenSessionByUserAndTeller(userId, tellerId, officeId, date); +``` + +### Deliverables +- Update `CashierSessionRepository.findOpenSessionByUser()` to accept `tellerId` +- Update session open validation to check uniqueness per `(cashier_id, teller_id, session_date)` +- Update GL routing in `AccountingProcessorForLoan` and `AccountingProcessorForSavings` to resolve teller account from the active session's `teller_id` +- Add `teller_id` index on `m_cashier_sessions` diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/domain/JournalEntry.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/domain/JournalEntry.java index d190e9f18d0..4a91eae95c6 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/domain/JournalEntry.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/domain/JournalEntry.java @@ -3,15 +3,13 @@ * 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 + * 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 + * 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. @@ -91,6 +89,7 @@ public class JournalEntry extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "amount", scale = 6, precision = 19, nullable = false) private BigDecimal amount; + @Setter @Column(name = "description", length = 500) private String description; @@ -154,4 +153,4 @@ public boolean isCreditEntry() { return JournalEntryType.CREDIT.getValue().equals(this.type); } -} +} \ No newline at end of file diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/domain/JournalEntryRepository.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/domain/JournalEntryRepository.java index b078042ab49..07aabee61c6 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/domain/JournalEntryRepository.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/domain/JournalEntryRepository.java @@ -57,11 +57,11 @@ public interface JournalEntryRepository extends JpaRepository findTrialBalanceLinesForDate(@Param("transactionDate") LocalDate transactionDate); diff --git a/fineract-branch/src/main/java/org/apache/fineract/infrastructure/core/exceptionmapper/CashierAssignmentExpiredExceptionMapper.java b/fineract-branch/src/main/java/org/apache/fineract/infrastructure/core/exceptionmapper/CashierAssignmentExpiredExceptionMapper.java new file mode 100644 index 00000000000..905ac276292 --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/infrastructure/core/exceptionmapper/CashierAssignmentExpiredExceptionMapper.java @@ -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.infrastructure.core.exceptionmapper; + +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.ErrorHandler; +import org.apache.fineract.organisation.teller.exception.CashierAssignmentExpiredException; +import org.springframework.stereotype.Component; + +@Provider +@Component +@Slf4j +public class CashierAssignmentExpiredExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(final CashierAssignmentExpiredException exception) { + log.warn("Exception occurred", ErrorHandler.findMostSpecificException(exception)); + final ApiParameterError error = ApiParameterError.generalError("error.msg.cashier.assignment.expired", exception.getMessage()); + return Response.status(422).entity(error).type(MediaType.APPLICATION_JSON).build(); + } +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/CashierSessionApiResource.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/CashierSessionApiResource.java new file mode 100644 index 00000000000..1f56fc059e3 --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/CashierSessionApiResource.java @@ -0,0 +1,168 @@ +/** + * 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.organisation.teller.api; + +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.teller.data.CashierSessionData; +import org.apache.fineract.organisation.teller.data.CashierSessionSummaryData; +import org.apache.fineract.organisation.teller.service.CashierSessionReadPlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/tellers") +@Component +@Tag(name = "Cashier Session Management", description = "Endpoints for managing cashier session lifecycle.") +@RequiredArgsConstructor +public class CashierSessionApiResource { + + private static final String RESOURCE_NAME = "CASHIERSESSION"; + + private final CashierSessionReadPlatformService readService; + private final PortfolioCommandSourceWritePlatformService commandWritePlatformService; + private final PlatformSecurityContext context; + + /** + * POST /tellers/{id}/cashiers/{cId}/sessions — Open a new cashier session. + */ + @POST + @Path("{tellerId}/cashiers/{cashierId}/sessions") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Open a cashier session", description = "Opens a new cashier session for the given cashier on the given teller.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public CommandProcessingResult openSession( + @PathParam("tellerId") @Parameter(description = "tellerId") final Long tellerId, + @PathParam("cashierId") @Parameter(description = "cashierId") final Long cashierId, + @QueryParam("currencyCode") @Parameter(description = "currencyCode") final String currencyCode) { + context.authenticatedUser().validateHasCreatePermission(RESOURCE_NAME); + final JsonObject json = new JsonObject(); + json.addProperty("currencyCode", currencyCode != null ? currencyCode : ""); + final CommandWrapper request = new CommandWrapperBuilder() + .openCashierSession(tellerId, cashierId) + .withJson(json.toString()) + .build(); + return commandWritePlatformService.logCommandSource(request); + } + + /** + * GET /tellers/{id}/cashiers/{cId}/sessions/active — Get the currently open session. + */ + @GET + @Path("{tellerId}/cashiers/{cashierId}/sessions/active") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get active cashier session", description = "Returns the currently open session for the given cashier on the given teller.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public Optional getActiveSession( + @PathParam("tellerId") @Parameter(description = "tellerId") final Long tellerId, + @PathParam("cashierId") @Parameter(description = "cashierId") final Long cashierId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME); + return readService.findActiveSession(cashierId, tellerId); + } + + /** + * GET /tellers/{id}/cashiers/{cId}/sessions — List all sessions (paginated). + */ + @GET + @Path("{tellerId}/cashiers/{cashierId}/sessions") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List cashier sessions", description = "Returns all sessions for the given cashier on the given teller.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public List listSessions( + @PathParam("tellerId") @Parameter(description = "tellerId") final Long tellerId, + @PathParam("cashierId") @Parameter(description = "cashierId") final Long cashierId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME); + return readService.findAllSessions(cashierId, tellerId); + } + + /** + * POST /tellers/{id}/cashiers/{cId}/sessions/{sId}/close — Close session. + */ + @POST + @Path("{tellerId}/cashiers/{cashierId}/sessions/{sessionId}/close") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Close a cashier session", description = "Closes the specified cashier session.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public CommandProcessingResult closeSession( + @PathParam("tellerId") @Parameter(description = "tellerId") final Long tellerId, + @PathParam("cashierId") @Parameter(description = "cashierId") final Long cashierId, + @PathParam("sessionId") @Parameter(description = "sessionId") final Long sessionId) { + context.authenticatedUser().validateHasPermissionTo("CLOSE_CASHIERSESSION"); + final CommandWrapper request = new CommandWrapperBuilder() + .closeCashierSession(tellerId, cashierId, sessionId) + .withJson("{}") + .build(); + return commandWritePlatformService.logCommandSource(request); + } + + /** + * GET /tellers/{id}/cashiers/{cId}/sessions/{sId}/summary — Full session summary with GL reconciliation. + */ + @GET + @Path("{tellerId}/cashiers/{cashierId}/sessions/{sessionId}/summary") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get session summary", description = "Returns a full session summary with GL reconciliation for the given session.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public CashierSessionSummaryData getSessionSummary( + @PathParam("tellerId") @Parameter(description = "tellerId") final Long tellerId, + @PathParam("cashierId") @Parameter(description = "cashierId") final Long cashierId, + @PathParam("sessionId") @Parameter(description = "sessionId") final Long sessionId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME); + return readService.getSessionSummary(sessionId); + } + + /** + * GET /tellers/branch/{officeId}/dashboard — Supervisor view: all open sessions and positions. + */ + @GET + @Path("branch/{officeId}/dashboard") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get branch dashboard", description = "Returns all open cashier sessions for the given office (supervisor view).") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public List getBranchDashboard( + @PathParam("officeId") @Parameter(description = "officeId") final Long officeId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME); + return readService.findOpenSessionsByOffice(officeId); + + } +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/TellerApiResource.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/TellerApiResource.java index 35053c7c43e..c35ca01400f 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/TellerApiResource.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/TellerApiResource.java @@ -40,6 +40,8 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.Collection; +import java.util.Locale; +import org.apache.fineract.infrastructure.core.serialization.JsonParserHelper; import lombok.RequiredArgsConstructor; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; @@ -289,18 +291,27 @@ public Page getTransactionsForCashier( @QueryParam("offset") @Parameter(description = "offset") final Integer offset, @QueryParam("limit") @Parameter(description = "limit") final Integer limit, @QueryParam("orderBy") @Parameter(description = "orderBy") final String orderBy, - @QueryParam("sortOrder") @Parameter(description = "sortOrder") final String sortOrder) { + @QueryParam("sortOrder") @Parameter(description = "sortOrder") final String sortOrder, + @QueryParam("fromDate") @Parameter(description = "fromDate") final String fromDateStr, + @QueryParam("toDate") @Parameter(description = "toDate") final String toDateStr, + @QueryParam("dateFormat") @Parameter(description = "dateFormat") final String dateFormat, + @QueryParam("locale") @Parameter(description = "locale") final String locale) { final SearchParameters searchParameters = SearchParameters.builder().limit(limit).offset(offset).orderBy(orderBy) .sortOrder(sortOrder).build(); - return this.readPlatformService.retrieveCashierTransactions(cashierId, false, null, null, currencyCode, searchParameters); + final LocalDate fromDate = parseDateParam(fromDateStr, "fromDate", dateFormat, locale); + final LocalDate toDate = parseDateParam(toDateStr, "toDate", dateFormat, locale); + return this.readPlatformService.retrieveCashierTransactions(cashierId, false, fromDate, toDate, currencyCode, searchParameters, + null); } @GET @Path("{tellerId}/cashiers/{cashierId}/summaryandtransactions") @Consumes({ MediaType.APPLICATION_JSON }) @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "Retrieve Transactions With Summary For Cashier", description = "") + @Operation(summary = "Retrieve Transactions With Summary For Cashier", description = "Returns a combined summary and transaction list for a cashier. " + + "When sessionId is omitted this is the legacy all-time view. " + + "When sessionId is provided only transactions belonging to that session are included (Option A).") @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = TellerApiResourceSwagger.GetTellersTellerIdCashiersCashiersIdSummaryAndTransactionsResponse.class))) }) public CashierTransactionsWithSummaryData getTransactionsWithSummaryForCashier( @@ -310,13 +321,20 @@ public CashierTransactionsWithSummaryData getTransactionsWithSummaryForCashier( @QueryParam("offset") @Parameter(description = "offset") final Integer offset, @QueryParam("limit") @Parameter(description = "limit") final Integer limit, @QueryParam("orderBy") @Parameter(description = "orderBy") final String orderBy, - @QueryParam("sortOrder") @Parameter(description = "sortOrder") final String sortOrder) { + @QueryParam("sortOrder") @Parameter(description = "sortOrder") final String sortOrder, + @QueryParam("fromDate") @Parameter(description = "fromDate") final String fromDateStr, + @QueryParam("toDate") @Parameter(description = "toDate") final String toDateStr, + @QueryParam("dateFormat") @Parameter(description = "dateFormat") final String dateFormat, + @QueryParam("locale") @Parameter(description = "locale") final String locale, + @QueryParam("sessionId") @Parameter(description = "Optional cashier session ID. When provided, only transactions for that session are returned. When omitted, all transactions for the cashier are returned (backwards-compatible behaviour).") final Long sessionId) { final SearchParameters searchParameters = SearchParameters.builder().limit(limit).offset(offset).orderBy(orderBy) .sortOrder(sortOrder).build(); + final LocalDate fromDate = parseDateParam(fromDateStr, "fromDate", dateFormat, locale); + final LocalDate toDate = parseDateParam(toDateStr, "toDate", dateFormat, locale); - return this.readPlatformService.retrieveCashierTransactionsWithSummary(cashierId, false, null, null, currencyCode, - searchParameters); + return this.readPlatformService.retrieveCashierTransactionsWithSummary(cashierId, false, fromDate, toDate, currencyCode, + searchParameters, sessionId); } @GET @@ -366,4 +384,12 @@ public Collection getJournalData(@PathParam("tellerId") @Para return this.readPlatformService.fetchTellerJournals(tellerId, cashierDate, dateRangeHolder.getStartDate(), dateRangeHolder.getEndDate()); } + + private LocalDate parseDateParam(final String dateStr, final String parameterName, final String dateFormat, final String locale) { + if (dateStr == null || dateFormat == null || locale == null) { + return null; + } + final Locale localeObj = JsonParserHelper.localeFromString(locale); + return JsonParserHelper.convertFrom(dateStr, parameterName, dateFormat, localeObj); + } } diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/UserSessionApiResource.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/UserSessionApiResource.java new file mode 100644 index 00000000000..a006522ee66 --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/UserSessionApiResource.java @@ -0,0 +1,64 @@ +/** + * 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.organisation.teller.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.teller.data.CashierSessionData; +import org.apache.fineract.organisation.teller.service.CashierSessionReadPlatformService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Component; + +@Path("/v1/users") +@Component +@Tag(name = "User Session Management", description = "Endpoint for resolving the active cashier session for a user.") +@RequiredArgsConstructor +public class UserSessionApiResource { + + private final CashierSessionReadPlatformService readService; + private final PlatformSecurityContext context; + + /** + * GET /users/{id}/session/active — Resolve active session for logged-in user. + */ + @GET + @Path("{userId}/session/active") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get active session for user", description = "Returns the currently open cashier session for the given user.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public Optional getActiveSessionForUser( + @PathParam("userId") @Parameter(description = "userId") final Long userId) { + final AppUser currentUser = context.authenticatedUser(); + final Long officeId = currentUser.getOffice().getId(); + return readService.findActiveSessionForUser(userId, officeId); + } +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierData.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierData.java index 1024962f8fc..25344d3d10f 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierData.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierData.java @@ -53,6 +53,8 @@ public final class CashierData implements Serializable { private String startTime; private String endTime; + private boolean expiryWarning; + // Template fields private String officeName; private String tellerName; diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierSessionData.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierSessionData.java new file mode 100644 index 00000000000..8633d269eb6 --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierSessionData.java @@ -0,0 +1,51 @@ +/** + * 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.organisation.teller.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; +import org.apache.fineract.organisation.teller.domain.CashierSessionStatus; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Accessors(chain = true) +public class CashierSessionData implements Serializable { + + private Long id; + private Long cashierId; + private Long tellerId; + private Long userId; + private Long officeId; + private LocalDate sessionDate; + private LocalDateTime openedAt; + private LocalDateTime closedAt; + private BigDecimal openingAllocation; + private BigDecimal totalSettled; + private CashierSessionStatus status; + private Long openingTxnId; + private Long closingTxnId; + private String currencyCode; +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierSessionSummaryData.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierSessionSummaryData.java new file mode 100644 index 00000000000..a9f87d660e9 --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierSessionSummaryData.java @@ -0,0 +1,41 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.organisation.teller.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Accessors(chain = true) +public class CashierSessionSummaryData implements Serializable { + + private CashierSessionData session; + private BigDecimal openingAllocation; + private BigDecimal totalCashIn; + private BigDecimal totalCashOut; + private BigDecimal expectedCash; + private BigDecimal settledAmount; + private BigDecimal variance; +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierTransactionDataValidator.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierTransactionDataValidator.java index 7667fdfba50..317697ef405 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierTransactionDataValidator.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/data/CashierTransactionDataValidator.java @@ -24,12 +24,14 @@ import java.util.HashMap; import java.util.Map; import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.core.service.SearchParameters; import org.apache.fineract.organisation.teller.domain.Cashier; import org.apache.fineract.organisation.teller.domain.Teller; import org.apache.fineract.organisation.teller.exception.CashierAlreadyAllocated; +import org.apache.fineract.organisation.teller.exception.CashierAssignmentExpiredException; import org.apache.fineract.organisation.teller.exception.CashierDateRangeOutOfTellerDateRangeException; import org.apache.fineract.organisation.teller.exception.CashierInsufficientAmountException; import org.apache.fineract.organisation.teller.service.TellerManagementReadPlatformService; @@ -58,7 +60,7 @@ public CashierTransactionDataValidator(final TellerManagementReadPlatformService public void validateSettleCashAndCashOutTransactions(final Long cashierId, String currencyCode, final BigDecimal transactionAmount) { final SearchParameters searchParameters = SearchParameters.builder().build(); final CashierTransactionsWithSummaryData cashierTxnWithSummary = this.tellerManagementReadPlatformService - .retrieveCashierTransactionsWithSummary(cashierId, false, null, null, currencyCode, searchParameters); + .retrieveCashierTransactionsWithSummary(cashierId, false, null, null, currencyCode, searchParameters, null); if (MathUtil.isGreaterThan(transactionAmount, cashierTxnWithSummary.getNetCash())) { throw new CashierInsufficientAmountException(); } @@ -70,6 +72,24 @@ public void validateSettleCashAndCashOutTransactions(final Long cashierId, JsonC validateSettleCashAndCashOutTransactions(cashierId, currencyCode, transactionAmount); } + public void validateAllocateCashTransactions(final Long cashierId, final JsonCommand command) { + // Verify cashier exists (throws exception if not found) + final CashierData cashierData = this.tellerManagementReadPlatformService.findCashier(cashierId); + + // Check cashier assignment is not expired + if (cashierData.getEndDate() != null && cashierData.getEndDate().isBefore(DateUtils.getLocalDateOfTenant())) { + final String cashierName = cashierData.getStaffName() != null ? cashierData.getStaffName() : String.valueOf(cashierId); + throw new CashierAssignmentExpiredException(cashierName, cashierData.getEndDate()); + } + + // Validate currency code is present + final String currencyCode = command.stringValueOfParameterNamed("currencyCode"); + if (currencyCode == null || currencyCode.isBlank()) { + throw new GeneralPlatformDomainRuleException("error.msg.cashier.allocation.currency.code.required", + "Currency code is required for cashier cash allocation."); + } + } + public void validateCashierAllowedDateAndTime(final Cashier cashier, final Teller teller) { Long staffId = cashier.getStaff().getId(); final LocalDate fromDate = cashier.getStartDate(); diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Cashier.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Cashier.java index 4089a805977..7bab8d91e75 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Cashier.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Cashier.java @@ -26,7 +26,6 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.Transient; -import jakarta.persistence.UniqueConstraint; import java.time.LocalDate; import java.util.LinkedHashMap; import java.util.Map; @@ -48,8 +47,7 @@ * @since 2.0.0 */ @Entity -@Table(name = "m_cashiers", uniqueConstraints = { - @UniqueConstraint(name = "ux_cashiers_staff_teller", columnNames = { "staff_id", "teller_id" }) }) +@Table(name = "m_cashiers") @Getter @Setter @NoArgsConstructor diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierSession.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierSession.java new file mode 100644 index 00000000000..6734bdf44da --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierSession.java @@ -0,0 +1,100 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.organisation.teller.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.organisation.office.domain.Office; + +@Entity +@Table(name = "m_cashier_sessions") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Accessors(chain = true) +public class CashierSession extends AbstractPersistableCustom { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cashier_id", nullable = false) + private Cashier cashier; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "teller_id", nullable = false) + private Teller teller; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "office_id", nullable = false) + private Office office; + + @Column(name = "session_date", nullable = false) + private LocalDate sessionDate; + + @Column(name = "opened_at", nullable = false) + private LocalDateTime openedAt; + + @Column(name = "closed_at") + private LocalDateTime closedAt; + + @Column(name = "opening_allocation", precision = 19, scale = 6, nullable = false) + private BigDecimal openingAllocation = BigDecimal.ZERO; + + @Column(name = "total_settled", precision = 19, scale = 6, nullable = false) + private BigDecimal totalSettled = BigDecimal.ZERO; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 20, nullable = false) + private CashierSessionStatus status; + + @Column(name = "opening_txn_id") + private Long openingTxnId; + + @Column(name = "closing_txn_id") + private Long closingTxnId; + + @Column(name = "currency_code", length = 3, nullable = false) + private String currencyCode; + + @Column(name = "supervisor_note", length = 500, nullable = true) + private String supervisorNote; + + @Column(name = "created_by", nullable = false) + private Long createdBy; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierSessionRepository.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierSessionRepository.java new file mode 100644 index 00000000000..73683b08eab --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierSessionRepository.java @@ -0,0 +1,51 @@ +/** + * 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.organisation.teller.domain; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CashierSessionRepository extends JpaRepository { + + // Find OPEN session for a cashier+teller+date (enforces one session per station per day) + @Query("SELECT cs FROM CashierSession cs WHERE cs.cashier.id = :cashierId AND cs.teller.id = :tellerId AND cs.sessionDate = :sessionDate AND cs.status = org.apache.fineract.organisation.teller.domain.CashierSessionStatus.OPEN") + Optional findOpenSession(@Param("cashierId") Long cashierId, @Param("tellerId") Long tellerId, + @Param("sessionDate") LocalDate sessionDate); + + // Find all OPEN sessions by user on any teller (for GL routing; supports multi-teller scenario) + @Query("SELECT cs FROM CashierSession cs WHERE cs.userId = :userId AND cs.office.id = :officeId AND cs.sessionDate = :sessionDate AND cs.status = org.apache.fineract.organisation.teller.domain.CashierSessionStatus.OPEN") + List findOpenSessionByUser(@Param("userId") Long userId, @Param("officeId") Long officeId, + @Param("sessionDate") LocalDate sessionDate); + + // Find OPEN session by user on a specific teller (for per-teller GL routing) + @Query("SELECT cs FROM CashierSession cs WHERE cs.userId = :userId AND cs.teller.id = :tellerId AND cs.office.id = :officeId AND cs.sessionDate = :sessionDate AND cs.status = org.apache.fineract.organisation.teller.domain.CashierSessionStatus.OPEN") + Optional findOpenSessionByUserAndTeller(@Param("userId") Long userId, @Param("tellerId") Long tellerId, + @Param("officeId") Long officeId, @Param("sessionDate") LocalDate sessionDate); + + // Find any unsettled OPEN sessions from prior days (blocks opening new session) + @Query("SELECT cs FROM CashierSession cs WHERE cs.userId = :userId AND cs.sessionDate < :today AND cs.status = org.apache.fineract.organisation.teller.domain.CashierSessionStatus.OPEN") + List findUnsettledPriorSessions(@Param("userId") Long userId, @Param("today") LocalDate today); + + // List all sessions for a cashier on a teller + List findByCashierIdAndTellerIdOrderBySessionDateDesc(Long cashierId, Long tellerId); +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierSessionStatus.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierSessionStatus.java new file mode 100644 index 00000000000..16aa636f8c4 --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierSessionStatus.java @@ -0,0 +1,25 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.organisation.teller.domain; + +public enum CashierSessionStatus { + OPEN, + SETTLED, + CLOSED +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierTransaction.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierTransaction.java index 098a1c68319..090772920af 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierTransaction.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierTransaction.java @@ -59,6 +59,10 @@ public class CashierTransaction extends AbstractPersistableCustom { @JoinColumn(name = "cashier_id", nullable = false) private Cashier cashier; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cashier_session_id", nullable = true) + private CashierSession cashierSession; + @Column(name = "txn_type", nullable = false) private Integer txnType; diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierTransactionRepository.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierTransactionRepository.java index 5e3dbbadaa9..62c745c365e 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierTransactionRepository.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/CashierTransactionRepository.java @@ -18,10 +18,24 @@ */ package org.apache.fineract.organisation.teller.domain; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface CashierTransactionRepository extends JpaRepository, JpaSpecificationExecutor { - // no added behavior + + @Query("SELECT COALESCE(SUM(ct.txnAmount), 0) FROM CashierTransaction ct WHERE ct.cashier.id = :cashierId AND ct.txnType = :txnType AND ct.txnDate = :sessionDate") + BigDecimal sumAmountByCashierAndTxnTypeAndDate(@Param("cashierId") Long cashierId, @Param("txnType") Integer txnType, + @Param("sessionDate") LocalDate sessionDate); + + @Query("SELECT COALESCE(SUM(ct.txnAmount), 0) FROM CashierTransaction ct WHERE ct.cashierSession.id = :sessionId AND ct.txnType = :txnType") + BigDecimal sumAmountBySessionAndTxnType(@Param("sessionId") Long sessionId, @Param("txnType") Integer txnType); + + @Query("SELECT COALESCE(SUM(ct.txnAmount), 0) FROM CashierTransaction ct WHERE ct.cashierSession.id = :sessionId AND ct.txnType IN :txnTypes") + BigDecimal sumAmountBySessionAndTxnTypes(@Param("sessionId") Long sessionId, @Param("txnTypes") List txnTypes); } diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Teller.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Teller.java index 91e22d10e27..63dd895b80b 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Teller.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Teller.java @@ -91,9 +91,10 @@ public static Teller fromJson(final Office tellerOffice, final JsonCommand comma .setStatus(status.getValue()); } - public Map update(Office tellerOffice, final JsonCommand command) { + public Map update(Office tellerOffice, GLAccount newDebitAccount, GLAccount newCreditAccount, + final JsonCommand command) { - final Map actualChanges = new LinkedHashMap<>(7); + final Map actualChanges = new LinkedHashMap<>(9); final String dateFormatAsInput = command.dateFormat(); final String localeAsInput = command.locale(); @@ -150,6 +151,22 @@ public Map update(Office tellerOffice, final JsonCommand command } } + final String debitAccountIdParamName = "debitAccountId"; + final Long currentDebitAccountId = this.debitAccount != null ? this.debitAccount.getId() : null; + if (command.isChangeInLongParameterNamed(debitAccountIdParamName, currentDebitAccountId)) { + final Long newValue = command.longValueOfParameterNamed(debitAccountIdParamName); + actualChanges.put(debitAccountIdParamName, newValue); + this.debitAccount = newDebitAccount; + } + + final String creditAccountIdParamName = "creditAccountId"; + final Long currentCreditAccountId = this.creditAccount != null ? this.creditAccount.getId() : null; + if (command.isChangeInLongParameterNamed(creditAccountIdParamName, currentCreditAccountId)) { + final Long newValue = command.longValueOfParameterNamed(creditAccountIdParamName); + actualChanges.put(creditAccountIdParamName, newValue); + this.creditAccount = newCreditAccount; + } + return actualChanges; } diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/CashierAssignmentExpiredException.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/CashierAssignmentExpiredException.java new file mode 100644 index 00000000000..fdf2f4047ca --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/CashierAssignmentExpiredException.java @@ -0,0 +1,33 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.organisation.teller.exception; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@SuppressWarnings("serial") +public class CashierAssignmentExpiredException extends RuntimeException { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + public CashierAssignmentExpiredException(final String cashierName, final LocalDate expiryDate) { + super("Cashier assignment for " + cashierName + " expired on " + expiryDate.format(DATE_FORMATTER) + + ". Please renew the assignment."); + } +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/CashierSessionAlreadyOpenException.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/CashierSessionAlreadyOpenException.java new file mode 100644 index 00000000000..a0d2ae1a4d9 --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/CashierSessionAlreadyOpenException.java @@ -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.organisation.teller.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class CashierSessionAlreadyOpenException extends AbstractPlatformDomainRuleException { + + public CashierSessionAlreadyOpenException(Long cashierId, Long tellerId) { + super("cashier.session.already.open", + "An open session already exists for cashier " + cashierId + " on teller " + tellerId + " today."); + } +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/CashierSessionNotFoundException.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/CashierSessionNotFoundException.java new file mode 100644 index 00000000000..1ad947cbe18 --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/CashierSessionNotFoundException.java @@ -0,0 +1,31 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.organisation.teller.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; + +public class CashierSessionNotFoundException extends AbstractPlatformResourceNotFoundException { + + private static final String ERROR_MESSAGE_CODE = "error.msg.cashier.session.not.found"; + private static final String DEFAULT_ERROR_MESSAGE = "Cashier session with identifier {0,number,long} not found!"; + + public CashierSessionNotFoundException(Long sessionId) { + super(ERROR_MESSAGE_CODE, DEFAULT_ERROR_MESSAGE, sessionId); + } +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/CashierSessionUnsettledPriorDayException.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/CashierSessionUnsettledPriorDayException.java new file mode 100644 index 00000000000..e845cf44b8b --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/CashierSessionUnsettledPriorDayException.java @@ -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.organisation.teller.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class CashierSessionUnsettledPriorDayException extends AbstractPlatformDomainRuleException { + + public CashierSessionUnsettledPriorDayException() { + super("cashier.session.unsettled.prior.day", + "Cannot open a new session: there is an unsettled open session from a prior day."); + } +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/handler/CloseCashierSessionCommandHandler.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/handler/CloseCashierSessionCommandHandler.java new file mode 100644 index 00000000000..b7377b480a3 --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/handler/CloseCashierSessionCommandHandler.java @@ -0,0 +1,44 @@ +/** + * 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.organisation.teller.handler; + +import java.math.BigDecimal; +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.organisation.teller.service.CashierSessionWritePlatformService; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@CommandType(entity = "CASHIERSESSION", action = "CLOSECASHIERSESSION") +public class CloseCashierSessionCommandHandler implements NewCommandSourceHandler { + + private final CashierSessionWritePlatformService cashierSessionWritePlatformService; + + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + final Long sessionId = command.entityId(); + final BigDecimal settledAmount = command.bigDecimalValueOfParameterNamed("settledAmount"); + final String supervisorNote = command.stringValueOfParameterNamed("supervisorNote"); + return cashierSessionWritePlatformService.closeSession(sessionId, settledAmount, supervisorNote); + } +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/handler/DeleteCashierAllocationCommandHandler.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/handler/DeleteCashierAllocationCommandHandler.java index adcb9109eef..a0e62a3f835 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/handler/DeleteCashierAllocationCommandHandler.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/handler/DeleteCashierAllocationCommandHandler.java @@ -30,7 +30,7 @@ * Handles a delete cashier command. * * @author Markus Geiss - * @see org.apache.fineract.organisation.teller.service.CashierWritePlatformService + * @see org.apache.fineract.organisation.teller.service.TellerWritePlatformService * @since 2.0.0 */ @Service diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/handler/ModifyCashierCommandHandler.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/handler/OpenCashierSessionCommandHandler.java similarity index 62% rename from fineract-branch/src/main/java/org/apache/fineract/organisation/teller/handler/ModifyCashierCommandHandler.java rename to fineract-branch/src/main/java/org/apache/fineract/organisation/teller/handler/OpenCashierSessionCommandHandler.java index cabda9a1473..7389e8492e3 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/handler/ModifyCashierCommandHandler.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/handler/OpenCashierSessionCommandHandler.java @@ -19,25 +19,25 @@ package org.apache.fineract.organisation.teller.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.organisation.teller.service.CashierWritePlatformService; +import org.apache.fineract.organisation.teller.service.CashierSessionWritePlatformService; +import org.springframework.stereotype.Service; -/** - * Handles a modify cashier command. - * - * @author Markus Geiss - * @see org.apache.fineract.organisation.teller.service.CashierWritePlatformService - * @since 2.0.0 - */ +@Service @RequiredArgsConstructor -public class ModifyCashierCommandHandler implements NewCommandSourceHandler { +@CommandType(entity = "CASHIERSESSION", action = "OPENCASHIERSESSION") +public class OpenCashierSessionCommandHandler implements NewCommandSourceHandler { - private final CashierWritePlatformService writePlatformService; + private final CashierSessionWritePlatformService cashierSessionWritePlatformService; @Override public CommandProcessingResult processCommand(final JsonCommand command) { - return this.writePlatformService.modifyCashier(command.entityId(), command); + final Long tellerId = command.entityId(); + final Long cashierId = command.subentityId(); + final String currencyCode = command.stringValueOfParameterNamed("currencyCode"); + return cashierSessionWritePlatformService.openSession(tellerId, cashierId, currencyCode); } } diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/serialization/TellerCommandFromApiJsonDeserializer.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/serialization/TellerCommandFromApiJsonDeserializer.java index 5123699c17f..adf8f2db416 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/serialization/TellerCommandFromApiJsonDeserializer.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/serialization/TellerCommandFromApiJsonDeserializer.java @@ -68,13 +68,15 @@ public final class TellerCommandFromApiJsonDeserializer { public static final String TXN_DATE = "txnDate"; public static final String TXN_NOTE = "txnNote"; public static final String TELLER = "teller"; + public static final String DEBIT_ACCOUNT_ID = "debitAccountId"; + public static final String CREDIT_ACCOUNT_ID = "creditAccountId"; private static final String START_TIME = "startTime"; /** * The parameters supported for this command. */ private static final Set SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(OFFICE_ID, NAME, DESCRIPTION, START_DATE, END_DATE, STATUS, DATE_FORMAT, LOCALE, IS_FULL_DAY, STAFF_ID, HOUR_START_TIME, MIN_START_TIME, HOUR_END_TIME, MIN_END_TIME, TXN_AMOUNT, - TXN_DATE, TXN_NOTE, ENTITY_TYPE, ENTITY_ID, CURRENCY_CODE)); + TXN_DATE, TXN_NOTE, ENTITY_TYPE, ENTITY_ID, CURRENCY_CODE, DEBIT_ACCOUNT_ID, CREDIT_ACCOUNT_ID)); private final FromJsonHelper fromApiJsonHelper; @Autowired diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/CashierSessionReadPlatformService.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/CashierSessionReadPlatformService.java new file mode 100644 index 00000000000..de5a185475b --- /dev/null +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/CashierSessionReadPlatformService.java @@ -0,0 +1,39 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.organisation.teller.service; + +import java.util.List; +import java.util.Optional; +import org.apache.fineract.organisation.teller.data.CashierSessionData; +import org.apache.fineract.organisation.teller.data.CashierSessionSummaryData; + +public interface CashierSessionReadPlatformService { + + Optional findActiveSession(Long cashierId, Long tellerId); + + List findAllSessions(Long cashierId, Long tellerId); + + CashierSessionData findSessionById(Long sessionId); + + CashierSessionSummaryData getSessionSummary(Long sessionId); + + Optional findActiveSessionForUser(Long userId, Long officeId); + + List findOpenSessionsByOffice(Long officeId); +} diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/CashierWritePlatformService.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/CashierSessionWritePlatformService.java similarity index 63% rename from fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/CashierWritePlatformService.java rename to fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/CashierSessionWritePlatformService.java index ce7a7196b86..ef3f7ee1730 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/CashierWritePlatformService.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/CashierSessionWritePlatformService.java @@ -18,24 +18,14 @@ */ package org.apache.fineract.organisation.teller.service; -import org.apache.fineract.infrastructure.core.api.JsonCommand; +import java.math.BigDecimal; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; -public class CashierWritePlatformService { +public interface CashierSessionWritePlatformService { - public CommandProcessingResult allocateCashierToTeller(JsonCommand command) { - // TODO Auto-generated method stub - return null; - } + CommandProcessingResult openSession(Long tellerId, Long cashierId, String currencyCode); - public CommandProcessingResult deleteCashier(Long entityId) { - // TODO Auto-generated method stub - return null; - } - - public CommandProcessingResult modifyCashier(Long entityId, JsonCommand command) { - // TODO Auto-generated method stub - return null; - } + CommandProcessingResult closeSession(Long sessionId); + CommandProcessingResult closeSession(Long sessionId, BigDecimal settledAmount, String supervisorNote); } diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformService.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformService.java index 4b7fa79a9fe..4e10c60e31a 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformService.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformService.java @@ -56,9 +56,13 @@ public interface TellerManagementReadPlatformService { Collection retrieveCashiersForTellers(Long tellerId); Page retrieveCashierTransactions(Long cashierId, boolean includeAllTellers, LocalDate fromDate, - LocalDate toDate, String currencyCode, SearchParameters searchParameters); + LocalDate toDate, String currencyCode, SearchParameters searchParameters, Long sessionId); + /** + * Option A: sessionId is optional. When null, returns the all-time legacy view for the cashier (backwards + * compatible). When non-null, filters results to the specified cashier session only. + */ CashierTransactionsWithSummaryData retrieveCashierTransactionsWithSummary(Long cashierId, boolean includeAllTellers, LocalDate fromDate, - LocalDate toDate, String currencyCode, SearchParameters searchParameters); + LocalDate toDate, String currencyCode, SearchParameters searchParameters, Long sessionId); } diff --git a/fineract-branch/src/main/resources/db/changelog/tenant/module/branch/module-changelog-master.xml b/fineract-branch/src/main/resources/db/changelog/tenant/module/branch/module-changelog-master.xml index 035b136c7da..f86dfd82081 100644 --- a/fineract-branch/src/main/resources/db/changelog/tenant/module/branch/module-changelog-master.xml +++ b/fineract-branch/src/main/resources/db/changelog/tenant/module/branch/module-changelog-master.xml @@ -23,4 +23,6 @@ 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"> + diff --git a/fineract-branch/src/main/resources/db/changelog/tenant/module/branch/parts/4001_drop_unique_constraint_cashiers_staff_teller.xml b/fineract-branch/src/main/resources/db/changelog/tenant/module/branch/parts/4001_drop_unique_constraint_cashiers_staff_teller.xml new file mode 100644 index 00000000000..03ed36a333d --- /dev/null +++ b/fineract-branch/src/main/resources/db/changelog/tenant/module/branch/parts/4001_drop_unique_constraint_cashiers_staff_teller.xml @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/fineract-branch/src/main/resources/jpa/static-weaving/module/fineract-branch/persistence.xml b/fineract-branch/src/main/resources/jpa/static-weaving/module/fineract-branch/persistence.xml index 5e972e532d9..5d0f7c0e558 100644 --- a/fineract-branch/src/main/resources/jpa/static-weaving/module/fineract-branch/persistence.xml +++ b/fineract-branch/src/main/resources/jpa/static-weaving/module/fineract-branch/persistence.xml @@ -81,6 +81,7 @@ org.apache.fineract.organisation.teller.domain.CashierTransaction + org.apache.fineract.organisation.teller.domain.CashierSession org.apache.fineract.organisation.teller.domain.Teller org.apache.fineract.organisation.teller.domain.Cashier org.apache.fineract.organisation.teller.domain.TellerTransaction diff --git a/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyProvider.java b/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyProvider.java index 01e6b6d2513..dcd9477a98f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyProvider.java +++ b/fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyProvider.java @@ -250,6 +250,8 @@ private static void init() { commandStrategies.put(CommandContext .resource("v1\\/loans\\/external-id\\/" + UUID_PARAM_REGEX + "\\/interest-pauses\\/" + NUMBER_REGEX).method(PUT).build(), "updateLoanInterestPauseByExternalIdCommandStrategy"); + commandStrategies.put(CommandContext.resource("v1\\/journalentries" + OPTIONAL_COMMAND_PARAM_REGEX).method(POST).build(), + "createJournalEntryCommandStrategy"); } } diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index ff96583fb54..895f5e394ce 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -2881,6 +2881,24 @@ public CommandWrapperBuilder settleCashFromCashier(final Long tellerId, final Lo return this; } + public CommandWrapperBuilder openCashierSession(final Long tellerId, final Long cashierId) { + this.actionName = "OPENCASHIERSESSION"; + this.entityName = "CASHIERSESSION"; + this.entityId = tellerId; + this.subentityId = cashierId; + this.href = "/tellers/" + tellerId + "/cashiers/" + cashierId + "/sessions"; + return this; + } + + public CommandWrapperBuilder closeCashierSession(final Long tellerId, final Long cashierId, final Long sessionId) { + this.actionName = "CLOSECASHIERSESSION"; + this.entityName = "CASHIERSESSION"; + this.entityId = sessionId; + this.subentityId = cashierId; + this.href = "/tellers/" + tellerId + "/cashiers/" + cashierId + "/sessions/" + sessionId + "/close"; + return this; + } + public CommandWrapperBuilder deleteRole(Long roleId) { this.actionName = "DELETE"; this.entityName = "ROLE"; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/search/data/SearchData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/search/data/SearchData.java index 0438156f78b..34706c6081f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/search/data/SearchData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/search/data/SearchData.java @@ -35,10 +35,11 @@ public class SearchData { private final EnumOptionData entityStatus; private final String parentType; private final String subEntityType; + private final Long productId; public SearchData(final Long entityId, final String entityAccountNo, final String entityExternalId, final String entityName, final String entityType, final Long parentId, final String parentName, final String parentType, final String entityMobileNo, - final EnumOptionData entityStatus, final String subEntityType) { + final EnumOptionData entityStatus, final String subEntityType, final Long productId) { this.entityId = entityId; this.entityAccountNo = entityAccountNo; @@ -51,6 +52,7 @@ public SearchData(final Long entityId, final String entityAccountNo, final Strin this.entityMobileNo = entityMobileNo; this.entityStatus = entityStatus; this.subEntityType = subEntityType; + this.productId = productId; } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java index 168ab04ab32..8706694bb5e 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java @@ -155,6 +155,10 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom + * For cash payment types ({@code isCashPayment == true}): + *
    + *
  • If the current user has one or more open cashier sessions for the given office on the transaction date (i.e. + * the cashier is active at one or more teller windows), the {@code CASH_AT_TELLER} (11140) financial-activity GL + * account is returned.
  • + *
  • Otherwise, the {@code CASH_AT_MAINVAULT} (11130) financial-activity GL account is returned as fallback.
  • + *
+ *

+ * Returns an empty {@link Optional} when {@code paymentTypeId} is {@code null} or the payment type is not a cash + * payment, signalling callers to fall back to the product-specific fund-source mapping. + * + * @param paymentTypeId + * the payment type id; may be {@code null} + * @param officeId + * the office for which to look up the cashier session + * @param transactionDate + * the transaction date + * @return an {@link Optional} containing the resolved {@link GLAccount} (always present for cash payment types, + * empty for non-cash or null payment types) + */ + public Optional resolveCashGLAccount(final Long paymentTypeId, final Long officeId, final LocalDate transactionDate) { + if (paymentTypeId == null) { + return Optional.empty(); + } + final PaymentType paymentType = paymentTypeRepositoryWrapper.findOneWithNotFoundDetection(paymentTypeId); + if (paymentType.getIsCashPayment() == null || !paymentType.getIsCashPayment()) { + return Optional.empty(); + } + final Long currentUserId = securityContext.authenticatedUser().getId(); + final List activeSessions = cashierSessionRepository.findOpenSessionByUser(currentUserId, officeId, + transactionDate); + final int financialActivityId = !activeSessions.isEmpty() ? FinancialActivity.CASH_AT_TELLER.getValue() + : FinancialActivity.CASH_AT_MAINVAULT.getValue(); + return Optional.of(financialActivityAccountRepository.findByFinancialActivityTypeWithNotFoundDetection(financialActivityId) + .getGlAccount()); + } + public BigDecimal createCreditJournalEntryOrReversalForClientPayments(final Office office, final String currencyCode, final Long clientId, final Long transactionId, final LocalDate transactionDate, final Boolean isReversal, final List clientChargePaymentDTOs) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForLoan.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForLoan.java index 6081ab9e039..081357b22a9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForLoan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForLoan.java @@ -24,6 +24,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.closure.domain.GLClosure; import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan; @@ -476,8 +477,15 @@ private void createJournalEntriesForDisbursements(final LoanDTO loanDTO, final L this.helper.createCreditJournalEntryForLoan(office, currencyCode, FinancialActivity.LIABILITY_TRANSFER.getValue(), loanProductId, paymentTypeId, loanId, transactionId, transactionDate, loanTransactionDTO.getAmount()); } else { - this.helper.createCreditJournalEntryForLoan(office, currencyCode, CashAccountsForLoan.FUND_SOURCE.getValue(), loanProductId, - paymentTypeId, loanId, transactionId, transactionDate, loanTransactionDTO.getAmount()); + final Optional cashGLAccount = this.helper.resolveCashGLAccount(paymentTypeId, loanTransactionDTO.getOfficeId(), + transactionDate); + if (cashGLAccount.isPresent()) { + this.helper.createCreditJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate, + loanTransactionDTO.getAmount(), cashGLAccount.get()); + } else { + this.helper.createCreditJournalEntryForLoan(office, currencyCode, CashAccountsForLoan.FUND_SOURCE.getValue(), loanProductId, + paymentTypeId, loanId, transactionId, transactionDate, loanTransactionDTO.getAmount()); + } } } @@ -816,8 +824,15 @@ private void createJournalEntriesForLoanRepayments(LoanDTO loanDTO, LoanTransact } } else { - this.helper.createDebitJournalEntryForLoan(office, currencyCode, CashAccountsForLoan.FUND_SOURCE.getValue(), loanProductId, - paymentTypeId, loanId, transactionId, transactionDate, totalDebitAmount); + final Optional cashGLAccount = this.helper.resolveCashGLAccount(paymentTypeId, + loanTransactionDTO.getOfficeId(), transactionDate); + if (cashGLAccount.isPresent()) { + this.helper.createDebitJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate, + totalDebitAmount, cashGLAccount.get()); + } else { + this.helper.createDebitJournalEntryForLoan(office, currencyCode, CashAccountsForLoan.FUND_SOURCE.getValue(), + loanProductId, paymentTypeId, loanId, transactionId, transactionDate, totalDebitAmount); + } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java index 6b5f7f52e40..a70bba887d5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java @@ -21,10 +21,12 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.closure.domain.GLClosure; import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings; import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO; import org.apache.fineract.accounting.journalentry.data.SavingsDTO; import org.apache.fineract.accounting.journalentry.data.SavingsTransactionDTO; @@ -69,15 +71,32 @@ public void createJournalEntriesForSavings(final SavingsDTO savingsDTO) { amount.subtract(overdraftAmount), isReversal); } } else { - this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, - CashAccountsForSavings.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), - CashAccountsForSavings.SAVINGS_REFERENCE.getValue(), savingsProductId, paymentTypeId, savingsId, transactionId, - transactionDate, overdraftAmount, isReversal); - if (isPositive) { + final Optional cashGLAccount = this.helper.resolveCashGLAccount(paymentTypeId, + savingsTransactionDTO.getOfficeId(), transactionDate); + if (cashGLAccount.isPresent()) { + this.helper.createCashBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, + CashAccountsForSavings.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, overdraftAmount, isReversal); + this.helper.createCashBasedCreditJournalEntryForSavings(office, currencyCode, cashGLAccount.get(), savingsId, + transactionId, transactionDate, overdraftAmount, isReversal); + if (isPositive) { + this.helper.createCashBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount.subtract(overdraftAmount), isReversal); + this.helper.createCashBasedCreditJournalEntryForSavings(office, currencyCode, cashGLAccount.get(), savingsId, + transactionId, transactionDate, amount.subtract(overdraftAmount), isReversal); + } + } else { this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, - CashAccountsForSavings.SAVINGS_CONTROL.getValue(), CashAccountsForSavings.SAVINGS_REFERENCE.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, - amount.subtract(overdraftAmount), isReversal); + CashAccountsForSavings.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), + CashAccountsForSavings.SAVINGS_REFERENCE.getValue(), savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, overdraftAmount, isReversal); + if (isPositive) { + this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), CashAccountsForSavings.SAVINGS_REFERENCE.getValue(), + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, + amount.subtract(overdraftAmount), isReversal); + } } } } else if (savingsTransactionDTO.getTransactionType().isDeposit() && savingsTransactionDTO.isOverdraftTransaction()) { @@ -93,15 +112,32 @@ public void createJournalEntriesForSavings(final SavingsDTO savingsDTO) { amount.subtract(overdraftAmount), isReversal); } } else { - this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, - CashAccountsForSavings.SAVINGS_REFERENCE.getValue(), - CashAccountsForSavings.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, overdraftAmount, isReversal); - if (isPositive) { + final Optional cashGLAccount = this.helper.resolveCashGLAccount(paymentTypeId, + savingsTransactionDTO.getOfficeId(), transactionDate); + if (cashGLAccount.isPresent()) { + this.helper.createCashBasedDebitJournalEntryForSavings(office, currencyCode, cashGLAccount.get(), savingsId, + transactionId, transactionDate, overdraftAmount, isReversal); + this.helper.createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, + CashAccountsForSavings.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, overdraftAmount, isReversal); + if (isPositive) { + this.helper.createCashBasedDebitJournalEntryForSavings(office, currencyCode, cashGLAccount.get(), savingsId, + transactionId, transactionDate, amount.subtract(overdraftAmount), isReversal); + this.helper.createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount.subtract(overdraftAmount), isReversal); + } + } else { this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, - CashAccountsForSavings.SAVINGS_REFERENCE.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, - amount.subtract(overdraftAmount), isReversal); + CashAccountsForSavings.SAVINGS_REFERENCE.getValue(), + CashAccountsForSavings.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, overdraftAmount, isReversal); + if (isPositive) { + this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, + CashAccountsForSavings.SAVINGS_REFERENCE.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(), + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, + amount.subtract(overdraftAmount), isReversal); + } } } } @@ -113,9 +149,19 @@ else if (savingsTransactionDTO.getTransactionType().isDeposit()) { FinancialActivity.LIABILITY_TRANSFER.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(), savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); } else { - this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, - CashAccountsForSavings.SAVINGS_REFERENCE.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + final Optional cashGLAccount = this.helper.resolveCashGLAccount(paymentTypeId, + savingsTransactionDTO.getOfficeId(), transactionDate); + if (cashGLAccount.isPresent()) { + this.helper.createCashBasedDebitJournalEntryForSavings(office, currencyCode, cashGLAccount.get(), savingsId, + transactionId, transactionDate, amount, isReversal); + this.helper.createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount, isReversal); + } else { + this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, + CashAccountsForSavings.SAVINGS_REFERENCE.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(), + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + } } } @@ -132,9 +178,19 @@ else if (savingsTransactionDTO.getTransactionType().isWithdrawal()) { CashAccountsForSavings.SAVINGS_CONTROL.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(), savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); } else { - this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, - CashAccountsForSavings.SAVINGS_CONTROL.getValue(), CashAccountsForSavings.SAVINGS_REFERENCE.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + final Optional cashGLAccount = this.helper.resolveCashGLAccount(paymentTypeId, + savingsTransactionDTO.getOfficeId(), transactionDate); + if (cashGLAccount.isPresent()) { + this.helper.createCashBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount, isReversal); + this.helper.createCashBasedCreditJournalEntryForSavings(office, currencyCode, cashGLAccount.get(), savingsId, + transactionId, transactionDate, amount, isReversal); + } else { + this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), CashAccountsForSavings.SAVINGS_REFERENCE.getValue(), + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java index dad040d9f2f..4efe8496ef8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java @@ -337,13 +337,19 @@ public CommandProcessingResult revertJournalEntry(final JsonCommand command) { // is the transaction Id valid final List journalEntries = this.glJournalEntryRepository .findUnReversedManualJournalEntriesByTransactionId(command.getTransactionId()); - String reversalComment = command.stringValueOfParameterNamed("comments"); if (journalEntries.size() <= 1) { throw new JournalEntriesNotFoundException(command.getTransactionId()); } - final String reversalTransactionId = revertJournalEntry(journalEntries, reversalComment); - return new CommandProcessingResultBuilder().withTransactionId(reversalTransactionId).build(); + + // For manual journal entries, simply mark all entries in the transaction as reversed. + // No new offsetting entries should be created — this is not a transaction adjustment. + for (final JournalEntry journalEntry : journalEntries) { + journalEntry.setReversed(true); + helper.persistJournalEntry(journalEntry); + } + + return new CommandProcessingResultBuilder().withTransactionId(command.getTransactionId()).build(); } @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java index 57c1a1f2a0f..825273a2eae 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java @@ -47,11 +47,13 @@ import org.apache.fineract.organisation.office.domain.OfficeRepository; import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; import org.apache.fineract.organisation.office.service.OfficeReadPlatformService; +import org.apache.fineract.organisation.teller.domain.CashierSessionRepository; import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.paymenttype.domain.PaymentTypeRepositoryWrapper; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -67,10 +69,11 @@ public AccountingProcessorHelper accountingProcessorHelper(JournalEntryRepositor FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository, GLAccountRepository glAccountRepository, OfficeRepository officeRepository, AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper, - BusinessEventNotifierService businessEventNotifierService) { + BusinessEventNotifierService businessEventNotifierService, PaymentTypeRepositoryWrapper paymentTypeRepositoryWrapper, + CashierSessionRepository cashierSessionRepository, PlatformSecurityContext securityContext) { return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository, closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper, - businessEventNotifierService); + businessEventNotifierService, paymentTypeRepositoryWrapper, cashierSessionRepository, securityContext); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/CreateJournalEntryCommandStrategy.java b/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/CreateJournalEntryCommandStrategy.java new file mode 100644 index 00000000000..ea6c790f0d7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/batch/command/internal/CreateJournalEntryCommandStrategy.java @@ -0,0 +1,86 @@ +/** + * 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.batch.command.internal; + +import static org.apache.fineract.batch.command.CommandStrategyUtils.relativeUrlWithoutVersion; + +import com.google.common.base.Splitter; +import jakarta.ws.rs.core.UriInfo; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.journalentry.api.JournalEntriesApiResource; +import org.apache.fineract.batch.command.CommandStrategy; +import org.apache.fineract.batch.domain.BatchRequest; +import org.apache.fineract.batch.domain.BatchResponse; +import org.apache.http.HttpStatus; +import org.springframework.stereotype.Component; + +/** + * Implements {@link org.apache.fineract.batch.command.CommandStrategy} to handle creation of journal entries. It + * passes the contents of the body from the BatchRequest to + * {@link org.apache.fineract.accounting.journalentry.api.JournalEntriesApiResource} and gets back the response. This + * class will also catch any errors raised by {@link org.apache.fineract.accounting.journalentry.api.JournalEntriesApiResource} + * and map those errors to appropriate status codes in BatchResponse. + * + * @see org.apache.fineract.batch.command.CommandStrategy + * @see org.apache.fineract.batch.domain.BatchRequest + * @see org.apache.fineract.batch.domain.BatchResponse + */ +@Component +@RequiredArgsConstructor +public class CreateJournalEntryCommandStrategy implements CommandStrategy { + + private final JournalEntriesApiResource journalEntriesApiResource; + + @Override + public BatchResponse execute(final BatchRequest request, @SuppressWarnings("unused") UriInfo uriInfo) { + + final BatchResponse response = new BatchResponse(); + final String responseBody; + + response.setRequestId(request.getRequestId()); + response.setHeaders(request.getHeaders()); + + // Extract command parameter if present + String commandParam = null; + final List pathParameters = Splitter.on('/').splitToList(relativeUrlWithoutVersion(request)); + + // Check if there's a query parameter with command + if (pathParameters.size() > 1) { + final String lastPart = pathParameters.get(pathParameters.size() - 1); + final Pattern commandPattern = Pattern.compile("\\?command=([\\w\\-]+)"); + final Matcher commandMatcher = commandPattern.matcher(lastPart); + + if (commandMatcher.find()) { + commandParam = commandMatcher.group(1); + } + } + + // Calls 'createGLJournalEntry' function from 'JournalEntriesApiResource' to create a new journal entry + responseBody = journalEntriesApiResource.createGLJournalEntry(request.getBody(), commandParam); + + response.setStatusCode(HttpStatus.SC_OK); + // Sets the body of the response after the successful creation of the journal entry + response.setBody(responseBody); + + return response; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/CashierSessionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/CashierSessionReadPlatformServiceImpl.java new file mode 100644 index 00000000000..8a13499156f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/CashierSessionReadPlatformServiceImpl.java @@ -0,0 +1,160 @@ +/** + * 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.organisation.teller.service; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.domain.JdbcSupport; +import org.apache.fineract.organisation.teller.data.CashierSessionData; +import org.apache.fineract.organisation.teller.data.CashierSessionSummaryData; +import org.apache.fineract.organisation.teller.domain.CashierSessionStatus; +import org.apache.fineract.organisation.teller.domain.CashierTransactionRepository; +import org.apache.fineract.organisation.teller.domain.CashierTxnType; +import org.apache.fineract.organisation.teller.exception.CashierSessionNotFoundException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +@RequiredArgsConstructor +public class CashierSessionReadPlatformServiceImpl implements CashierSessionReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + private final CashierTransactionRepository cashierTransactionRepository; + + private static final class CashierSessionMapper implements RowMapper { + + public String schema() { + return "cs.id as id, cs.cashier_id as cashier_id, cs.teller_id as teller_id, " + + "cs.user_id as user_id, cs.office_id as office_id, cs.session_date as session_date, " + + "cs.opened_at as opened_at, cs.closed_at as closed_at, " + + "cs.opening_allocation as opening_allocation, cs.total_settled as total_settled, " + + "cs.status as status, cs.opening_txn_id as opening_txn_id, " + + "cs.closing_txn_id as closing_txn_id, cs.currency_code as currency_code " + + "from m_cashier_sessions cs "; + } + + @Override + public CashierSessionData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long cashierId = rs.getLong("cashier_id"); + final Long tellerId = rs.getLong("teller_id"); + final Long userId = rs.getLong("user_id"); + final Long officeId = rs.getLong("office_id"); + final LocalDate sessionDate = JdbcSupport.getLocalDate(rs, "session_date"); + + final Timestamp openedAtTs = rs.getTimestamp("opened_at"); + final LocalDateTime openedAt = openedAtTs != null ? openedAtTs.toLocalDateTime() : null; + + final Timestamp closedAtTs = rs.getTimestamp("closed_at"); + final LocalDateTime closedAt = closedAtTs != null ? closedAtTs.toLocalDateTime() : null; + + final BigDecimal openingAllocation = rs.getBigDecimal("opening_allocation"); + final BigDecimal totalSettled = rs.getBigDecimal("total_settled"); + final String statusStr = rs.getString("status"); + final CashierSessionStatus status = statusStr != null ? CashierSessionStatus.valueOf(statusStr) : null; + final Long openingTxnId = rs.getLong("opening_txn_id"); + final Long closingTxnId = rs.getLong("closing_txn_id"); + final String currencyCode = rs.getString("currency_code"); + + return new CashierSessionData(id, cashierId, tellerId, userId, officeId, sessionDate, openedAt, closedAt, openingAllocation, + totalSettled, status, openingTxnId == 0 ? null : openingTxnId, closingTxnId == 0 ? null : closingTxnId, currencyCode); + } + } + + @Override + public Optional findActiveSession(final Long cashierId, final Long tellerId) { + try { + final CashierSessionMapper mapper = new CashierSessionMapper(); + final String sql = "select " + mapper.schema() + + " where cs.cashier_id = ? and cs.teller_id = ? and cs.status = 'OPEN' and cs.session_date = CURRENT_DATE"; + return Optional.ofNullable(jdbcTemplate.queryForObject(sql, mapper, cashierId, tellerId)); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + @Override + public List findAllSessions(final Long cashierId, final Long tellerId) { + final CashierSessionMapper mapper = new CashierSessionMapper(); + final String sql = "select " + mapper.schema() + + " where cs.cashier_id = ? and cs.teller_id = ? order by cs.session_date desc"; + return jdbcTemplate.query(sql, mapper, cashierId, tellerId); + } + + @Override + public CashierSessionData findSessionById(final Long sessionId) { + try { + final CashierSessionMapper mapper = new CashierSessionMapper(); + final String sql = "select " + mapper.schema() + " where cs.id = ?"; + return jdbcTemplate.queryForObject(sql, mapper, sessionId); + } catch (EmptyResultDataAccessException e) { + throw new CashierSessionNotFoundException(sessionId); + } + } + + @Override + public CashierSessionSummaryData getSessionSummary(final Long sessionId) { + final CashierSessionData session = findSessionById(sessionId); + + // Cash-in: ALLOCATE (101) — cash allocated to cashier, SETTLE (102) — cash settled from client, + // INWARD_CASH_TXN (103) — direct cash-in from savings/loan/client transactions. + // Cash-out: OUTWARD_CASH_TXN (104) — cash paid out for withdrawals/disbursements. + final BigDecimal totalCashIn = cashierTransactionRepository.sumAmountBySessionAndTxnTypes(sessionId, + List.of(CashierTxnType.ALLOCATE.getId(), CashierTxnType.SETTLE.getId(), CashierTxnType.INWARD_CASH_TXN.getId())); + final BigDecimal totalCashOut = cashierTransactionRepository.sumAmountBySessionAndTxnTypes(sessionId, + List.of(CashierTxnType.OUTWARD_CASH_TXN.getId())); + + final BigDecimal openingAllocation = session.getOpeningAllocation() != null ? session.getOpeningAllocation() : BigDecimal.ZERO; + final BigDecimal safeTotalCashIn = totalCashIn != null ? totalCashIn : BigDecimal.ZERO; + final BigDecimal safeTotalCashOut = totalCashOut != null ? totalCashOut : BigDecimal.ZERO; + + final BigDecimal expectedCash = openingAllocation.add(safeTotalCashIn).subtract(safeTotalCashOut); + final BigDecimal settledAmount = session.getTotalSettled() != null ? session.getTotalSettled() : BigDecimal.ZERO; + final BigDecimal variance = settledAmount.subtract(expectedCash); + + return new CashierSessionSummaryData(session, openingAllocation, safeTotalCashIn, safeTotalCashOut, expectedCash, settledAmount, + variance); + } + + @Override + public Optional findActiveSessionForUser(final Long userId, final Long officeId) { + try { + final CashierSessionMapper mapper = new CashierSessionMapper(); + final String sql = "select " + mapper.schema() + + " where cs.user_id = ? and cs.office_id = ? and cs.status = 'OPEN' and cs.session_date = CURRENT_DATE"; + return Optional.ofNullable(jdbcTemplate.queryForObject(sql, mapper, userId, officeId)); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + @Override + public List findOpenSessionsByOffice(final Long officeId) { + final CashierSessionMapper mapper = new CashierSessionMapper(); + final String sql = "select " + mapper.schema() + " where cs.office_id = ? and cs.status = 'OPEN'"; + return jdbcTemplate.query(sql, mapper, officeId); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/CashierSessionWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/CashierSessionWritePlatformServiceImpl.java new file mode 100644 index 00000000000..9eb1ccdb599 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/CashierSessionWritePlatformServiceImpl.java @@ -0,0 +1,215 @@ +/** + * 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.organisation.teller.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.teller.domain.Cashier; +import org.apache.fineract.organisation.teller.domain.CashierRepository; +import org.apache.fineract.organisation.teller.domain.CashierSession; +import org.apache.fineract.organisation.teller.domain.CashierSessionRepository; +import org.apache.fineract.organisation.teller.domain.CashierSessionStatus; +import org.apache.fineract.organisation.teller.domain.CashierTransactionRepository; +import org.apache.fineract.organisation.teller.domain.Teller; +import org.apache.fineract.organisation.teller.domain.TellerRepositoryWrapper; +import org.apache.fineract.organisation.teller.exception.CashierNotFoundException; +import org.apache.fineract.organisation.teller.exception.CashierSessionAlreadyOpenException; +import org.apache.fineract.organisation.teller.exception.CashierSessionNotFoundException; +import org.apache.fineract.organisation.teller.exception.CashierSessionUnsettledPriorDayException; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +public class CashierSessionWritePlatformServiceImpl implements CashierSessionWritePlatformService { + + private static final String GL_CODE_CASH_SHORTAGE_TELLER = "53920"; + private static final String GL_CODE_MISCELLANEOUS_INCOME = "43210"; + + private final PlatformSecurityContext context; + private final CashierSessionRepository cashierSessionRepository; + private final CashierRepository cashierRepository; + private final TellerRepositoryWrapper tellerRepositoryWrapper; + private final CashierTransactionRepository cashierTransactionRepository; + private final JournalEntryRepository glJournalEntryRepository; + private final FinancialActivityAccountRepositoryWrapper financialActivityAccountRepositoryWrapper; + private final GLAccountRepositoryWrapper glAccountRepositoryWrapper; + + @Override + @Transactional + public CommandProcessingResult openSession(final Long tellerId, final Long cashierId, final String currencyCode) { + final AppUser currentUser = context.authenticatedUser(); + + final Cashier cashier = cashierRepository.findById(cashierId) + .orElseThrow(() -> new CashierNotFoundException(cashierId)); + final Teller teller = tellerRepositoryWrapper.findOneWithNotFoundDetection(tellerId); + + final LocalDate today = DateUtils.getBusinessLocalDate(); + + // Business rule: one OPEN session per cashier per teller per day + cashierSessionRepository.findOpenSession(cashierId, tellerId, today).ifPresent(s -> { + throw new CashierSessionAlreadyOpenException(cashierId, tellerId); + }); + + // Business rule: cannot open if prior day has unsettled session + final List unsettled = cashierSessionRepository.findUnsettledPriorSessions(currentUser.getId(), today); + if (!unsettled.isEmpty()) { + throw new CashierSessionUnsettledPriorDayException(); + } + + final LocalDateTime now = LocalDateTime.now(); + final CashierSession session = new CashierSession() + .setCashier(cashier) + .setTeller(teller) + .setUserId(currentUser.getId()) + .setOffice(teller.getOffice()) + .setSessionDate(today) + .setOpenedAt(now) + .setStatus(CashierSessionStatus.OPEN) + .setCurrencyCode(currencyCode != null ? currencyCode : "") + .setCreatedBy(currentUser.getId()) + .setCreatedDate(now); + + cashierSessionRepository.save(session); + + return new CommandProcessingResultBuilder() + .withEntityId(session.getId()) + .withOfficeId(teller.getOffice().getId()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult closeSession(final Long sessionId) { + return closeSession(sessionId, null, null); + } + + @Override + @Transactional + public CommandProcessingResult closeSession(final Long sessionId, final BigDecimal settledAmount, final String supervisorNote) { + context.authenticatedUser(); + + final CashierSession session = cashierSessionRepository.findById(sessionId) + .orElseThrow(() -> new CashierSessionNotFoundException(sessionId)); + + final BigDecimal resolvedSettledAmount = settledAmount != null ? settledAmount : BigDecimal.ZERO; + + // Compute expected cash: openingAllocation + sumCashIn - sumCashOut + // Cash-in types: 1 (legacy direct cash-in), 101 (ALLOCATE), 102 (SETTLE from client) + // Cash-out types: 2 (legacy direct cash-out), 201 (legacy savings cash-out), 202 (legacy loan cash-out) + final BigDecimal sumCashIn = cashierTransactionRepository.sumAmountBySessionAndTxnTypes( + sessionId, List.of(1, 101, 102)); + final BigDecimal sumCashOut = cashierTransactionRepository.sumAmountBySessionAndTxnTypes( + sessionId, List.of(2, 201, 202)); + + final BigDecimal openingAllocation = session.getOpeningAllocation() != null ? session.getOpeningAllocation() : BigDecimal.ZERO; + final BigDecimal safeCashIn = sumCashIn != null ? sumCashIn : BigDecimal.ZERO; + final BigDecimal safeCashOut = sumCashOut != null ? sumCashOut : BigDecimal.ZERO; + + final BigDecimal expectedCash = openingAllocation.add(safeCashIn).subtract(safeCashOut); + final BigDecimal variance = resolvedSettledAmount.subtract(expectedCash); + final boolean hasVariance = variance.compareTo(BigDecimal.ZERO) != 0; + + // Validate: supervisor note required when variance != 0 + if (hasVariance && (supervisorNote == null || supervisorNote.isBlank())) { + throw new PlatformApiDataValidationException( + "validation.msg.cashierSession.supervisorNote.required", + "A supervisor note is required when a variance exists between settled amount and expected cash.", + "supervisorNote"); + } + + // Post GL variance journal entry if needed + if (hasVariance) { + postVarianceJournalEntry(session, variance, supervisorNote); + } + + // Update session fields + session.setTotalSettled(resolvedSettledAmount); + if (hasVariance) { + session.setSupervisorNote(supervisorNote); + } + session.setStatus(CashierSessionStatus.SETTLED); + session.setClosedAt(LocalDateTime.now()); + + cashierSessionRepository.save(session); + + return new CommandProcessingResultBuilder() + .withEntityId(sessionId) + .build(); + } + + private void postVarianceJournalEntry(final CashierSession session, final BigDecimal variance, final String note) { + + final FinancialActivityAccount tellerCashAccount = financialActivityAccountRepositoryWrapper + .findByFinancialActivityTypeWithNotFoundDetection(FinancialActivity.CASH_AT_TELLER.getValue()); + final GLAccount tellerCashGlAccount = tellerCashAccount.getGlAccount(); + + final Office office = session.getOffice(); + final String currencyCode = session.getCurrencyCode(); + final LocalDate entryDate = session.getSessionDate(); + + final String transactionId = java.util.UUID.randomUUID().toString().replace("-", ""); + + final String description = note != null ? note : "Session variance adjustment"; + + final GLAccount debitAccount; + final GLAccount creditAccount; + + if (variance.compareTo(BigDecimal.ZERO) < 0) { + // Short settlement: cashier returned less than expected + // DEBIT Cash Shortage - Teller (53920) / CREDIT Teller Cash (11140) + debitAccount = glAccountRepositoryWrapper.findOneByGlCodeWithNotFoundDetection(GL_CODE_CASH_SHORTAGE_TELLER); + creditAccount = tellerCashGlAccount; + } else { + // Over settlement: cashier returned more than expected + // DEBIT Teller Cash (11140) / CREDIT Miscellaneous Income (43210) + debitAccount = tellerCashGlAccount; + creditAccount = glAccountRepositoryWrapper.findOneByGlCodeWithNotFoundDetection(GL_CODE_MISCELLANEOUS_INCOME); + } + + final BigDecimal absVariance = variance.abs(); + + final JournalEntry debitEntry = JournalEntry.createNew(office, null, debitAccount, currencyCode, + transactionId, false, entryDate, JournalEntryType.DEBIT, absVariance, description, + null, null, null, null, null, null, null); + + final JournalEntry creditEntry = JournalEntry.createNew(office, null, creditAccount, currencyCode, + transactionId, false, entryDate, JournalEntryType.CREDIT, absVariance, description, + null, null, null, null, null, null, null); + + glJournalEntryRepository.saveAndFlush(debitEntry); + glJournalEntryRepository.saveAndFlush(creditEntry); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java index aa694751a0a..db6e5e71fb4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java @@ -23,10 +23,13 @@ import java.sql.SQLException; import java.time.LocalDate; import java.time.OffsetDateTime; +import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; +import java.util.List; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.domain.JdbcSupport; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.Page; import org.apache.fineract.infrastructure.core.service.PaginationHelper; import org.apache.fineract.infrastructure.core.service.SearchParameters; @@ -268,16 +271,68 @@ public CashierTransactionData retrieveCashierTxnTemplate(Long cashierId) { @Override public CashierTransactionsWithSummaryData retrieveCashierTransactionsWithSummary(final Long cashierId, final boolean includeAllTellers, - final LocalDate fromDate, final LocalDate toDate, final String currencyCode, final SearchParameters searchParameters) { + final LocalDate fromDate, final LocalDate toDate, final String currencyCode, final SearchParameters searchParameters, + final Long sessionId) { sqlValidator.validate(searchParameters.getOrderBy()); sqlValidator.validate(searchParameters.getSortOrder()); final String nextDay = sqlGenerator.incrementDateByOneDay("c.end_date"); + final String fromDateExpr = fromDate != null ? "?" : "c.start_date"; + final String toDateExprForCashierTxn = toDate != null ? "?" : "c.end_date"; + final String toDateExprForOtherTxn = toDate != null ? "?" : nextDay; + final CashierTransactionSummaryMapper ctsm = new CashierTransactionSummaryMapper(); - final String sql = "SELECT " + ctsm.cashierTxnSummarySchema(nextDay) + " LIMIT 1000"; + final String sql = "SELECT " + ctsm.cashierTxnSummarySchema(fromDateExpr, toDateExprForCashierTxn, toDateExprForOtherTxn, + sessionId != null) + + " LIMIT 1000"; + + final List summaryParamsList = new ArrayList<>(); + // cashier_txns section: upper bound uses c.end_date (inclusive date, no +1) + summaryParamsList.add(cashierId); + summaryParamsList.add(currencyCode); + if (fromDate != null) { + summaryParamsList.add(fromDate); + } + if (toDate != null) { + summaryParamsList.add(toDate); + } + // Option A: when sessionId is provided, add it as a parameter for the cashier_session_id = ? filter + // in the m_cashier_transactions (txn alias) subquery of the summary SQL + if (sessionId != null) { + summaryParamsList.add(sessionId); + } + // savings section: the summary SQL originally used c.end_date (not nextDay), so use toDate directly + summaryParamsList.add(cashierId); + summaryParamsList.add(currencyCode); + if (fromDate != null) { + summaryParamsList.add(fromDate); + } + if (toDate != null) { + summaryParamsList.add(toDate); + } + // loans section: SQL uses DATE_ADD(c.end_date, 1 DAY) to make the upper bound inclusive of transactions + // on the end date itself when transaction_date is a timestamp; mirror that with plusDays(1) + summaryParamsList.add(cashierId); + summaryParamsList.add(currencyCode); + if (fromDate != null) { + summaryParamsList.add(fromDate); + } + if (toDate != null) { + summaryParamsList.add(toDate.plusDays(1)); + } + // client section: same reasoning as loans — use plusDays(1) to match nextDay behaviour + summaryParamsList.add(cashierId); + summaryParamsList.add(currencyCode); + if (fromDate != null) { + summaryParamsList.add(fromDate); + } + if (toDate != null) { + summaryParamsList.add(toDate.plusDays(1)); + } + Collection cashierTxnTypeTotals = this.jdbcTemplate.query(sql, ctsm, // NOSONAR - new Object[] { cashierId, currencyCode, cashierId, currencyCode, cashierId, currencyCode, cashierId, currencyCode }); + summaryParamsList.toArray()); Iterator itr = cashierTxnTypeTotals.iterator(); BigDecimal allocAmount = new BigDecimal(0); @@ -301,7 +356,7 @@ public CashierTransactionsWithSummaryData retrieveCashierTransactionsWithSummary } final Page cashierTransactions = retrieveCashierTransactions(cashierId, includeAllTellers, fromDate, toDate, - currencyCode, searchParameters); + currencyCode, searchParameters, sessionId); CashierTransactionData cashierTxnTemplate = retrieveCashierTxnTemplate(cashierId); @@ -313,7 +368,8 @@ public CashierTransactionsWithSummaryData retrieveCashierTransactionsWithSummary @Override public Page retrieveCashierTransactions(final Long cashierId, final boolean includeAllTellers, - final LocalDate fromDate, final LocalDate toDate, final String currencyCode, final SearchParameters searchParameters) { + final LocalDate fromDate, final LocalDate toDate, final String currencyCode, final SearchParameters searchParameters, + final Long sessionId) { sqlValidator.validate(searchParameters.getOrderBy()); sqlValidator.validate(searchParameters.getSortOrder()); @@ -321,19 +377,27 @@ public Page retrieveCashierTransactions(final Long cashi final CashierTransactionMapper ctm = new CashierTransactionMapper(); + final String fromDateExpr = fromDate != null ? "?" : "c.start_date"; + final String toDateExprForCashierTxn = toDate != null ? "?" : "c.end_date"; + final String toDateExprForOtherTxn = toDate != null ? "?" : nextDay; + + // Option A: when sessionId is provided, filter m_cashier_transactions to the specific session only + final String sessionFilter = sessionId != null ? " AND txn.cashier_session_id = ?" : ""; + String sql = "SELECT * FROM (SELECT " + ctm.cashierTxnSchema() + " WHERE txn.cashier_id = ? AND txn.currency_code = ? " - + "AND ((txn.created_date between c.start_date AND c.end_date ) or txn.txn_type = 101)) cashier_txns " + " union (select " - + ctm.savingsTxnSchema() + " where sav_txn.is_reversed = false and c.id = ? and sav.currency_code = ? " - + "and sav_txn.transaction_date between c.start_date and " + nextDay + + "AND ((txn.created_date between " + fromDateExpr + " AND " + toDateExprForCashierTxn + " ) or txn.txn_type = 101)" + + sessionFilter + ") cashier_txns " + + " union (select " + ctm.savingsTxnSchema() + " where sav_txn.is_reversed = false and c.id = ? and sav.currency_code = ? " + + "and sav_txn.transaction_date between " + fromDateExpr + " and " + toDateExprForOtherTxn + " and renum.enum_value in ('deposit','withdrawal fee', 'Pay Charge', 'withdrawal', 'Annual Fee', 'Waive Charge', 'Interest Posting', 'Overdraft Interest') " + " and (sav_txn.payment_detail_id IS NULL OR payType.is_cash_payment = true) AND acnttrans.id IS NULL ) " + " union (select " + ctm.loansTxnSchema() + " where loan_txn.is_reversed = false and c.id = ? and loan.currency_code = ? " - + "and loan_txn.transaction_date between c.start_date and " + nextDay + + "and loan_txn.transaction_date between " + fromDateExpr + " and " + toDateExprForOtherTxn + " and renum.enum_value IN ('REPAYMENT_AT_DISBURSEMENT','REPAYMENT', 'RECOVERY_REPAYMENT','DISBURSEMENT', 'CHARGE_PAYMENT', 'WAIVE_CHARGES', 'WAIVE_INTEREST', 'WRITEOFF') " + " and (loan_txn.payment_detail_id IS NULL OR payType.is_cash_payment = true) " + " AND acnttrans.id IS NULL ) " + " union (select " + ctm.clientTxnSchema() + " where cli_txn.is_reversed = false and c.id = ? and cli_txn.currency_code = ? " + "and cli_txn.transaction_date " - + " between c.start_date and " + nextDay + " and renum.enum_value IN ('PAY_CHARGE', 'WAIVE_CHARGE') " + + " between " + fromDateExpr + " and " + toDateExprForOtherTxn + " and renum.enum_value IN ('PAY_CHARGE', 'WAIVE_CHARGE') " + " and (cli_txn.payment_detail_id IS NULL OR payType.is_cash_payment = true) ) " + " order by created_date "; if (searchParameters.hasLimit()) { @@ -344,18 +408,57 @@ public Page retrieveCashierTransactions(final Long cashi sql += sqlGenerator.limit(searchParameters.getLimit()); } } - // return this.jdbcTemplate.query(sql, ctm, new Object[] { cashierId, - // currencyCode, hierarchySearchString, cashierId, currencyCode, - // hierarchySearchString, cashierId, currencyCode, - // hierarchySearchString, cashierId, currencyCode, hierarchySearchString - // }); - Object[] params = new Object[] { cashierId, currencyCode, cashierId, currencyCode, cashierId, currencyCode, cashierId, - currencyCode, }; - return this.paginationHelper.fetchPage(this.jdbcTemplate, sql, params, ctm); + + final List paramsList = new ArrayList<>(); + // cashier_txns section: upper bound is c.end_date (exact date, no +1 needed for allocations) + paramsList.add(cashierId); + paramsList.add(currencyCode); + if (fromDate != null) { + paramsList.add(fromDate); + } + if (toDate != null) { + paramsList.add(toDate); + } + // Option A: when sessionId is provided, add it for the txn.cashier_session_id = ? filter + // (txn is the alias for m_cashier_transactions in the cashier_txns sub-query) + if (sessionId != null) { + paramsList.add(sessionId); + } + // savings, loans, client sections: SQL uses DATE_ADD(c.end_date, 1 DAY) to ensure transactions + // recorded on the end date itself are included; mirror that with plusDays(1) for explicit toDate + paramsList.add(cashierId); + paramsList.add(currencyCode); + if (fromDate != null) { + paramsList.add(fromDate); + } + if (toDate != null) { + paramsList.add(toDate.plusDays(1)); + } + // loans section + paramsList.add(cashierId); + paramsList.add(currencyCode); + if (fromDate != null) { + paramsList.add(fromDate); + } + if (toDate != null) { + paramsList.add(toDate.plusDays(1)); + } + // client section + paramsList.add(cashierId); + paramsList.add(currencyCode); + if (fromDate != null) { + paramsList.add(fromDate); + } + if (toDate != null) { + paramsList.add(toDate.plusDays(1)); + } + return this.paginationHelper.fetchPage(this.jdbcTemplate, sql, paramsList.toArray(), ctm); } private static final class CashierMapper implements RowMapper { + private static final int EXPIRY_WARNING_DAYS = 7; + public String schema() { final StringBuilder sqlBuilder = new StringBuilder(400); @@ -387,8 +490,11 @@ public CashierData mapRow(final ResultSet rs, final int rowNum) throws SQLExcept final String startTime = rs.getString("start_time"); final String endTime = rs.getString("end_time"); + final LocalDate today = DateUtils.getLocalDateOfTenant(); + final boolean expiryWarning = endDate != null && !endDate.isBefore(today) && endDate.isBefore(today.plusDays(EXPIRY_WARNING_DAYS + 1)); + return CashierData.instance(id, null, null, staffId, staffName, tellerId, tellerName, description, startDate, endDate, fullDay, - startTime, endTime); + startTime, endTime).setExpiryWarning(expiryWarning); } } @@ -558,7 +664,8 @@ public CashierTransactionData mapRow(final ResultSet rs, final int rowNum) throw private static final class CashierTransactionSummaryMapper implements RowMapper { - public String cashierTxnSummarySchema(String nextDay) { + public String cashierTxnSummarySchema(String fromDateExpr, String toDateExprForCashierTxn, String toDateExprForOtherTxn, + boolean filterBySession) { final StringBuilder sqlBuilder = new StringBuilder(400); @@ -576,18 +683,23 @@ public String cashierTxnSummarySchema(String nextDay) { sqlBuilder.append(" left join m_office o on o.id = t.office_id "); sqlBuilder.append(" left join m_staff s on s.id = c.staff_id "); sqlBuilder.append(" where txn.cashier_id = ? "); - sqlBuilder.append(" AND (( txn.created_date between c.start_date AND c.end_date ) or txn.txn_type = 101) "); + sqlBuilder.append(" AND (( txn.created_date between " + fromDateExpr + " AND " + toDateExprForCashierTxn + + " ) or txn.txn_type = " + CashierTxnType.ALLOCATE.getId() + ") "); sqlBuilder.append(" and txn.currency_code = ? "); + if (filterBySession) { + // Option A: filter to a specific cashier session when sessionId is provided + sqlBuilder.append(" AND txn.cashier_session_id = ? "); + } sqlBuilder.append(" ) cashier_txns "); sqlBuilder.append(" UNION "); sqlBuilder.append(" (select sav_txn.id as txn_id, c.id as cashier_id, "); sqlBuilder.append(" case "); sqlBuilder.append(" when renum.enum_value in ('deposit','withdrawal fee', 'Pay Charge', 'Annual Fee') "); - sqlBuilder.append(" then 103 "); + sqlBuilder.append(" then " + CashierTxnType.INWARD_CASH_TXN.getId() + " "); // INWARD_CASH_TXN sqlBuilder.append(" when renum.enum_value in ('withdrawal', 'Waive Charge', 'Interest Posting', 'Overdraft Interest') "); - sqlBuilder.append(" then 104 "); + sqlBuilder.append(" then " + CashierTxnType.OUTWARD_CASH_TXN.getId() + " "); // OUTWARD_CASH_TXN sqlBuilder.append(" else "); - sqlBuilder.append(" 105 "); + sqlBuilder.append(" 105 "); // unclassified teller transaction sqlBuilder.append(" end as cash_txn_type, "); sqlBuilder.append(" sav_txn.amount as txn_amount, sav_txn.transaction_date as txn_date, "); sqlBuilder.append( @@ -611,7 +723,7 @@ public String cashierTxnSummarySchema(String nextDay) { sqlBuilder.append(" or acnttrans.to_savings_transaction_id = sav_txn.id) "); sqlBuilder.append(" where sav_txn.is_reversed = false and c.id = ? "); sqlBuilder.append(" and sav.currency_code = ? "); - sqlBuilder.append(" and sav_txn.transaction_date between c.start_date and c.end_date "); + sqlBuilder.append(" and sav_txn.transaction_date between " + fromDateExpr + " and " + toDateExprForCashierTxn); sqlBuilder.append(" and (sav_txn.payment_detail_id IS NULL OR payType.is_cash_payment = true) "); sqlBuilder.append(" AND acnttrans.id IS NULL "); sqlBuilder.append(" ) "); @@ -621,11 +733,11 @@ public String cashierTxnSummarySchema(String nextDay) { sqlBuilder.append(" case "); sqlBuilder.append( " when renum.enum_value in ('REPAYMENT_AT_DISBURSEMENT','REPAYMENT', 'RECOVERY_REPAYMENT', 'CHARGE_PAYMENT') "); - sqlBuilder.append(" then 103 "); + sqlBuilder.append(" then " + CashierTxnType.INWARD_CASH_TXN.getId() + " "); // INWARD_CASH_TXN sqlBuilder.append(" when renum.enum_value in ('DISBURSEMENT', 'WAIVE_INTEREST', 'WRITEOFF', 'WAIVE_CHARGES') "); - sqlBuilder.append(" then 104 "); + sqlBuilder.append(" then " + CashierTxnType.OUTWARD_CASH_TXN.getId() + " "); // OUTWARD_CASH_TXN sqlBuilder.append(" else "); - sqlBuilder.append(" 105 "); + sqlBuilder.append(" 105 "); // unclassified teller transaction sqlBuilder.append(" end as cash_txn_type, "); sqlBuilder.append(" loan_txn.amount as txn_amount, loan_txn.transaction_date as txn_date, "); sqlBuilder.append( @@ -649,7 +761,7 @@ public String cashierTxnSummarySchema(String nextDay) { sqlBuilder.append(" or acnttrans.to_loan_transaction_id = loan_txn.id) "); sqlBuilder.append(" where loan_txn.is_reversed = false and c.id = ? "); sqlBuilder.append(" and loan.currency_code = ? "); - sqlBuilder.append(" and loan_txn.transaction_date between c.start_date and " + nextDay); + sqlBuilder.append(" and loan_txn.transaction_date between " + fromDateExpr + " and " + toDateExprForOtherTxn); sqlBuilder.append(" and (loan_txn.payment_detail_id IS NULL OR payType.is_cash_payment = true) "); sqlBuilder.append(" AND acnttrans.id IS NULL "); sqlBuilder.append(" ) "); @@ -658,11 +770,11 @@ public String cashierTxnSummarySchema(String nextDay) { sqlBuilder.append(" SELECT cli_txn.id AS txn_id, c.id AS cashier_id, "); sqlBuilder.append(" case "); sqlBuilder.append(" WHEN renum.enum_value IN ('PAY_CHARGE') "); - sqlBuilder.append(" then 103 "); + sqlBuilder.append(" then " + CashierTxnType.INWARD_CASH_TXN.getId() + " "); // INWARD_CASH_TXN sqlBuilder.append(" WHEN renum.enum_value IN ('WAIVE_CHARGE') "); - sqlBuilder.append(" then 104 "); + sqlBuilder.append(" then " + CashierTxnType.OUTWARD_CASH_TXN.getId() + " "); // OUTWARD_CASH_TXN sqlBuilder.append(" else "); - sqlBuilder.append(" 105 "); + sqlBuilder.append(" 105 "); // unclassified teller transaction sqlBuilder.append(" end as cash_txn_type, "); sqlBuilder.append(" cli_txn.amount as txn_amount, cli_txn.transaction_date as txn_date, "); sqlBuilder.append( @@ -682,7 +794,7 @@ public String cashierTxnSummarySchema(String nextDay) { sqlBuilder.append(" left join m_payment_type payType on payType.id = payDetails.payment_type_id "); sqlBuilder.append(" where cli_txn.is_reversed = false AND c.id = ? "); sqlBuilder.append(" and cli_txn.currency_code = ? "); - sqlBuilder.append(" and cli_txn.transaction_date between c.start_date and " + nextDay); + sqlBuilder.append(" and cli_txn.transaction_date between " + fromDateExpr + " and " + toDateExprForOtherTxn); sqlBuilder.append(" and (cli_txn.payment_detail_id IS NULL OR payType.is_cash_payment = true) "); sqlBuilder.append(" ) "); sqlBuilder.append(" ) txns "); diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java index b00b5c98ba1..9568b8f5811 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java @@ -28,6 +28,7 @@ import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount; import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper; import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; import org.apache.fineract.accounting.journalentry.domain.JournalEntry; import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; @@ -36,6 +37,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.exception.ErrorHandler; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.organisation.office.domain.Office; @@ -46,11 +48,14 @@ import org.apache.fineract.organisation.teller.data.CashierTransactionDataValidator; import org.apache.fineract.organisation.teller.domain.Cashier; import org.apache.fineract.organisation.teller.domain.CashierRepository; +import org.apache.fineract.organisation.teller.domain.CashierSession; +import org.apache.fineract.organisation.teller.domain.CashierSessionRepository; import org.apache.fineract.organisation.teller.domain.CashierTransaction; import org.apache.fineract.organisation.teller.domain.CashierTransactionRepository; import org.apache.fineract.organisation.teller.domain.CashierTxnType; import org.apache.fineract.organisation.teller.domain.Teller; import org.apache.fineract.organisation.teller.domain.TellerRepositoryWrapper; +import org.apache.fineract.organisation.teller.exception.CashierAssignmentExpiredException; import org.apache.fineract.organisation.teller.exception.CashierExistForTellerException; import org.apache.fineract.organisation.teller.exception.CashierNotFoundException; import org.apache.fineract.organisation.teller.serialization.TellerCommandFromApiJsonDeserializer; @@ -70,9 +75,11 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer private final StaffRepository staffRepository; private final CashierRepository cashierRepository; private final CashierTransactionRepository cashierTxnRepository; + private final CashierSessionRepository cashierSessionRepository; private final JournalEntryRepository glJournalEntryRepository; private final FinancialActivityAccountRepositoryWrapper financialActivityAccountRepositoryWrapper; private final CashierTransactionDataValidator cashierTransactionDataValidator; + private final GLAccountRepositoryWrapper glAccountRepositoryWrapper; @Override @Transactional @@ -120,7 +127,17 @@ public CommandProcessingResult modifyTeller(Long tellerId, JsonCommand command) final Teller teller = validateUserPriviledgeOnTellerAndRetrieve(currentUser, tellerId); - final Map changes = teller.update(tellerOffice, command); + final Long debitAccountId = command.longValueOfParameterNamed("debitAccountId"); + final GLAccount debitAccount = debitAccountId != null + ? this.glAccountRepositoryWrapper.findOneWithNotFoundDetection(debitAccountId) + : null; + + final Long creditAccountId = command.longValueOfParameterNamed("creditAccountId"); + final GLAccount creditAccount = creditAccountId != null + ? this.glAccountRepositoryWrapper.findOneWithNotFoundDetection(creditAccountId) + : null; + + final Map changes = teller.update(tellerOffice, debitAccount, creditAccount, command); if (!changes.isEmpty()) { this.tellerRepositoryWrapper.saveAndFlush(teller); @@ -328,6 +345,7 @@ public CommandProcessingResult deleteCashierAllocation(Long tellerId, Long cashi @Override public CommandProcessingResult allocateCashToCashier(final Long cashierId, JsonCommand command) { + this.cashierTransactionDataValidator.validateAllocateCashTransactions(cashierId, command); return doTransactionForCashier(cashierId, CashierTxnType.ALLOCATE, command); // For // fund // allocation @@ -353,36 +371,20 @@ private CommandProcessingResult doTransactionForCashier(final Long cashierId, fi final Cashier cashier = this.cashierRepository.findById(cashierId).orElseThrow(() -> new CashierNotFoundException(cashierId)); - this.fromApiJsonDeserializer.validateForCashTxnForCashier(command.json()); - - // TODO: can we please remove this whole block?!? this is 20 lines of dead code!!! - final String entityType = command.stringValueOfParameterNamed("entityType"); - if (entityType != null) { - if (entityType.equals("loan account")) { - // TODO : Check if loan account exists - // LoanAccount loan = null; - // if (loan == null) { throw new - // LoanAccountFoundException(entityId); } - } else if (entityType.equals("savings account")) { - // TODO : Check if loan account exists - // SavingsAccount savingsaccount = null; - // if (savingsaccount == null) { throw new - // SavingsAccountNotFoundException(entityId); } - - } - if (entityType.equals("client")) { - // TODO: Check if client exists - // Client client = null; - // if (client == null) { throw new - // ClientNotFoundException(entityId); } - } else { - // TODO : Invalid type handling - } + if (cashier.getEndDate() != null && cashier.getEndDate().isBefore(DateUtils.getLocalDateOfTenant())) { + final String cashierName = cashier.getStaff() != null ? cashier.getStaff().displayName() : String.valueOf(cashierId); + throw new CashierAssignmentExpiredException(cashierName, cashier.getEndDate()); } + this.fromApiJsonDeserializer.validateForCashTxnForCashier(command.json()); + final CashierTransaction cashierTxn = CashierTransaction.fromJson(cashier, command); cashierTxn.setTxnType(txnType.getId()); + // Link to active session if one exists + cashierSessionRepository.findOpenSession(cashier.getId(), cashier.getTeller().getId(), DateUtils.getBusinessLocalDate()) + .ifPresent(cashierTxn::setCashierSession); + this.cashierTxnRepository.save(cashierTxn); // Pass the journal entries diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/starter/OrganisationTellerConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/starter/OrganisationTellerConfiguration.java index 87136fa0c7e..5aa5c3861f4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/starter/OrganisationTellerConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/starter/OrganisationTellerConfiguration.java @@ -19,6 +19,7 @@ package org.apache.fineract.organisation.teller.starter; import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper; +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; import org.apache.fineract.infrastructure.core.service.PaginationHelper; import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; @@ -34,6 +35,11 @@ import org.apache.fineract.organisation.teller.domain.CashierTransactionRepository; import org.apache.fineract.organisation.teller.domain.TellerRepositoryWrapper; import org.apache.fineract.organisation.teller.serialization.TellerCommandFromApiJsonDeserializer; +import org.apache.fineract.organisation.teller.domain.CashierSessionRepository; +import org.apache.fineract.organisation.teller.service.CashierSessionReadPlatformService; +import org.apache.fineract.organisation.teller.service.CashierSessionReadPlatformServiceImpl; +import org.apache.fineract.organisation.teller.service.CashierSessionWritePlatformService; +import org.apache.fineract.organisation.teller.service.CashierSessionWritePlatformServiceImpl; import org.apache.fineract.organisation.teller.service.TellerManagementReadPlatformService; import org.apache.fineract.organisation.teller.service.TellerManagementReadPlatformServiceImpl; import org.apache.fineract.organisation.teller.service.TellerWritePlatformService; @@ -61,11 +67,33 @@ public TellerManagementReadPlatformService tellerManagementReadPlatformService(J public TellerWritePlatformService tellerWritePlatformService(PlatformSecurityContext context, TellerCommandFromApiJsonDeserializer fromApiJsonDeserializer, TellerRepositoryWrapper tellerRepositoryWrapper, OfficeRepositoryWrapper officeRepositoryWrapper, StaffRepository staffRepository, CashierRepository cashierRepository, - CashierTransactionRepository cashierTxnRepository, JournalEntryRepository glJournalEntryRepository, + CashierTransactionRepository cashierTxnRepository, CashierSessionRepository cashierSessionRepository, + JournalEntryRepository glJournalEntryRepository, FinancialActivityAccountRepositoryWrapper financialActivityAccountRepositoryWrapper, - CashierTransactionDataValidator cashierTransactionDataValidator) { + CashierTransactionDataValidator cashierTransactionDataValidator, + GLAccountRepositoryWrapper glAccountRepositoryWrapper) { return new TellerWritePlatformServiceJpaImpl(context, fromApiJsonDeserializer, tellerRepositoryWrapper, officeRepositoryWrapper, - staffRepository, cashierRepository, cashierTxnRepository, glJournalEntryRepository, - financialActivityAccountRepositoryWrapper, cashierTransactionDataValidator); + staffRepository, cashierRepository, cashierTxnRepository, cashierSessionRepository, glJournalEntryRepository, + financialActivityAccountRepositoryWrapper, cashierTransactionDataValidator, glAccountRepositoryWrapper); + } + + @Bean + @ConditionalOnMissingBean(CashierSessionReadPlatformService.class) + public CashierSessionReadPlatformService cashierSessionReadPlatformService(JdbcTemplate jdbcTemplate, + CashierTransactionRepository cashierTransactionRepository) { + return new CashierSessionReadPlatformServiceImpl(jdbcTemplate, cashierTransactionRepository); + } + + @Bean + @ConditionalOnMissingBean(CashierSessionWritePlatformService.class) + public CashierSessionWritePlatformService cashierSessionWritePlatformService(PlatformSecurityContext context, + CashierSessionRepository cashierSessionRepository, CashierRepository cashierRepository, + TellerRepositoryWrapper tellerRepositoryWrapper, CashierTransactionRepository cashierTransactionRepository, + JournalEntryRepository glJournalEntryRepository, + FinancialActivityAccountRepositoryWrapper financialActivityAccountRepositoryWrapper, + GLAccountRepositoryWrapper glAccountRepositoryWrapper) { + return new CashierSessionWritePlatformServiceImpl(context, cashierSessionRepository, cashierRepository, tellerRepositoryWrapper, + cashierTransactionRepository, glJournalEntryRepository, financialActivityAccountRepositoryWrapper, + glAccountRepositoryWrapper); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java index 306230d6825..de52c18cfd8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java @@ -481,7 +481,7 @@ public String retrieveLoan(@PathParam("loanId") @Parameter(description = "loanId @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "List Loans", description = "The list capability of loans can support pagination and sorting.\n" + "Example Requests:\n" + "\n" + "loans\n" + "\n" + "loans?fields=accountNo\n" + "\n" + "loans?offset=10&limit=50\n" + "\n" - + "loans?orderBy=accountNo&sortOrder=DESC") + + "loans?orderBy=accountNo&sortOrder=DESC\n" + "\n" + "loans?productId=1\n" + "\n" + "loans?productId=1&status=300") @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoansApiResourceSwagger.GetLoansResponse.class))) }) public String retrieveAll(@Context final UriInfo uriInfo, @@ -494,7 +494,8 @@ public String retrieveAll(@Context final UriInfo uriInfo, @QueryParam("accountNo") @Parameter(description = "accountNo") final String accountNo, @QueryParam("associations") @Parameter(description = "associations") final String associations, @QueryParam("clientId") @Parameter(description = "clientId") final Long clientId, - @QueryParam("status") @Parameter(description = "status") final String status) { + @QueryParam("status") @Parameter(description = "status") final String status, + @QueryParam("productId") @Parameter(description = "productId") final Long productId) { this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); @@ -503,7 +504,8 @@ public String retrieveAll(@Context final UriInfo uriInfo, sqlValidator.validate(accountNo); sqlValidator.validate(externalId); final SearchParameters searchParameters = SearchParameters.builder().accountNo(accountNo).sortOrder(sortOrder) - .externalId(externalId).offset(offset).limit(limit).orderBy(orderBy).status(status).clientId(clientId).build(); + .externalId(externalId).offset(offset).limit(limit).orderBy(orderBy).status(status).clientId(clientId) + .productId(productId).build(); final Page loanBasicDetails = this.loanReadPlatformService.retrieveAll(searchParameters); final Set associationParameters = ApiParameterHelper.extractAssociationsForResponseIfProvided(uriInfo.getQueryParameters()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java index f82d12a2aa0..8679fb66c65 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -39,6 +40,9 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.teller.domain.CashierSession; +import org.apache.fineract.organisation.teller.domain.CashierSessionRepository; import org.apache.fineract.infrastructure.event.business.domain.loan.LoanBalanceChangedBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.loan.LoanBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanChargePaymentPostBusinessEvent; @@ -118,6 +122,7 @@ import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.data.PostDatedChecksStatus; import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecks; import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository; +import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -160,6 +165,8 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService { private final LoanTransactionService loanTransactionService; private final LoanAccountDomainServiceJpaHelper loanAccountDomainServiceJpaHelper; private final LoanJournalEntryPoster journalEntryPoster; + private final PlatformSecurityContext context; + private final CashierSessionRepository cashierSessionRepository; @Transactional @Override @@ -239,6 +246,7 @@ public LoanTransaction makeRepayment(final LoanTransactionType repaymentTransact newRepaymentTransaction = LoanTransaction.repaymentType(repaymentTransactionType, loan.getOffice(), repaymentAmount, paymentDetail, transactionDate, txnExternalId, chargeRefundChargeType); } + stampCashierSession(newRepaymentTransaction, loan.getOfficeId()); LocalDate recalculateFrom = null; if (loan.isInterestBearingAndInterestRecalculationEnabled()) { @@ -398,6 +406,7 @@ public LoanTransaction makeChargePayment(final Loan loan, final Long chargeId, f final LoanTransaction newPaymentTransaction = LoanTransaction.loanPayment(null, loan.getOffice(), paymentAmout, paymentDetail, transactionDate, txnExternalId, loanTransactionType); + stampCashierSession(newPaymentTransaction, loan.getOfficeId()); if (loanTransactionType.isRepaymentAtDisbursement()) { handlePayDisbursementTransaction(loan, chargeId, newPaymentTransaction); @@ -469,6 +478,23 @@ private void checkClientOrGroupActive(final Loan loan) { } + /** + * Looks up an OPEN cashier session for the currently authenticated user (today, at the loan's office) and stamps + * its id on the transaction. Safe to call even when there is no authenticated user (batch jobs) or no active + * session -- in those cases the field is left null. + */ + private void stampCashierSession(final LoanTransaction loanTransaction, final Long officeId) { + final AppUser currentUser = context.getAuthenticatedUserIfPresent(); + if (currentUser == null) { + return; + } + final List sessions = cashierSessionRepository.findOpenSessionByUser(currentUser.getId(), officeId, + DateUtils.getBusinessLocalDate()); + if (!sessions.isEmpty()) { + loanTransaction.setCashierSessionId(sessions.get(0).getId()); + } + } + @Override public LoanTransaction makeRefund(final Long accountId, final CommandProcessingResultBuilder builderResult, final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail, final String noteText, @@ -486,6 +512,7 @@ public LoanTransaction makeRefund(final Long accountId, final CommandProcessingR final Money refundAmount = Money.of(loan.getCurrency(), transactionAmount); final LoanTransaction newRefundTransaction = LoanTransaction.refund(loan.getOffice(), refundAmount, paymentDetail, transactionDate, txnExternalId); + stampCashierSession(newRefundTransaction, loan.getOfficeId()); final boolean allowTransactionsOnHoliday = this.configurationDomainService.allowTransactionsOnHolidayEnabled(); final List holidays = this.holidayRepository.findByOfficeIdAndGreaterThanDate(loan.getOfficeId(), transactionDate, HolidayStatusType.ACTIVE.getValue()); @@ -538,6 +565,7 @@ public LoanTransaction makeDisburseTransaction(final Long loanId, final LocalDat final Money amount = Money.of(loan.getCurrency(), transactionAmount); LoanTransaction disbursementTransaction = LoanTransaction.disbursement(loan, amount, paymentDetail, transactionDate, txnExternalId, loan.getTotalOverpaidAsMoney()); + stampCashierSession(disbursementTransaction, loan.getOfficeId()); // Subtract Previous loan outstanding balance from netDisbursalAmount loan.deductFromNetDisbursalAmount(transactionAmount); @@ -621,6 +649,7 @@ public LoanTransaction creditBalanceRefund(final Loan loan, final LocalDate tran final Money refundAmount = Money.of(loan.getCurrency(), transactionAmount); LoanTransaction newCreditBalanceRefundTransaction = LoanTransaction.creditBalanceRefund(loan, loan.getOffice(), refundAmount, transactionDate, externalId, paymentDetail); + stampCashierSession(newCreditBalanceRefundTransaction, loan.getOfficeId()); loanDownPaymentTransactionValidator.validateAccountStatus(loan, LoanEvent.LOAN_CREDIT_BALANCE_REFUND); loanTransactionValidator.validateRefundDateIsAfterLastRepayment(loan, newCreditBalanceRefundTransaction.getTransactionDate()); @@ -661,6 +690,7 @@ public LoanTransaction makeRefundForActiveLoan(Long accountId, CommandProcessing } final LoanTransaction newRefundTransaction = LoanTransaction.refundForActiveLoan(loan.getOffice(), refundAmount, paymentDetail, transactionDate, txnExternalId); + stampCashierSession(newRefundTransaction, loan.getOfficeId()); loanTransactionValidator.validateRefundDateIsAfterLastRepayment(loan, newRefundTransaction.getTransactionDate()); final boolean allowTransactionsOnHoliday = this.configurationDomainService.allowTransactionsOnHolidayEnabled(); final List holidays = this.holidayRepository.findByOfficeIdAndGreaterThanDate(loan.getOfficeId(), transactionDate, @@ -819,6 +849,7 @@ public Pair makeRefund(final Loan loan, final } LoanTransaction refundTransaction = LoanTransaction.refund(loan, loanTransactionType, transactionAmount, paymentDetail, transactionDate, txnExternalId); + stampCashierSession(refundTransaction, loan.getOfficeId()); final boolean isTransactionChronologicallyLatest = loanTransactionService.isChronologicallyLatestRepaymentOrWaiver(loan, refundTransaction); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java index 669206db30a..5156975ee19 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java @@ -379,6 +379,12 @@ public Page retrieveAll(final SearchParameters searchParameters arrayPos = arrayPos + 1; } + if (searchParameters.hasProductId()) { + sqlBuilder.append(" and l.product_id = ?"); + extraCriterias.add(searchParameters.getProductId()); + arrayPos = arrayPos + 1; + } + if (searchParameters.hasOrderBy()) { sqlBuilder.append(" order by ").append(searchParameters.getOrderBy()); this.columnValidator.validateSqlInjection(sqlBuilder.toString(), searchParameters.getOrderBy()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/search/service/SearchReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/search/service/SearchReadPlatformServiceImpl.java index e04b3226b19..1de6f0053cd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/search/service/SearchReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/search/service/SearchReadPlatformServiceImpl.java @@ -79,35 +79,35 @@ public String searchSchema(final SearchConditions searchConditions) { final String union = " union "; final String clientMatchSql = "( (select 'CLIENT' as entityType, c.id as entityId, c.display_name as entityName, c.external_id as entityExternalId, c.account_no as entityAccountNo " - + " , c.office_id as parentId, o.name as parentName, c.mobile_no as entityMobileNo,c.status_enum as entityStatusEnum, null as subEntityType, null as parentType " - + " from m_client c join m_office o on o.id = c.office_id where o.hierarchy like :hierarchy and (c.account_no like :search or c.display_name like :search or c.external_id like :search or c.mobile_no like :search)) " + + " , c.office_id as parentId, o.name as parentName, c.mobile_no as entityMobileNo,c.status_enum as entityStatusEnum, null as subEntityType, null as parentType, null as productId " + + " from m_client c join m_office o on o.id = c.office_id where o.hierarchy like :hierarchy and (UPPER(c.account_no) like UPPER(:search) or UPPER(c.display_name) like UPPER(:search) or UPPER(c.external_id) like UPPER(:search) or UPPER(c.mobile_no) like UPPER(:search))) " + " order by c.id desc)"; final String loanMatchSql = "( (select 'LOAN' as entityType, l.id as entityId, pl.name as entityName, l.external_id as entityExternalId, l.account_no as entityAccountNo " - + " , coalesce(c.id,g.id) as parentId, coalesce(c.display_name,g.display_name) as parentName, null as entityMobileNo, l.loan_status_id as entityStatusEnum, null as subEntityType, CASE WHEN g.id is null THEN 'client' ELSE 'group' END as parentType " - + " from m_loan l left join m_client c on l.client_id = c.id left join m_group g ON l.group_id = g.id left join m_office o on o.id = c.office_id left join m_product_loan pl on pl.id=l.product_id where (o.hierarchy IS NULL OR o.hierarchy like :hierarchy) and (l.account_no like :search or l.external_id like :search)) " + + " , coalesce(c.id,g.id) as parentId, coalesce(c.display_name,g.display_name) as parentName, null as entityMobileNo, l.loan_status_id as entityStatusEnum, null as subEntityType, CASE WHEN g.id is null THEN 'client' ELSE 'group' END as parentType, pl.id as productId " + + " from m_loan l left join m_client c on l.client_id = c.id left join m_group g ON l.group_id = g.id left join m_office o on o.id = c.office_id left join m_product_loan pl on pl.id=l.product_id where (o.hierarchy IS NULL OR o.hierarchy like :hierarchy) and (UPPER(l.account_no) like UPPER(:search) or UPPER(l.external_id) like UPPER(:search))) " + " order by l.id desc)"; final String savingMatchSql = "( (select 'SAVING' as entityType, s.id as entityId, sp.name as entityName, s.external_id as entityExternalId, s.account_no as entityAccountNo " - + " , coalesce(c.id,g.id) as parentId, coalesce(c.display_name, g.display_name) as parentName, null as entityMobileNo, s.status_enum as entityStatusEnum, concat(s.deposit_type_enum, '') as subEntityType, CASE WHEN g.id is null THEN 'client' ELSE 'group' END as parentType " + + " , coalesce(c.id,g.id) as parentId, coalesce(c.display_name, g.display_name) as parentName, null as entityMobileNo, s.status_enum as entityStatusEnum, concat(s.deposit_type_enum, '') as subEntityType, CASE WHEN g.id is null THEN 'client' ELSE 'group' END as parentType, sp.id as productId " + " from m_savings_account s left join m_client c on s.client_id = c.id left join m_group g ON s.group_id = g.id left join m_office o on o.id = c.office_id left join m_savings_product sp on sp.id=s.product_id " - + " where (o.hierarchy IS NULL OR o.hierarchy like :hierarchy) and (s.account_no like :search or s.external_id like :search)) " + + " where (o.hierarchy IS NULL OR o.hierarchy like :hierarchy) and (UPPER(s.account_no) like UPPER(:search) or UPPER(s.external_id) like UPPER(:search))) " + " order by s.id desc)"; final String shareMatchSql = "( (select 'SHARE' as entityType, s.id as entityId, sp.name as entityName, s.external_id as entityExternalId, s.account_no as entityAccountNo " - + " , c.id as parentId, c.display_name as parentName, null as entityMobileNo, s.status_enum as entityStatusEnum, null as subEntityType, 'client' as parentType " + + " , c.id as parentId, c.display_name as parentName, null as entityMobileNo, s.status_enum as entityStatusEnum, null as subEntityType, 'client' as parentType, sp.id as productId " + " from m_share_account s left join m_client c on s.client_id = c.id left join m_office o on o.id = c.office_id left join m_share_product sp on sp.id=s.product_id " - + " where (o.hierarchy IS NULL OR o.hierarchy like :hierarchy) and (s.account_no like :search or s.external_id like :search)) " + + " where (o.hierarchy IS NULL OR o.hierarchy like :hierarchy) and (UPPER(s.account_no) like UPPER(:search) or UPPER(s.external_id) like UPPER(:search))) " + " order by s.id desc)"; final String clientIdentifierMatchSql = "( (select 'CLIENTIDENTIFIER' as entityType, ci.id as entityId, ci.document_key as entityName, " - + " null as entityExternalId, null as entityAccountNo, c.id as parentId, c.display_name as parentName,null as entityMobileNo, c.status_enum as entityStatusEnum, null as subEntityType, null as parentType " + + " null as entityExternalId, null as entityAccountNo, c.id as parentId, c.display_name as parentName,null as entityMobileNo, c.status_enum as entityStatusEnum, null as subEntityType, null as parentType, null as productId " + " from m_client_identifier ci join m_client c on ci.client_id=c.id join m_office o on o.id = c.office_id " - + " where o.hierarchy like :hierarchy and ci.document_key like :search ) " + " order by ci.id desc)"; + + " where o.hierarchy like :hierarchy and UPPER(ci.document_key) like UPPER(:search) ) " + " order by ci.id desc)"; final String groupMatchSql = "( (select CASE WHEN g.level_id=1 THEN 'CENTER' ELSE 'GROUP' END as entityType, g.id as entityId, g.display_name as entityName, g.external_id as entityExternalId, g.account_no as entityAccountNo, " - + " g.office_id as parentId, o.name as parentName, null as entityMobileNo, g.status_enum as entityStatusEnum, null as subEntityType, null as parentType " - + " from m_group g join m_office o on o.id = g.office_id where o.hierarchy like :hierarchy and (g.account_no like :search or g.display_name like :search or g.external_id like :search )) " + + " g.office_id as parentId, o.name as parentName, null as entityMobileNo, g.status_enum as entityStatusEnum, null as subEntityType, null as parentType, null as productId " + + " from m_group g join m_office o on o.id = g.office_id where o.hierarchy like :hierarchy and (UPPER(g.account_no) like UPPER(:search) or UPPER(g.display_name) like UPPER(:search) or UPPER(g.external_id) like UPPER(:search))) " + " order by g.id desc)"; final StringBuilder sql = new StringBuilder(); @@ -159,6 +159,7 @@ public SearchData mapRow(final ResultSet rs, @SuppressWarnings("unused") final i final Integer entityStatusEnum = JdbcSupport.getInteger(rs, "entityStatusEnum"); final String parentType = rs.getString("parentType"); final Integer subEntityTypeValue = JdbcSupport.getInteger(rs, "subEntityType"); + final Long productId = JdbcSupport.getLong(rs, "productId"); final EnumOptionData subEntityTypeCode = SavingsEnumerations.depositType(subEntityTypeValue); EnumOptionData entityStatus = new EnumOptionData(0L, "", ""); @@ -178,7 +179,7 @@ else if (entityType.equalsIgnoreCase("loan")) { } return new SearchData(entityId, entityAccountNo, entityExternalId, entityName, entityType, parentId, parentName, parentType, - entityMobileNo, entityStatus, subEntityTypeCode.getCode()); + entityMobileNo, entityStatus, subEntityTypeCode.getCode(), productId); } } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 231310cf678..fbae67039e8 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -227,4 +227,9 @@ + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0209_add_m_cashier_sessions_table.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0209_add_m_cashier_sessions_table.xml new file mode 100644 index 00000000000..642edf599f4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0209_add_m_cashier_sessions_table.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0210_add_cashier_session_id_fk_to_transactions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0210_add_cashier_session_id_fk_to_transactions.xml new file mode 100644 index 00000000000..50940c3d689 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0210_add_cashier_session_id_fk_to_transactions.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0211_add_supervisor_note_to_cashier_sessions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0211_add_supervisor_note_to_cashier_sessions.xml new file mode 100644 index 00000000000..fa0b0c68112 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0211_add_supervisor_note_to_cashier_sessions.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0212_add_cashier_session_id_to_cashier_transactions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0212_add_cashier_session_id_to_cashier_transactions.xml new file mode 100644 index 00000000000..a3f6537039f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0212_add_cashier_session_id_to_cashier_transactions.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0213_add_cashiersession_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0213_add_cashiersession_permissions.xml new file mode 100644 index 00000000000..c1ce1b1475e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0213_add_cashiersession_permissions.xml @@ -0,0 +1,71 @@ + + + + + + + + SELECT COUNT(1) FROM m_permission WHERE code = 'READ_CASHIERSESSION' + + + + + + + + + + + + + + + SELECT COUNT(1) FROM m_permission WHERE code = 'CREATE_CASHIERSESSION' + + + + + + + + + + + + + + + SELECT COUNT(1) FROM m_permission WHERE code = 'CLOSE_CASHIERSESSION' + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml b/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml index f3998cc5a5e..e29320f5a22 100644 --- a/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml +++ b/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml @@ -162,6 +162,7 @@ org.apache.fineract.organisation.teller.domain.CashierTransaction + org.apache.fineract.organisation.teller.domain.CashierSession org.apache.fineract.organisation.teller.domain.Teller org.apache.fineract.organisation.teller.domain.Cashier org.apache.fineract.organisation.teller.domain.TellerTransaction diff --git a/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyProviderTest.java b/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyProviderTest.java index 0048699748e..bd6100388e2 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyProviderTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/batch/command/CommandStrategyProviderTest.java @@ -39,6 +39,7 @@ import org.apache.fineract.batch.command.internal.CreateChargeCommandStrategy; import org.apache.fineract.batch.command.internal.CreateClientCommandStrategy; import org.apache.fineract.batch.command.internal.CreateDatatableEntryCommandStrategy; +import org.apache.fineract.batch.command.internal.CreateJournalEntryCommandStrategy; import org.apache.fineract.batch.command.internal.CreateLoanRescheduleRequestCommandStrategy; import org.apache.fineract.batch.command.internal.CreateTransactionByLoanExternalIdCommandStrategy; import org.apache.fineract.batch.command.internal.CreateTransactionLoanCommandStrategy; @@ -229,7 +230,13 @@ private static Stream provideCommandStrategies() { Arguments.of( "loans/external-id/8dfad438-2319-48ce-8520-10a62801e9a1/transactions/reage-preview?frequency-number=2&frequencyType=long-string", HttpMethod.GET, "getReagePreviewByLoanExternalIdCommandStrategy", - mock(GetReagePreviewByLoanExternalIdCommandStrategy.class))); + mock(GetReagePreviewByLoanExternalIdCommandStrategy.class)), + Arguments.of("journalentries", HttpMethod.POST, "createJournalEntryCommandStrategy", + mock(CreateJournalEntryCommandStrategy.class)), + Arguments.of("journalentries?command=updateRunningBalance", HttpMethod.POST, "createJournalEntryCommandStrategy", + mock(CreateJournalEntryCommandStrategy.class)), + Arguments.of("journalentries?command=defineOpeningBalance", HttpMethod.POST, "createJournalEntryCommandStrategy", + mock(CreateJournalEntryCommandStrategy.class))); } /** diff --git a/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/CreateJournalEntryCommandStrategyTest.java b/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/CreateJournalEntryCommandStrategyTest.java new file mode 100644 index 00000000000..1042f5a49aa --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/batch/command/internal/CreateJournalEntryCommandStrategyTest.java @@ -0,0 +1,161 @@ +/** + * 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.batch.command.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.core.UriInfo; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.fineract.accounting.journalentry.api.JournalEntriesApiResource; +import org.apache.fineract.batch.domain.BatchRequest; +import org.apache.fineract.batch.domain.BatchResponse; +import org.apache.http.HttpStatus; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** + * Test class for {@link CreateJournalEntryCommandStrategy}. + */ +public class CreateJournalEntryCommandStrategyTest { + + /** + * Test {@link CreateJournalEntryCommandStrategy#execute} happy path scenario for standard journal entry creation. + */ + @Test + public void testExecuteSuccessScenarioStandardCreate() { + final TestContext testContext = new TestContext(); + final BatchRequest batchRequest = getBatchRequest(null); + final String responseBody = "myResponseBody"; + + when(testContext.journalEntriesApiResource.createGLJournalEntry(batchRequest.getBody(), null)).thenReturn(responseBody); + + BatchResponse batchResponse = testContext.subjectToTest.execute(batchRequest, testContext.uriInfo); + + assertEquals(HttpStatus.SC_OK, batchResponse.getStatusCode()); + assertSame(responseBody, batchResponse.getBody()); + assertEquals(batchRequest.getRequestId(), batchResponse.getRequestId()); + assertEquals(batchRequest.getHeaders(), batchResponse.getHeaders()); + + verify(testContext.journalEntriesApiResource).createGLJournalEntry(batchRequest.getBody(), null); + } + + /** + * Test {@link CreateJournalEntryCommandStrategy#execute} happy path scenario with command parameter. + */ + @Test + public void testExecuteSuccessScenarioWithCommand() { + final TestContext testContext = new TestContext(); + final String command = "updateRunningBalance"; + final BatchRequest batchRequest = getBatchRequest(command); + final String responseBody = "myResponseBody"; + + when(testContext.journalEntriesApiResource.createGLJournalEntry(batchRequest.getBody(), command)).thenReturn(responseBody); + + BatchResponse batchResponse = testContext.subjectToTest.execute(batchRequest, testContext.uriInfo); + + assertEquals(HttpStatus.SC_OK, batchResponse.getStatusCode()); + assertSame(responseBody, batchResponse.getBody()); + assertEquals(batchRequest.getRequestId(), batchResponse.getRequestId()); + assertEquals(batchRequest.getHeaders(), batchResponse.getHeaders()); + + verify(testContext.journalEntriesApiResource).createGLJournalEntry(batchRequest.getBody(), command); + } + + /** + * Test {@link CreateJournalEntryCommandStrategy#execute} happy path scenario with defineOpeningBalance command. + */ + @Test + public void testExecuteSuccessScenarioDefineOpeningBalance() { + final TestContext testContext = new TestContext(); + final String command = "defineOpeningBalance"; + final BatchRequest batchRequest = getBatchRequest(command); + final String responseBody = "myResponseBody"; + + when(testContext.journalEntriesApiResource.createGLJournalEntry(batchRequest.getBody(), command)).thenReturn(responseBody); + + BatchResponse batchResponse = testContext.subjectToTest.execute(batchRequest, testContext.uriInfo); + + assertEquals(HttpStatus.SC_OK, batchResponse.getStatusCode()); + assertSame(responseBody, batchResponse.getBody()); + assertEquals(batchRequest.getRequestId(), batchResponse.getRequestId()); + assertEquals(batchRequest.getHeaders(), batchResponse.getHeaders()); + + verify(testContext.journalEntriesApiResource).createGLJournalEntry(batchRequest.getBody(), command); + } + + /** + * Creates and returns a request with the optional command parameter. + * + * @param command + * the command parameter (can be null) + * @return BatchRequest + */ + private BatchRequest getBatchRequest(final String command) { + + final BatchRequest br = new BatchRequest(); + String relativeUrl = "v1/journalentries"; + if (command != null) { + relativeUrl += "?command=" + command; + } + + br.setRequestId(Long.valueOf(RandomStringUtils.randomNumeric(5))); + br.setRelativeUrl(relativeUrl); + br.setMethod(HttpMethod.POST); + br.setReference(Long.valueOf(RandomStringUtils.randomNumeric(5))); + br.setBody("{}"); + + return br; + } + + /** + * Private test context class used since testng runs in parallel to avoid state between tests + */ + private static class TestContext { + + /** + * Mock URI info. + */ + @Mock + private UriInfo uriInfo; + + /** + * Mock journal entries API resource. + */ + @Mock + private JournalEntriesApiResource journalEntriesApiResource; + + /** + * The {@link CreateJournalEntryCommandStrategy} under test. + */ + private final CreateJournalEntryCommandStrategy subjectToTest; + + /** + * Constructor. + */ + TestContext() { + MockitoAnnotations.openMocks(this); + subjectToTest = new CreateJournalEntryCommandStrategy(journalEntriesApiResource); + } + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountTransaction.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountTransaction.java index 791a126aa78..2a743b77118 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountTransaction.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountTransaction.java @@ -40,6 +40,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import lombok.Setter; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -138,6 +139,10 @@ public final class SavingsAccountTransaction extends AbstractAuditableWithUTCDat @Column(name = "ref_no", nullable = true) private String refNo; + @Setter + @Column(name = "cashier_session_id") + private Long cashierSessionId; + SavingsAccountTransaction() {} private SavingsAccountTransaction(final SavingsAccount savingsAccount, final Office office, final PaymentDetail paymentDetail, diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BatchApiJournalEntryTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BatchApiJournalEntryTest.java new file mode 100644 index 00000000000..a5ea4c47836 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BatchApiJournalEntryTest.java @@ -0,0 +1,199 @@ +/** + * 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.integrationtests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.builder.ResponseSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import io.restassured.specification.ResponseSpecification; +import java.util.ArrayList; +import java.util.List; +import org.apache.fineract.batch.domain.BatchRequest; +import org.apache.fineract.batch.domain.BatchResponse; +import org.apache.fineract.integrationtests.common.BatchHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.http.HttpStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Integration test for creating journal entries through the Batch API. + * + * This test validates that: + * 1. Journal entries can be created via batch API + * 2. Command parameters (updateRunningBalance, defineOpeningBalance) work correctly + * 3. Batch requests with enclosingTransaction execute atomically + */ +public class BatchApiJournalEntryTest { + + private ResponseSpecification responseSpec; + private RequestSpecification requestSpec; + + @BeforeEach + public void setup() { + Utils.initializeRESTAssured(); + this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); + } + + /** + * Test that a simple journal entry can be created through the batch API. + */ + @Test + public void testCreateJournalEntryViaBatchApi() { + // Create a batch request with a single journal entry + final List batchRequests = new ArrayList<>(); + + // Create journal entry request: Debit from GL Account 1 (Asset), Credit to GL Account 2 (Liability) + final BatchRequest journalEntryRequest = BatchHelper.createJournalEntryRequest(1L, // requestId + 1, // officeId + "08 January 2026", // transactionDate + "Test journal entry via batch API", // comments + "USD", // currencyCode + "[{\"glAccountId\":1,\"amount\":100}]", // debits + "[{\"glAccountId\":2,\"amount\":100}]" // credits + ); + + batchRequests.add(journalEntryRequest); + + // Execute batch request + final String jsonifiedRequest = BatchHelper.toJsonString(batchRequests); + final List responses = BatchHelper.fromJsonString( + Utils.performServerPost(this.requestSpec, this.responseSpec, BatchHelper.BATCH_API_URL, jsonifiedRequest), BatchResponse.class); + + // Verify response + assertEquals(1, responses.size(), "Expected exactly one response"); + final BatchResponse response = responses.get(0); + assertEquals(HttpStatus.SC_OK, response.getStatusCode(), "Expected HTTP 200 OK"); + assertNotNull(response.getBody(), "Response body should not be null"); + } + + /** + * Test creating journal entry with updateRunningBalance command parameter. + */ + @Test + public void testCreateJournalEntryWithUpdateRunningBalanceCommand() { + final List batchRequests = new ArrayList<>(); + + // Create journal entry request with command parameter + final BatchRequest journalEntryRequest = BatchHelper.createJournalEntryRequest(1L, // requestId + 1, // officeId + "08 January 2026", // transactionDate + "Journal entry with updateRunningBalance", // comments + "USD", // currencyCode + "[{\"glAccountId\":1,\"amount\":200}]", // debits + "[{\"glAccountId\":2,\"amount\":200}]", // credits + "updateRunningBalance" // command + ); + + batchRequests.add(journalEntryRequest); + + // Execute batch request + final String jsonifiedRequest = BatchHelper.toJsonString(batchRequests); + final List responses = BatchHelper.fromJsonString( + Utils.performServerPost(this.requestSpec, this.responseSpec, BatchHelper.BATCH_API_URL, jsonifiedRequest), BatchResponse.class); + + // Verify response + assertEquals(1, responses.size(), "Expected exactly one response"); + final BatchResponse response = responses.get(0); + assertEquals(HttpStatus.SC_OK, response.getStatusCode(), "Expected HTTP 200 OK"); + assertNotNull(response.getBody(), "Response body should not be null"); + } + + /** + * Test creating journal entry with defineOpeningBalance command parameter. + */ + @Test + public void testCreateJournalEntryWithDefineOpeningBalanceCommand() { + final List batchRequests = new ArrayList<>(); + + // Create journal entry request with defineOpeningBalance command + final BatchRequest journalEntryRequest = BatchHelper.createJournalEntryRequest(1L, // requestId + 1, // officeId + "01 January 2026", // transactionDate + "Opening balance journal entry", // comments + "USD", // currencyCode + "[{\"glAccountId\":1,\"amount\":1000}]", // debits + "[{\"glAccountId\":2,\"amount\":1000}]", // credits + "defineOpeningBalance" // command + ); + + batchRequests.add(journalEntryRequest); + + // Execute batch request + final String jsonifiedRequest = BatchHelper.toJsonString(batchRequests); + final List responses = BatchHelper.fromJsonString( + Utils.performServerPost(this.requestSpec, this.responseSpec, BatchHelper.BATCH_API_URL, jsonifiedRequest), BatchResponse.class); + + // Verify response + assertEquals(1, responses.size(), "Expected exactly one response"); + final BatchResponse response = responses.get(0); + assertEquals(HttpStatus.SC_OK, response.getStatusCode(), "Expected HTTP 200 OK"); + assertNotNull(response.getBody(), "Response body should not be null"); + } + + /** + * Test atomic transaction with enclosingTransaction=true. This simulates a scenario where multiple operations + * (e.g., loan repayment and journal entry) must succeed or fail together. + */ + @Test + public void testJournalEntryWithEnclosingTransaction() { + final List batchRequests = new ArrayList<>(); + + // First request: Create journal entry for security deposit release + final BatchRequest journalEntry1 = BatchHelper.createJournalEntryRequest(1L, // requestId + 1, // officeId + "08 January 2026", // transactionDate + "Release security deposit from liability to cash", // comments + "USD", // currencyCode + "[{\"glAccountId\":1,\"amount\":500}]", // debits - Cash account + "[{\"glAccountId\":2,\"amount\":500}]" // credits - Liability account + ); + + // Second request: Another journal entry that should execute in same transaction + final BatchRequest journalEntry2 = BatchHelper.createJournalEntryRequest(2L, // requestId + 1, // officeId + "08 January 2026", // transactionDate + "Related accounting entry", // comments + "USD", // currencyCode + "[{\"glAccountId\":3,\"amount\":500}]", // debits + "[{\"glAccountId\":4,\"amount\":500}]" // credits + ); + + batchRequests.add(journalEntry1); + batchRequests.add(journalEntry2); + + // Execute batch request with enclosingTransaction=true for atomicity + final String jsonifiedRequest = BatchHelper.toJsonString(batchRequests); + final List responses = BatchHelper.fromJsonString(Utils.performServerPost(this.requestSpec, this.responseSpec, + BatchHelper.BATCH_API_URL_EXT, jsonifiedRequest), BatchResponse.class); + + // Verify both requests succeeded + assertEquals(2, responses.size(), "Expected exactly two responses"); + for (BatchResponse response : responses) { + assertEquals(HttpStatus.SC_OK, response.getStatusCode(), "Expected HTTP 200 OK for both requests"); + assertNotNull(response.getBody(), "Response body should not be null"); + } + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/BatchHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/BatchHelper.java index 39781556280..406eb595a16 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/BatchHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/BatchHelper.java @@ -1754,4 +1754,73 @@ public static BatchRequest releaseAmountOnSavingAccount(final Long requestId, fi return br; } + + /** + * Creates and returns a {@link org.apache.fineract.batch.command.internal.CreateJournalEntryCommandStrategy} + * Request for creating a journal entry in batch. + * + * @param requestId + * the request ID + * @param officeId + * the office ID + * @param transactionDate + * the transaction date (e.g., "08 January 2026") + * @param comments + * the journal entry comments + * @param currencyCode + * the currency code (e.g., "USD") + * @param debits + * JSON string for debits array (e.g., "[{\"glAccountId\":123,\"amount\":100}]") + * @param credits + * JSON string for credits array (e.g., "[{\"glAccountId\":456,\"amount\":100}]") + * @return BatchRequest the batch request + */ + public static BatchRequest createJournalEntryRequest(final Long requestId, final Integer officeId, final String transactionDate, + final String comments, final String currencyCode, final String debits, final String credits) { + return createJournalEntryRequest(requestId, officeId, transactionDate, comments, currencyCode, debits, credits, null); + } + + /** + * Creates and returns a {@link org.apache.fineract.batch.command.internal.CreateJournalEntryCommandStrategy} + * Request for creating a journal entry in batch with optional command parameter. + * + * @param requestId + * the request ID + * @param officeId + * the office ID + * @param transactionDate + * the transaction date (e.g., "08 January 2026") + * @param comments + * the journal entry comments + * @param currencyCode + * the currency code (e.g., "USD") + * @param debits + * JSON string for debits array (e.g., "[{\"glAccountId\":123,\"amount\":100}]") + * @param credits + * JSON string for credits array (e.g., "[{\"glAccountId\":456,\"amount\":100}]") + * @param command + * optional command parameter (e.g., "updateRunningBalance", "defineOpeningBalance") + * @return BatchRequest the batch request + */ + public static BatchRequest createJournalEntryRequest(final Long requestId, final Integer officeId, final String transactionDate, + final String comments, final String currencyCode, final String debits, final String credits, final String command) { + + final BatchRequest br = new BatchRequest(); + + br.setRequestId(requestId); + String relativeUrl = "v1/journalentries"; + if (command != null && !command.isEmpty()) { + relativeUrl += "?command=" + command; + } + br.setRelativeUrl(relativeUrl); + br.setMethod(HttpMethod.POST); + + final String body = "{\"officeId\":" + officeId + ",\"transactionDate\":\"" + transactionDate + + "\",\"dateFormat\":\"dd MMMM yyyy\",\"locale\":\"en\"," + "\"comments\":\"" + comments + "\",\"currencyCode\":\"" + + currencyCode + "\"," + "\"debits\":" + debits + ",\"credits\":" + credits + "}"; + + br.setBody(body); + + return br; + } } diff --git a/scripts/create-issue38-issues.sh b/scripts/create-issue38-issues.sh new file mode 100644 index 00000000000..a0435510e3d --- /dev/null +++ b/scripts/create-issue38-issues.sh @@ -0,0 +1,263 @@ +#!/bin/bash +# Script to create Issue 38 code-quality and architecture issues in andrew-nkhoma/fineract +# Run: bash scripts/create-issue38-issues.sh + +set -e + +REPO="andrew-nkhoma/fineract" + +echo "Creating Issue 38 code-quality and architecture issues in $REPO ..." + +# Issue 1 +gh issue create --repo "$REPO" \ + --title "Bug: getSessionSummary() queries transactions by cashier+date instead of sessionId" \ + --label "bug" \ + --body '## Problem + +`CashierSessionReadPlatformServiceImpl.getSessionSummary()` computes cash-in and cash-out totals using `cashier_id + session_date` as the filter. This means if two sessions exist for the same cashier on the same date (e.g., morning/afternoon shift), they share totals — producing wrong summary figures for both. + +### Code Reference +- File: `fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/CashierSessionReadPlatformServiceImpl.java` +- Method: `getSessionSummary()` — lines ~117–140 + +```java +final String cashInSql = "select coalesce(sum(ct.txn_amount), 0) from m_cashier_transactions ct " + + "where ct.cashier_id = ? and ct.txn_date >= ? and ct.txn_type in (1, 101, 102)"; +// ❌ Filter is by cashier + date, not by cashier_session_id +``` + +### Fix Required +- Add a `cashier_session_id` column to `m_cashier_transactions` (see Issue #5 for loan/savings FKs) +- Rewrite the cash-in/cash-out SQL to filter by `cashier_session_id` instead of cashier+date +- Update `CashierTransaction.java` JPA entity to include `@ManyToOne CashierSession cashierSession` +- Populate `cashier_session_id` when creating transactions inside an active session + +### Depends On +- Enhancement: Add `m_cashier_sessions` table (#4)' + +echo "✅ Issue 1 created" + +# Issue 2 +gh issue create --repo "$REPO" \ + --title "Bug: closeSession() variance calculation uses incorrect transaction type IDs and is not session-scoped" \ + --label "bug" \ + --body '## Problem + +`CashierSessionWritePlatformServiceImpl.closeSession()` calculates variance using `CashierTxnType.INWARD_CASH_TXN.getId()` and `OUTWARD_CASH_TXN.getId()` as the `txnType` filter. These IDs are `103` and `104`. But `getSessionSummary()` in the read service uses `txn_type in (1, 101, 102)` for cash-in and `(2, 201, 202)` for cash-out — completely inconsistent sets. Additionally, both queries filter by `cashier_id + date`, not by `cashier_session_id`, so multi-session days produce wrong variance. + +### Code References + +**Write service (closeSession):** +- File: `fineract-provider/.../service/CashierSessionWritePlatformServiceImpl.java` lines ~130–145 +```java +final BigDecimal sumCashIn = cashierTransactionRepository + .sumAmountByCashierAndTxnTypeAndDate(cashierId, CashierTxnType.INWARD_CASH_TXN.getId(), sessionDate); // txnType = 103 +``` + +**Read service (getSessionSummary):** +- File: `fineract-provider/.../service/CashierSessionReadPlatformServiceImpl.java` lines ~119–122 +```java +"where ct.cashier_id = ? and ct.txn_date >= ? and ct.txn_type in (1, 101, 102)" // completely different IDs! +``` + +### Fix Required +- Agree on a single canonical set of `txn_type` IDs for cash-in vs cash-out +- Make both `closeSession()` and `getSessionSummary()` use `cashier_session_id` as the filter (not cashier+date) +- Add `sumAmountBySessionAndTxnType()` method to `CashierTransactionRepository` + +### Depends On +- Bug: Fix `getSessionSummary()` to use sessionId (#N)' + +echo "✅ Issue 2 created" + +# Issue 3 +gh issue create --repo "$REPO" \ + --title "Bug/Cleanup: Remove dead entityType code block from doTransactionForCashier()" \ + --label "bug" \ + --body '## Problem + +`TellerWritePlatformServiceJpaImpl.doTransactionForCashier()` contains a block of ~20 lines of commented-out code that is explicitly marked with `// TODO: can we please remove this whole block?!?`. The code does nothing — every branch is either empty or commented out. It reads `entityType` from the command but then performs no validation whatsoever. + +### Code Reference +- File: `fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java` +- Lines ~376–400 + +```java +// TODO: can we please remove this whole block?!? this is 20 lines of dead code!!! +final String entityType = command.stringValueOfParameterNamed("entityType"); +if (entityType != null) { + if (entityType.equals("loan account")) { + // TODO : Check if loan account exists + // LoanAccount loan = null; + // if (loan == null) { throw new LoanAccountFoundException(entityId); } + } else if (entityType.equals("savings account")) { + // TODO : Check if loan account exists + // SavingsAccount savingsaccount = null; + } + if (entityType.equals("client")) { + // TODO: Check if client exists + } else { + // TODO : Invalid type handling + } +} +``` + +### Fix Required +- Delete the entire `entityType` block from `doTransactionForCashier()` +- If entity validation is genuinely needed in the future, implement it properly (not as dead commented code)' + +echo "✅ Issue 3 created" + +# Issue 4 +gh issue create --repo "$REPO" \ + --title "Enhancement: allocateCashToCashier() is missing the cashier transaction data validation that settleCashFromCashier() performs" \ + --label "enhancement" \ + --body '## Problem + +`settleCashFromCashier()` calls `this.cashierTransactionDataValidator.validateSettleCashAndCashOutTransactions(cashierId, command)` before delegating to `doTransactionForCashier()`. But `allocateCashToCashier()` calls `doTransactionForCashier()` directly with **no pre-validation**. Both operations should validate the cashier has sufficient balance / active session before proceeding. + +### Code Reference +- File: `fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java` + +```java +// allocateCashToCashier — no pre-validation: +@Override +public CommandProcessingResult allocateCashToCashier(final Long cashierId, JsonCommand command) { + return doTransactionForCashier(cashierId, CashierTxnType.ALLOCATE, command); // ❌ no validator call +} + +// settleCashFromCashier — has pre-validation: +@Override +public CommandProcessingResult settleCashFromCashier(final Long cashierId, JsonCommand command) { + this.cashierTransactionDataValidator.validateSettleCashAndCashOutTransactions(cashierId, command); // ✅ + return doTransactionForCashier(cashierId, CashierTxnType.SETTLE, command); +} +``` + +### Fix Required +- Add a corresponding validator method `validateAllocateCashTransactions(cashierId, command)` to `CashierTransactionDataValidator` +- Call it from `allocateCashToCashier()` before delegating to `doTransactionForCashier()` +- The validation should at minimum check: cashier exists, cashier assignment is not expired, currency code is valid' + +echo "✅ Issue 4 created" + +# Issue 5 +gh issue create --repo "$REPO" \ + --title "Bug: CashierWritePlatformService stub returns null for all operations — either implement or delete" \ + --label "bug" \ + --body '## Problem + +`fineract-branch/.../service/CashierWritePlatformService.java` is a concrete class (not an interface) with three methods that all return `null` and contain `// TODO Auto-generated method stub` comments. It is not wired up to any handler, not injected anywhere, and its presence alongside the working `TellerWritePlatformService` creates confusion about which class is the correct one to use. + +### Code Reference +- File: `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/CashierWritePlatformService.java` + +```java +public class CashierWritePlatformService { + + public CommandProcessingResult allocateCashierToTeller(JsonCommand command) { + // TODO Auto-generated method stub + return null; + } + + public CommandProcessingResult deleteCashier(Long entityId) { + // TODO Auto-generated method stub + return null; + } + + public CommandProcessingResult modifyCashier(Long entityId, JsonCommand command) { + // TODO Auto-generated method stub + return null; + } +} +``` + +### Fix Required +**Option A (preferred):** Delete `CashierWritePlatformService.java` entirely if it is not intended to be used — all cashier write operations are already handled by `TellerWritePlatformServiceJpaImpl`. + +**Option B:** If a dedicated cashier service is planned, convert it to a proper interface, create an implementation, and wire it into the relevant command handlers.' + +echo "✅ Issue 5 created" + +# Issue 6 +gh issue create --repo "$REPO" \ + --title "Enhancement: CashierSessionApiResource endpoints have no @Permission checks" \ + --label "enhancement" \ + --body '## Problem + +All endpoints in `CashierSessionApiResource.java` (open session, close session, get active session, list sessions, get summary, branch dashboard) call service methods directly without any `context.authenticatedUser()` permission check or `@Permission` annotation. Any authenticated user can open, close, or view any cashier session in any office. + +### Code Reference +- File: `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/CashierSessionApiResource.java` + +The `openSession()` endpoint passes the `currencyCode` directly from the request body and delegates to `commandsSourceWritePlatformService`. But read endpoints like `getActiveSession()`, `getAllSessions()`, `getSessionSummary()`, and `getBranchDashboard()` call read services directly with no office-hierarchy or role check. + +### Fix Required +- Add `context.authenticatedUser()` at the top of each read endpoint method +- Validate that the requesting user'"'"'s office is in the hierarchy of the teller'"'"'s office (reuse `validateUserPriviledgeOnTellerAndRetrieve()` pattern from `TellerWritePlatformServiceJpaImpl`) +- Add explicit permission codes (e.g., `READ_CASHIERSESSION`, `CREATE_CASHIERSESSION`, `CLOSE_CASHIERSESSION`) to the permission table and reference them in a `@Permission` annotation or platform security check' + +echo "✅ Issue 6 created" + +# Issue 7 +gh issue create --repo "$REPO" \ + --title "Enhancement: Replace hardcoded txn_type integers in getSessionSummary SQL with named constants" \ + --label "enhancement" \ + --body '## Problem + +`CashierSessionReadPlatformServiceImpl.getSessionSummary()` uses raw integer literals `(1, 101, 102)` and `(2, 201, 202)` in SQL to identify cash-in and cash-out transaction types. These numbers have no documentation and do not correspond to the `CashierTxnType` constants (`ALLOCATE=101`, `SETTLE=102`, `INWARD_CASH_TXN=103`, `OUTWARD_CASH_TXN=104`). The values `1` and `2` appear to refer to loan/savings transaction type enums from unrelated tables — this mix is undocumented and fragile. + +### Code Reference +- File: `fineract-provider/.../service/CashierSessionReadPlatformServiceImpl.java` lines ~119–122 + +```java +final String cashInSql = "select coalesce(sum(ct.txn_amount), 0) from m_cashier_transactions ct " + + "where ct.cashier_id = ? and ct.txn_date >= ? and ct.txn_type in (1, 101, 102)"; + +final String cashOutSql = "select coalesce(sum(ct.txn_amount), 0) from m_cashier_transactions ct " + + "where ct.cashier_id = ? and ct.txn_date >= ? and ct.txn_type in (2, 201, 202)"; +// ❌ What are 1, 2, 201, 202? Undocumented. Inconsistent with CashierTxnType enum. +``` + +### Fix Required +- Define the canonical set of `txnType` IDs that represent "cash in" and "cash out" on the cashier transaction table +- Replace literals with `CashierTxnType.INWARD_CASH_TXN.getId()` / `CashierTxnType.OUTWARD_CASH_TXN.getId()` (or a dedicated helper) +- Add a code comment explaining which transaction types map to cash-in vs cash-out +- Cross-check with `TellerManagementReadPlatformServiceImpl.cashierTxnSummarySchema()` which uses the same magic numbers for consistency' + +echo "✅ Issue 7 created" + +# Issue 8 +gh issue create --repo "$REPO" \ + --title "Enhancement: Make summaryandtransactions endpoint session-aware (filter by cashier_session_id)" \ + --label "enhancement" \ + --body '## Problem + +The `GET /tellers/{id}/cashiers/{cashierId}/summaryandtransactions` endpoint returns a combined summary + transaction list for a cashier, but it aggregates **all** transactions for the cashier in the given currency, regardless of session. Once session-based tracking is in place (Issue #4, #5), callers need to be able to request the summary and transactions for a **specific session**, not just for the entire cashier history. + +### Current Behaviour +`summaryandtransactions` returns totals computed from all `m_cashier_transactions` for the cashier in the given currency code and date range. There is no way to isolate a single session'"'"'s figures. + +### Required Change + +**Option A:** Add an optional `sessionId` query parameter to the existing endpoint: +``` +GET /tellers/{id}/cashiers/{cashierId}/summaryandtransactions?sessionId=42&currencyCode=ZMK +``` + +**Option B:** Use the new session summary endpoint (`GET /tellers/{id}/cashiers/{cashierId}/sessions/{sId}/summary`) as the canonical session-scoped view, and document that `summaryandtransactions` is the legacy all-time view. + +### Deliverables +- Decision documented in code comment or API description +- If Option A: add `@QueryParam("sessionId")` and filter SQL by `cashier_session_id` when provided +- If Option B: mark `summaryandtransactions` as deprecated in the OpenAPI annotation + +### Depends On +- Enhancement: Add `m_cashier_sessions` table (#4) +- Enhancement: Add `cashier_session_id` FK to transaction tables (#5) +- Bug: Fix `getSessionSummary()` to use sessionId (Issue #1 in this script)' + +echo "✅ Issue 8 created" + +echo "" +echo "🎉 All 8 issues created in $REPO" diff --git a/scripts/create-teller-issues.sh b/scripts/create-teller-issues.sh new file mode 100755 index 00000000000..bcc68ec70d0 --- /dev/null +++ b/scripts/create-teller-issues.sh @@ -0,0 +1,476 @@ +#!/bin/bash +# Script to create Teller Cash Management issues in andrew-nkhoma/fineract +# Run: bash scripts/create-teller-issues.sh + +set -e + +REPO="andrew-nkhoma/fineract" + +echo "Creating Teller Cash Management issues in $REPO ..." + +# Issue 1 +gh issue create --repo "$REPO" \ + --title "Bug: PUT /tellers drops debitAccountId and creditAccountId" \ + --label "bug" \ + --body '## Problem + +The `Teller.update()` method in `Teller.java` does not handle updating `debitAccountId` and `creditAccountId` from a PUT payload. Requests to update these fields via `PUT /tellers/{id}` are silently ignored, even though the fields exist in the database (`debit_account_id`, `credit_account_id` on `m_tellers`) and on the JPA entity. + +### Code Reference +- File: `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Teller.java` +- Method: `update()` — lines 94–153 +- The method handles: `officeId`, `name`, `description`, `startDate`, `endDate`, `status` +- ❌ Never reads or persists `debitAccountId` or `creditAccountId` + +```java +// In Teller.update(), only these fields are handled: +officeId, name, description, startDate, endDate, status +// ❌ debitAccountId and creditAccountId are completely absent +``` + +### Workaround +Direct SQL only: +```sql +UPDATE m_tellers SET debit_account_id = ?, credit_account_id = ? WHERE id = ?; +``` + +### Fix Required +- Update `Teller.update()` to read and persist `debitAccountId` and `creditAccountId` (look up via `GLAccountRepository`) +- Update `TellerCommandFromApiJsonDeserializer` to allow and validate these fields +- Update the PUT request DTO to include these fields + +### Spec Reference +Spec §2.5 — API Bugs Discovered, Phase 2' + +echo "✅ Issue 1 created" + +# Issue 2 +gh issue create --repo "$REPO" \ + --title "Bug: fromDate/toDate query parameters are ignored on cashier transactions endpoints" \ + --label "bug" \ + --body '## Problem + +The endpoints `/tellers/{id}/cashiers/{cashierId}/transactions` and `/tellers/{id}/cashiers/{cashierId}/summaryandtransactions` accept `currencyCode` but completely ignore `fromDate` and `toDate` — they are not even declared as `@QueryParam` in the API resource. The SQL always uses the cashier assignment dates (`c.start_date` / `c.end_date`) regardless of what is passed in the URL. + +### Code References + +**API layer — `fromDate`/`toDate` not declared as `@QueryParam`:** +- File: `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/TellerApiResource.java` +- Method: `getTransactionsForCashier()` — `fromDate`/`toDate` are absent from the method signature + +**Service layer — params never bound into SQL:** +- File: `fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java` +- Method: `retrieveCashierTransactions()` — lines 314–354 +- `fromDate` and `toDate` exist in the method signature but are **never referenced** in the SQL string or params array: + +```java +// Params array — fromDate/toDate completely absent: +Object[] params = new Object[] { cashierId, currencyCode, cashierId, currencyCode, cashierId, currencyCode, cashierId, currencyCode }; +``` + +**SQL always uses cashier assignment dates:** +```java +"and sav_txn.transaction_date between c.start_date and " + nextDay // c.start_date / c.end_date — never the caller'"'"'s dates +"and loan_txn.transaction_date between c.start_date and " + nextDay +``` + +### Example +Calling: +``` +GET /tellers/1/cashiers/1/transactions?currencyCode=ZMK&fromDate=01 March 2026&toDate=31 March 2026&dateFormat=dd MMMM yyyy&locale=en +``` +Returns the same result as calling with `currencyCode=ZMK` only. The date range parameters do nothing. + +### Impact +It is impossible to narrow a cashier'"'"'s transactions to a sub-range of their assignment period. All queries always return the full assignment window. + +### Fix Required +- Add `@QueryParam("fromDate")` and `@QueryParam("toDate")` (with `@QueryParam("dateFormat")` and `@QueryParam("locale")`) to `getTransactionsForCashier()` and `getTransactionsWithSummaryForCashier()` in `TellerApiResource.java` +- Pass the parsed dates to the service method +- Bind them into the SQL WHERE clauses, replacing `c.start_date`/`c.end_date` when provided + +### Spec Reference +Spec §2.5 — API Bugs Discovered, Phase 2' + +echo "✅ Issue 2 created" + +# Issue 3 +gh issue create --repo "$REPO" \ + --title "Bug: m_cashiers unique constraint (staff_id, teller_id) prevents cashier re-assignment to the same teller" \ + --label "bug" \ + --body '## Problem + +The `m_cashiers` table enforces a unique constraint on `(staff_id, teller_id)`, meaning a staff member can only ever be assigned to a given teller **once in the database'"'"'s lifetime**. Re-assigning the same cashier to the same teller on a new date period is impossible without deleting the old record first. + +### Code Reference +- File: `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Cashier.java` +- Lines 48–51: + +```java +@Table(name = "m_cashiers", uniqueConstraints = { + @UniqueConstraint(name = "ux_cashiers_staff_teller", columnNames = { "staff_id", "teller_id" }) }) +``` + +### Real-world Impact +If Jasinta is assigned to Teller 1 for March, she cannot be assigned to Teller 1 again in April without deleting the March assignment. This blocks normal shift rotation in any real MFI environment. + +### Fix Required +- Drop the unique constraint `ux_cashiers_staff_teller` from `m_cashiers` +- Provide a Liquibase migration to remove the constraint +- Rely on the existing date-overlap check in `CashierTransactionDataValidator.validateCashierAllowedDateAndTime()` as the only guard against duplicate active assignments + +### Spec Reference +Spec §2.1 — Data Model Limitations, Phase 2' + +echo "✅ Issue 3 created" + +# Issue 4 +gh issue create --repo "$REPO" \ + --title "Enhancement: Add m_cashier_sessions table and JPA entity" \ + --label "enhancement" \ + --body '## Feature: Cashier Session as a First-Class Entity + +Introduce a `m_cashier_sessions` table and corresponding JPA entity. This is the **foundational change** for the entire session-aware teller redesign. All subsequent issues (GL routing, transaction isolation, session API endpoints) depend on this. + +### Background +The current teller module tracks transactions by user and date range, not by session. There is no concept of a "session" — a cashier opens, processes transactions, and settles, but none of that is recorded as a discrete event with a start/end boundary. This causes reconciliation failures in multi-cashier, rotating-shift environments (see Spec §2.4). + +### Schema +```sql +CREATE TABLE m_cashier_sessions ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + cashier_id BIGINT NOT NULL, + teller_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + office_id BIGINT NOT NULL, + session_date DATE NOT NULL, + opened_at TIMESTAMP NOT NULL, + closed_at TIMESTAMP NULL, + opening_allocation DECIMAL(19,6) NOT NULL DEFAULT 0, + total_settled DECIMAL(19,6) NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL, -- OPEN, SETTLED, CLOSED + opening_txn_id BIGINT NULL, + closing_txn_id BIGINT NULL, + currency_code VARCHAR(3) NOT NULL, + created_by BIGINT NOT NULL, + created_date TIMESTAMP NOT NULL +); +``` + +### Deliverables +- Liquibase changeset for `m_cashier_sessions` +- `CashierSession.java` JPA entity +- `CashierSessionRepository.java` +- `CashierSessionStatus` enum: `OPEN`, `SETTLED`, `CLOSED` + +### Business Rules (from Spec §3.5.1) +- A cashier can only have ONE open session per day per teller +- A user cannot open a new session if they have an unsettled session from a previous day + +### Depends On +- Issue: Relax or remove `m_cashiers` unique constraint (#3) + +### Spec Reference +Spec §3.2.1, §3.3 — New Data Model, Phase 3' + +echo "✅ Issue 4 created" + +# Issue 5 +gh issue create --repo "$REPO" \ + --title "Enhancement: Add cashier_session_id FK to m_loan_transactions and m_savings_account_transaction" \ + --label "enhancement" \ + --body '## Feature: Link Transactions to Cashier Sessions + +Add a `cashier_session_id` nullable foreign key column to both `m_loan_transactions` and `m_savings_account_transaction`. This is what enables true per-session transaction isolation and accurate session-level reconciliation. + +### Background +Currently, loan repayments and savings transactions have **no linkage to a teller or cashier session**. They are only linked to the `m_appuser` who created them. This means: +- Transaction queries are by user + date range, not by session +- Rotating cashiers see each other'"'"'s transactions if date ranges overlap +- The teller summary screen is a user activity report, not a GL position + +### Schema +```sql +ALTER TABLE m_loan_transactions + ADD COLUMN cashier_session_id BIGINT NULL, + ADD CONSTRAINT fk_loan_txn_cashier_session + FOREIGN KEY (cashier_session_id) REFERENCES m_cashier_sessions(id); + +ALTER TABLE m_savings_account_transaction + ADD COLUMN cashier_session_id BIGINT NULL, + ADD CONSTRAINT fk_sav_txn_cashier_session + FOREIGN KEY (cashier_session_id) REFERENCES m_cashier_sessions(id); +``` + +### Deliverables +- Liquibase changeset for both ALTER statements +- Update `LoanTransaction.java` JPA entity to include `@ManyToOne CashierSession cashierSession` +- Update `SavingsAccountTransaction.java` JPA entity similarly + +### Depends On +- Issue: Add `m_cashier_sessions` table and JPA entity (#4) + +### Spec Reference +Spec §3.2.2 — Alter existing tables, Phase 3' + +echo "✅ Issue 5 created" + +# Issue 6 +gh issue create --repo "$REPO" \ + --title "Enhancement: Implement Cashier Session lifecycle API endpoints" \ + --label "enhancement" \ + --body '## Feature: Cashier Session API Endpoints + +Implement the full cashier session lifecycle via new REST API endpoints. These replace the implicit user-tracking approach with explicit session management. + +### New Endpoints Required + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/tellers/{id}/cashiers/{cId}/sessions` | Open a new cashier session | +| `GET` | `/tellers/{id}/cashiers/{cId}/sessions/active` | Get the currently open session | +| `GET` | `/tellers/{id}/cashiers/{cId}/sessions` | List all sessions (paginated) | +| `POST` | `/tellers/{id}/cashiers/{cId}/sessions/{sId}/close` | Close session, post variance entry if applicable | +| `GET` | `/tellers/{id}/cashiers/{cId}/sessions/{sId}/summary` | Full session summary with GL reconciliation | +| `GET` | `/users/{id}/session/active` | Resolve active session for logged-in user | +| `GET` | `/tellers/branch/{officeId}/dashboard` | Supervisor view — all open sessions and positions | + +### Session Lifecycle (Spec §3.3) + +| Step | Action | GL Entry | +|------|--------|----------| +| 1 | Open Session | None — record created | +| 2 | Allocate Cash | DR 11140 Teller Cash / CR 11130 Vault | +| 3 | Process Transactions | Auto-linked to open session | +| 4 | Settle Cash | DR 11130 Vault / CR 11140 Teller Cash | +| 5 | Close Session | Variance journal if applicable | + +### Business Rules (Spec §3.5.1) +- One open session per cashier per day per teller +- Cannot open if prior day has unsettled session +- Allocations only on OPEN sessions +- Settlements only on sessions with at least one allocation + +### Transaction Blocking Rules (Spec §3.5.2) + +| Transaction Type | OPEN | SETTLED | No Session | +|-----------------|------|---------|------------| +| Cash Repayment | ✅ Allow | ⚠ Warn | ✅ Allow (vault) | +| Cash Disbursement | ✅ Allow | 🚫 Block | ✅ Allow (bank) | +| Bank/Mobile Wallet | ✅ Allow | ✅ Allow | ✅ Allow | +| New Cash Allocation | ✅ Allow (top-up) | 🚫 Block | 🚫 Block | +| Settlement | ✅ Allow | ✅ Allow | 🚫 Block | + +### Deliverables +- `CashierSessionApiResource.java` +- `CashierSessionWritePlatformService.java` + implementation +- `CashierSessionReadPlatformService.java` + implementation +- Command wrapper additions in `CommandWrapperBuilder` +- `CashierSessionData.java` DTO +- `CashierSessionSummaryData.java` DTO + +### Depends On +- Issue: Add `m_cashier_sessions` table (#4) +- Issue: Add `cashier_session_id` FK to transaction tables (#5) + +### Spec Reference +Spec §3.3, §4 — Session Lifecycle + API Endpoints, Phase 4' + +echo "✅ Issue 6 created" + +# Issue 7 +gh issue create --repo "$REPO" \ + --title "Bug/Enhancement: Route cash GL through Teller Cash (11140) when a cashier session is open" \ + --label "bug" --label "enhancement" \ + --body '## Problem + Feature: GL Routing for Cash Transactions + +Currently, all cash repayments and disbursements post to `11130 Cash on Hand (Vault)` regardless of whether a cashier session is active. They should route through `11140 Teller Cash` when an active session exists. + +### Current Behaviour (Wrong) +``` +-- Cash repayment with active cashier session: +DEBIT 11130 Cash on Hand (Vault) ← wrong, bypasses teller +CREDIT 11210 Gross Loan Portfolio +``` + +### Expected Behaviour +``` +-- Cash repayment with active cashier session: +DEBIT 11140 Teller Cash ← correct, routes through cashier drawer +CREDIT 11210 Gross Loan Portfolio + +-- No active session (fallback): +DEBIT 11130 Cash on Hand (Vault) ← correct fallback +CREDIT 11210 Gross Loan Portfolio +``` + +### Files to Update +- `fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorForLoan.java` +- `fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorForSavings.java` + +### Required Logic (Spec §3.4) +```java +if (paymentDetail.isCashPayment()) { + CashierSession activeSession = cashierSessionRepository + .findOpenSessionByUser(currentUser.getId(), officeId, transactionDate); + + if (activeSession != null) { + // Route through teller cash account (Financial Activity 102 = cashAtTeller) + glAccount = financialActivityAccountRepository + .findByFinancialActivity(CASH_AT_TELLER); // 11140 + transaction.setCashierSessionId(activeSession.getId()); + } else { + // Fall back to vault (Financial Activity 101 = cashAtMainVault) + glAccount = financialActivityAccountRepository + .findByFinancialActivity(CASH_AT_MAIN_VAULT); // 11130 + } +} +``` + +### Financial Activity Mapping Required +| ID | Activity | GL Code | Account Name | +|----|----------|---------|--------------| +| 101 | cashAtMainVault | 11130 | Cash on Hand (Vault) | +| 102 | cashAtTeller | 11140 | Teller Cash — Head Office | + +### Depends On +- Issue: Add `m_cashier_sessions` table (#4) +- Issue: Add `cashier_session_id` FK to transaction tables (#5) +- Issue: Implement Session API endpoints (#6) + +### Spec Reference +Spec §2.3, §3.4 — GL Routing Fix, Phase 5' + +echo "✅ Issue 7 created" + +# Issue 8 +gh issue create --repo "$REPO" \ + --title "Enhancement: Auto-post GL variance journal entry on unbalanced settlement" \ + --label "enhancement" \ + --body '## Feature: Variance Journal Entry on Settlement + +When a cashier settles and the amount returned does not equal the expected cash on hand, the system must automatically calculate and post a correcting GL journal entry. + +### Variance Formula (Spec §3.5.3) +``` +Expected Cash = Opening Allocation + Cash In − Cash Out +Variance = Settled Amount − Expected Cash +``` + +### GL Entries + +**Short settlement (cashier returns less than expected):** +```sql +-- Cashier owes the difference: +DEBIT 53920 Cash Shortage — Teller [variance amount] +CREDIT 11140 Teller Cash — Head Office [variance amount] +``` + +**Over settlement (cashier returns more than expected):** +```sql +-- Cashier returned too much: +DEBIT 11140 Teller Cash — Head Office [variance amount] +CREDIT 43210 Miscellaneous Income [variance amount] +``` + +### GL Accounts Required +| GL Code | Account Name | Type | +|---------|-------------|------| +| 53920 | Cash Shortage — Teller | Expense — Detail | +| 43210 | Miscellaneous Income | Income — Detail | + +### Business Rules +- If variance ≠ 0, a mandatory supervisor note is required before settlement is accepted +- The correcting journal must be auto-posted as part of the session close flow +- Both overage and shortage must be handled + +### Implementation Location +- `TellerWritePlatformServiceJpaImpl.java` — `settleCashFromCashier()` method +- Or new `CashierSessionWritePlatformServiceImpl.java` session close handler + +### Depends On +- Issue: Implement Session API endpoints (#6) +- Issue: Fix GL routing through Teller Cash (#7) + +### Spec Reference +Spec §3.5.3 — Variance Handling, Phase 7' + +echo "✅ Issue 8 created" + +# Issue 9 +gh issue create --repo "$REPO" \ + --title "Bug: Cashier assignment expiry causes silent failure with no notification" \ + --label "bug" \ + --body '## Problem + +When a cashier'"'"'s `end_date` on `m_cashiers` is reached, operations fail silently. No notification is sent, no warning is shown, and errors returned to users are unclear. Cashiers simply stop being able to operate without understanding why. + +### Current Behaviour +- Cashier assignment expires silently at midnight on `end_date` +- Subsequent allocation, repayment, or settlement calls fail without a helpful error +- No API response distinguishes "cashier assignment expired" from other errors +- No advance warning when expiry is approaching + +### Files Involved +- `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/Cashier.java` +- `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java` — allocation/settlement handlers +- `fineract-branch/src/main/java/org/apache/fineract/organisation/teller/api/TellerApiResource.java` + +### Fix Required +- On any teller operation (allocate, transact, settle), check if `cashier.getEndDate()` is in the past and return a clear `422 Unprocessable Entity` with message: `"Cashier assignment for [name] expired on [date]. Please renew the assignment."` +- On cashier retrieval (`GET /tellers/{id}/cashiers/{cId}`), include an `expiryWarning` flag in the response if `end_date` is within 7 days +- Optional: add a scheduled job or startup check to flag assignments expiring within 7 days + +### Spec Reference +Spec §7 — Known Limitations, Phase 9' + +echo "✅ Issue 9 created" + +# Issue 10 +gh issue create --repo "$REPO" \ + --title "Enhancement: Multi-teller concurrent session support" \ + --label "enhancement" \ + --body '## Feature: Allow Cashier to Hold Concurrent Sessions on Multiple Tellers + +The proposed session model (Issue #6) assumes one active session per cashier per day. To support cashiers rotating between multiple teller stations within the same day, the session model needs to permit concurrent sessions on different tellers. + +### Background +In some MFI branch configurations, a staff member may operate at multiple teller windows in a single day. Under the current session model (one-session-per-cashier-per-day), this is not supported. + +### Required Changes + +**Session uniqueness rule change:** +- Current proposed rule: one open session per cashier per day +- New rule: one open session per cashier **per teller** per day + +**Session lookup change:** +```java +// Current (to be built in Issue #6): +cashierSessionRepository.findOpenSessionByUser(userId, officeId, date); + +// Required (this issue): +cashierSessionRepository.findOpenSessionByUserAndTeller(userId, tellerId, officeId, date); +``` + +**GL routing change:** +The teller cash account (11140) will need to be resolved per session, not just per user, since different tellers may have different GL accounts configured. + +### Deliverables +- Update `CashierSessionRepository.findOpenSessionByUser()` to accept `tellerId` +- Update session open validation to check uniqueness per `(cashier_id, teller_id, session_date)` +- Update GL routing in `AccountingProcessorForLoan` and `AccountingProcessorForSavings` to resolve teller account from the active session'"'"'s `teller_id` +- Add `teller_id` index on `m_cashier_sessions` + +### Depends On +- Issue: Add `m_cashier_sessions` table (#4) +- Issue: Add `cashier_session_id` FK to transaction tables (#5) +- Issue: Session API endpoints (#6) +- Issue: GL routing fix (#7) + +### Spec Reference +Spec §8 — Implementation Plan Phase 10' + +echo "✅ Issue 10 created" + +echo "" +echo "🎉 All 10 Teller Cash Management issues created in $REPO"