Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 7 additions & 43 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,12 @@ build/
.tmp/
.env
.env.*

# Java / Maven
target/
*.class
*.jar
*.war
*.iml
.idea/

10 changes: 10 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "automatic"
}
117 changes: 92 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -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/<your-name>` (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)
108 changes: 108 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.7</version>
<relativePath/>
</parent>

<groupId>com.wallettransfer</groupId>
<artifactId>wallet-transfer</artifactId>
<version>1.0.0</version>
<name>wallet-transfer</name>
<description>Wallet Transfer Service - Coding Assignment</description>

<properties>
<java.version>21</java.version>
<springdoc.version>2.0.4</springdoc.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Loading