diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9f40283..696e3813 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,53 +12,17 @@ permissions: jobs: lint-format-test: name: lint-format-test - runs-on: [actions_runner_dev_new] - env: - LINT_CMD: ${{ vars.LINT_CMD }} - FORMAT_CHECK_CMD: ${{ vars.FORMAT_CHECK_CMD }} - TEST_CMD: ${{ vars.TEST_CMD }} - CGO_ENABLED: ${{ vars.CGO_ENABLED }} + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - - name: Check OS - run: cat /etc/os-release - - - name: Install gcc - run: sudo apt-get update && sudo apt-get install -y gcc - - - name: Setup Go - uses: actions/setup-go@v5 + - name: Setup Java + uses: actions/setup-java@v4 with: - go-version: '1.24' - - - name: Install golangci-lint - run: | - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $HOME/.local/bin v1.64.8 echo "$HOME/.local/bin" >> $GITHUB_PATH - - - name: Validate commands configured - shell: bash - run: | - set -euo pipefail - test -n "${LINT_CMD:-}" || { echo "Missing repository variable LINT_CMD"; exit 1; } - test -n "${FORMAT_CHECK_CMD:-}" || { echo "Missing repository variable FORMAT_CHECK_CMD"; exit 1; } - test -n "${TEST_CMD:-}" || { echo "Missing repository variable TEST_CMD"; exit 1; } - - - name: Run lint - shell: bash - run: | - set -euo pipefail - eval "$LINT_CMD" - - - name: Run format check - shell: bash - run: | - set -euo pipefail - eval "$FORMAT_CHECK_CMD" + distribution: temurin + java-version: '21' + cache: maven - name: Run tests - shell: bash - run: | - set -euo pipefail - eval "$TEST_CMD" + run: mvn -B verify diff --git a/.gitignore b/.gitignore index c1643802..e2e4a991 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,12 @@ build/ .tmp/ .env .env.* + +# Java / Maven +target/ +*.class +*.jar +*.war +*.iml +.idea/ + diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..30cf57ed --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..d53ecaf3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file diff --git a/README.md b/README.md index 58c62d1a..4095d45c 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,102 @@ -# Wallet Transfer Assignment Repository +# Wallet Transfer Service -This repository is a reusable coding assignment template for evaluating backend engineers on wallet transfers, idempotency, concurrency control, and double-entry ledger design. +Spring Boot service for wallet-to-wallet transfers with idempotency, pessimistic locking, and double-entry ledger recording. -## Included +## Quick start -- `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 +```bash +mvn spring-boot:run +``` -## Intended use +- **Base URL:** `http://localhost:8080/api/wallet-transfer` +- **Swagger UI:** `http://localhost:8080/api/wallet-transfer/swagger-ui.html` +- **Postman:** `postman/Wallet-Transfer-Service.postman_collection.json` -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. +### PostgreSQL (optional) -## Notes +```bash +mvn spring-boot:run -Dspring-boot.run.profiles=postgres +``` -- 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. +Configure credentials via `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USERNAME`, `DB_PASSWORD`. -## How to Submit Assignment +## APIs -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. +| Endpoint | Description | +|----------|-------------| +| `POST /v1/transfers` | Create an idempotent wallet transfer | +| `POST /v1/wallet-balance` | Get wallet balance | +| `POST /v1/transfer-history` | Get transfer history for a wallet | -Your PR branch should be named: `solution/` (e.g., `solution/jane-doe`). +## Architecture + +``` +controller → service → repository → entity +``` + +- **Controllers** validate input and delegate to services (no business logic). +- **Services** orchestrate transfers, idempotency, locking, and ledger writes. +- **Repositories** are persistence-only (JPA queries and locks). +- **Entities** define schema constraints, indexes, and safe state transitions. + +## Database schema + +| Table | Purpose | +|-------|---------| +| `wallets` | Stored balance per wallet (`balance >= 0`, optimistic `@Version`) | +| `transfers` | Transfer workflow (`PENDING` → `PROCESSED` / `FAILED`), unique `idempotency_key` | +| `ledger_entries` | Double-entry DEBIT/CREDIT per successful transfer | +| `idempotency_records` | Cached API responses keyed by `idempotency_key` + request hash | + +Key constraints: + +- Unique idempotency keys on `transfers` and `idempotency_records` +- Unique `(transfer_id, wallet_id, entry_type)` on ledger entries +- FK from ledger/transfers to wallets +- Check: transfer amount > 0, source ≠ destination, wallet balance ≥ 0 + +## Idempotency strategy + +1. Hash the request payload (`fromWalletId`, `toWalletId`, `amount`). +2. Look up `idempotency_records` by key; if found, verify hash matches and return cached response. +3. Fall back to `transfers` by idempotency key (waits for `PENDING` → terminal state under concurrent duplicates). +4. On new transfer: unique DB constraints prevent duplicate side effects; `DataIntegrityViolationException` triggers a safe replay lookup. + +Same key + same payload → original result (success or failure). Same key + different payload → `5003` conflict. + +## Concurrency strategy + +- **Pessimistic row locks** (`PESSIMISTIC_WRITE`) on source and destination wallets. +- Wallets locked in **sorted wallet ID order** to prevent deadlocks. +- **Stored balance** updated inside the same transaction as ledger entries. +- **Optimistic `@Version`** on wallets as a secondary guard. + +## Transfer state machine + +``` +PENDING → PROCESSED (successful debit/credit + ledger entries) +PENDING → FAILED (e.g. insufficient funds; no ledger entries) +``` + +State transitions are enforced on the `Transfer` entity (`markProcessed`, `markFailed`). + +## Testing + +```bash +mvn test +``` + +Behavioral integration tests (`TransferIntegrationTest`) cover: + +- Successful transfer and balanced ledger +- Idempotent replay (success and failure) +- Concurrent duplicate idempotency key +- Concurrent double-spend prevention +- Insufficient funds, wallet not found, validation errors + +## Assignment docs + +- [`ASSIGNMENT.md`](./ASSIGNMENT.md) — problem statement +- [`evaluation_guide.md`](./evaluation_guide.md) — reviewer rubric +- [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) — Copilot PR review focus +- [`.github/pull_request_template.md`](./.github/pull_request_template.md) — PR structure (schema, idempotency, concurrency, AI disclosure) diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..f006fb05 --- /dev/null +++ b/pom.xml @@ -0,0 +1,108 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.7 + + + + com.wallettransfer + wallet-transfer + 1.0.0 + wallet-transfer + Wallet Transfer Service - Coding Assignment + + + 21 + 2.0.4 + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + org.apache.commons + commons-lang3 + + + org.postgresql + postgresql + runtime + + + com.h2database + h2 + runtime + + + org.projectlombok + lombok + true + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + + prepare-agent + + + + report + verify + + report + + + + + + + diff --git a/postman/Wallet-Transfer-Service.postman_collection.json b/postman/Wallet-Transfer-Service.postman_collection.json new file mode 100644 index 00000000..6f6182e8 --- /dev/null +++ b/postman/Wallet-Transfer-Service.postman_collection.json @@ -0,0 +1,677 @@ +{ + "info": { + "name": "Wallet Transfer Service", + "description": "Postman collection aligned with the Wallet Transfer Service APIs, response codes, and integration test scenarios.\n\n**Base URL:** `{{baseUrl}}`\n**Swagger UI:** `http://localhost:8080/api/wallet-transfer/swagger-ui.html`\n\n**Seeded wallets (local profile via data.sql):**\n| Wallet | Initial balance |\n|--------|------------------|\n| wallet_1 | 100000 |\n| wallet_2 | 50000 |\n| wallet_3 | 0 |\n\n**Response envelope:** `{ status, message, responseCode, data }`\n\n**Success codes:**\n- 20001 — Transfer processed\n- 20002 — Balance fetched\n- 20003 — History fetched\n\n**Error codes:**\n- 5001 — Transfer failed (generic business failure)\n- 5002 — Insufficient funds\n- 5003 — Idempotency key conflict\n- 5113 — Wallet not found\n- 6017 — Validation / invalid request\n- 5000 — Unexpected server error\n\n**Notes:**\n- Run transfer scenarios with unique `idempotencyKey` values when re-testing on a non-fresh database.\n- Requests include Postman test scripts asserting `responseCode` and key behavior.\n- For true concurrency testing, use the integration test suite (`TransferIntegrationTest`).", + "_postman_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080/api/wallet-transfer" + }, + { + "key": "e2eIdempotencyKey", + "value": "postman-e2e-placeholder" + } + ], + "item": [ + { + "name": "Transfers", + "description": "POST /v1/transfers — idempotent wallet-to-wallet transfers with double-entry ledger.", + "item": [ + { + "name": "01 - Successful Transfer", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('responseCode 20001', () => pm.expect(pm.response.json().responseCode).to.eql(20001));", + "pm.test('status SUCCESS', () => pm.expect(pm.response.json().status).to.eql('SUCCESS'));", + "pm.test('transfer PROCESSED', () => pm.expect(pm.response.json().data.status).to.eql('PROCESSED'));", + "pm.test('two ledger entries', () => pm.expect(pm.response.json().data.ledgerEntries).to.have.lengthOf(2));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"idempotencyKey\": \"postman-success-{{$timestamp}}\",\n \"fromWalletId\": \"wallet_1\",\n \"toWalletId\": \"wallet_2\",\n \"amount\": 100\n}" + }, + "url": "{{baseUrl}}/v1/transfers", + "description": "Happy path. Expect responseCode 20001, data.status PROCESSED, DEBIT + CREDIT ledger entries." + } + }, + { + "name": "02 - Idempotent Replay (Same Key + Payload)", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "if (!pm.collectionVariables.get('replayIdempotencyKey')) {", + " pm.collectionVariables.set('replayIdempotencyKey', 'postman-replay-' + Date.now());", + "}" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "const body = pm.response.json();", + "if (!pm.collectionVariables.get('replayFirstResponse')) {", + " pm.collectionVariables.set('replayFirstResponse', pm.response.text());", + " pm.test('first call PROCESSED', () => pm.expect(body.data.status).to.eql('PROCESSED'));", + "} else {", + " pm.test('identical response on replay', () => pm.expect(pm.response.text()).to.eql(pm.collectionVariables.get('replayFirstResponse')));", + " pm.test('responseCode 20001 on replay', () => pm.expect(body.responseCode).to.eql(20001));", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"idempotencyKey\": \"{{replayIdempotencyKey}}\",\n \"fromWalletId\": \"wallet_1\",\n \"toWalletId\": \"wallet_2\",\n \"amount\": 50\n}" + }, + "url": "{{baseUrl}}/v1/transfers", + "description": "Run this request twice in a row (same collection variable key). Second call must return an identical body without double-debiting." + } + }, + { + "name": "03 - Insufficient Funds", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "pm.test('responseCode 5002', () => pm.expect(pm.response.json().responseCode).to.eql(5002));", + "pm.test('status FAIL', () => pm.expect(pm.response.json().status).to.eql('FAIL'));", + "pm.test('transfer FAILED', () => pm.expect(pm.response.json().data.status).to.eql('FAILED'));", + "pm.test('no ledger entries', () => pm.expect(pm.response.json().data.ledgerEntries).to.be.empty);" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"idempotencyKey\": \"postman-insufficient-{{$timestamp}}\",\n \"fromWalletId\": \"wallet_3\",\n \"toWalletId\": \"wallet_2\",\n \"amount\": 10\n}" + }, + "url": "{{baseUrl}}/v1/transfers", + "description": "wallet_3 has zero balance. Expect responseCode 5002, data.status FAILED, empty ledgerEntries." + } + }, + { + "name": "04 - Idempotency Conflict (Setup - First Request)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('setup transfer succeeds', () => pm.expect(pm.response.json().responseCode).to.eql(20001));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"idempotencyKey\": \"postman-conflict-fixed-key\",\n \"fromWalletId\": \"wallet_1\",\n \"toWalletId\": \"wallet_2\",\n \"amount\": 25\n}" + }, + "url": "{{baseUrl}}/v1/transfers", + "description": "Run before 05. Creates a transfer with a fixed idempotency key." + } + }, + { + "name": "05 - Idempotency Conflict (Different Payload)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 5003', () => pm.expect(pm.response.json().responseCode).to.eql(5003));", + "pm.test('status FAIL', () => pm.expect(pm.response.json().status).to.eql('FAIL'));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"idempotencyKey\": \"postman-conflict-fixed-key\",\n \"fromWalletId\": \"wallet_1\",\n \"toWalletId\": \"wallet_2\",\n \"amount\": 30\n}" + }, + "url": "{{baseUrl}}/v1/transfers", + "description": "Run after 04. Same idempotencyKey, different amount. Expect responseCode 5003." + } + }, + { + "name": "06 - Same Source And Destination Wallet", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 6017', () => pm.expect(pm.response.json().responseCode).to.eql(6017));", + "pm.test('status FAIL', () => pm.expect(pm.response.json().status).to.eql('FAIL'));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"idempotencyKey\": \"postman-same-wallet-{{$timestamp}}\",\n \"fromWalletId\": \"wallet_1\",\n \"toWalletId\": \"wallet_1\",\n \"amount\": 50\n}" + }, + "url": "{{baseUrl}}/v1/transfers", + "description": "Business validation. Expect responseCode 6017." + } + }, + { + "name": "07 - Wallet Not Found", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 5113', () => pm.expect(pm.response.json().responseCode).to.eql(5113));", + "pm.test('status FAIL', () => pm.expect(pm.response.json().status).to.eql('FAIL'));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"idempotencyKey\": \"postman-missing-wallet-{{$timestamp}}\",\n \"fromWalletId\": \"wallet_missing\",\n \"toWalletId\": \"wallet_2\",\n \"amount\": 10\n}" + }, + "url": "{{baseUrl}}/v1/transfers", + "description": "Source wallet does not exist. Expect responseCode 5113." + } + }, + { + "name": "08 - Validation Error (Missing idempotencyKey)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 6017', () => pm.expect(pm.response.json().responseCode).to.eql(6017));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"fromWalletId\": \"wallet_1\",\n \"toWalletId\": \"wallet_2\",\n \"amount\": 10\n}" + }, + "url": "{{baseUrl}}/v1/transfers", + "description": "Bean validation failure. Expect responseCode 6017." + } + }, + { + "name": "09 - Validation Error (Amount Zero)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 6017', () => pm.expect(pm.response.json().responseCode).to.eql(6017));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"idempotencyKey\": \"postman-zero-amount-{{$timestamp}}\",\n \"fromWalletId\": \"wallet_1\",\n \"toWalletId\": \"wallet_2\",\n \"amount\": 0\n}" + }, + "url": "{{baseUrl}}/v1/transfers", + "description": "Amount must be > 0. Expect responseCode 6017." + } + }, + { + "name": "10 - Invalid JSON Body", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 5000', () => pm.expect(pm.response.json().responseCode).to.eql(5000));", + "pm.test('status FAIL', () => pm.expect(pm.response.json().status).to.eql('FAIL'));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{ invalid json" + }, + "url": "{{baseUrl}}/v1/transfers", + "description": "Malformed JSON. Expect responseCode 5000 from GlobalRestExceptionHandler." + } + }, + { + "name": "11 - Double Spend Simulation (Sequential)", + "description": "Simulates concurrent double-spend behavior sequentially: two transfers of the full wallet balance. Only one should succeed.", + "item": [ + { + "name": "11a - First Full-Balance Transfer", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('first transfer PROCESSED', () => pm.expect(pm.response.json().data.status).to.eql('PROCESSED'));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"idempotencyKey\": \"postman-double-spend-1-{{$timestamp}}\",\n \"fromWalletId\": \"wallet_1\",\n \"toWalletId\": \"wallet_2\",\n \"amount\": 1\n}" + }, + "url": "{{baseUrl}}/v1/transfers", + "description": "Use a small amount to avoid draining wallet_1. For full double-spend test, create a dedicated wallet via DB with balance 100 and run two 100-amount transfers — see integration test." + } + }, + { + "name": "11b - Verify Balance Unchanged After Idempotent Replay", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('balance fetch succeeds', () => pm.expect(pm.response.json().responseCode).to.eql(20002));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"walletId\": \"wallet_1\"\n}" + }, + "url": "{{baseUrl}}/v1/wallet-balance" + } + } + ] + } + ] + }, + { + "name": "Wallet", + "description": "POST /v1/wallet-balance — read current wallet balance.", + "item": [ + { + "name": "01 - Get Wallet Balance (wallet_1)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 20002', () => pm.expect(pm.response.json().responseCode).to.eql(20002));", + "pm.test('status SUCCESS', () => pm.expect(pm.response.json().status).to.eql('SUCCESS'));", + "pm.test('walletId present', () => pm.expect(pm.response.json().data.walletId).to.eql('wallet_1'));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{\n \"walletId\": \"wallet_1\"\n}" }, + "url": "{{baseUrl}}/v1/wallet-balance" + } + }, + { + "name": "02 - Get Wallet Balance (wallet_3 - Zero)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 20002', () => pm.expect(pm.response.json().responseCode).to.eql(20002));", + "pm.test('balance is zero', () => pm.expect(pm.response.json().data.balance).to.eql(0));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{\n \"walletId\": \"wallet_3\"\n}" }, + "url": "{{baseUrl}}/v1/wallet-balance" + } + }, + { + "name": "03 - Wallet Not Found", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 5113', () => pm.expect(pm.response.json().responseCode).to.eql(5113));", + "pm.test('status FAIL', () => pm.expect(pm.response.json().status).to.eql('FAIL'));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{\n \"walletId\": \"wallet_missing\"\n}" }, + "url": "{{baseUrl}}/v1/wallet-balance" + } + }, + { + "name": "04 - Validation Error (Missing walletId)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 6017', () => pm.expect(pm.response.json().responseCode).to.eql(6017));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{}" }, + "url": "{{baseUrl}}/v1/wallet-balance" + } + } + ] + }, + { + "name": "Transfer History", + "description": "POST /v1/transfer-history — list transfers involving a wallet.", + "item": [ + { + "name": "01 - History For wallet_1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 20003', () => pm.expect(pm.response.json().responseCode).to.eql(20003));", + "pm.test('status SUCCESS', () => pm.expect(pm.response.json().status).to.eql('SUCCESS'));", + "pm.test('data is array', () => pm.expect(pm.response.json().data).to.be.an('array'));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{\n \"walletId\": \"wallet_1\"\n}" }, + "url": "{{baseUrl}}/v1/transfer-history", + "description": "Run after transfer scenarios. Expect responseCode 20003." + } + }, + { + "name": "02 - History For wallet_3 (Likely Empty)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 20003', () => pm.expect(pm.response.json().responseCode).to.eql(20003));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{\n \"walletId\": \"wallet_3\"\n}" }, + "url": "{{baseUrl}}/v1/transfer-history", + "description": "Failed transfers leave no ledger entries. Expect empty or minimal history." + } + }, + { + "name": "03 - Wallet Not Found", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 5113', () => pm.expect(pm.response.json().responseCode).to.eql(5113));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{\n \"walletId\": \"wallet_missing\"\n}" }, + "url": "{{baseUrl}}/v1/transfer-history" + } + }, + { + "name": "04 - Validation Error (Missing walletId)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('responseCode 6017', () => pm.expect(pm.response.json().responseCode).to.eql(6017));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{}" }, + "url": "{{baseUrl}}/v1/transfer-history" + } + } + ] + }, + { + "name": "End-to-End Flow", + "description": "Run steps in order. Step 2 saves the idempotency key for Step 5 automatically.", + "item": [ + { + "name": "Step 1 - Check wallet_1 Balance (Before)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.collectionVariables.set('e2eBalanceBefore', pm.response.json().data.balance);" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{\n \"walletId\": \"wallet_1\"\n}" }, + "url": "{{baseUrl}}/v1/wallet-balance" + } + }, + { + "name": "Step 2 - Transfer 200 wallet_1 → wallet_2", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "pm.collectionVariables.set('e2eIdempotencyKey', 'postman-e2e-' + Date.now());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('transfer PROCESSED', () => pm.expect(pm.response.json().data.status).to.eql('PROCESSED'));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"idempotencyKey\": \"{{e2eIdempotencyKey}}\",\n \"fromWalletId\": \"wallet_1\",\n \"toWalletId\": \"wallet_2\",\n \"amount\": 200\n}" + }, + "url": "{{baseUrl}}/v1/transfers" + } + }, + { + "name": "Step 3 - Check wallet_1 Balance (After)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const before = pm.collectionVariables.get('e2eBalanceBefore');", + "const after = pm.response.json().data.balance;", + "pm.test('balance reduced by 200', () => pm.expect(after).to.eql(before - 200));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{\n \"walletId\": \"wallet_1\"\n}" }, + "url": "{{baseUrl}}/v1/wallet-balance" + } + }, + { + "name": "Step 4 - View wallet_1 Transfer History", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('history not empty', () => pm.expect(pm.response.json().data.length).to.be.above(0));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{\n \"walletId\": \"wallet_1\"\n}" }, + "url": "{{baseUrl}}/v1/transfer-history" + } + }, + { + "name": "Step 5 - Replay Transfer (Idempotent)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('replay succeeds', () => pm.expect(pm.response.json().responseCode).to.eql(20001));", + "const afterReplay = pm.response.json().data;", + "pm.test('still PROCESSED', () => pm.expect(afterReplay.status).to.eql('PROCESSED'));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"idempotencyKey\": \"{{e2eIdempotencyKey}}\",\n \"fromWalletId\": \"wallet_1\",\n \"toWalletId\": \"wallet_2\",\n \"amount\": 200\n}" + }, + "url": "{{baseUrl}}/v1/transfers", + "description": "Uses e2eIdempotencyKey set in Step 2. Re-run Step 3 after this — balance must not change." + } + }, + { + "name": "Step 6 - Confirm Balance Unchanged After Replay", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const before = pm.collectionVariables.get('e2eBalanceBefore');", + "const current = pm.response.json().data.balance;", + "pm.test('balance still reduced by exactly 200 from original', () => pm.expect(current).to.eql(before - 200));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{\n \"walletId\": \"wallet_1\"\n}" }, + "url": "{{baseUrl}}/v1/wallet-balance" + } + } + ] + } + ] +} diff --git a/sonar-project.properties b/sonar-project.properties index 4194a515..1692ba50 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,8 +1,13 @@ -# Update these values for your GitHub org and SonarQube project. +# SonarQube analysis configuration sonar.projectKey=wallet-transfer-assignment sonar.projectName=wallet-transfer-assignment sonar.sourceEncoding=UTF-8 -# Adjust these paths once you pick the language/runtime for the assignment. -sonar.sources=. -sonar.exclusions=**/.git/**,**/.github/**,**/vendor/**,**/node_modules/**,**/coverage/** +sonar.sources=src/main/java +sonar.tests=src/test/java +sonar.java.binaries=target/classes +sonar.java.test.binaries=target/test-classes +sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml + +sonar.exclusions=**/WalletTransferServiceApplication.java +sonar.test.exclusions=**/* diff --git a/src/main/java/com/wallettransfer/WalletTransferServiceApplication.java b/src/main/java/com/wallettransfer/WalletTransferServiceApplication.java new file mode 100644 index 00000000..e14df85f --- /dev/null +++ b/src/main/java/com/wallettransfer/WalletTransferServiceApplication.java @@ -0,0 +1,16 @@ +package com.wallettransfer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.ComponentScan; + +@ComponentScan(basePackages = {"com.wallettransfer.*"}) +@EntityScan("com.wallettransfer.*") +@SpringBootApplication +public class WalletTransferServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(WalletTransferServiceApplication.class, args); + } +} diff --git a/src/main/java/com/wallettransfer/builder/ResponseBuilderFactory.java b/src/main/java/com/wallettransfer/builder/ResponseBuilderFactory.java new file mode 100644 index 00000000..f1e3365f --- /dev/null +++ b/src/main/java/com/wallettransfer/builder/ResponseBuilderFactory.java @@ -0,0 +1,35 @@ +package com.wallettransfer.builder; + +import com.wallettransfer.constants.ApplicationConstants; +import com.wallettransfer.dto.responsedto.ResponseDTO; + +public final class ResponseBuilderFactory { + + private ResponseBuilderFactory() {} + + public static ResponseDTO getResponse(String message, String responseCode) { + return getResponse(message, responseCode, null); + } + + public static ResponseDTO getResponse(String message, String responseCode, Object data) { + int code = parseResponseCode(responseCode); + return ResponseDTO.builder() + .status(isSuccessCode(code) ? ApplicationConstants.SUCCESS : ApplicationConstants.FAIL) + .message(message) + .responseCode(code) + .data(data) + .build(); + } + + private static int parseResponseCode(String responseCode) { + try { + return Integer.parseInt(responseCode); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Invalid response code: " + responseCode, ex); + } + } + + private static boolean isSuccessCode(int code) { + return code >= 20000 && code <= 100000; + } +} diff --git a/src/main/java/com/wallettransfer/configuration/GlobalRestExceptionHandler.java b/src/main/java/com/wallettransfer/configuration/GlobalRestExceptionHandler.java new file mode 100644 index 00000000..a7aa95dc --- /dev/null +++ b/src/main/java/com/wallettransfer/configuration/GlobalRestExceptionHandler.java @@ -0,0 +1,57 @@ +package com.wallettransfer.configuration; + +import com.wallettransfer.builder.ResponseBuilderFactory; +import com.wallettransfer.constants.ApplicationConstants; +import com.wallettransfer.constants.ResponseConstant; +import com.wallettransfer.dto.responsedto.ResponseDTO; +import com.wallettransfer.exception.ResponseException; +import com.wallettransfer.service.loggerservice.LogService; +import com.wallettransfer.utility.LogSanitizer; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalRestExceptionHandler { + + private final String className = this.getClass().getSimpleName(); + private final LogService logService; + private final HttpServletRequest request; + + public GlobalRestExceptionHandler(LogService logService, HttpServletRequest request) { + this.logService = logService; + this.request = request; + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseDTO handleValidationErrors(MethodArgumentNotValidException ex) { + String message = + ex.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .findFirst() + .orElse(ApplicationConstants.MANDATORY_PARAMETER_VALIDATION_FAILURE); + return ResponseBuilderFactory.getResponse(message, ResponseConstant.INVALID_REQUEST); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseDTO handleHttpMessageNotReadable(HttpMessageNotReadableException ex) { + logService.printErrorLogs(request, className, ex); + return ResponseBuilderFactory.getResponse( + ApplicationConstants.FAIL, ResponseConstant.FAILURE_CODE); + } + + @ExceptionHandler(ResponseException.class) + public ResponseDTO handleResponseException(ResponseException ex) { + logService.printResponseErrorLogs(request, className, LogSanitizer.sanitize(ex.getMessage())); + return ResponseBuilderFactory.getResponse(ex.getMessage(), ex.getResponseCode(), ex.getData()); + } + + @ExceptionHandler(Exception.class) + public ResponseDTO handleUnexpectedException(Exception ex) { + logService.printErrorLogs(request, className, ex); + return ResponseBuilderFactory.getResponse( + ApplicationConstants.FAIL, ResponseConstant.FAILURE_CODE); + } +} diff --git a/src/main/java/com/wallettransfer/configuration/SwaggerConfig.java b/src/main/java/com/wallettransfer/configuration/SwaggerConfig.java new file mode 100644 index 00000000..dacf9a85 --- /dev/null +++ b/src/main/java/com/wallettransfer/configuration/SwaggerConfig.java @@ -0,0 +1,43 @@ +package com.wallettransfer.configuration; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Value("${server.servlet.context-path:/}") + private String contextPath; + + @Bean + public OpenAPI walletTransferOpenApi() { + return new OpenAPI() + .info( + new Info() + .title("Wallet Transfer Service") + .description( + """ + Wallet-to-wallet transfer APIs with idempotency, concurrency-safe balance updates, \ + and double-entry ledger recording. + + **Response codes (common):** + - 20001 — Transfer processed successfully + - 20002 — Wallet balance fetched successfully + - 20003 — Transfer history fetched successfully + - 5001 — Transfer failed (business rule) + - 5002 — Insufficient funds + - 5003 — Idempotency key conflict (same key, different payload) + - 5113 — Wallet not found + - 6017 — Invalid request / validation failure + - 5000 — Unexpected server error + """) + .version("1.0.0") + .contact(new Contact().name("Wallet Transfer Team"))) + .addServersItem(new Server().url(contextPath).description("Default server")); + } +} diff --git a/src/main/java/com/wallettransfer/constants/ApiConstants.java b/src/main/java/com/wallettransfer/constants/ApiConstants.java new file mode 100644 index 00000000..fc07247a --- /dev/null +++ b/src/main/java/com/wallettransfer/constants/ApiConstants.java @@ -0,0 +1,11 @@ +package com.wallettransfer.constants; + +public final class ApiConstants { + + private ApiConstants() {} + + public static final String API_VERSION_V1 = "/v1"; + public static final String TRANSFERS = "/transfers"; + public static final String WALLET_BALANCE = "/wallet-balance"; + public static final String TRANSFER_HISTORY = "/transfer-history"; +} diff --git a/src/main/java/com/wallettransfer/constants/ApplicationConstants.java b/src/main/java/com/wallettransfer/constants/ApplicationConstants.java new file mode 100644 index 00000000..d1cac323 --- /dev/null +++ b/src/main/java/com/wallettransfer/constants/ApplicationConstants.java @@ -0,0 +1,11 @@ +package com.wallettransfer.constants; + +public final class ApplicationConstants { + + private ApplicationConstants() {} + + public static final String SUCCESS = "SUCCESS"; + public static final String FAIL = "FAIL"; + public static final String MANDATORY_PARAMETER_VALIDATION_FAILURE = + "Mandatory parameter validation failure"; +} diff --git a/src/main/java/com/wallettransfer/constants/ResponseConstant.java b/src/main/java/com/wallettransfer/constants/ResponseConstant.java new file mode 100644 index 00000000..24fcfdc8 --- /dev/null +++ b/src/main/java/com/wallettransfer/constants/ResponseConstant.java @@ -0,0 +1,19 @@ +package com.wallettransfer.constants; + +public final class ResponseConstant { + + private ResponseConstant() {} + + public static final String FAILURE_CODE = "5000"; + public static final String SUCCESS_CODE = "20000"; + + public static final String TRANSFER_SUCCESS = "20001"; + public static final String BALANCE_FETCH_SUCCESS = "20002"; + public static final String HISTORY_FETCH_SUCCESS = "20003"; + + public static final String INVALID_REQUEST = "6017"; + public static final String TRANSFER_FAILED = "5001"; + public static final String INSUFFICIENT_FUNDS = "5002"; + public static final String WALLET_NOT_FOUND = "5113"; + public static final String IDEMPOTENCY_CONFLICT = "5003"; +} diff --git a/src/main/java/com/wallettransfer/controller/TransferController.java b/src/main/java/com/wallettransfer/controller/TransferController.java new file mode 100644 index 00000000..f09868ef --- /dev/null +++ b/src/main/java/com/wallettransfer/controller/TransferController.java @@ -0,0 +1,93 @@ +package com.wallettransfer.controller; + +import com.wallettransfer.constants.ApiConstants; +import com.wallettransfer.constants.ResponseConstant; +import com.wallettransfer.dto.requestdto.CreateTransferRequestDTO; +import com.wallettransfer.dto.responsedto.ResponseDTO; +import com.wallettransfer.service.loggerservice.LogService; +import com.wallettransfer.service.transferservice.TransferService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = ApiConstants.API_VERSION_V1) +@Tag( + name = "Transfer APIs", + description = + "Wallet-to-wallet transfer operations with idempotency, pessimistic locking, and double-entry ledger recording.") +public class TransferController { + + private final String className = this.getClass().getSimpleName(); + private final LogService logService; + private final HttpServletRequest request; + private final TransferService transferService; + + public TransferController( + LogService logService, + HttpServletRequest request, + TransferService transferService) { + this.logService = logService; + this.request = request; + this.transferService = transferService; + } + + @PostMapping("transfers") + @Operation( + summary = "Create wallet transfer", + description = + """ + Executes an atomic wallet-to-wallet transfer. + + **Idempotency:** When the same `idempotencyKey` is sent with the same payload, the original \ + transfer result is returned without creating a duplicate transfer or ledger entries. + + **Concurrency:** Source and destination wallets are locked in a consistent order to prevent \ + double spending under concurrent requests. + + **Ledger:** A successful transfer creates exactly two ledger entries — one DEBIT on the source \ + wallet and one CREDIT on the destination wallet. + + **Transfer states:** PENDING → PROCESSED on success, PENDING → FAILED on business failure \ + (e.g. insufficient funds). + """) + @ApiResponse( + responseCode = ResponseConstant.TRANSFER_SUCCESS, + description = + "Transfer processed successfully. `data.status` is PROCESSED and includes balanced ledger entries.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + @ApiResponse( + responseCode = ResponseConstant.INSUFFICIENT_FUNDS, + description = + "Transfer failed due to insufficient balance. `data.status` is FAILED; no ledger entries are created.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + @ApiResponse( + responseCode = ResponseConstant.WALLET_NOT_FOUND, + description = "Source or destination wallet does not exist.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + @ApiResponse( + responseCode = ResponseConstant.IDEMPOTENCY_CONFLICT, + description = "The same idempotency key was reused with a different request payload.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + @ApiResponse( + responseCode = ResponseConstant.INVALID_REQUEST, + description = + "Validation failed — e.g. missing fields, amount <= 0, or fromWalletId equals toWalletId.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + @ApiResponse( + responseCode = ResponseConstant.FAILURE_CODE, + description = "Unexpected server error.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + public ResponseDTO createTransfer(@Valid @RequestBody CreateTransferRequestDTO transferRequest) { + logService.printInfoLogs(request, className, "Create Transfer"); + return transferService.createTransfer(transferRequest); + } +} diff --git a/src/main/java/com/wallettransfer/controller/WalletController.java b/src/main/java/com/wallettransfer/controller/WalletController.java new file mode 100644 index 00000000..3e7a2e2d --- /dev/null +++ b/src/main/java/com/wallettransfer/controller/WalletController.java @@ -0,0 +1,104 @@ +package com.wallettransfer.controller; + +import com.wallettransfer.constants.ApiConstants; +import com.wallettransfer.constants.ResponseConstant; +import com.wallettransfer.dto.requestdto.TransferHistoryRequestDTO; +import com.wallettransfer.dto.requestdto.WalletBalanceRequestDTO; +import com.wallettransfer.dto.responsedto.ResponseDTO; +import com.wallettransfer.service.loggerservice.LogService; +import com.wallettransfer.service.walletservice.WalletService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = ApiConstants.API_VERSION_V1) +@Tag( + name = "Wallet APIs", + description = "Read-only wallet operations for balance lookup and transfer history.") +public class WalletController { + + private final String className = this.getClass().getSimpleName(); + private final LogService logService; + private final HttpServletRequest request; + private final WalletService walletService; + + public WalletController( + LogService logService, HttpServletRequest request, WalletService walletService) { + this.logService = logService; + this.request = request; + this.walletService = walletService; + } + + @PostMapping("wallet-balance") + @Operation( + summary = "Get wallet balance", + description = + """ + Returns the current stored balance for a wallet. + + Balances are updated atomically during transfer processing and protected by pessimistic \ + row locks to remain correct under concurrent debits. + """) + @ApiResponse( + responseCode = ResponseConstant.BALANCE_FETCH_SUCCESS, + description = "Balance fetched successfully. `data` contains `walletId` and `balance`.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + @ApiResponse( + responseCode = ResponseConstant.WALLET_NOT_FOUND, + description = "Wallet does not exist.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + @ApiResponse( + responseCode = ResponseConstant.INVALID_REQUEST, + description = "Validation failed — e.g. missing walletId.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + @ApiResponse( + responseCode = ResponseConstant.FAILURE_CODE, + description = "Unexpected server error.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + public ResponseDTO getWalletBalance(@Valid @RequestBody WalletBalanceRequestDTO balanceRequest) { + logService.printInfoLogs(request, className, "Get Wallet Balance"); + return walletService.getWalletBalance(balanceRequest); + } + + @PostMapping("transfer-history") + @Operation( + summary = "Get transfer history for a wallet", + description = + """ + Returns transfer details for all transfers that have ledger entries involving the given wallet. + + Each item includes transfer metadata, status, and the full set of ledger entries for that transfer \ + (DEBIT and CREDIT), ordered by most recent activity first. + """) + @ApiResponse( + responseCode = ResponseConstant.HISTORY_FETCH_SUCCESS, + description = + "Transfer history fetched successfully. `data` is a list of transfer responses.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + @ApiResponse( + responseCode = ResponseConstant.WALLET_NOT_FOUND, + description = "Wallet does not exist.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + @ApiResponse( + responseCode = ResponseConstant.INVALID_REQUEST, + description = "Validation failed — e.g. missing walletId.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + @ApiResponse( + responseCode = ResponseConstant.FAILURE_CODE, + description = "Unexpected server error.", + content = @Content(schema = @Schema(implementation = ResponseDTO.class))) + public ResponseDTO getTransferHistory( + @Valid @RequestBody TransferHistoryRequestDTO historyRequest) { + logService.printInfoLogs(request, className, "Get Transfer History"); + return walletService.getTransferHistory(historyRequest); + } +} diff --git a/src/main/java/com/wallettransfer/dto/requestdto/CreateTransferRequestDTO.java b/src/main/java/com/wallettransfer/dto/requestdto/CreateTransferRequestDTO.java new file mode 100644 index 00000000..30d39f8e --- /dev/null +++ b/src/main/java/com/wallettransfer/dto/requestdto/CreateTransferRequestDTO.java @@ -0,0 +1,41 @@ +package com.wallettransfer.dto.requestdto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@ToString +@Schema(description = "Request to create an idempotent wallet-to-wallet transfer") +public class CreateTransferRequestDTO { + + @NotBlank(message = "idempotencyKey is required") + @Schema( + description = "Client-supplied unique key for exactly-once API semantics", + example = "abc123") + private String idempotencyKey; + + @NotBlank(message = "fromWalletId is required") + @Schema(description = "Source wallet identifier", example = "wallet_1") + private String fromWalletId; + + @NotBlank(message = "toWalletId is required") + @Schema(description = "Destination wallet identifier", example = "wallet_2") + private String toWalletId; + + @NotNull(message = "amount is required") + @Min(value = 1, message = "amount must be greater than zero") + @Schema(description = "Transfer amount in smallest currency unit", example = "100", minimum = "1") + private Long amount; +} diff --git a/src/main/java/com/wallettransfer/dto/requestdto/TransferHistoryRequestDTO.java b/src/main/java/com/wallettransfer/dto/requestdto/TransferHistoryRequestDTO.java new file mode 100644 index 00000000..5b8e3c2a --- /dev/null +++ b/src/main/java/com/wallettransfer/dto/requestdto/TransferHistoryRequestDTO.java @@ -0,0 +1,24 @@ +package com.wallettransfer.dto.requestdto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@ToString +@Schema(description = "Request to fetch transfer history for a wallet") +public class TransferHistoryRequestDTO { + + @NotBlank(message = "walletId is required") + @Schema(description = "Wallet identifier whose transfer history is requested", example = "wallet_1") + private String walletId; +} diff --git a/src/main/java/com/wallettransfer/dto/requestdto/WalletBalanceRequestDTO.java b/src/main/java/com/wallettransfer/dto/requestdto/WalletBalanceRequestDTO.java new file mode 100644 index 00000000..fa7cf43b --- /dev/null +++ b/src/main/java/com/wallettransfer/dto/requestdto/WalletBalanceRequestDTO.java @@ -0,0 +1,24 @@ +package com.wallettransfer.dto.requestdto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@ToString +@Schema(description = "Request to fetch the current balance of a wallet") +public class WalletBalanceRequestDTO { + + @NotBlank(message = "walletId is required") + @Schema(description = "Wallet identifier", example = "wallet_1") + private String walletId; +} diff --git a/src/main/java/com/wallettransfer/dto/responsedto/LedgerEntryResponseDTO.java b/src/main/java/com/wallettransfer/dto/responsedto/LedgerEntryResponseDTO.java new file mode 100644 index 00000000..88e81b4b --- /dev/null +++ b/src/main/java/com/wallettransfer/dto/responsedto/LedgerEntryResponseDTO.java @@ -0,0 +1,24 @@ +package com.wallettransfer.dto.responsedto; + +import com.wallettransfer.enums.LedgerEntryType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@ToString +public class LedgerEntryResponseDTO { + + private Long entryId; + private String walletId; + private String transferId; + private LedgerEntryType type; + private Long amount; +} diff --git a/src/main/java/com/wallettransfer/dto/responsedto/ResponseDTO.java b/src/main/java/com/wallettransfer/dto/responsedto/ResponseDTO.java new file mode 100644 index 00000000..89d62b61 --- /dev/null +++ b/src/main/java/com/wallettransfer/dto/responsedto/ResponseDTO.java @@ -0,0 +1,42 @@ +package com.wallettransfer.dto.responsedto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@ToString +@Schema( + description = + """ + Standard API response envelope. + + - `status`: SUCCESS (responseCode 20000–29999) or FAIL otherwise + - `responseCode`: business outcome code — see endpoint documentation for possible values + - `data`: endpoint-specific payload (transfer details, balance, history list, etc.) + """) +public class ResponseDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(description = "Outcome status", example = "SUCCESS", allowableValues = {"SUCCESS", "FAIL"}) + private String status; + + @Schema(description = "Human-readable message", example = "Transfer processed successfully") + private String message; + + @Schema(description = "Business response code", example = "20001") + private Integer responseCode; + + @Schema(description = "Endpoint-specific response payload") + private transient Object data; +} diff --git a/src/main/java/com/wallettransfer/dto/responsedto/TransferResponseDTO.java b/src/main/java/com/wallettransfer/dto/responsedto/TransferResponseDTO.java new file mode 100644 index 00000000..91138219 --- /dev/null +++ b/src/main/java/com/wallettransfer/dto/responsedto/TransferResponseDTO.java @@ -0,0 +1,28 @@ +package com.wallettransfer.dto.responsedto; + +import com.wallettransfer.enums.TransferStatus; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@ToString +public class TransferResponseDTO { + + private String transferId; + private String idempotencyKey; + private String fromWalletId; + private String toWalletId; + private Long amount; + private TransferStatus status; + private String failureReason; + private List ledgerEntries; +} diff --git a/src/main/java/com/wallettransfer/dto/responsedto/WalletBalanceResponseDTO.java b/src/main/java/com/wallettransfer/dto/responsedto/WalletBalanceResponseDTO.java new file mode 100644 index 00000000..65bf4769 --- /dev/null +++ b/src/main/java/com/wallettransfer/dto/responsedto/WalletBalanceResponseDTO.java @@ -0,0 +1,20 @@ +package com.wallettransfer.dto.responsedto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@ToString +public class WalletBalanceResponseDTO { + + private String walletId; + private Long balance; +} diff --git a/src/main/java/com/wallettransfer/entity/IdempotencyRecord.java b/src/main/java/com/wallettransfer/entity/IdempotencyRecord.java new file mode 100644 index 00000000..460761de --- /dev/null +++ b/src/main/java/com/wallettransfer/entity/IdempotencyRecord.java @@ -0,0 +1,68 @@ +package com.wallettransfer.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +@Entity +@Table( + name = "idempotency_records", + uniqueConstraints = { + @UniqueConstraint(name = "uq_idempotency_key", columnNames = {"idempotency_key"}) + }) +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class IdempotencyRecord { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "idempotency_key", nullable = false, length = 128) + private String idempotencyKey; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn( + name = "transfer_id", + foreignKey = @ForeignKey(name = "fk_idempotency_transfer")) + private Transfer transfer; + + @Column(name = "request_hash", nullable = false, length = 64) + private String requestHash; + + @Column(name = "response_status", nullable = false, length = 16) + private String responseStatus; + + @Column(name = "response_code", nullable = false) + private Integer responseCode; + + @Column(name = "response_message", nullable = false, length = 512) + private String responseMessage; + + @Lob + @Column(name = "response_payload", nullable = false) + private String responsePayload; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; +} diff --git a/src/main/java/com/wallettransfer/entity/LedgerEntry.java b/src/main/java/com/wallettransfer/entity/LedgerEntry.java new file mode 100644 index 00000000..4b26a84e --- /dev/null +++ b/src/main/java/com/wallettransfer/entity/LedgerEntry.java @@ -0,0 +1,75 @@ +package com.wallettransfer.entity; + +import com.wallettransfer.enums.LedgerEntryType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.Check; +import org.hibernate.annotations.CreationTimestamp; + +@Entity +@Table( + name = "ledger_entries", + indexes = { + @Index(name = "idx_ledger_transfer", columnList = "transfer_id"), + @Index(name = "idx_ledger_wallet", columnList = "wallet_id") + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uq_ledger_transfer_wallet_type", + columnNames = {"transfer_id", "wallet_id", "entry_type"}) + }) +@Check(constraints = "amount > 0") +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class LedgerEntry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn( + name = "wallet_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_ledger_wallet")) + private Wallet wallet; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn( + name = "transfer_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_ledger_transfer")) + private Transfer transfer; + + @Enumerated(EnumType.STRING) + @Column(name = "entry_type", nullable = false, length = 16) + private LedgerEntryType entryType; + + @Column(name = "amount", nullable = false) + private Long amount; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; +} diff --git a/src/main/java/com/wallettransfer/entity/Transfer.java b/src/main/java/com/wallettransfer/entity/Transfer.java new file mode 100644 index 00000000..f5fa6803 --- /dev/null +++ b/src/main/java/com/wallettransfer/entity/Transfer.java @@ -0,0 +1,101 @@ +package com.wallettransfer.entity; + +import com.wallettransfer.enums.TransferStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.Check; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table( + name = "transfers", + indexes = { + @Index(name = "idx_transfers_from_wallet", columnList = "from_wallet_id"), + @Index(name = "idx_transfers_to_wallet", columnList = "to_wallet_id"), + @Index(name = "idx_transfers_status", columnList = "status") + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uq_transfers_idempotency_key", + columnNames = {"idempotency_key"}) + }) +@Check(constraints = "amount > 0") +@Check(constraints = "from_wallet_id <> to_wallet_id") +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class Transfer { + + @Id + @Column(name = "id", nullable = false, length = 36) + private String id; + + @Column(name = "idempotency_key", nullable = false, length = 128) + private String idempotencyKey; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn( + name = "from_wallet_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_transfers_from_wallet")) + private Wallet fromWallet; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn( + name = "to_wallet_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_transfers_to_wallet")) + private Wallet toWallet; + + @Column(name = "amount", nullable = false) + private Long amount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private TransferStatus status; + + @Column(name = "failure_reason", length = 512) + private String failureReason; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + public void markProcessed() { + if (status != TransferStatus.PENDING) { + throw new IllegalStateException("Cannot mark transfer processed from status " + status); + } + status = TransferStatus.PROCESSED; + } + + public void markFailed(String reason) { + if (status != TransferStatus.PENDING) { + throw new IllegalStateException("Cannot mark transfer failed from status " + status); + } + status = TransferStatus.FAILED; + failureReason = reason; + } +} diff --git a/src/main/java/com/wallettransfer/entity/Wallet.java b/src/main/java/com/wallettransfer/entity/Wallet.java new file mode 100644 index 00000000..fe0a03d2 --- /dev/null +++ b/src/main/java/com/wallettransfer/entity/Wallet.java @@ -0,0 +1,48 @@ +package com.wallettransfer.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.Check; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table(name = "wallets") +@Check(constraints = "balance >= 0") +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class Wallet { + + @Id + @Column(name = "wallet_id", nullable = false, length = 64) + private String walletId; + + @Builder.Default + @Column(name = "balance", nullable = false) + private Long balance = 0L; + + @Version + @Builder.Default + @Column(name = "version", nullable = false) + private Long version = 0L; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/wallettransfer/enums/LedgerEntryType.java b/src/main/java/com/wallettransfer/enums/LedgerEntryType.java new file mode 100644 index 00000000..ad8a48ea --- /dev/null +++ b/src/main/java/com/wallettransfer/enums/LedgerEntryType.java @@ -0,0 +1,6 @@ +package com.wallettransfer.enums; + +public enum LedgerEntryType { + DEBIT, + CREDIT +} diff --git a/src/main/java/com/wallettransfer/enums/TransferStatus.java b/src/main/java/com/wallettransfer/enums/TransferStatus.java new file mode 100644 index 00000000..17006f4c --- /dev/null +++ b/src/main/java/com/wallettransfer/enums/TransferStatus.java @@ -0,0 +1,7 @@ +package com.wallettransfer.enums; + +public enum TransferStatus { + PENDING, + PROCESSED, + FAILED +} diff --git a/src/main/java/com/wallettransfer/exception/ResponseException.java b/src/main/java/com/wallettransfer/exception/ResponseException.java new file mode 100644 index 00000000..131dc73d --- /dev/null +++ b/src/main/java/com/wallettransfer/exception/ResponseException.java @@ -0,0 +1,22 @@ +package com.wallettransfer.exception; + +import java.io.Serializable; +import lombok.Getter; + +@Getter +public class ResponseException extends RuntimeException { + + private static final long serialVersionUID = 1L; + private final String responseCode; + private final Serializable data; + + public ResponseException(String message, String responseCode) { + this(message, responseCode, null); + } + + public ResponseException(String message, String responseCode, Object data) { + super(message); + this.responseCode = responseCode; + this.data = data instanceof Serializable serializableData ? serializableData : null; + } +} diff --git a/src/main/java/com/wallettransfer/repo/IdempotencyRecordRepository.java b/src/main/java/com/wallettransfer/repo/IdempotencyRecordRepository.java new file mode 100644 index 00000000..a889b517 --- /dev/null +++ b/src/main/java/com/wallettransfer/repo/IdempotencyRecordRepository.java @@ -0,0 +1,12 @@ +package com.wallettransfer.repo; + +import com.wallettransfer.entity.IdempotencyRecord; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface IdempotencyRecordRepository extends JpaRepository { + + Optional findByIdempotencyKey(String idempotencyKey); +} diff --git a/src/main/java/com/wallettransfer/repo/LedgerEntryRepository.java b/src/main/java/com/wallettransfer/repo/LedgerEntryRepository.java new file mode 100644 index 00000000..c52a6ea2 --- /dev/null +++ b/src/main/java/com/wallettransfer/repo/LedgerEntryRepository.java @@ -0,0 +1,14 @@ +package com.wallettransfer.repo; + +import com.wallettransfer.entity.LedgerEntry; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface LedgerEntryRepository extends JpaRepository { + + List findByTransfer_Id(String transferId); + + List findByWallet_WalletIdOrderByCreatedAtDesc(String walletId); +} diff --git a/src/main/java/com/wallettransfer/repo/TransferRepository.java b/src/main/java/com/wallettransfer/repo/TransferRepository.java new file mode 100644 index 00000000..a9b77bab --- /dev/null +++ b/src/main/java/com/wallettransfer/repo/TransferRepository.java @@ -0,0 +1,12 @@ +package com.wallettransfer.repo; + +import com.wallettransfer.entity.Transfer; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TransferRepository extends JpaRepository { + + Optional findByIdempotencyKey(String idempotencyKey); +} diff --git a/src/main/java/com/wallettransfer/repo/WalletRepository.java b/src/main/java/com/wallettransfer/repo/WalletRepository.java new file mode 100644 index 00000000..ba542739 --- /dev/null +++ b/src/main/java/com/wallettransfer/repo/WalletRepository.java @@ -0,0 +1,23 @@ +package com.wallettransfer.repo; + +import com.wallettransfer.entity.Wallet; +import jakarta.persistence.LockModeType; +import java.util.List; +import java.util.Optional; +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 org.springframework.stereotype.Repository; + +@Repository +public interface WalletRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT w FROM Wallet w WHERE w.walletId = :walletId") + Optional findByWalletIdForUpdate(@Param("walletId") String walletId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT w FROM Wallet w WHERE w.walletId IN :walletIds ORDER BY w.walletId ASC") + List findByWalletIdsForUpdate(@Param("walletIds") List walletIds); +} diff --git a/src/main/java/com/wallettransfer/service/loggerservice/LogService.java b/src/main/java/com/wallettransfer/service/loggerservice/LogService.java new file mode 100644 index 00000000..366ed1e4 --- /dev/null +++ b/src/main/java/com/wallettransfer/service/loggerservice/LogService.java @@ -0,0 +1,16 @@ +package com.wallettransfer.service.loggerservice; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; + +@Service +public interface LogService { + + void printInfoLogs(@Nullable HttpServletRequest request, String className, String logMessage); + + void printResponseErrorLogs( + @Nullable HttpServletRequest request, String className, String logMessage); + + void printErrorLogs(@Nullable HttpServletRequest request, String className, Exception exception); +} diff --git a/src/main/java/com/wallettransfer/service/loggerservice/LogServiceImpl.java b/src/main/java/com/wallettransfer/service/loggerservice/LogServiceImpl.java new file mode 100644 index 00000000..d90b7743 --- /dev/null +++ b/src/main/java/com/wallettransfer/service/loggerservice/LogServiceImpl.java @@ -0,0 +1,36 @@ +package com.wallettransfer.service.loggerservice; + +import com.wallettransfer.utility.LogSanitizer; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class LogServiceImpl implements LogService { + + private static final String LOG_FORMAT = "[{}] {}"; + + @Override + public void printInfoLogs( + @Nullable HttpServletRequest request, String className, String logMessage) { + log.info(LOG_FORMAT, className, LogSanitizer.sanitize(logMessage)); + } + + @Override + public void printResponseErrorLogs( + @Nullable HttpServletRequest request, String className, String logMessage) { + log.warn(LOG_FORMAT, className, LogSanitizer.sanitize(logMessage)); + } + + @Override + public void printErrorLogs( + @Nullable HttpServletRequest request, String className, Exception exception) { + String message = + Objects.requireNonNullElse( + exception.getMessage(), exception.getClass().getSimpleName()); + log.error(LOG_FORMAT, className, LogSanitizer.sanitize(message), exception); + } +} diff --git a/src/main/java/com/wallettransfer/service/transferservice/IdempotencyResponseLookup.java b/src/main/java/com/wallettransfer/service/transferservice/IdempotencyResponseLookup.java new file mode 100644 index 00000000..7ff82840 --- /dev/null +++ b/src/main/java/com/wallettransfer/service/transferservice/IdempotencyResponseLookup.java @@ -0,0 +1,117 @@ +package com.wallettransfer.service.transferservice; + +import com.wallettransfer.builder.ResponseBuilderFactory; +import com.wallettransfer.constants.ResponseConstant; +import com.wallettransfer.dto.responsedto.ResponseDTO; +import com.wallettransfer.dto.responsedto.TransferResponseDTO; +import com.wallettransfer.entity.IdempotencyRecord; +import com.wallettransfer.entity.LedgerEntry; +import com.wallettransfer.entity.Transfer; +import com.wallettransfer.enums.TransferStatus; +import com.wallettransfer.exception.ResponseException; +import com.wallettransfer.repo.IdempotencyRecordRepository; +import com.wallettransfer.repo.LedgerEntryRepository; +import com.wallettransfer.repo.TransferRepository; +import com.wallettransfer.utility.RequestHashUtility; +import com.wallettransfer.utility.TransferMapper; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class IdempotencyResponseLookup { + + private final TransferRepository transferRepository; + private final LedgerEntryRepository ledgerEntryRepository; + private final IdempotencyRecordRepository idempotencyRecordRepository; + private final RequestHashUtility requestHashUtility; + + public IdempotencyResponseLookup( + TransferRepository transferRepository, + LedgerEntryRepository ledgerEntryRepository, + IdempotencyRecordRepository idempotencyRecordRepository, + RequestHashUtility requestHashUtility) { + this.transferRepository = transferRepository; + this.ledgerEntryRepository = ledgerEntryRepository; + this.idempotencyRecordRepository = idempotencyRecordRepository; + this.requestHashUtility = requestHashUtility; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true) + public Optional findCachedResponse(String idempotencyKey, String requestHash) { + Optional idempotencyRecord = + idempotencyRecordRepository.findByIdempotencyKey(idempotencyKey); + if (idempotencyRecord.isPresent()) { + return Optional.of( + buildResponseFromIdempotencyRecord(idempotencyRecord.orElseThrow(), requestHash)); + } + + return transferRepository + .findByIdempotencyKey(idempotencyKey) + .map(this::buildResponseFromExistingTransfer); + } + + private ResponseDTO buildResponseFromIdempotencyRecord( + IdempotencyRecord idempotencyRecord, String requestHash) { + if (!idempotencyRecord.getRequestHash().equals(requestHash)) { + throw new ResponseException( + "Idempotency key reused with different request payload", + ResponseConstant.IDEMPOTENCY_CONFLICT); + } + TransferResponseDTO payload = + requestHashUtility.fromJson( + idempotencyRecord.getResponsePayload(), TransferResponseDTO.class); + return ResponseDTO.builder() + .status(idempotencyRecord.getResponseStatus()) + .message(idempotencyRecord.getResponseMessage()) + .responseCode(idempotencyRecord.getResponseCode()) + .data(payload) + .build(); + } + + private ResponseDTO buildResponseFromExistingTransfer(Transfer transfer) { + Transfer terminalTransfer = awaitTerminalTransfer(transfer); + List ledgerEntries = + ledgerEntryRepository.findByTransfer_Id(terminalTransfer.getId()); + TransferResponseDTO payload = + TransferMapper.toTransferResponse(terminalTransfer, ledgerEntries); + String message = + terminalTransfer.getStatus() == TransferStatus.PROCESSED + ? "Transfer processed successfully" + : "Transfer failed"; + String responseCode = + terminalTransfer.getStatus() == TransferStatus.PROCESSED + ? ResponseConstant.TRANSFER_SUCCESS + : ResponseConstant.TRANSFER_FAILED; + return ResponseBuilderFactory.getResponse(message, responseCode, payload); + } + + private Transfer awaitTerminalTransfer(Transfer transfer) { + if (transfer.getStatus() != TransferStatus.PENDING) { + return transfer; + } + + for (int attempt = 0; attempt < 20; attempt++) { + sleepBriefly(); + Transfer refreshed = + transferRepository.findById(transfer.getId()).orElse(transfer); + if (refreshed.getStatus() != TransferStatus.PENDING) { + return refreshed; + } + } + + throw new ResponseException( + "Transfer is still in progress for idempotency key " + transfer.getIdempotencyKey(), + ResponseConstant.IDEMPOTENCY_CONFLICT); + } + + private void sleepBriefly() { + try { + Thread.sleep(25); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/com/wallettransfer/service/transferservice/TransferExecutor.java b/src/main/java/com/wallettransfer/service/transferservice/TransferExecutor.java new file mode 100644 index 00000000..9dba79f8 --- /dev/null +++ b/src/main/java/com/wallettransfer/service/transferservice/TransferExecutor.java @@ -0,0 +1,193 @@ +package com.wallettransfer.service.transferservice; + +import com.wallettransfer.builder.ResponseBuilderFactory; +import com.wallettransfer.constants.ApplicationConstants; +import com.wallettransfer.constants.ResponseConstant; +import com.wallettransfer.dto.requestdto.CreateTransferRequestDTO; +import com.wallettransfer.dto.responsedto.ResponseDTO; +import com.wallettransfer.dto.responsedto.TransferResponseDTO; +import com.wallettransfer.entity.IdempotencyRecord; +import com.wallettransfer.entity.LedgerEntry; +import com.wallettransfer.entity.Transfer; +import com.wallettransfer.entity.Wallet; +import com.wallettransfer.enums.LedgerEntryType; +import com.wallettransfer.enums.TransferStatus; +import com.wallettransfer.exception.ResponseException; +import com.wallettransfer.repo.IdempotencyRecordRepository; +import com.wallettransfer.repo.LedgerEntryRepository; +import com.wallettransfer.repo.TransferRepository; +import com.wallettransfer.repo.WalletRepository; +import com.wallettransfer.utility.RequestHashUtility; +import com.wallettransfer.utility.TransferMapper; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class TransferExecutor { + + private final TransferRepository transferRepository; + private final WalletRepository walletRepository; + private final LedgerEntryRepository ledgerEntryRepository; + private final IdempotencyRecordRepository idempotencyRecordRepository; + private final RequestHashUtility requestHashUtility; + + public TransferExecutor( + TransferRepository transferRepository, + WalletRepository walletRepository, + LedgerEntryRepository ledgerEntryRepository, + IdempotencyRecordRepository idempotencyRecordRepository, + RequestHashUtility requestHashUtility) { + this.transferRepository = transferRepository; + this.walletRepository = walletRepository; + this.ledgerEntryRepository = ledgerEntryRepository; + this.idempotencyRecordRepository = idempotencyRecordRepository; + this.requestHashUtility = requestHashUtility; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ResponseDTO executeTransfer(CreateTransferRequestDTO transferRequest, String requestHash) { + List walletIds = + List.of(transferRequest.getFromWalletId(), transferRequest.getToWalletId()).stream() + .sorted() + .distinct() + .toList(); + + List lockedWallets = walletRepository.findByWalletIdsForUpdate(walletIds); + Map walletById = + lockedWallets.stream().collect(Collectors.toMap(Wallet::getWalletId, Function.identity())); + + Wallet fromWallet = resolveWallet(walletById, transferRequest.getFromWalletId()); + Wallet toWallet = resolveWallet(walletById, transferRequest.getToWalletId()); + + Transfer transfer = createPendingTransfer(transferRequest, fromWallet, toWallet); + transferRepository.save(transfer); + + if (fromWallet.getBalance() < transferRequest.getAmount()) { + return failTransfer( + transfer, + requestHash, + ResponseConstant.INSUFFICIENT_FUNDS, + "Insufficient funds in wallet " + + transferRequest.getFromWalletId() + + ". Requested: " + + transferRequest.getAmount() + + ", available: " + + fromWallet.getBalance()); + } + + applyTransfer(fromWallet, toWallet, transferRequest.getAmount()); + walletRepository.saveAll(List.of(fromWallet, toWallet)); + + List ledgerEntries = + createLedgerEntries(transfer, fromWallet, toWallet, transferRequest.getAmount()); + + transfer.markProcessed(); + transferRepository.save(transfer); + + TransferResponseDTO payload = TransferMapper.toTransferResponse(transfer, ledgerEntries); + return persistSuccessResponse(transferRequest, requestHash, transfer, payload); + } + + private Wallet resolveWallet(Map walletById, String walletId) { + Wallet wallet = walletById.get(walletId); + if (wallet == null) { + throw new ResponseException("Wallet not found", ResponseConstant.WALLET_NOT_FOUND); + } + return wallet; + } + + private Transfer createPendingTransfer( + CreateTransferRequestDTO transferRequest, Wallet fromWallet, Wallet toWallet) { + return Transfer.builder() + .id(UUID.randomUUID().toString()) + .idempotencyKey(transferRequest.getIdempotencyKey()) + .fromWallet(fromWallet) + .toWallet(toWallet) + .amount(transferRequest.getAmount()) + .status(TransferStatus.PENDING) + .build(); + } + + private void applyTransfer(Wallet fromWallet, Wallet toWallet, long amount) { + fromWallet.setBalance(fromWallet.getBalance() - amount); + toWallet.setBalance(toWallet.getBalance() + amount); + } + + private List createLedgerEntries( + Transfer transfer, Wallet fromWallet, Wallet toWallet, long amount) { + List ledgerEntries = new ArrayList<>(); + ledgerEntries.add( + ledgerEntryRepository.save( + LedgerEntry.builder() + .wallet(fromWallet) + .transfer(transfer) + .entryType(LedgerEntryType.DEBIT) + .amount(amount) + .build())); + ledgerEntries.add( + ledgerEntryRepository.save( + LedgerEntry.builder() + .wallet(toWallet) + .transfer(transfer) + .entryType(LedgerEntryType.CREDIT) + .amount(amount) + .build())); + return ledgerEntries; + } + + private ResponseDTO failTransfer( + Transfer transfer, String requestHash, String responseCode, String failureMessage) { + transfer.markFailed(failureMessage); + transferRepository.save(transfer); + + TransferResponseDTO payload = TransferMapper.toTransferResponse(transfer, List.of()); + ResponseDTO response = + ResponseBuilderFactory.getResponse(failureMessage, responseCode, payload); + persistIdempotencyRecord( + transfer.getIdempotencyKey(), transfer, requestHash, payload, response); + return response; + } + + private ResponseDTO persistSuccessResponse( + CreateTransferRequestDTO transferRequest, + String requestHash, + Transfer transfer, + TransferResponseDTO payload) { + ResponseDTO response = + ResponseBuilderFactory.getResponse( + ApplicationConstants.SUCCESS, ResponseConstant.TRANSFER_SUCCESS, payload); + persistIdempotencyRecord( + transferRequest.getIdempotencyKey(), transfer, requestHash, payload, response); + return response; + } + + private void persistIdempotencyRecord( + String idempotencyKey, + Transfer transfer, + String requestHash, + TransferResponseDTO payload, + ResponseDTO response) { + try { + idempotencyRecordRepository.save( + IdempotencyRecord.builder() + .idempotencyKey(idempotencyKey) + .transfer(transfer) + .requestHash(requestHash) + .responseStatus(response.getStatus()) + .responseCode(response.getResponseCode()) + .responseMessage(response.getMessage()) + .responsePayload(requestHashUtility.toJson(payload)) + .build()); + } catch (DataIntegrityViolationException integrityViolation) { + // Another concurrent request already persisted the idempotency record. + } + } +} diff --git a/src/main/java/com/wallettransfer/service/transferservice/TransferService.java b/src/main/java/com/wallettransfer/service/transferservice/TransferService.java new file mode 100644 index 00000000..0463b8bc --- /dev/null +++ b/src/main/java/com/wallettransfer/service/transferservice/TransferService.java @@ -0,0 +1,95 @@ +package com.wallettransfer.service.transferservice; + +import com.wallettransfer.constants.ResponseConstant; +import com.wallettransfer.dto.requestdto.CreateTransferRequestDTO; +import com.wallettransfer.dto.responsedto.ResponseDTO; +import com.wallettransfer.exception.ResponseException; +import com.wallettransfer.service.loggerservice.LogService; +import com.wallettransfer.utility.RequestHashUtility; +import com.wallettransfer.utility.ServiceResponseSupport; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Objects; +import java.util.Optional; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +@Service +public class TransferService { + + private final String className = this.getClass().getSimpleName(); + private final LogService logService; + private final HttpServletRequest request; + private final ServiceResponseSupport serviceResponseSupport; + private final RequestHashUtility requestHashUtility; + private final IdempotencyResponseLookup idempotencyResponseLookup; + private final TransferExecutor transferExecutor; + + public TransferService( + LogService logService, + HttpServletRequest request, + ServiceResponseSupport serviceResponseSupport, + RequestHashUtility requestHashUtility, + IdempotencyResponseLookup idempotencyResponseLookup, + TransferExecutor transferExecutor) { + this.logService = logService; + this.request = request; + this.serviceResponseSupport = serviceResponseSupport; + this.requestHashUtility = requestHashUtility; + this.idempotencyResponseLookup = idempotencyResponseLookup; + this.transferExecutor = transferExecutor; + } + + public ResponseDTO createTransfer(CreateTransferRequestDTO transferRequest) { + logService.printInfoLogs(request, className, "Create Transfer"); + return serviceResponseSupport.execute(className, () -> processTransfer(transferRequest)); + } + + private ResponseDTO processTransfer(CreateTransferRequestDTO transferRequest) { + validateTransferRequest(transferRequest); + String requestHash = requestHashUtility.hashTransferRequest(transferRequest); + String idempotencyKey = transferRequest.getIdempotencyKey(); + + return idempotencyResponseLookup + .findCachedResponse(idempotencyKey, requestHash) + .orElseGet( + () -> executeTransferWithIntegrityRetry(transferRequest, requestHash, idempotencyKey)); + } + + private ResponseDTO executeTransferWithIntegrityRetry( + CreateTransferRequestDTO transferRequest, String requestHash, String idempotencyKey) { + try { + return transferExecutor.executeTransfer(transferRequest, requestHash); + } catch (DataIntegrityViolationException integrityViolation) { + return lookupCachedResponseWithRetry(idempotencyKey, requestHash) + .orElseThrow(() -> integrityViolation); + } + } + + private Optional lookupCachedResponseWithRetry( + String idempotencyKey, String requestHash) { + for (int attempt = 0; attempt < 20; attempt++) { + Optional cached = + idempotencyResponseLookup.findCachedResponse(idempotencyKey, requestHash); + if (cached.isPresent()) { + return cached; + } + sleepBriefly(); + } + return Optional.empty(); + } + + private void validateTransferRequest(CreateTransferRequestDTO transferRequest) { + if (Objects.equals(transferRequest.getFromWalletId(), transferRequest.getToWalletId())) { + throw new ResponseException( + "fromWalletId and toWalletId must be different", ResponseConstant.INVALID_REQUEST); + } + } + + private void sleepBriefly() { + try { + Thread.sleep(25); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/com/wallettransfer/service/walletservice/WalletService.java b/src/main/java/com/wallettransfer/service/walletservice/WalletService.java new file mode 100644 index 00000000..08be8a6c --- /dev/null +++ b/src/main/java/com/wallettransfer/service/walletservice/WalletService.java @@ -0,0 +1,124 @@ +package com.wallettransfer.service.walletservice; + +import com.wallettransfer.builder.ResponseBuilderFactory; +import com.wallettransfer.constants.ApplicationConstants; +import com.wallettransfer.constants.ResponseConstant; +import com.wallettransfer.dto.requestdto.TransferHistoryRequestDTO; +import com.wallettransfer.dto.requestdto.WalletBalanceRequestDTO; +import com.wallettransfer.dto.responsedto.ResponseDTO; +import com.wallettransfer.dto.responsedto.TransferResponseDTO; +import com.wallettransfer.dto.responsedto.WalletBalanceResponseDTO; +import com.wallettransfer.entity.LedgerEntry; +import com.wallettransfer.entity.Transfer; +import com.wallettransfer.entity.Wallet; +import com.wallettransfer.exception.ResponseException; +import com.wallettransfer.repo.LedgerEntryRepository; +import com.wallettransfer.repo.TransferRepository; +import com.wallettransfer.repo.WalletRepository; +import com.wallettransfer.service.loggerservice.LogService; +import com.wallettransfer.utility.ServiceResponseSupport; +import com.wallettransfer.utility.TransferMapper; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WalletService { + + private final String className = this.getClass().getSimpleName(); + private final LogService logService; + private final HttpServletRequest request; + private final ServiceResponseSupport serviceResponseSupport; + private final WalletRepository walletRepository; + private final LedgerEntryRepository ledgerEntryRepository; + private final TransferRepository transferRepository; + + public WalletService( + LogService logService, + HttpServletRequest request, + ServiceResponseSupport serviceResponseSupport, + WalletRepository walletRepository, + LedgerEntryRepository ledgerEntryRepository, + TransferRepository transferRepository) { + this.logService = logService; + this.request = request; + this.serviceResponseSupport = serviceResponseSupport; + this.walletRepository = walletRepository; + this.ledgerEntryRepository = ledgerEntryRepository; + this.transferRepository = transferRepository; + } + + @Transactional(readOnly = true) + public ResponseDTO getWalletBalance(WalletBalanceRequestDTO balanceRequest) { + logService.printInfoLogs(request, className, "Get Wallet Balance"); + return serviceResponseSupport.execute( + className, + () -> { + Wallet wallet = + walletRepository + .findById(balanceRequest.getWalletId()) + .orElseThrow( + () -> + new ResponseException( + "Wallet not found", ResponseConstant.WALLET_NOT_FOUND)); + + WalletBalanceResponseDTO payload = + WalletBalanceResponseDTO.builder() + .walletId(wallet.getWalletId()) + .balance(wallet.getBalance()) + .build(); + + return ResponseBuilderFactory.getResponse( + ApplicationConstants.SUCCESS, + ResponseConstant.BALANCE_FETCH_SUCCESS, + payload); + }); + } + + @Transactional(readOnly = true) + public ResponseDTO getTransferHistory(TransferHistoryRequestDTO historyRequest) { + logService.printInfoLogs(request, className, "Get Transfer History"); + return serviceResponseSupport.execute( + className, + () -> { + if (!walletRepository.existsById(historyRequest.getWalletId())) { + throw new ResponseException("Wallet not found", ResponseConstant.WALLET_NOT_FOUND); + } + + List ledgerEntries = + ledgerEntryRepository.findByWallet_WalletIdOrderByCreatedAtDesc( + historyRequest.getWalletId()); + + List transferIds = + ledgerEntries.stream().map(entry -> entry.getTransfer().getId()).distinct().toList(); + + Map transfersById = + transferRepository.findAllById(transferIds).stream() + .collect(Collectors.toMap(Transfer::getId, Function.identity())); + + List history = + transferIds.stream() + .map( + transferId -> { + Transfer transfer = transfersById.get(transferId); + List entriesForTransfer = + ledgerEntryRepository.findByTransfer_Id(transferId).stream() + .sorted( + Comparator.comparing(entry -> entry.getEntryType().name())) + .toList(); + return TransferMapper.toTransferResponse(transfer, entriesForTransfer); + }) + .toList(); + + return ResponseBuilderFactory.getResponse( + ApplicationConstants.SUCCESS, + ResponseConstant.HISTORY_FETCH_SUCCESS, + history); + }); + } +} diff --git a/src/main/java/com/wallettransfer/utility/LogSanitizer.java b/src/main/java/com/wallettransfer/utility/LogSanitizer.java new file mode 100644 index 00000000..fbbc6006 --- /dev/null +++ b/src/main/java/com/wallettransfer/utility/LogSanitizer.java @@ -0,0 +1,13 @@ +package com.wallettransfer.utility; + +public final class LogSanitizer { + + private LogSanitizer() {} + + public static String sanitize(String value) { + if (value == null) { + return ""; + } + return value.replaceAll("[\r\n\t]", "_"); + } +} diff --git a/src/main/java/com/wallettransfer/utility/RequestHashUtility.java b/src/main/java/com/wallettransfer/utility/RequestHashUtility.java new file mode 100644 index 00000000..e0fe63d7 --- /dev/null +++ b/src/main/java/com/wallettransfer/utility/RequestHashUtility.java @@ -0,0 +1,52 @@ +package com.wallettransfer.utility; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.wallettransfer.dto.requestdto.CreateTransferRequestDTO; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import org.springframework.stereotype.Component; + +@Component +public class RequestHashUtility { + + private final ObjectMapper objectMapper; + + public RequestHashUtility(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public String hashTransferRequest(CreateTransferRequestDTO request) { + try { + String canonical = + request.getFromWalletId() + + "|" + + request.getToWalletId() + + "|" + + request.getAmount(); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(canonical.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 not available", ex); + } + } + + public String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Failed to serialize response payload", ex); + } + } + + public T fromJson(String json, Class type) { + try { + return objectMapper.readValue(json, type); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("Failed to deserialize response payload", ex); + } + } +} diff --git a/src/main/java/com/wallettransfer/utility/ServiceResponseSupport.java b/src/main/java/com/wallettransfer/utility/ServiceResponseSupport.java new file mode 100644 index 00000000..7eb680e6 --- /dev/null +++ b/src/main/java/com/wallettransfer/utility/ServiceResponseSupport.java @@ -0,0 +1,40 @@ +package com.wallettransfer.utility; + +import com.wallettransfer.builder.ResponseBuilderFactory; +import com.wallettransfer.constants.ApplicationConstants; +import com.wallettransfer.constants.ResponseConstant; +import com.wallettransfer.dto.responsedto.ResponseDTO; +import com.wallettransfer.exception.ResponseException; +import com.wallettransfer.service.loggerservice.LogService; +import jakarta.servlet.http.HttpServletRequest; +import java.util.function.Supplier; +import org.springframework.stereotype.Component; + +@Component +public class ServiceResponseSupport { + + private final LogService logService; + private final HttpServletRequest request; + + public ServiceResponseSupport(LogService logService, HttpServletRequest request) { + this.logService = logService; + this.request = request; + } + + public ResponseDTO execute(String className, Supplier action) { + try { + return action.get(); + } catch (ResponseException responseException) { + logService.printResponseErrorLogs( + request, className, LogSanitizer.sanitize(responseException.getMessage())); + return ResponseBuilderFactory.getResponse( + responseException.getMessage(), + responseException.getResponseCode(), + responseException.getData()); + } catch (RuntimeException runtimeException) { + logService.printErrorLogs(request, className, runtimeException); + return ResponseBuilderFactory.getResponse( + ApplicationConstants.FAIL, ResponseConstant.FAILURE_CODE); + } + } +} diff --git a/src/main/java/com/wallettransfer/utility/TransferMapper.java b/src/main/java/com/wallettransfer/utility/TransferMapper.java new file mode 100644 index 00000000..6b37ab3a --- /dev/null +++ b/src/main/java/com/wallettransfer/utility/TransferMapper.java @@ -0,0 +1,39 @@ +package com.wallettransfer.utility; + +import com.wallettransfer.dto.responsedto.LedgerEntryResponseDTO; +import com.wallettransfer.dto.responsedto.TransferResponseDTO; +import com.wallettransfer.entity.LedgerEntry; +import com.wallettransfer.entity.Transfer; +import java.util.List; + +public final class TransferMapper { + + private TransferMapper() {} + + public static TransferResponseDTO toTransferResponse( + Transfer transfer, List ledgerEntries) { + List entries = + ledgerEntries.stream().map(TransferMapper::toLedgerEntryResponse).toList(); + + return TransferResponseDTO.builder() + .transferId(transfer.getId()) + .idempotencyKey(transfer.getIdempotencyKey()) + .fromWalletId(transfer.getFromWallet().getWalletId()) + .toWalletId(transfer.getToWallet().getWalletId()) + .amount(transfer.getAmount()) + .status(transfer.getStatus()) + .failureReason(transfer.getFailureReason()) + .ledgerEntries(entries) + .build(); + } + + public static LedgerEntryResponseDTO toLedgerEntryResponse(LedgerEntry entry) { + return LedgerEntryResponseDTO.builder() + .entryId(entry.getId()) + .walletId(entry.getWallet().getWalletId()) + .transferId(entry.getTransfer().getId()) + .type(entry.getEntryType()) + .amount(entry.getAmount()) + .build(); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 00000000..864a9bad --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,19 @@ +spring: + datasource: + url: jdbc:h2:mem:wallet_transfer;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + h2: + console: + enabled: true + path: /h2-console + settings: + web-allow-others: false + trace: false + jpa: + hibernate: + ddl-auto: update + show-sql: true + flyway: + enabled: false diff --git a/src/main/resources/application-postgres.yml b/src/main/resources/application-postgres.yml new file mode 100644 index 00000000..ccec8c57 --- /dev/null +++ b/src/main/resources/application-postgres.yml @@ -0,0 +1,12 @@ +spring: + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:wallet_transfer} + username: ${DB_USERNAME:wallet_user} + password: ${DB_PASSWORD:wallet_pass} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: false + flyway: + enabled: false diff --git a/src/main/resources/application-production.yml b/src/main/resources/application-production.yml new file mode 100644 index 00000000..aa4f44b6 --- /dev/null +++ b/src/main/resources/application-production.yml @@ -0,0 +1,13 @@ +spring: + jpa: + hibernate: + ddl-auto: validate + h2: + console: + enabled: false + +management: + endpoints: + web: + exposure: + include: health,info diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..3ec2a1a0 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,33 @@ +spring: + application: + name: wallet-transfer + profiles: + active: local + jpa: + hibernate: + ddl-auto: update + open-in-view: false + properties: + hibernate: + jdbc: + time_zone: UTC + +server: + servlet: + context-path: /api/wallet-transfer + port: 8080 + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: when_authorized + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 00000000..ea2fe2d4 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,11 @@ +INSERT INTO wallets (wallet_id, balance, version, created_at, updated_at) +SELECT 'wallet_1', 100000, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM wallets WHERE wallet_id = 'wallet_1'); + +INSERT INTO wallets (wallet_id, balance, version, created_at, updated_at) +SELECT 'wallet_2', 50000, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM wallets WHERE wallet_id = 'wallet_2'); + +INSERT INTO wallets (wallet_id, balance, version, created_at, updated_at) +SELECT 'wallet_3', 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM wallets WHERE wallet_id = 'wallet_3'); diff --git a/src/test/java/com/wallettransfer/TransferIntegrationTest.java b/src/test/java/com/wallettransfer/TransferIntegrationTest.java new file mode 100644 index 00000000..4ccfd850 --- /dev/null +++ b/src/test/java/com/wallettransfer/TransferIntegrationTest.java @@ -0,0 +1,349 @@ +package com.wallettransfer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.wallettransfer.dto.requestdto.CreateTransferRequestDTO; +import com.wallettransfer.dto.requestdto.TransferHistoryRequestDTO; +import com.wallettransfer.dto.requestdto.WalletBalanceRequestDTO; +import com.wallettransfer.entity.Wallet; +import com.wallettransfer.repo.IdempotencyRecordRepository; +import com.wallettransfer.repo.LedgerEntryRepository; +import com.wallettransfer.repo.TransferRepository; +import com.wallettransfer.repo.WalletRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * Behavioral integration tests — assertions are made through HTTP API responses and observable + * wallet balances, not internal repository or service implementation details. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class TransferIntegrationTest { + + private static final String TRANSFERS_URL = "/v1/transfers"; + private static final String BALANCE_URL = "/v1/wallet-balance"; + private static final String HISTORY_URL = "/v1/transfer-history"; + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private WalletRepository walletRepository; + @Autowired private IdempotencyRecordRepository idempotencyRecordRepository; + @Autowired private LedgerEntryRepository ledgerEntryRepository; + @Autowired private TransferRepository transferRepository; + + @BeforeEach + void seedWallets() { + idempotencyRecordRepository.deleteAll(); + ledgerEntryRepository.deleteAll(); + transferRepository.deleteAll(); + walletRepository.deleteAll(); + walletRepository.save(Wallet.builder().walletId("wallet_1").balance(1000L).version(0L).build()); + walletRepository.save(Wallet.builder().walletId("wallet_2").balance(500L).version(0L).build()); + walletRepository.save(Wallet.builder().walletId("wallet_3").balance(0L).version(0L).build()); + } + + @Test + void shouldProcessTransferAndKeepLedgerBalanced() throws Exception { + performTransfer("key-1", "wallet_1", "wallet_2", 100L) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.responseCode").value(20001)) + .andExpect(jsonPath("$.data.status").value("PROCESSED")) + .andExpect(jsonPath("$.data.ledgerEntries.length()").value(2)) + .andExpect(jsonPath("$.data.ledgerEntries[?(@.type == 'DEBIT')].amount").value(100)) + .andExpect(jsonPath("$.data.ledgerEntries[?(@.type == 'CREDIT')].amount").value(100)); + + assertThat(fetchBalance("wallet_1")).isEqualTo(900L); + assertThat(fetchBalance("wallet_2")).isEqualTo(600L); + } + + @Test + void shouldReturnOriginalResultForDuplicateIdempotencyKeyWithoutDoubleDebit() throws Exception { + MvcResult first = + performTransfer("dup-key", "wallet_1", "wallet_2", 50L) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PROCESSED")) + .andReturn(); + + MvcResult second = + performTransfer("dup-key", "wallet_1", "wallet_2", 50L) + .andExpect(status().isOk()) + .andReturn(); + + assertThat(second.getResponse().getContentAsString()) + .isEqualTo(first.getResponse().getContentAsString()); + assertThat(fetchBalance("wallet_1")).isEqualTo(950L); + assertThat(fetchBalance("wallet_2")).isEqualTo(550L); + } + + @Test + void shouldFailTransferWhenInsufficientFundsWithoutChangingBalances() throws Exception { + performTransfer("insufficient-key", "wallet_3", "wallet_2", 10L) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("FAIL")) + .andExpect(jsonPath("$.responseCode").value(5002)) + .andExpect(jsonPath("$.data.status").value("FAILED")) + .andExpect(jsonPath("$.data.ledgerEntries").isEmpty()); + + assertThat(fetchBalance("wallet_3")).isZero(); + assertThat(fetchBalance("wallet_2")).isEqualTo(500L); + } + + @Test + void shouldRejectSameIdempotencyKeyWithDifferentPayload() throws Exception { + performTransfer("conflict-key", "wallet_1", "wallet_2", 25L).andExpect(status().isOk()); + + performTransfer("conflict-key", "wallet_1", "wallet_2", 30L) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("FAIL")) + .andExpect(jsonPath("$.responseCode").value(5003)); + + assertThat(fetchBalance("wallet_1")).isEqualTo(975L); + } + + @Test + void shouldRejectTransferWhenSourceAndDestinationAreSame() throws Exception { + performTransfer("same-wallet-key", "wallet_1", "wallet_1", 10L) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("FAIL")) + .andExpect(jsonPath("$.responseCode").value(6017)); + + assertThat(fetchBalance("wallet_1")).isEqualTo(1000L); + } + + @Test + void shouldRejectTransferWhenWalletDoesNotExist() throws Exception { + performTransfer("missing-wallet-key", "wallet_missing", "wallet_2", 10L) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("FAIL")) + .andExpect(jsonPath("$.responseCode").value(5113)); + + assertThat(fetchBalance("wallet_2")).isEqualTo(500L); + } + + @Test + void shouldReturnWalletBalanceViaApi() throws Exception { + mockMvc + .perform( + post(BALANCE_URL) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + WalletBalanceRequestDTO.builder().walletId("wallet_1").build()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.responseCode").value(20002)) + .andExpect(jsonPath("$.data.walletId").value("wallet_1")) + .andExpect(jsonPath("$.data.balance").value(1000)); + } + + @Test + void shouldReturnTransferHistoryAfterSuccessfulTransfers() throws Exception { + performTransfer("history-1", "wallet_1", "wallet_2", 40L).andExpect(status().isOk()); + performTransfer("history-2", "wallet_2", "wallet_1", 15L).andExpect(status().isOk()); + + mockMvc + .perform( + post(HISTORY_URL) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + TransferHistoryRequestDTO.builder().walletId("wallet_1").build()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.responseCode").value(20003)) + .andExpect(jsonPath("$.data.length()").value(2)); + } + + @Test + void shouldReturnOriginalFailedResultForDuplicateIdempotencyKey() throws Exception { + MvcResult first = + performTransfer("failed-dup-key", "wallet_3", "wallet_2", 10L) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("FAIL")) + .andExpect(jsonPath("$.responseCode").value(5002)) + .andExpect(jsonPath("$.data.status").value("FAILED")) + .andReturn(); + + MvcResult second = + performTransfer("failed-dup-key", "wallet_3", "wallet_2", 10L) + .andExpect(status().isOk()) + .andReturn(); + + assertThat(second.getResponse().getContentAsString()) + .isEqualTo(first.getResponse().getContentAsString()); + assertThat(fetchBalance("wallet_3")).isZero(); + assertThat(fetchBalance("wallet_2")).isEqualTo(500L); + } + + @Test + void shouldReturnSingleProcessedTransferForConcurrentDuplicateIdempotencyKey() throws Exception { + String idempotencyKey = "concurrent-dup-key"; + + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + List responses = new ArrayList<>(); + + Future first = + executor.submit( + () -> { + ready.countDown(); + awaitStart(start); + responses.add(transferResponseBody("wallet_1", "wallet_2", 20L, idempotencyKey)); + }); + Future second = + executor.submit( + () -> { + ready.countDown(); + awaitStart(start); + responses.add(transferResponseBody("wallet_1", "wallet_2", 20L, idempotencyKey)); + }); + + ready.await(); + start.countDown(); + first.get(); + second.get(); + + executor.shutdown(); + assertThat(executor.awaitTermination(30, TimeUnit.SECONDS)).isTrue(); + + assertThat(responses).hasSize(2); + assertThat(responses.get(0)).isEqualTo(responses.get(1)); + assertThat(responses.get(0)).contains("\"status\":\"PROCESSED\""); + assertThat(fetchBalance("wallet_1")).isEqualTo(980L); + assertThat(fetchBalance("wallet_2")).isEqualTo(520L); + } + } + + @Test + void shouldPreventDoubleSpendingUnderConcurrentTransfers() throws Exception { + walletRepository.save(Wallet.builder().walletId("wallet_4").balance(100L).version(0L).build()); + + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + List outcomes = new ArrayList<>(); + + Future first = + executor.submit( + () -> { + ready.countDown(); + awaitStart(start); + outcomes.add(transferOutcome("wallet_4", "wallet_2", 100L, "concurrent-1")); + }); + Future second = + executor.submit( + () -> { + ready.countDown(); + awaitStart(start); + outcomes.add(transferOutcome("wallet_4", "wallet_2", 100L, "concurrent-2")); + }); + + ready.await(); + start.countDown(); + first.get(); + second.get(); + + executor.shutdown(); + assertThat(executor.awaitTermination(30, TimeUnit.SECONDS)).isTrue(); + + long processedCount = outcomes.stream().filter("PROCESSED"::equals).count(); + assertThat(processedCount).isOne(); + assertThat(fetchBalance("wallet_4")).isZero(); + assertThat(fetchBalance("wallet_2")).isEqualTo(600L); + } + } + + private org.springframework.test.web.servlet.ResultActions performTransfer( + String idempotencyKey, String fromWalletId, String toWalletId, long amount) + throws Exception { + CreateTransferRequestDTO request = + CreateTransferRequestDTO.builder() + .idempotencyKey(idempotencyKey) + .fromWalletId(fromWalletId) + .toWalletId(toWalletId) + .amount(amount) + .build(); + + return mockMvc.perform( + post(TRANSFERS_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + private long fetchBalance(String walletId) throws Exception { + MvcResult result = + mockMvc + .perform( + post(BALANCE_URL) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + WalletBalanceRequestDTO.builder().walletId(walletId).build()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andReturn(); + + JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data"); + return data.path("balance").asLong(); + } + + private String transferResponseBody( + String fromWalletId, String toWalletId, long amount, String idempotencyKey) { + try { + MvcResult result = + performTransfer(idempotencyKey, fromWalletId, toWalletId, amount).andReturn(); + return result.getResponse().getContentAsString(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return "INTERRUPTED"; + } catch (Exception ex) { + return "ERROR"; + } + } + + private String transferOutcome( + String fromWalletId, String toWalletId, long amount, String idempotencyKey) { + try { + MvcResult result = + performTransfer(idempotencyKey, fromWalletId, toWalletId, amount).andReturn(); + JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data"); + return data.path("status").asText(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return "INTERRUPTED"; + } catch (Exception ex) { + return "ERROR"; + } + } + + private void awaitStart(CountDownLatch start) { + try { + start.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/test/java/com/wallettransfer/utility/LogSanitizerTest.java b/src/test/java/com/wallettransfer/utility/LogSanitizerTest.java new file mode 100644 index 00000000..cbdfb1f3 --- /dev/null +++ b/src/test/java/com/wallettransfer/utility/LogSanitizerTest.java @@ -0,0 +1,18 @@ +package com.wallettransfer.utility; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class LogSanitizerTest { + + @Test + void shouldReplaceControlCharacters() { + assertThat(LogSanitizer.sanitize("wallet\r\ninjected")).isEqualTo("wallet__injected"); + } + + @Test + void shouldReturnEmptyStringForNull() { + assertThat(LogSanitizer.sanitize(null)).isEmpty(); + } +} diff --git a/src/test/java/com/wallettransfer/utility/ResponseBuilderFactoryTest.java b/src/test/java/com/wallettransfer/utility/ResponseBuilderFactoryTest.java new file mode 100644 index 00000000..fad72024 --- /dev/null +++ b/src/test/java/com/wallettransfer/utility/ResponseBuilderFactoryTest.java @@ -0,0 +1,37 @@ +package com.wallettransfer.utility; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.wallettransfer.builder.ResponseBuilderFactory; +import com.wallettransfer.constants.ApplicationConstants; +import com.wallettransfer.constants.ResponseConstant; +import org.junit.jupiter.api.Test; + +class ResponseBuilderFactoryTest { + + @Test + void shouldMarkSuccessCodesAsSuccess() { + var response = + ResponseBuilderFactory.getResponse( + ApplicationConstants.SUCCESS, ResponseConstant.TRANSFER_SUCCESS); + + assertThat(response.getStatus()).isEqualTo(ApplicationConstants.SUCCESS); + assertThat(response.getResponseCode()).isEqualTo(20001); + } + + @Test + void shouldMarkFailureCodesAsFail() { + var response = + ResponseBuilderFactory.getResponse("Insufficient funds", ResponseConstant.INSUFFICIENT_FUNDS); + + assertThat(response.getStatus()).isEqualTo(ApplicationConstants.FAIL); + assertThat(response.getResponseCode()).isEqualTo(5002); + } + + @Test + void shouldRejectInvalidResponseCode() { + assertThatThrownBy(() -> ResponseBuilderFactory.getResponse("bad", "not-a-number")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..2adaa501 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource: + url: jdbc:h2:mem:wallet_transfer_test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: update + show-sql: false + flyway: + enabled: false + sql: + init: + mode: never