Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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 ###
Original file line number Diff line number Diff line change
@@ -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 ###
64 changes: 9 additions & 55 deletions bank-app/backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
43 changes: 39 additions & 4 deletions bank-app/backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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")
7 changes: 7 additions & 0 deletions bank-app/backend/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 79 additions & 1 deletion bank-app/backend/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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]
Loading
Loading