diff --git a/bank-app/backend/alembic/versions/4e8e514ff17a_add_transfer_table.py b/bank-app/backend/alembic/versions/4e8e514ff17a_add_transfer_table.py new file mode 100644 index 0000000..8d6c6b2 --- /dev/null +++ b/bank-app/backend/alembic/versions/4e8e514ff17a_add_transfer_table.py @@ -0,0 +1,78 @@ +"""add transfer table + +Revision ID: 4e8e514ff17a +Revises: e9713222ea96 +Create Date: 2026-08-31 23:37:05.883395 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "4e8e514ff17a" +down_revision: Union[str, Sequence[str], None] = "e9713222ea96" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "transfers", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("from_account_id", sa.UUID(), nullable=False), + sa.Column("to_account_id", sa.UUID(), nullable=False), + sa.Column("amount", sa.Numeric(precision=12, scale=2), nullable=False), + sa.Column("status", sa.String(length=12), nullable=False), + sa.ForeignKeyConstraint( + ["from_account_id"], + ["accounts.id"], + ), + sa.ForeignKeyConstraint( + ["to_account_id"], + ["accounts.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_transfers_idempotency_key"), + "transfers", + ["idempotency_key"], + unique=True, + ) + op.add_column("transactions", sa.Column("transfer_id", sa.UUID(), nullable=True)) + op.drop_index(op.f("ix_transactions_idempotency_key"), table_name="transactions") + op.create_foreign_key(None, "transactions", "transfers", ["transfer_id"], ["id"]) + op.drop_column("transactions", "idempotency_key") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "transactions", + sa.Column( + "idempotency_key", + sa.VARCHAR(length=255), + autoincrement=False, + nullable=False, + ), + ) + op.drop_constraint(None, "transactions", type_="foreignkey") + op.create_index( + op.f("ix_transactions_idempotency_key"), + "transactions", + ["idempotency_key"], + unique=True, + ) + op.drop_column("transactions", "transfer_id") + op.drop_index(op.f("ix_transfers_idempotency_key"), table_name="transfers") + op.drop_table("transfers") + # ### end Alembic commands ### diff --git a/bank-app/backend/alembic/versions/c00d8c18bf5b_temporary_idempotency_key_in_.py b/bank-app/backend/alembic/versions/c00d8c18bf5b_temporary_idempotency_key_in_.py new file mode 100644 index 0000000..6d15624 --- /dev/null +++ b/bank-app/backend/alembic/versions/c00d8c18bf5b_temporary_idempotency_key_in_.py @@ -0,0 +1,43 @@ +"""temporary idempotency_key in transactions table + +Revision ID: c00d8c18bf5b +Revises: 4e8e514ff17a +Create Date: 2026-09-01 00:06:21.314328 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "c00d8c18bf5b" +down_revision: Union[str, Sequence[str], None] = "4e8e514ff17a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "transactions", + sa.Column("idempotency_key", sa.String(length=255), nullable=True), + ) + op.create_index( + op.f("ix_transactions_idempotency_key"), + "transactions", + ["idempotency_key"], + unique=True, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_transactions_idempotency_key"), table_name="transactions") + op.drop_column("transactions", "idempotency_key") + # ### end Alembic commands ### diff --git a/bank-app/backend/main.py b/bank-app/backend/main.py index 628a803..29e86f1 100644 --- a/bank-app/backend/main.py +++ b/bank-app/backend/main.py @@ -20,12 +20,13 @@ TransactionResponse, DepositRequest, WithdrawalRequest, + TransferRequest, UserCreate, UserResponse, ) from tasks import send_transaction_notification, send_registration_notification -from services import deposit, withdrawal +from services import deposit, withdrawal, transfer from slowapi.errors import RateLimitExceeded from limiter import limiter @@ -322,68 +323,21 @@ def withdrawal_transaction( return withdrawal(db, account.id, transaction.amount, transaction.idempotency_key) -@app.patch("/accounts/{id}/transaction", response_model=TransactionResponse) +@app.patch("/accounts/{id}/transfer", response_model=list[TransactionResponse]) @limiter.limit("100/minute") -def transaction( +def transfer_transaction( request: Request, - id: UUID, - transaction: AccountTransaction, + transaction: TransferRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), - idempotency_key: str = Header(...), ): - account = get_account(id, db, current_user) - - existing_transaction = ( - db.query(Transaction) - .filter(Transaction.idempotency_key == idempotency_key) - .first() - ) - - if ( - existing_transaction - ): # if that transaction was already complete return IMMEDIATELY - return existing_transaction - - balance_before = account.balance - - if account.frozen: - raise HTTPException(status_code=400, detail="Account is currently frozen") - - if transaction.transaction_type == "deposit": - account.balance += transaction.amount + from_account = get_account(transaction.from_account, db, current_user) + to_account = db.query(Account).filter(Account.id == transaction.to_account).first() - elif transaction.transaction_type == "withdrawal": - if transaction.amount > account.balance: - raise HTTPException(status_code=400, detail="Insufficient funds") - - account.balance -= transaction.amount - - else: - raise HTTPException(status_code=400, detail="Invalid transaction type") - - new_transaction = Transaction( - account_id=account.id, - transaction_type=transaction.transaction_type, - amount=transaction.amount, - balance_before=balance_before, - balance_after=account.balance, - status="completed", - idempotency_key=idempotency_key, - ) - - db.add(new_transaction) - db.commit() - db.refresh(new_transaction) - - send_transaction_notification.delay( - current_user.id, transaction.amount, transaction.transaction_type + return transfer( + db, from_account, to_account, transaction.amount, transaction.idempotency_key ) - delete_cache(f"accounts:user:{current_user.id}") - - return new_transaction - @app.get("/accounts/{id}/transaction", response_model=list[TransactionResponse]) def get_transactions( diff --git a/bank-app/backend/models.py b/bank-app/backend/models.py index 7e298e9..ae5be16 100644 --- a/bank-app/backend/models.py +++ b/bank-app/backend/models.py @@ -86,6 +86,10 @@ class Transaction(Base): UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) + transfer_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("transfers.id"), nullable=True + ) + account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id"), nullable=False, index=True ) @@ -100,16 +104,47 @@ class Transaction(Base): status: Mapped[str] = mapped_column(String(12), nullable=False, default="pending") - idempotency_key: Mapped[str] = mapped_column( - String(255), unique=True, nullable=False, index=True - ) - created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) + idempotency_key: Mapped[str | None] = mapped_column( + String(255), unique=True, nullable=True, index=True + ) # temporary until we add Operations/Payment table + account: Mapped["Account"] = relationship(back_populates="transactions") + transfer: Mapped["Transfer | None"] = relationship(back_populates="transactions") __table_args__ = ( CheckConstraint("amount > 0", name="check_transaction_amount_positive"), ) + + +class Transfer(Base): + __tablename__ = "transfers" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + + idempotency_key: Mapped[str] = mapped_column( + String(255), unique=True, nullable=False, index=True + ) + + from_account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("accounts.id"), nullable=False + ) + + to_account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("accounts.id"), nullable=False + ) + + amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) + + status: Mapped[str] = mapped_column(String(12), nullable=False, default="pending") + + from_account: Mapped["Account"] = relationship(foreign_keys=[from_account_id]) + + to_account: Mapped["Account"] = relationship(foreign_keys=[to_account_id]) + + transactions: Mapped[list["Transaction"]] = relationship(back_populates="transfer") diff --git a/bank-app/backend/schemas.py b/bank-app/backend/schemas.py index e46d88d..8f80dc1 100644 --- a/bank-app/backend/schemas.py +++ b/bank-app/backend/schemas.py @@ -58,6 +58,13 @@ class WithdrawalRequest(BaseModel): idempotency_key: str +class TransferRequest(BaseModel): + from_account: UUID + to_account: UUID + amount: Decimal + idempotency_key: str + + class TransactionResponse(BaseModel): id: UUID account_id: UUID diff --git a/bank-app/backend/services.py b/bank-app/backend/services.py index 3d100ba..deb567f 100644 --- a/bank-app/backend/services.py +++ b/bank-app/backend/services.py @@ -2,7 +2,7 @@ from uuid import UUID from sqlalchemy.orm import Session -from models import Account, Transaction +from models import Account, Transaction, Transfer def deposit(db: Session, account_id: UUID, amount: Decimal, idempotency_key: str): @@ -78,3 +78,81 @@ def withdrawal(db: Session, account_id: UUID, amount: Decimal, idempotency_key: db.commit() return transaction + + +def transfer( + db: Session, + from_account: UUID, + to_account: UUID, + amount: Decimal, + idempotency_key: str, +): + existing_transfer = ( + db.query(Transfer).filter(Transfer.idempotency_key == idempotency_key).first() + ) + + if existing_transfer: + return existing_transfer + + f_acc = db.query(Account).filter(Account.id == from_account).first() + t_acc = db.query(Account).filter(Account.id == to_account).first() + + if not f_acc or not t_acc: + raise ValueError("Account not found") + + if from_account == to_account: + raise ValueError("Cannot fund same account") + + if f_acc.balance < amount: + raise ValueError("Insufficient funds") + + f_acc_balance_before = f_acc.balance + t_acc_balance_before = t_acc.balance + + f_acc_balance_after = f_acc_balance_before - amount + t_acc_balance_after = t_acc_balance_before + amount + + f_acc.balance = f_acc_balance_after + t_acc.balance = t_acc_balance_after + + transfer_record = Transfer( + idempotency_key=idempotency_key, + from_account_id=f_acc.id, + to_account_id=t_acc.id, + amount=amount, + status="completed", + ) + + db.add(transfer_record) + db.flush() # generates the uuid + + transaction_sent = Transaction( + account_id=f_acc.id, + transfer=transfer_record, + transaction_type="transfer", + amount=amount, + balance_before=f_acc_balance_before, + balance_after=f_acc_balance_after, + status="completed", + ) + + transaction_received = Transaction( + account_id=t_acc.id, + transfer=transfer_record, + transaction_type="transfer", + amount=amount, + balance_before=t_acc_balance_before, + balance_after=t_acc_balance_after, + status="completed", + ) + + db.add(transaction_sent) + db.add(transaction_received) + + db.commit() + + db.refresh(transfer_record) + db.refresh(transaction_sent) + db.refresh(transaction_received) + + return [transaction_sent, transaction_received] diff --git a/bank-app/backend/test/test_services_transfer.py b/bank-app/backend/test/test_services_transfer.py new file mode 100644 index 0000000..98d70d6 --- /dev/null +++ b/bank-app/backend/test/test_services_transfer.py @@ -0,0 +1,65 @@ +from decimal import Decimal +from uuid import uuid4 +from models import Account, Transfer +from datetime import datetime +from services import transfer +import pytest + + +def test_services_transfer(db, create_account, create_user): + from_account = create_account + + to_account = Account( + id=uuid4(), + owner_name="test_user_2", + account_type="chequing", + balance=Decimal("100.00"), + created_at=datetime.utcnow(), + frozen=False, + owner_id=create_user.id, + ) + + db.add(to_account) + db.commit() + db.refresh(to_account) + + e_transfer = transfer( + db=db, + from_account=from_account.id, + to_account=to_account.id, + amount=Decimal("100.00"), + idempotency_key="test_transfer_001", + ) + + assert len(e_transfer) == 2 + + sent_transaction = e_transfer[0] + received_transaction = e_transfer[1] + + assert sent_transaction.transaction_type == "transfer" + assert sent_transaction.amount == Decimal("100.00") + assert sent_transaction.balance_before == Decimal("100.00") + assert sent_transaction.balance_after == Decimal("0.00") + assert sent_transaction.status == "completed" + + assert received_transaction.transaction_type == "transfer" + assert received_transaction.amount == Decimal("100.00") + assert received_transaction.balance_before == Decimal("100.00") + assert received_transaction.balance_after == Decimal("200.00") + assert received_transaction.status == "completed" + + assert sent_transaction.transfer_id == received_transaction.transfer_id + + transfer_record = ( + db.query(Transfer).filter(Transfer.id == sent_transaction.transfer_id).first() + ) + + assert transfer_record is not None + assert transfer_record.idempotency_key == "test_transfer_001" + assert transfer_record.from_account_id == from_account.id + assert transfer_record.to_account_id == to_account.id + assert transfer_record.amount == Decimal("100.00") + assert transfer_record.status == "completed" + + assert to_account.balance == Decimal("200.00") + assert from_account.balance == Decimal("0.00") diff --git a/bank-app/backend/test/test_transactions.py b/bank-app/backend/test/test_transactions.py deleted file mode 100644 index a5b25a9..0000000 --- a/bank-app/backend/test/test_transactions.py +++ /dev/null @@ -1,96 +0,0 @@ -import pytest - - -@pytest.mark.asyncio -async def test_get_transactions(client, authenticated_user): - headers = authenticated_user["headers"] - - created_response = await client.post( - "/accounts", - json={ - "owner_name": "test_user", - "balance": 1000.00, - "account_type": "chequing", - }, - headers=headers, - ) - - assert created_response.status_code == 200 - - account_id = created_response.json()["id"] - - transaction1_headers = { - **headers, - "Idempotency-Key": "test-deposit-001", - } - - transaction1 = await client.patch( - f"/accounts/{account_id}/transaction", - json={"amount": 1000.00, "transaction_type": "deposit"}, - headers=transaction1_headers, - ) - - assert transaction1.status_code == 200 - - transaction2_headers = { - **headers, - "Idempotency-Key": "test-deposit-002", - } - - transaction2 = await client.patch( - f"/accounts/{account_id}/transaction", - json={"amount": 100.00, "transaction_type": "withdrawal"}, - headers=transaction2_headers, - ) - - assert transaction2.status_code == 200 - - response = await client.get(f"/accounts/{account_id}/transaction", headers=headers) - - transactions = response.json() - - assert len(transactions) == 2 - assert transactions[0]["amount"] == "1000.00" - assert transactions[0]["transaction_type"] == "deposit" - assert transactions[0]["account_id"] == account_id - assert transactions[0]["balance_before"] == "1000.00" - assert transactions[0]["balance_after"] == "2000.00" - - assert transactions[1]["amount"] == "100.00" - assert transactions[1]["transaction_type"] == "withdrawal" - assert transactions[1]["account_id"] == account_id - assert transactions[1]["balance_before"] == "2000.00" - assert transactions[1]["balance_after"] == "1900.00" - - -@pytest.mark.asyncio -async def test_transactions_with_frozen_account(client, authenticated_user): - headers = authenticated_user["headers"] - - created_response = await client.post( - "/accounts", - json={"owner_name": "test_user", "balance": 1000.0, "account_type": "checking"}, - headers=headers, - ) - - assert created_response.status_code == 200 - - account_id = created_response.json()["id"] - - frozen = await client.patch( - f"/accounts/{account_id}/set_freeze", json={"freeze": True}, headers=headers - ) - - assert frozen.status_code == 200 - assert frozen.json()["frozen"] is True - - transaction_header = {**headers, "Idempotency-Key": "test-deposit-001"} - - transaction = await client.patch( - f"/accounts/{account_id}/transaction", - json={"amount": 1000.0, "transaction_type": "deposit"}, - headers=transaction_header, - ) - - assert transaction.status_code == 400 - assert transaction.json()["detail"] == "Account is currently frozen"