From 7b913a06c988902a9dbf294b3b6b10ad0df4732d Mon Sep 17 00:00:00 2001 From: Sudhanshu Kumar Roy <153078686+SkroyCodes@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:21:35 +0530 Subject: [PATCH 1/4] docs: add initial understanding and AI usage note --- AI_USAGE.md | 11 ++++++ APPROACH.md | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 AI_USAGE.md create mode 100644 APPROACH.md diff --git a/AI_USAGE.md b/AI_USAGE.md new file mode 100644 index 00000000..4a023059 --- /dev/null +++ b/AI_USAGE.md @@ -0,0 +1,11 @@ +# AI Usage Note + +## Tool Used + +ChatGPT + +## How I Used AI + +I used ChatGPT as a reviewing assistant to structure my initial approach around idempotency, transaction boundaries, database design, and testing scenarios. + +I did not use AI to blindly generate the full solution. I used it to reason about edge cases and organize my implementation approach. \ No newline at end of file diff --git a/APPROACH.md b/APPROACH.md new file mode 100644 index 00000000..0896eeda --- /dev/null +++ b/APPROACH.md @@ -0,0 +1,98 @@ +# Initial Understanding and First Approach Ideas + +## Problem: + +We have to ensure wallet to wallet transfering of funds without repetition, handling and reporting failure and with balance correctness. + +## Approach: + +### The Point of concerns: + +1. Preventing duplicate transfers when same idempotencyKey is retried. +2. Double ledger entries for each transaction: one debit and one credit +3. Keeping change in balance correct under concurrent transfer requests. +4. Handling transfer states safely using PENDING, PROCESSED and FAILED. +5. Multiple Transfers from single wallet should happen sequentially (no 2 debits at once). +6. Avoid concurrent attempt to debit from single wallet (one after another transaction flow). + +## First thought of approach: + +### A: Successful Transaction + +1. Validate incoming API Request +2. Check an idempotency record using provided idempotencyKey. +3. Start a database transaction. +4. Create a transfer Record with Pending Status +5. Lock or safely update the wallet rows to prevent concurrent attempts of debit. +6. Validate the amount of debit availability in wallet. +7. Debit from source wallet and Credit to Destination wallet. +8. insert debit and credit ledger entries. +9. Mark transfer Record as Processed status. +10. Store final idempotency result. +11. Commit Transaction + +### B: Failed Transaction + +1. Validate field request before starting the transfer +2. If request is invalid, return a validation error. +3. Check an idempotency record using provided idempotencyKey. +4. Start a database transaction. +5. Create a transfer record with status PENDING. +6. Lock the wallet rows to prevent concurrent attempts of debit. +7. Validate wallet existence and source wallet balance inside the transaction. +8. If the transfer cannot be completed, mark the transfer as FAILED. +9. Do not debit/credit wallet balances. +10. Do not create successful debit/credit ledger entries. +11. Business Logic error: Commit Transaction for FAILED records (low balance, wallet not found....). +12. For Technical errors: rollback the transaction so partial wallet updates or ledger entries are not persisted. + +## Database Design Direction: + +I am considering PostgreSQL tables such as: + +* wallets +* transfers +* ledger_entries +* idempotency_records + +### A: wallets: + +1. wallet_id as primary key +2. wallet.balance always non-negative. + +### B: transfers + +1. transfer_id as primary key +2. source_wallet_id and destination_wallet_id as foreign keys to wallets. +3. transfers.status can only have PENDING, PROCESSED, FAILED. +4. Indexes on: source_wallet_id and destination_wallet_id + +### C: ledger_entries + +1. entry_id as primary key +2. transfer_id as foreign key to transfers. +3. wallet_id as foreign key to wallets. +4. transaction_type can only have DEBIT and CREDIT. +5. Index on: transfer_id + +### D: idempotency_records: + +The idempotency_records table will help make the API retry-safe by storing the idempotencyKey, processing status + +1. idempotency_record_id as primary key +2. idempotencyKey as unique entry +3. status can only have IN_PROGRESS, COMPLETED, and FAILED. +4. transfer_id as foreign key to transfers. +5. Index on: idempotencyKey + +Common to all entities/table: createdAt/updatedAt as date-time + +## Testing Strategies: + +1. All correct data request for Successful order. +2. Invalid wallet scenario. +3. duplicate request with same idempotencyKey. +4. Ledger entry validation. +5. Insufficient balance case. +6. Concurrent transfer attempts from the same wallet. +7. Retry-safe behaviour where a request is repeated after simulated timeout or duplicate delivery. From c2b3ee5fcfdbb62d601ed39237f475910b00b299 Mon Sep 17 00:00:00 2001 From: Sudhanshu Kumar Roy <153078686+SkroyCodes@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:24:56 +0530 Subject: [PATCH 2/4] docs: update initial understanding and Approach note --- APPROACH.md | 63 ++++++++++++++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/APPROACH.md b/APPROACH.md index 0896eeda..5b9acf2b 100644 --- a/APPROACH.md +++ b/APPROACH.md @@ -2,14 +2,14 @@ ## Problem: -We have to ensure wallet to wallet transfering of funds without repetition, handling and reporting failure and with balance correctness. +We need to ensure wallet-to-wallet transferring of funds without duplication, correctly handling/reporting failures, and maintaining correct balances. ## Approach: -### The Point of concerns: +### Points of concern: 1. Preventing duplicate transfers when same idempotencyKey is retried. -2. Double ledger entries for each transaction: one debit and one credit +2. Double ledger entries for each transaction: one debit and one credit. 3. Keeping change in balance correct under concurrent transfer requests. 4. Handling transfer states safely using PENDING, PROCESSED and FAILED. 5. Multiple Transfers from single wallet should happen sequentially (no 2 debits at once). @@ -19,32 +19,34 @@ We have to ensure wallet to wallet transfering of funds without repetition, hand ### A: Successful Transaction -1. Validate incoming API Request -2. Check an idempotency record using provided idempotencyKey. -3. Start a database transaction. -4. Create a transfer Record with Pending Status -5. Lock or safely update the wallet rows to prevent concurrent attempts of debit. -6. Validate the amount of debit availability in wallet. -7. Debit from source wallet and Credit to Destination wallet. -8. insert debit and credit ledger entries. -9. Mark transfer Record as Processed status. -10. Store final idempotency result. -11. Commit Transaction +1. Validate incoming API request. +2. Insert/claim an idempotency record using provided idempotencyKey with UNIQUE constraint and status IN_PROGRESS. +3. If idempotencyKey already exists, return the stored result/status for COMPLETED/FAILED or return still processing response for IN_PROGRESS. +4. Start the wallet transfer database transaction. +5. Create a transfer Record with Pending Status. +6. Lock or safely update the wallet rows to prevent concurrent attempts of debit. +7. Validate the amount of debit availability in wallet. +8. Debit from source wallet and Credit to Destination wallet. +9. Insert debit and credit ledger entries. +10. Mark transfer Record as Processed status. +11. Update the idempotency record to COMPLETED and store final idempotency result. +12. Commit Transaction. ### B: Failed Transaction -1. Validate field request before starting the transfer +1. Validate field request before starting the transfer. 2. If request is invalid, return a validation error. -3. Check an idempotency record using provided idempotencyKey. -4. Start a database transaction. -5. Create a transfer record with status PENDING. -6. Lock the wallet rows to prevent concurrent attempts of debit. -7. Validate wallet existence and source wallet balance inside the transaction. -8. If the transfer cannot be completed, mark the transfer as FAILED. -9. Do not debit/credit wallet balances. -10. Do not create successful debit/credit ledger entries. -11. Business Logic error: Commit Transaction for FAILED records (low balance, wallet not found....). -12. For Technical errors: rollback the transaction so partial wallet updates or ledger entries are not persisted. +3. Insert/claim an idempotency record using provided idempotencyKey with UNIQUE constraint and status IN_PROGRESS. +4. If idempotencyKey already exists, return the stored result/status for COMPLETED/FAILED or return still processing response for IN_PROGRESS. +5. Start the wallet transfer database transaction. +6. Create a transfer record with status PENDING. +7. Lock the wallet rows to prevent concurrent attempts of debit. +8. Validate wallet existence and source wallet balance inside the transaction. +9. If the transfer cannot be completed, mark the transfer as FAILED. +10. Do not debit/credit wallet balances. +11. Do not create successful debit/credit ledger entries. +12. For expected business failures, commit the transaction for FAILED records after storing the failed idempotency result. +13. For unexpected technical errors, rollback the transaction so partial wallet updates or ledger entries are not persisted. ## Database Design Direction: @@ -58,7 +60,7 @@ I am considering PostgreSQL tables such as: ### A: wallets: 1. wallet_id as primary key -2. wallet.balance always non-negative. +2. wallet.balance always non-negative, enforced with a CHECK constraint such as balance >= 0. ### B: transfers @@ -77,15 +79,16 @@ I am considering PostgreSQL tables such as: ### D: idempotency_records: -The idempotency_records table will help make the API retry-safe by storing the idempotencyKey, processing status +The idempotency_records table will help make the API retry-safe by storing the idempotencyKey, processing status, transfer reference, and final result details needed to return the same result for duplicate retries. 1. idempotency_record_id as primary key -2. idempotencyKey as unique entry +2. idempotencyKey with UNIQUE constraint 3. status can only have IN_PROGRESS, COMPLETED, and FAILED. 4. transfer_id as foreign key to transfers. -5. Index on: idempotencyKey +5. result_status/result_message to return previous result for duplicate retries. +6. Index on: idempotencyKey -Common to all entities/table: createdAt/updatedAt as date-time +Common to all entities/table: createdAt/updatedAt ## Testing Strategies: From 9b29bec6b2f8123b6fc2b6ac65a9f02d6b91533a Mon Sep 17 00:00:00 2001 From: Sudhanshu Kumar Roy <153078686+SkroyCodes@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:34:58 +0530 Subject: [PATCH 3/4] docs: update README with implementation details --- README.md | 274 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 249 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 58c62d1a..9a32334d 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,259 @@ -# Wallet Transfer Assignment Repository +# Wallet Transfer Assignment -This repository is a reusable coding assignment template for evaluating backend engineers on wallet transfers, idempotency, concurrency control, and double-entry ledger design. +A Spring Boot backend service for wallet-to-wallet fund transfers. -## Included +This implementation focuses on correctness around wallet balance updates, idempotent request handling, double-entry ledger creation, and safe concurrent transfer execution. -- `ASSIGNMENT.md` - candidate-facing prompt -- `.github/pull_request_template.md` - required PR structure -- `.github/workflows/ci.yml` - lint, format, test placeholder workflow -- `.github/workflows/sonarqube.yml` - SonarQube pull request analysis -- `.github/copilot-instructions.md` - repository-level Copilot review guidance -- `evaluation_guide.md` - reviewer rubric -- `branch-protection-checklist.md` - GitHub setup checklist +--- -## Intended use +## Tech Stack -1. Mark this repository as a GitHub template repository. -2. Create one private repository per candidate from the template. -3. Add the candidate as a collaborator. -4. Ask them to submit via a pull request into `main`. -5. Enable required checks, SonarQube, and Copilot review in GitHub. +* Java 21 +* Spring Boot +* Spring Data JPA +* PostgreSQL +* Maven +* JUnit 5 -## Notes +--- -- Copilot automatic pull request review is configured in GitHub repository or organization settings, not purely through files in the repo. -- The `copilot-instructions.md` file included here provides repository-specific review guidance once Copilot review is enabled. -- The CI workflow is language-agnostic by default and expects you to set the `LINT_CMD`, `FORMAT_CHECK_CMD`, and `TEST_CMD` repository variables or replace the commands directly. +## Main Features -## How to Submit Assignment +* Wallet-to-wallet transfer API +* Idempotency support using `idempotencyKey` +* Pessimistic locking for safe concurrent wallet updates +* Transfer lifecycle using `PENDING`, `PROCESSED`, and `FAILED` +* Double-entry ledger entries for successful transfers +* Integration tests for success, failure, duplicate requests, and concurrency cases -1. **Fork this repository** to your own GitHub account. -2. Complete the assignment described in [`ASSIGNMENT.md`](./ASSIGNMENT.md). -3. **Raise a Pull Request** back to this repository (`main` branch) with your full solution. +--- -Your PR branch should be named: `solution/` (e.g., `solution/jane-doe`). +## Database Design + +The implementation uses four main entities/tables: + +### `wallet` + +Stores wallet balance. + +Important fields: + +* `wallet_id` +* `balance` + +The wallet balance is expected to remain non-negative. + +### `transfer` + +Stores each transfer attempt. + +Important fields: + +* `transfer_id` +* `source_wallet_id` +* `destination_wallet_id` +* `amount` +* `status` + +Transfer status values: + +* `PENDING` +* `PROCESSED` +* `FAILED` + +### `ledger_entry` + +Stores debit and credit records for successful transfers. + +Important fields: + +* `entry_id` +* `transfer_id` +* `wallet_id` +* `amount` +* `transaction_type` + +Transaction type values: + +* `DEBIT` +* `CREDIT` + +For every successful transfer, exactly two ledger entries are created: + +1. One debit entry for the source wallet. +2. One credit entry for the destination wallet. + +### `idempotency_record` + +Stores request-processing state for retry-safe behavior. + +Important fields: + +* `idempotency_record_id` +* `idempotency_key` +* `status` +* `transfer_id` +* `result_status` +* `result_message` + +The `idempotency_key` is unique, so the same request key cannot trigger the transfer flow more than once. + +--- + +## Idempotency Strategy + +The service uses an `idempotency_record` table to avoid duplicate transfer processing. + +Flow: + +1. A request comes with an `idempotencyKey`. +2. The service tries to claim the key using a unique insert. +3. If the key is new, the request proceeds. +4. If the key already exists, the transfer logic is not executed again. +5. The previously stored result/status is returned. +6. For expected business failures, such as insufficient balance, the failed result is also stored so retries return the same result. + +This makes the transfer operation retry-safe for duplicate requests or network retries. + +--- + +## Transaction Strategy + +The main transfer flow runs inside a database transaction. + +Within the transaction: + +1. The idempotency key is claimed. +2. A transfer record is created with `PENDING` status. +3. Source and destination wallet rows are locked. +4. Source wallet balance is validated. +5. Source wallet is debited. +6. Destination wallet is credited. +7. Debit and credit ledger entries are inserted. +8. Transfer is marked as `PROCESSED`. +9. Idempotency result is updated. + +For expected business failures, the transfer is marked as `FAILED` and committed with the idempotency result. + +For unexpected technical failures, the transaction is rolled back to avoid partial wallet updates or partial ledger entries. + +--- + +## Concurrency Strategy + +The implementation uses pessimistic locking with PostgreSQL `FOR UPDATE`. + +The source and destination wallet rows are locked before balance updates. Wallets are fetched in wallet ID order to reduce deadlock risk. + +This ensures that concurrent transfers from the same source wallet are handled safely. One transaction completes first, and the next transaction sees the updated balance before proceeding. + +--- + +## API + +### Create Transfer + +```http +POST /transfers +Content-Type: application/json +``` + +### Request Body + +```json +{ + "idempotencyKey": "abc123", + "fromWalletId": 1, + "toWalletId": 2, + "amount": 100.00 +} +``` + +### Success Response + +```json +{ + "transferId": 1, + "fromWalletId": 1, + "toWalletId": 2, + "amount": 100.00, + "status": "PROCESSED", + "message": "Transfer processed successfully" +} +``` + +### Failed Response Example + +```json +{ + "transferId": 2, + "fromWalletId": 1, + "toWalletId": 2, + "amount": 100.00, + "status": "FAILED", + "message": "Insufficient balance" +} +``` + +--- + +## Running the Application + +From the project root: + +```bash +mvn spring-boot:run +``` + +Or on Windows with Maven wrapper: + +```bash +.\mvnw.cmd spring-boot:run +``` + +The application starts on: + +```text +http://localhost:8080 +``` + +--- + +## Running Tests + +Run: + +```bash +mvn test +``` + +Or on Windows with Maven wrapper: + +```bash +.\mvnw.cmd test +``` + +--- + +## Tests Covered + +The test suite covers: + +* Successful wallet transfer +* Insufficient balance +* Invalid wallet scenario +* Duplicate idempotency key +* Ledger entry validation +* Concurrent transfer attempts from the same wallet + +--- + +## AI Usage + +AI usage is documented separately in: + +```text +AI_USAGE.md +``` + +I used ChatGPT as a planning, review, and debugging assistant. I did not use AI to blindly generate the full solution. \ No newline at end of file From 1c5d0eed51e9ddd1b9207c445730a0668ca0e1ff Mon Sep 17 00:00:00 2001 From: Sudhanshu Kumar Roy <153078686+SkroyCodes@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:13:13 +0530 Subject: [PATCH 4/4] feat: implement wallet transfer workflow with tests --- .gitattributes | 2 + .gitignore | 61 +++- .mvn/wrapper/maven-wrapper.properties | 3 + AI_USAGE.md | 54 +++- APPROACH.md | 4 +- README.md | 3 +- mvnw | 295 ++++++++++++++++++ mvnw.cmd | 189 +++++++++++ pom.xml | 124 ++++++++ .../WalletTransactionApplication.java | 13 + .../controller/PaymentRequestController.java | 26 ++ .../dto/PaymentRequestDto.java | 25 ++ .../dto/PaymentResponseDto.java | 28 ++ .../entities/IdempotencyRecord.java | 37 +++ .../entities/LedgerEntry.java | 41 +++ .../wallet_transaction/entities/Transfer.java | 41 +++ .../wallet_transaction/entities/Wallet.java | 24 ++ .../entities/enums/IdempotencyStatus.java | 7 + .../entities/enums/TransactionType.java | 5 + .../entities/enums/TransferStatus.java | 5 + .../IdempotencyRecordRepository.java | 21 ++ .../repository/LedgerEntryRepository.java | 7 + .../repository/TransferRepository.java | 7 + .../repository/WalletRepository.java | 19 ++ .../service/PaymentService.java | 154 +++++++++ src/main/resources/application.properties | 1 + src/main/resources/application.yaml | 13 + .../WalletTransactionApplicationTests.java | 228 ++++++++++++++ 28 files changed, 1425 insertions(+), 12 deletions(-) create mode 100644 .gitattributes create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 mvnw create mode 100644 mvnw.cmd create mode 100644 pom.xml create mode 100644 src/main/java/com/mycompany/wallet_transaction/WalletTransactionApplication.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/controller/PaymentRequestController.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/dto/PaymentRequestDto.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/dto/PaymentResponseDto.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/entities/IdempotencyRecord.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/entities/LedgerEntry.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/entities/Transfer.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/entities/Wallet.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/entities/enums/IdempotencyStatus.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/entities/enums/TransactionType.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/entities/enums/TransferStatus.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/repository/IdempotencyRecordRepository.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/repository/LedgerEntryRepository.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/repository/TransferRepository.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/repository/WalletRepository.java create mode 100644 src/main/java/com/mycompany/wallet_transaction/service/PaymentService.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yaml create mode 100644 src/test/java/com/mycompany/wallet_transaction/WalletTransactionApplicationTests.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..3b41682a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore index c1643802..5abd8be7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,60 @@ +# OS files + .DS_Store -coverage/ -dist/ -build/ -.tmp/ +Thumbs.db + +# Logs + +*.log + +# Environment files + .env .env.* + +# Java / Maven + +target/ +*.class +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +# Build outputs + +build/ +dist/ +coverage/ +.tmp/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +# Spring / STS + +HELP.md +.apt_generated +.classpath +.factorypath +.project +.settings/ +.springBeans +.sts4-cache/ + +# IntelliJ IDEA + +.idea/ +*.iws +*.iml +*.ipr + +# NetBeans + +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +# VS Code + +.vscode/ \ No newline at end of file diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..216df058 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/AI_USAGE.md b/AI_USAGE.md index 4a023059..81091230 100644 --- a/AI_USAGE.md +++ b/AI_USAGE.md @@ -1,11 +1,57 @@ # AI Usage Note -## Tool Used +## Tool used ChatGPT -## How I Used AI +## How I used it -I used ChatGPT as a reviewing assistant to structure my initial approach around idempotency, transaction boundaries, database design, and testing scenarios. +I used ChatGPT as a planning, review, and implementation-support assistant while working on this assignment. -I did not use AI to blindly generate the full solution. I used it to reason about edge cases and organize my implementation approach. \ No newline at end of file +I worked design-first. I first prepared the initial approach and decided the main solution direction myself: wallet-to-wallet transfer flow, idempotency handling, transaction boundaries, ledger entries, duplicate request handling, and failed transfer handling. + +I used AI mainly to improve clarity, validate edge cases, and speed up repetitive implementation work. + +Specifically, I used it to: + +* Review the transfer flow from the initial approach document. +* Improve the clean service flow for successful and failed transfers. +* Reason about retry-safe behavior using a separate idempotency record. +* Decide how expected business failures like insufficient balance should be recorded. +* Review error scenarios such as invalid wallet, insufficient balance, duplicate request, and concurrent debit attempts. +* Plan test cases based on the approach document. +* Generate and refine integration test scenarios for success, failure, duplicate idempotency key, ledger entry validation, and concurrent transfer cases. +* Improve documentation wording for README and implementation explanation. +* Review local test failures at a high level and correct the implementation accordingly. + +I reviewed and corrected the suggestions before applying them. I implemented, ran, debugged, and verified the final solution locally. + +## Representative prompts + +These are representative prompts similar to what I used during the session: + +1. Help me understand the wallet transfer assignment expectations around idempotency, retries, concurrency, transaction safety, database design, and testing. + +2. Help me structure an initial approach document for a wallet-to-wallet transfer assignment where duplicate requests, retries, failed transfers, and concurrent debits need to be handled correctly. + +3. Review this successful transfer flow: validate request, claim idempotency key, create transfer as pending, lock involved wallets, debit source, credit destination, create ledger entries, mark transfer processed, and store the final result. + +4. Review this failed transfer flow: if the source wallet has insufficient balance, mark the transfer as failed, do not create ledger entries, and store the failed result so retries return the same response. + +5. Help me decide whether a business failure should be rolled back or committed as a failed transfer record. + +6. Help me keep the controller thin and move the main business logic into the service layer. + +7. Review the clean code flow for the transfer service so that idempotency handling, validation, wallet updates, ledger entries, and response creation are easy to follow. + +8. Help me identify possible error scenarios for this wallet transfer service, including invalid wallet, insufficient balance, duplicate request, and concurrent transfer attempts. + +9. Help me design integration tests based on my approach document for successful transfer, insufficient balance, invalid wallet, duplicate idempotency key, ledger entries, and concurrent transfers from the same wallet. + +10. Help me refine the test assertions so they verify balances, transfer status, ledger entries, and idempotency behavior correctly. + +11. Help me prepare a concise README explaining the implementation, idempotency strategy, transaction strategy, concurrency handling, API, and tests. + +## Validation + +I ran the test suite locally and verified that the implemented scenarios passed before submitting the solution. diff --git a/APPROACH.md b/APPROACH.md index 5b9acf2b..c5bb0246 100644 --- a/APPROACH.md +++ b/APPROACH.md @@ -60,13 +60,13 @@ I am considering PostgreSQL tables such as: ### A: wallets: 1. wallet_id as primary key -2. wallet.balance always non-negative, enforced with a CHECK constraint such as balance >= 0. +2. balance always non-negative, enforced with a CHECK constraint such as balance >= 0. ### B: transfers 1. transfer_id as primary key 2. source_wallet_id and destination_wallet_id as foreign keys to wallets. -3. transfers.status can only have PENDING, PROCESSED, FAILED. +3. status can only have PENDING, PROCESSED, FAILED. 4. Indexes on: source_wallet_id and destination_wallet_id ### C: ledger_entries diff --git a/README.md b/README.md index 9a32334d..9bac4f97 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,7 @@ This implementation focuses on correctness around wallet balance updates, idempo * Spring Data JPA * PostgreSQL * Maven -* JUnit 5 - +* JUni --- ## Main Features diff --git a/mvnw b/mvnw new file mode 100644 index 00000000..bd8896bf --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 00000000..92450f93 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..b5041111 --- /dev/null +++ b/pom.xml @@ -0,0 +1,124 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.15 + + + com.mycompany + wallet-transaction + 0.0.1-SNAPSHOT + + + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-web + + + + + + + + + + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + compile + + compile + + + + + org.projectlombok + lombok + + + + + + default-testCompile + test-compile + + testCompile + + + + + org.projectlombok + lombok + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/com/mycompany/wallet_transaction/WalletTransactionApplication.java b/src/main/java/com/mycompany/wallet_transaction/WalletTransactionApplication.java new file mode 100644 index 00000000..383d4e5d --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/WalletTransactionApplication.java @@ -0,0 +1,13 @@ +package com.mycompany.wallet_transaction; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class WalletTransactionApplication { + + public static void main(String[] args) { + SpringApplication.run(WalletTransactionApplication.class, args); + } + +} diff --git a/src/main/java/com/mycompany/wallet_transaction/controller/PaymentRequestController.java b/src/main/java/com/mycompany/wallet_transaction/controller/PaymentRequestController.java new file mode 100644 index 00000000..e5216674 --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/controller/PaymentRequestController.java @@ -0,0 +1,26 @@ +package com.mycompany.wallet_transaction.controller; + +import com.mycompany.wallet_transaction.dto.PaymentRequestDto; +import com.mycompany.wallet_transaction.dto.PaymentResponseDto; +import com.mycompany.wallet_transaction.service.PaymentService; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class PaymentRequestController { + + PaymentService paymentService; + + public PaymentRequestController(PaymentService paymentService) { + this.paymentService = paymentService; + } + + @PostMapping("/transfers") + public ResponseEntity makeTransfer(@Valid @RequestBody PaymentRequestDto paymentRequest){ + PaymentResponseDto response = paymentService.makeTransfer(paymentRequest); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/mycompany/wallet_transaction/dto/PaymentRequestDto.java b/src/main/java/com/mycompany/wallet_transaction/dto/PaymentRequestDto.java new file mode 100644 index 00000000..b3e30279 --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/dto/PaymentRequestDto.java @@ -0,0 +1,25 @@ +package com.mycompany.wallet_transaction.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class PaymentRequestDto { + + @NotBlank + private String idempotencyKey; + + @NotNull + private Long fromWalletId; + + @NotNull + private Long toWalletId; + + @NotNull + @DecimalMin(value = "0.01") + private BigDecimal amount; +} \ No newline at end of file diff --git a/src/main/java/com/mycompany/wallet_transaction/dto/PaymentResponseDto.java b/src/main/java/com/mycompany/wallet_transaction/dto/PaymentResponseDto.java new file mode 100644 index 00000000..c5ecf321 --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/dto/PaymentResponseDto.java @@ -0,0 +1,28 @@ +package com.mycompany.wallet_transaction.dto; + +import com.mycompany.wallet_transaction.entities.enums.TransferStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class PaymentResponseDto { + + private Long transferId; + + private Long fromWalletId; + + private Long toWalletId; + + private BigDecimal amount; + + private TransferStatus status; + + private String message; +} \ No newline at end of file diff --git a/src/main/java/com/mycompany/wallet_transaction/entities/IdempotencyRecord.java b/src/main/java/com/mycompany/wallet_transaction/entities/IdempotencyRecord.java new file mode 100644 index 00000000..574700c6 --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/entities/IdempotencyRecord.java @@ -0,0 +1,37 @@ +package com.mycompany.wallet_transaction.entities; + +import com.mycompany.wallet_transaction.entities.enums.IdempotencyStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class IdempotencyRecord { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long idempotencyRecordId; + + @Column(name = "idempotency_key", nullable = false, unique = true) + private String idempotencyKey; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private IdempotencyStatus status; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "transfer_id") + private Transfer transfer; + + @Column(name = "result_status") + private String resultStatus; + + @Column(name = "result_message") + private String resultMessage; +} diff --git a/src/main/java/com/mycompany/wallet_transaction/entities/LedgerEntry.java b/src/main/java/com/mycompany/wallet_transaction/entities/LedgerEntry.java new file mode 100644 index 00000000..d56b933d --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/entities/LedgerEntry.java @@ -0,0 +1,41 @@ +package com.mycompany.wallet_transaction.entities; + +import com.mycompany.wallet_transaction.entities.enums.TransactionType; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Entity +@Table( + name = "ledger_entry", + indexes = { + @Index(name = "idx_ledger_entry_transfer_id", columnList = "transfer_id") + } +) +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class LedgerEntry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long entryId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "transfer_id", nullable = false) + private Transfer transfer; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "wallet_id", nullable = false) + private Wallet wallet; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TransactionType transactionType; + + @Column(nullable = false) + private BigDecimal amount; +} diff --git a/src/main/java/com/mycompany/wallet_transaction/entities/Transfer.java b/src/main/java/com/mycompany/wallet_transaction/entities/Transfer.java new file mode 100644 index 00000000..b3a2a930 --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/entities/Transfer.java @@ -0,0 +1,41 @@ +package com.mycompany.wallet_transaction.entities; + +import com.mycompany.wallet_transaction.entities.enums.TransferStatus; +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; + +@Entity +@Table( + name = "transfer", + indexes = { + @Index(name = "idx_transfers_source_wallet_id", columnList = "source_wallet_id"), + @Index(name = "idx_transfers_destination_wallet_id", columnList = "destination_wallet_id") + } +) +@Builder +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class Transfer { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long transferId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "source_wallet_id", nullable = false) + private Wallet sourceWallet; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "destination_wallet_id", nullable = false) + private Wallet destinationWallet; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TransferStatus status; + + @Column(nullable = false) + private BigDecimal amount; +} diff --git a/src/main/java/com/mycompany/wallet_transaction/entities/Wallet.java b/src/main/java/com/mycompany/wallet_transaction/entities/Wallet.java new file mode 100644 index 00000000..c695ffbb --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/entities/Wallet.java @@ -0,0 +1,24 @@ +package com.mycompany.wallet_transaction.entities; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; + +@Entity +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class Wallet { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long walletId; + + @Column(nullable = false) + private BigDecimal balance; +} diff --git a/src/main/java/com/mycompany/wallet_transaction/entities/enums/IdempotencyStatus.java b/src/main/java/com/mycompany/wallet_transaction/entities/enums/IdempotencyStatus.java new file mode 100644 index 00000000..aea32701 --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/entities/enums/IdempotencyStatus.java @@ -0,0 +1,7 @@ +package com.mycompany.wallet_transaction.entities.enums; + +public enum IdempotencyStatus { + IN_PROGRESS, + COMPLETED, + FAILED +} diff --git a/src/main/java/com/mycompany/wallet_transaction/entities/enums/TransactionType.java b/src/main/java/com/mycompany/wallet_transaction/entities/enums/TransactionType.java new file mode 100644 index 00000000..2e162c62 --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/entities/enums/TransactionType.java @@ -0,0 +1,5 @@ +package com.mycompany.wallet_transaction.entities.enums; + +public enum TransactionType { + DEBIT, CREDIT +} diff --git a/src/main/java/com/mycompany/wallet_transaction/entities/enums/TransferStatus.java b/src/main/java/com/mycompany/wallet_transaction/entities/enums/TransferStatus.java new file mode 100644 index 00000000..41d26156 --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/entities/enums/TransferStatus.java @@ -0,0 +1,5 @@ +package com.mycompany.wallet_transaction.entities.enums; + +public enum TransferStatus { + PENDING, PROCESSED, FAILED +} diff --git a/src/main/java/com/mycompany/wallet_transaction/repository/IdempotencyRecordRepository.java b/src/main/java/com/mycompany/wallet_transaction/repository/IdempotencyRecordRepository.java new file mode 100644 index 00000000..6a8efa8d --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/repository/IdempotencyRecordRepository.java @@ -0,0 +1,21 @@ +package com.mycompany.wallet_transaction.repository; + +import com.mycompany.wallet_transaction.entities.IdempotencyRecord; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface IdempotencyRecordRepository extends JpaRepository { + Optional findByIdempotencyKey(String idempotencyKey); + + @Modifying + @Query(value = """ + insert into idempotency_record (idempotency_key,status,result_status, result_message) + values (:idempotencyKey, 'IN_PROGRESS', null, null) + on conflict (idempotency_key) do nothing + """, nativeQuery = true) + int claimIdempotencyKey(@Param("idempotencyKey") String idempotencyKey); +} diff --git a/src/main/java/com/mycompany/wallet_transaction/repository/LedgerEntryRepository.java b/src/main/java/com/mycompany/wallet_transaction/repository/LedgerEntryRepository.java new file mode 100644 index 00000000..0ffd706b --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/repository/LedgerEntryRepository.java @@ -0,0 +1,7 @@ +package com.mycompany.wallet_transaction.repository; + +import com.mycompany.wallet_transaction.entities.LedgerEntry; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LedgerEntryRepository extends JpaRepository { +} diff --git a/src/main/java/com/mycompany/wallet_transaction/repository/TransferRepository.java b/src/main/java/com/mycompany/wallet_transaction/repository/TransferRepository.java new file mode 100644 index 00000000..dbab9023 --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/repository/TransferRepository.java @@ -0,0 +1,7 @@ +package com.mycompany.wallet_transaction.repository; + +import com.mycompany.wallet_transaction.entities.Transfer; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TransferRepository extends JpaRepository { +} diff --git a/src/main/java/com/mycompany/wallet_transaction/repository/WalletRepository.java b/src/main/java/com/mycompany/wallet_transaction/repository/WalletRepository.java new file mode 100644 index 00000000..8dfc52cb --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/repository/WalletRepository.java @@ -0,0 +1,19 @@ +package com.mycompany.wallet_transaction.repository; + +import com.mycompany.wallet_transaction.entities.Wallet; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface WalletRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(value = """ + select w from Wallet w where w.walletId in :walletIds order by w.walletId + """) + List findAllByWalletIdInForUpdate(@Param("walletIds") List walletIds); +} diff --git a/src/main/java/com/mycompany/wallet_transaction/service/PaymentService.java b/src/main/java/com/mycompany/wallet_transaction/service/PaymentService.java new file mode 100644 index 00000000..99a073b4 --- /dev/null +++ b/src/main/java/com/mycompany/wallet_transaction/service/PaymentService.java @@ -0,0 +1,154 @@ +package com.mycompany.wallet_transaction.service; + +import com.mycompany.wallet_transaction.dto.PaymentRequestDto; +import com.mycompany.wallet_transaction.dto.PaymentResponseDto; +import com.mycompany.wallet_transaction.entities.IdempotencyRecord; +import com.mycompany.wallet_transaction.entities.LedgerEntry; +import com.mycompany.wallet_transaction.entities.Transfer; +import com.mycompany.wallet_transaction.entities.Wallet; +import com.mycompany.wallet_transaction.entities.enums.IdempotencyStatus; +import com.mycompany.wallet_transaction.entities.enums.TransactionType; +import com.mycompany.wallet_transaction.entities.enums.TransferStatus; +import com.mycompany.wallet_transaction.repository.IdempotencyRecordRepository; +import com.mycompany.wallet_transaction.repository.LedgerEntryRepository; +import com.mycompany.wallet_transaction.repository.TransferRepository; +import com.mycompany.wallet_transaction.repository.WalletRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final WalletRepository walletRepository; + private final TransferRepository transferRepository; + private final LedgerEntryRepository ledgerEntryRepository; + private final IdempotencyRecordRepository idempotencyRecordRepository; + + + @Transactional + public PaymentResponseDto makeTransfer(PaymentRequestDto request) { + int isKeyClaimed = idempotencyRecordRepository.claimIdempotencyKey(request.getIdempotencyKey()); + + if (isKeyClaimed == 0) { + return existingKeyResult(request.getIdempotencyKey()); + } + + IdempotencyRecord record = idempotencyRecordRepository.findByIdempotencyKey(request.getIdempotencyKey()).orElseThrow(); + + List walletInTransaction = walletRepository.findAllByWalletIdInForUpdate( + List.of(request.getFromWalletId(), request.getToWalletId()) + ); + + if (walletInTransaction.size() != 2) { + record.setStatus(IdempotencyStatus.FAILED); + record.setResultStatus("FAILED"); + record.setResultMessage("Invalid Wallet"); + + return PaymentResponseDto.builder() + .fromWalletId(request.getFromWalletId()) + .toWalletId(request.getToWalletId()) + .amount(request.getAmount()) + .status(TransferStatus.FAILED) + .message("Invalid wallet") + .build(); + } + + Wallet sourceWallet = walletInTransaction.stream() + .filter(wallet -> wallet.getWalletId().equals(request.getFromWalletId())) + .findFirst() + .orElseThrow(); + + Wallet destinationWallet = walletInTransaction.stream() + .filter(wallet -> wallet.getWalletId().equals(request.getToWalletId())) + .findFirst() + .orElseThrow(); + + Transfer transfer = Transfer.builder() + .sourceWallet(sourceWallet) + .destinationWallet(destinationWallet) + .amount(request.getAmount()) + .status(TransferStatus.PENDING) + .build(); + + transferRepository.save(transfer); + + if (sourceWallet.getBalance().compareTo(request.getAmount()) < 0) { + transfer.setStatus(TransferStatus.FAILED); + + record.setStatus(IdempotencyStatus.COMPLETED); + record.setTransfer(transfer); + record.setResultStatus("FAILED"); + record.setResultMessage("Insufficient balance"); + + return PaymentResponseDto.builder() + .transferId(transfer.getTransferId()) + .fromWalletId(sourceWallet.getWalletId()) + .toWalletId(destinationWallet.getWalletId()) + .amount(request.getAmount()) + .status(TransferStatus.FAILED) + .message("Insufficient balance") + .build(); + } + + sourceWallet.setBalance(sourceWallet.getBalance().subtract(request.getAmount())); + destinationWallet.setBalance(destinationWallet.getBalance().add(request.getAmount())); + + LedgerEntry debitEntry = LedgerEntry.builder() + .transfer(transfer) + .wallet(sourceWallet) + .amount(request.getAmount()) + .transactionType(TransactionType.DEBIT) + .build(); + + LedgerEntry creditEntry = LedgerEntry.builder() + .transfer(transfer) + .wallet(destinationWallet) + .amount(request.getAmount()) + .transactionType(TransactionType.CREDIT) + .build(); + + ledgerEntryRepository.save(debitEntry); + ledgerEntryRepository.save(creditEntry); + + record.setStatus(IdempotencyStatus.COMPLETED); + record.setTransfer(transfer); + record.setResultStatus("PROCESSED"); + record.setResultMessage("Transfer processed successfully"); + + return PaymentResponseDto.builder() + .transferId(transfer.getTransferId()) + .fromWalletId(sourceWallet.getWalletId()) + .toWalletId(destinationWallet.getWalletId()) + .amount(request.getAmount()) + .status(TransferStatus.PROCESSED) + .message("Transfer processed successfully") + .build(); + } + private PaymentResponseDto existingKeyResult(String idempotencyKey) { + IdempotencyRecord record = idempotencyRecordRepository + .findByIdempotencyKey(idempotencyKey) + .orElseThrow(); + + if (record.getStatus() == IdempotencyStatus.IN_PROGRESS) { + return PaymentResponseDto.builder() + .status(TransferStatus.PENDING) + .message("Request is still processing") + .build(); + } + + Transfer transfer = record.getTransfer(); + + return PaymentResponseDto.builder() + .transferId(transfer != null ? transfer.getTransferId() : null) + .fromWalletId(transfer != null ? transfer.getSourceWallet().getWalletId() : null) + .toWalletId(transfer != null ? transfer.getDestinationWallet().getWalletId() : null) + .amount(transfer != null ? transfer.getAmount() : null) + .status(TransferStatus.valueOf(record.getResultStatus())) + .message(record.getResultMessage()) + .build(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 00000000..b3596845 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=wallet-transaction diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 00000000..65ec16f8 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,13 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5434/quizapp + username: quizuser + password: quizpass + jpa: + hibernate: + ddl-auto: update + show-sql: true + +logging: + level: + root: debug diff --git a/src/test/java/com/mycompany/wallet_transaction/WalletTransactionApplicationTests.java b/src/test/java/com/mycompany/wallet_transaction/WalletTransactionApplicationTests.java new file mode 100644 index 00000000..61531732 --- /dev/null +++ b/src/test/java/com/mycompany/wallet_transaction/WalletTransactionApplicationTests.java @@ -0,0 +1,228 @@ +package com.mycompany.wallet_transaction; + +import com.mycompany.wallet_transaction.dto.PaymentRequestDto; +import com.mycompany.wallet_transaction.dto.PaymentResponseDto; +import com.mycompany.wallet_transaction.entities.Wallet; +import com.mycompany.wallet_transaction.entities.enums.TransferStatus; +import com.mycompany.wallet_transaction.repository.IdempotencyRecordRepository; +import com.mycompany.wallet_transaction.repository.LedgerEntryRepository; +import com.mycompany.wallet_transaction.repository.TransferRepository; +import com.mycompany.wallet_transaction.repository.WalletRepository; +import com.mycompany.wallet_transaction.service.PaymentService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.math.BigDecimal; +import java.util.concurrent.*; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +class WalletTransactionApplicationTests { + + @Autowired + private PaymentService paymentService; + + @Autowired + private WalletRepository walletRepository; + + @Autowired + private TransferRepository transferRepository; + + @Autowired + private LedgerEntryRepository ledgerEntryRepository; + + @Autowired + private IdempotencyRecordRepository idempotencyRecordRepository; + + @BeforeEach + void cleanUp() { + ledgerEntryRepository.deleteAll(); + idempotencyRecordRepository.deleteAll(); + transferRepository.deleteAll(); + walletRepository.deleteAll(); + } + + @Test + void successfulTransfer_shouldDebitSourceCreditDestinationAndCreateLedgerEntries() { + Wallet source = new Wallet(); + source.setBalance(new BigDecimal("500.00")); + source = walletRepository.save(source); + + Wallet destination = new Wallet(); + destination.setBalance(new BigDecimal("100.00")); + destination = walletRepository.save(destination); + + PaymentRequestDto request = new PaymentRequestDto(); + request.setIdempotencyKey("idem-success-1"); + request.setFromWalletId(source.getWalletId()); + request.setToWalletId(destination.getWalletId()); + request.setAmount(new BigDecimal("150.00")); + + PaymentResponseDto response = paymentService.makeTransfer(request); + + assertEquals(TransferStatus.PROCESSED, response.getStatus()); + + Wallet updatedSource = walletRepository.findById(source.getWalletId()).orElseThrow(); + Wallet updatedDestination = walletRepository.findById(destination.getWalletId()).orElseThrow(); + + assertEquals(0, updatedSource.getBalance().compareTo(new BigDecimal("350.00"))); + assertEquals(0, updatedDestination.getBalance().compareTo(new BigDecimal("250.00"))); + + assertEquals(1, transferRepository.count()); + assertEquals(2, ledgerEntryRepository.count()); + } + + @Test + void insufficientBalance_shouldMarkTransferFailedAndNotCreateLedgerEntries() { + Wallet source = new Wallet(); + source.setBalance(new BigDecimal("50.00")); + source = walletRepository.save(source); + + Wallet destination = new Wallet(); + destination.setBalance(new BigDecimal("100.00")); + destination = walletRepository.save(destination); + + PaymentRequestDto request = new PaymentRequestDto(); + request.setIdempotencyKey("idem-failed-1"); + request.setFromWalletId(source.getWalletId()); + request.setToWalletId(destination.getWalletId()); + request.setAmount(new BigDecimal("150.00")); + + PaymentResponseDto response = paymentService.makeTransfer(request); + + assertEquals(TransferStatus.FAILED, response.getStatus()); + + Wallet updatedSource = walletRepository.findById(source.getWalletId()).orElseThrow(); + Wallet updatedDestination = walletRepository.findById(destination.getWalletId()).orElseThrow(); + + assertEquals(0, updatedSource.getBalance().compareTo(new BigDecimal("50.00"))); + assertEquals(0, updatedDestination.getBalance().compareTo(new BigDecimal("100.00"))); + + assertEquals(1, transferRepository.count()); + assertEquals(0, ledgerEntryRepository.count()); + } + + @Test + void duplicateIdempotencyKey_shouldNotProcessTransferAgain() { + Wallet source = new Wallet(); + source.setBalance(new BigDecimal("500.00")); + source = walletRepository.save(source); + + Wallet destination = new Wallet(); + destination.setBalance(new BigDecimal("100.00")); + destination = walletRepository.save(destination); + + PaymentRequestDto request = new PaymentRequestDto(); + request.setIdempotencyKey("idem-duplicate-1"); + request.setFromWalletId(source.getWalletId()); + request.setToWalletId(destination.getWalletId()); + request.setAmount(new BigDecimal("100.00")); + + PaymentResponseDto firstResponse = paymentService.makeTransfer(request); + PaymentResponseDto secondResponse = paymentService.makeTransfer(request); + + assertEquals(TransferStatus.PROCESSED, firstResponse.getStatus()); + assertEquals(TransferStatus.PROCESSED, secondResponse.getStatus()); + + Wallet updatedSource = walletRepository.findById(source.getWalletId()).orElseThrow(); + Wallet updatedDestination = walletRepository.findById(destination.getWalletId()).orElseThrow(); + + assertEquals(0, updatedSource.getBalance().compareTo(new BigDecimal("400.00"))); + assertEquals(0, updatedDestination.getBalance().compareTo(new BigDecimal("200.00"))); + + assertEquals(1, transferRepository.count()); + assertEquals(2, ledgerEntryRepository.count()); + assertEquals(1, idempotencyRecordRepository.count()); + } + + @Test + void invalidWallet_shouldReturnFailedAndNotCreateLedgerEntries() { + Wallet source = new Wallet(); + source.setBalance(new BigDecimal("500.00")); + source = walletRepository.save(source); + + PaymentRequestDto request = new PaymentRequestDto(); + request.setIdempotencyKey("idem-invalid-wallet-1"); + request.setFromWalletId(source.getWalletId()); + request.setToWalletId(999999L); + request.setAmount(new BigDecimal("100.00")); + + PaymentResponseDto response = paymentService.makeTransfer(request); + + assertEquals(TransferStatus.FAILED, response.getStatus()); + assertEquals(0, ledgerEntryRepository.count()); + } + + @Test + void concurrentTransfersFromSameWallet_shouldAllowOnlyOneDebit() throws Exception { + Wallet source = new Wallet(); + source.setBalance(new BigDecimal("100.00")); + source = walletRepository.save(source); + + Wallet destination1 = new Wallet(); + destination1.setBalance(new BigDecimal("0.00")); + destination1 = walletRepository.save(destination1); + + Wallet destination2 = new Wallet(); + destination2.setBalance(new BigDecimal("0.00")); + destination2 = walletRepository.save(destination2); + + PaymentRequestDto request1 = new PaymentRequestDto(); + request1.setIdempotencyKey("idem-concurrent-1"); + request1.setFromWalletId(source.getWalletId()); + request1.setToWalletId(destination1.getWalletId()); + request1.setAmount(new BigDecimal("80.00")); + + PaymentRequestDto request2 = new PaymentRequestDto(); + request2.setIdempotencyKey("idem-concurrent-2"); + request2.setFromWalletId(source.getWalletId()); + request2.setToWalletId(destination2.getWalletId()); + request2.setAmount(new BigDecimal("80.00")); + + ExecutorService executorService = Executors.newFixedThreadPool(2); + CountDownLatch startLatch = new CountDownLatch(1); + + Callable task1 = () -> { + startLatch.await(); + return paymentService.makeTransfer(request1); + }; + + Callable task2 = () -> { + startLatch.await(); + return paymentService.makeTransfer(request2); + }; + + Future future1 = executorService.submit(task1); + Future future2 = executorService.submit(task2); + + startLatch.countDown(); + + PaymentResponseDto response1 = future1.get(); + PaymentResponseDto response2 = future2.get(); + + executorService.shutdown(); + + long processedCount = Stream.of(response1, response2) + .filter(response -> response.getStatus() == TransferStatus.PROCESSED) + .count(); + + long failedCount = Stream.of(response1, response2) + .filter(response -> response.getStatus() == TransferStatus.FAILED) + .count(); + + assertEquals(1, processedCount); + assertEquals(1, failedCount); + + Wallet updatedSource = walletRepository.findById(source.getWalletId()).orElseThrow(); + + assertEquals(0, updatedSource.getBalance().compareTo(new BigDecimal("20.00"))); + + assertEquals(2, transferRepository.count()); + assertEquals(2, ledgerEntryRepository.count()); + } + +}