Exchange is a currency exchange platform where clients can hold accounts with multi-currency wallets and convert funds between supported currencies.
The company earns revenue on every exchange through a spread — the difference between the market rate and the rate offered to the client. The spread is calculated dynamically based on the liquidity of the traded currency pair:
- A base spread of 0.5% is applied to every exchange.
- Each currency has a liquidity score (USD = 1.00 being the most liquid, HUF = 0.40 the least). Less liquid pairs receive a higher spread, because they carry more risk and are harder to hedge.
- The formula:
spread = price × (0.5% ÷ average pair liquidity)
Example: exchanging PLN (liquidity 0.55) to HUF (liquidity 0.40) gives an average pair liquidity of 0.475, so the
spread applied is 0.5% ÷ 0.475 ≈ 1.05% of the transaction value — compared to only 0.5% ÷ 0.975 ≈ 0.51% for a
USD/EUR pair.
Earnings across all wallets are tracked via the app:company-wallet console command.
Note: The original README (before code review) is preserved at
README_OLD.md.
Make sure you have Docker and Docker Compose installed on your machine.
Warning: Check that you don't have any other services running on port 80 before starting the containers.
Run this command from the project root directory:
docker compose up -dOnce the containers are up, install PHP dependencies inside the container:
docker exec -it php-fpm composer installApply the database schema:
docker exec -it php-fpm php bin/console doctrine:migrations:migrateThe application is available at:
http://localhost
All PHP/Symfony commands must be run inside the php-fpm container. The general pattern is:
docker exec -it php-fpm <command>Example — running tests:
docker exec -it php-fpm composer testsAll endpoints require Bearer token authentication. Obtain a token with app:create-user.
| Method | Path | Description |
|---|---|---|
GET |
/api/wallets |
List all wallets belonging to the authenticated user. |
POST |
/api/wallets |
Create a new wallet. Body: { "currency": "PLN" }. Supported currencies: PLN, EUR, USD, GBP, JPY, CHF, HUF. Returns 409 if a wallet for that currency already exists. |
POST |
/api/wallets/{id}/deposit |
Deposit funds into a wallet. Body: { "amount": "500.00" }. Maximum single deposit: 10000. Returns 422 if the wallet is blocked. |
POST |
/api/wallets/transfer |
Transfer funds between two wallets of the authenticated user (currency exchange supported). Body: { "fromWalletId": 1, "toWalletId": 2, "amount": "100.00" }. |
DELETE |
/api/wallets/{id} |
Delete a wallet. Returns 204 on success, 422 if the wallet has a non-zero balance or pending transactions, 404 if not found. |
A ready-to-use Postman collection is available at exchange-api.postman_collection.json.
Set the authToken variable to the token returned by app:create-user.
| Command | Description |
|---|---|
app:create-user |
Creates a user and returns an API token. Use this token when testing endpoints (e.g. in Postman). |
app:process-transactions |
Processes pending transactions — either approves or rejects them. |
app:company-wallet |
Displays the company wallets and shows how much the company has earned. |
How to run a console command:
docker exec -it php-fpm php bin/console <command-name>Example:
docker exec -it php-fpm php bin/console app:create-userThis project underwent a full code audit. Below is a summary of everything that was found and corrected, organized by branch.
| Branch | Status | Description |
|---|---|---|
main |
base | Original codebase with bugs |
fix/bugs |
merged to main via PR #1 |
Code audit — all bugs found and fixed, one commit per bug |
feat/delete-wallet |
current | New DELETE /api/wallets/{id} endpoint + regression fix |
Before starting any fixes, PHPStan was configured and a baseline was generated against the original codebase. This locked in the existing error state and ensured that no new static analysis errors were introduced during the bug-fixing process. Every fix was verified against the baseline — the rule was: fix the bug, don't add new PHPStan violations.
Commits: chore: add PHPStan with baseline, chore: add PHPStan config and update lockfiles
File: migrations/Version20260519090000.php
The balance column used the DOUBLE floating-point type. All other financial columns in the schema (transactions, company_wallets) correctly used DECIMAL(15,4). Floating-point arithmetic cannot represent decimal fractions exactly (e.g. 0.1 + 0.2 ≠ 0.3), which causes rounding errors that accumulate across operations.
Fix: Added a new migration with ALTER TABLE wallets MODIFY balance DECIMAL(15,4).
Commit: fix: change wallets.balance from DOUBLE to DECIMAL(15,4)
File: src/Service/TransferService.php
TransferService::transfer() did not check whether the source wallet had sufficient funds before deducting. The balance was modified unconditionally, allowing it to drop below zero.
Verified: A transfer of 800 PLN from a wallet with 700 PLN balance succeeded, leaving the wallet at −100 PLN.
Fix: Added a pre-transfer check; throws InsufficientFundsException (HTTP 422) when balance < amount.
Commit: fix: add insufficient funds guard in TransferService
File: src/Service/TransferService.php
DepositService correctly called setLastActivityAt() after every operation. TransferService did not — neither the source nor the destination wallet had their timestamp updated when a transfer was made.
Fix: Added setLastActivityAt(new DateTimeImmutable()) for both wallets before saving.
Commit: fix: add lastActivityAt on transfer and reject
Files: src/Service/TransferService.php, src/Service/TransactionProcessorService.php
Two related bugs:
TransferService::transfer()modified both wallet balances immediately on transfer creation.TransactionProcessorService::complete()then modified them a second time when the transaction was processed — causing a double-debit on every transaction.TransactionProcessorService::reject()only changed the transaction status and did nothing to wallet balances. If aFRAUD_REVIEWtransaction was rejected, the funds already moved inTransferServicewere never returned — making the anti-fraud mechanism non-functional.
Fix (in fix/bugs): Removed balance mutations from TransferService — only complete() was supposed to change balances. This was later corrected (see regression below).
Commits: fix: remove balance mutation from TransferService
File: tests/Service/TransferServiceTest.php
The test asserted expects($this->never())->method('setBalance') on both wallets but the production code called setBalance. The test was correct; the code was wrong.
Fix: Updated tests to reflect corrected behavior.
Commit: fix: repair failing tests and add missing cases
File: tests/Service/TransactionProcessorServiceTest.php
The test expected reject() to call findById() and save() on the wallet repository (updating lastActivityAt), but the original reject() implementation did nothing with wallets.
Fix: Added lastActivityAt update in reject() and aligned test expectations.
Commit: fix: add lastActivityAt on transfer and reject
Implemented a new endpoint for deleting a user's own wallet.
Business rules:
- Returns
204 No Contenton success. - Returns
422if the wallet has a non-zero balance (WalletNotEmptyException). - Returns
422if the wallet has anyPENDINGorFRAUD_REVIEWtransactions (WalletHasPendingTransactionsException). - Returns
404if the wallet does not exist or belongs to another user.
Commits:
feat: add wallet delete exceptionsfeat: add deleteWallet to WalletServicefeat: add DELETE /api/wallets/{id} endpointfeat: add tests for delete wallet
During manual testing on feat/delete-wallet, it was discovered that wallet balances did not change immediately after creating a transfer — they only changed after running app:process-transactions.
Root cause: The fix/bugs fix for double-debit removed balance mutations from TransferService instead of from TransactionProcessorService::complete(). This meant balances were only updated when the processor ran, which:
- Made
FRAUD_REVIEWtransactions not actually hold funds (anti-fraud broken again) - Allowed multiple pending transfers to be created that together exceed the available balance
Correct fix:
TransferService::transfer()— balance mutations restored (source decremented, destination incremented immediately at creation)TransactionProcessorService::complete()— balance mutations removed (only updates transaction status andlastActivityAt)TransactionProcessorService::reject()— added full balance reversal for both wallets (source+= fromAmount, destination-= toAmount), with null guards for deleted wallets
Commit: fix: apply balance changes immediately on transfer creation
| Test file | Change |
|---|---|
TransferServiceTest |
Replaced testTransferDoesNotMutateWalletBalances (old, incorrect expectation) with testTransferMutatesWalletBalancesImmediately (asserts immediate balance change) |
TransactionProcessorServiceTest |
Removed balance assertions from testCompleteSetsStatusAndUpdatesActivity (complete no longer changes balances); updated all reject() tests to use willReturnMap for both wallets; added testRejectReversesBothWalletBalances; renamed testRejectDoesNotSaveWalletWhenFromWalletNotFound to testRejectDoesNotSaveWalletWhenBothWalletsNotFound; updated testCompleteRejectsWhenFromWalletNotFound to assert toWallet balance reversal |
Final test count: 109 tests, 289 assertions, 0 failures.