diff --git a/README.md b/README.md index cab3c08..76507d5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/W3nV4mdD) # Banking management ## Overview diff --git a/src/is-equal.ts b/src/is-equal.ts new file mode 100644 index 0000000..78aebbb --- /dev/null +++ b/src/is-equal.ts @@ -0,0 +1,3 @@ +export default function isEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} diff --git a/src/models/BankAccount.ts b/src/models/BankAccount.ts new file mode 100644 index 0000000..c94aa11 --- /dev/null +++ b/src/models/BankAccount.ts @@ -0,0 +1 @@ +export { default } from '@/models/bank-account'; diff --git a/src/models/bank-account.ts b/src/models/bank-account.ts new file mode 100644 index 0000000..6e042c3 --- /dev/null +++ b/src/models/bank-account.ts @@ -0,0 +1,76 @@ +import { v4 as uuidv4 } from 'uuid'; +import type { BankAccountId, BankId } from '@/types/Common'; + +interface BankAccountOptions { + isNegativeAllowed?: boolean; +} + +export default class BankAccount { + private readonly id: BankAccountId; + private readonly bankId: BankId; + private balance: number; + private readonly isNegativeAllowed: boolean; + + private constructor( + id: BankAccountId, + bankId: BankId, + initialBalance: number, + options?: BankAccountOptions + ) { + this.id = id; + this.bankId = bankId; + this.balance = initialBalance; + this.isNegativeAllowed = options?.isNegativeAllowed ?? false; + } + + static create(bankId: BankId, initialBalance = 0, options?: BankAccountOptions): BankAccount { + return new BankAccount(uuidv4(), bankId, initialBalance, options); + } + + getId(): BankAccountId { + return this.id; + } + + getBankId(): BankId { + return this.bankId; + } + + getBalance(): number { + return this.balance; + } + + canDebit(amount: number): boolean { + if (amount < 0) { + return false; + } + if (this.isNegativeAllowed) { + return true; + } + return this.balance >= amount; + } + + credit(amount: number): void { + if (amount <= 0) { + throw new Error('Amount should be greater than zero'); + } + this.balance += amount; + } + + debit(amount: number): void { + if (amount <= 0) { + throw new Error('Amount should be greater than zero'); + } + if (!this.canDebit(amount)) { + throw new Error('Insufficient funds'); + } + this.balance -= amount; + } + + deposit(amount: number): void { + this.credit(amount); + } + + withdraw(amount: number): void { + this.debit(amount); + } +} diff --git a/src/models/bank.ts b/src/models/bank.ts new file mode 100644 index 0000000..2aa563e --- /dev/null +++ b/src/models/bank.ts @@ -0,0 +1,55 @@ +import { v4 as uuidv4 } from 'uuid'; +import BankAccount from '@/models/bank-account'; +import type { BankAccountId, BankId, UserId } from '@/types/Common'; +import GlobalRegistry from '@/services/GlobalRegistry'; +import TransactionService from '@/services/TransactionService'; + +interface BankOptions { + isNegativeAllowed?: boolean; +} + +export default class Bank { + private readonly id: BankId; + private readonly isNegativeAllowed: boolean; + private readonly accountIds: Set; + + private constructor(id: BankId, options?: BankOptions) { + this.id = id; + this.isNegativeAllowed = options?.isNegativeAllowed ?? false; + this.accountIds = new Set(); + } + + static create(options?: BankOptions): Bank { + const bank = new Bank(uuidv4(), options); + GlobalRegistry.registerBank(bank); + return bank; + } + + getId(): BankId { + return this.id; + } + + createAccount(initialBalance = 0): BankAccount { + const account = BankAccount.create(this.id, initialBalance, { + isNegativeAllowed: this.isNegativeAllowed + }); + this.accountIds.add(account.getId()); + GlobalRegistry.registerAccount(account); + return account; + } + + getAccount(accountId: BankAccountId): BankAccount { + if (!this.accountIds.has(accountId)) { + throw new Error('Bank account not found'); + } + return GlobalRegistry.getAccount(accountId); + } + + send(fromUserId: UserId, toUserId: UserId, amount: number, toBankId?: BankId): void { + TransactionService.transfer(this.id, fromUserId, toUserId, amount, toBankId); + } + + transfer(fromUserId: UserId, toUserId: UserId, amount: number, toBankId?: BankId): void { + this.send(fromUserId, toUserId, amount, toBankId); + } +} diff --git a/src/models/user.ts b/src/models/user.ts new file mode 100644 index 0000000..147868a --- /dev/null +++ b/src/models/user.ts @@ -0,0 +1,37 @@ +import { v4 as uuidv4 } from 'uuid'; +import type { BankAccountId, UserId } from '@/types/Common'; +import GlobalRegistry from '@/services/GlobalRegistry'; + +export default class User { + private readonly id: UserId; + private readonly name: string; + private readonly accountIds: BankAccountId[]; + + private constructor(id: UserId, name: string, accountIds: BankAccountId[]) { + this.id = id; + this.name = name; + this.accountIds = accountIds; + } + + static create(name: string, accountIds: BankAccountId[]): User { + const user = new User(uuidv4(), name, [...accountIds]); + GlobalRegistry.registerUser(user); + return user; + } + + getId(): UserId { + return this.id; + } + + getName(): string { + return this.name; + } + + getAccountIds(): BankAccountId[] { + return [...this.accountIds]; + } + + getAccounts(): BankAccountId[] { + return this.getAccountIds(); + } +} diff --git a/src/services/GlobalRegistry.ts b/src/services/GlobalRegistry.ts new file mode 100644 index 0000000..461a3ed --- /dev/null +++ b/src/services/GlobalRegistry.ts @@ -0,0 +1,52 @@ +import type Bank from '@/models/bank'; +import type BankAccount from '@/models/bank-account'; +import type User from '@/models/user'; +import type { BankAccountId, BankId, UserId } from '@/types/Common'; + +export default class GlobalRegistry { + private static banks = new Map(); + private static users = new Map(); + private static accounts = new Map(); + + static registerBank(bank: Bank): void { + this.banks.set(bank.getId(), bank); + } + + static registerUser(user: User): void { + this.users.set(user.getId(), user); + } + + static registerAccount(account: BankAccount): void { + this.accounts.set(account.getId(), account); + } + + static getBank(bankId: BankId): Bank { + const bank = this.banks.get(bankId); + if (!bank) { + throw new Error('Bank not found'); + } + return bank; + } + + static getUser(userId: UserId): User { + const user = this.users.get(userId); + if (!user) { + throw new Error('User not found'); + } + return user; + } + + static getAccount(accountId: BankAccountId): BankAccount { + const account = this.accounts.get(accountId); + if (!account) { + throw new Error('Bank account not found'); + } + return account; + } + + static clear(): void { + this.banks.clear(); + this.users.clear(); + this.accounts.clear(); + } +} diff --git a/src/services/TransactionService.ts b/src/services/TransactionService.ts new file mode 100644 index 0000000..ccdf93e --- /dev/null +++ b/src/services/TransactionService.ts @@ -0,0 +1,119 @@ +import type BankAccount from '@/models/bank-account'; +import type { BankId, UserId } from '@/types/Common'; +import GlobalRegistry from '@/services/GlobalRegistry'; + +export default class TransactionService { + static transfer( + fromBankId: BankId, + fromUserId: UserId, + toUserId: UserId, + amount: number, + toBankId?: BankId + ): void { + if (amount <= 0) { + throw new Error('Amount should be greater than zero'); + } + + const sourceBankId = fromBankId; + const targetBankId = toBankId ?? fromBankId; + const isSelfTransfer = fromUserId === toUserId; + + const fromUser = GlobalRegistry.getUser(fromUserId); + const toUser = GlobalRegistry.getUser(toUserId); + + // Validate banks exist before continuing transfer. + GlobalRegistry.getBank(sourceBankId); + GlobalRegistry.getBank(targetBankId); + + const sourceAccounts = this.pickSourceAccounts(fromUser.getAccountIds(), sourceBankId); + const targetAccount = this.pickTargetAccount( + toUser.getAccountIds(), + targetBankId, + isSelfTransfer ? sourceAccounts[0]?.getId() : undefined + ); + + this.debitAcrossAccounts(sourceAccounts, amount); + targetAccount.credit(amount); + } + + static send( + fromBankId: BankId, + fromUserId: UserId, + toUserId: UserId, + amount: number, + toBankId?: BankId + ): void { + this.transfer(fromBankId, fromUserId, toUserId, amount, toBankId); + } + + private static pickSourceAccounts(accountIds: string[], sourceBankId: BankId): BankAccount[] { + const sourceAccounts = accountIds + .map((accountId) => GlobalRegistry.getAccount(accountId)) + .filter((account) => account.getBankId() === sourceBankId); + + if (sourceAccounts.length === 0) { + throw new Error('Insufficient funds'); + } + + return sourceAccounts; + } + + private static debitAcrossAccounts(sourceAccounts: BankAccount[], amount: number): void { + let remaining = amount; + + for (const account of sourceAccounts) { + if (remaining <= 0) { + break; + } + + if (account.canDebit(remaining)) { + remaining = 0; + continue; + } + + const available = account.getBalance(); + if (available > 0) { + remaining -= Math.min(available, remaining); + } + } + + if (remaining > 0) { + throw new Error('Insufficient funds'); + } + + remaining = amount; + for (const account of sourceAccounts) { + if (remaining <= 0) { + break; + } + + if (account.canDebit(remaining)) { + account.debit(remaining); + remaining = 0; + break; + } + + const available = account.getBalance(); + if (available > 0) { + const debitAmount = Math.min(available, remaining); + account.debit(debitAmount); + remaining -= debitAmount; + } + } + } + + private static pickTargetAccount( + accountIds: string[], + targetBankId: BankId, + excludedAccountId?: string + ): BankAccount { + for (const accountId of accountIds) { + const account = GlobalRegistry.getAccount(accountId); + if (account.getBankId() === targetBankId && account.getId() !== excludedAccountId) { + return account; + } + } + + throw new Error('Target account not found'); + } +} diff --git a/src/services/global-registry.ts b/src/services/global-registry.ts new file mode 100644 index 0000000..7f9b813 --- /dev/null +++ b/src/services/global-registry.ts @@ -0,0 +1 @@ +export { default } from '@/services/GlobalRegistry'; diff --git a/src/services/transaction-service.ts b/src/services/transaction-service.ts new file mode 100644 index 0000000..11a9879 --- /dev/null +++ b/src/services/transaction-service.ts @@ -0,0 +1 @@ +export { default } from '@/services/TransactionService'; diff --git a/src/types/Common.ts b/src/types/Common.ts new file mode 100644 index 0000000..6d2d15c --- /dev/null +++ b/src/types/Common.ts @@ -0,0 +1,3 @@ +export type BankId = string; +export type BankAccountId = string; +export type UserId = string;