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
887 changes: 226 additions & 661 deletions package-lock.json

Large diffs are not rendered by default.

23 changes: 14 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
"license": "ISC",
"dependencies": {
"axios": "^1.3.5",
"csv-parse": "^5.4.0",
"figlet": "^1.6.0",
"lodash": "^4.17.21",
"luxon": "^3.3.0",
"uuid": "^9.0.0"
},
"files": [
Expand All @@ -28,45 +31,47 @@
],
"devDependencies": {
"@6river/prettier-config": "1.1.8",
"@types/chai-string": "1.4.1",
"@types/chai": "4.3.0",
"@types/chai-string": "1.4.1",
"@types/chance": "1.0.1",
"@types/figlet": "^1.5.6",
"@types/json-schema": "7.0.11",
"@types/lodash": "4.14.169",
"@types/luxon": "^3.3.0",
"@types/mocha": "5.2.7",
"@types/node": "^16.18.11",
"@types/node": "^18.16.0",
"@types/sinon": "~10.0.0",
"@types/url-join": "4.0.0",
"@types/uuid": "8.3.1",
"@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "^5.55.0",
"bunyan-prettystream": "0.1.3",
"chai-string": "1.4.0",
"chai": "4.2.0",
"chai-string": "1.4.0",
"chance": "1.0.13",
"eslint": "^8.36.0",
"eslint-config-6river": "7.0.1",
"eslint-config-google": "0.14.0",
"eslint-config-prettier": "^8.7.0",
"eslint-plugin-6river": "1.0.7",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-mocha": "^10.1.0",
"eslint": "^8.36.0",
"gulp": "4.0.2",
"gulp-sourcemaps": "3.0.0",
"gulp-typescript": "5.0.1",
"gulp": "4.0.2",
"http-status-codes": "1.4.0",
"mocha": "5.2.0",
"mocha-junit-reporter": "^2.2.0",
"mocha-lcov-reporter": "^1.3.0",
"mocha": "^10.2.0",
"moment": "~2.29.4",
"nock": "~13.2.4",
"npm-run-all": "4.1.5",
"nyc": "15.1.0",
"prettier-plugin-packagejson": "^2.4.2",
"prettier": "^2.8.8",
"prettier-plugin-packagejson": "^2.4.2",
"sinon": "~10.0.0",
"source-map-support": "0.5.16",
"typescript-eslint-parser": "22.0.0",
"typescript": "~4.9.4"
"typescript": "~4.9.4",
"typescript-eslint-parser": "22.0.0"
}
}
13 changes: 13 additions & 0 deletions src/atm/ATMError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export class ATMError extends Error {
readonly isATMError = true;
constructor(message?: string) {
super(message);
}
}

export function isATMError(error: unknown): error is ATMError {
if (error && (error as Error).message && ((error as ATMError).isATMError)) {
return true;
}
return false;
}
19 changes: 19 additions & 0 deletions src/atm/Account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export type Account = {
accountId: string;
pinHash: string;
balance: number;
transactions: Transaction[];
overdrawn: boolean;
};

export type Transaction = {
happenedAt: Date;
amount: number;
balance: number;
};

export type InitialAccount = {
ACCOUNT_ID: string;
PIN: string;
BALANCE: string;
};
24 changes: 24 additions & 0 deletions src/atm/AutomatedTellerMachine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export namespace AtmMessages {
export const AuthRequired = 'Authorization required.' as const;
export const AuthFailed = 'Authorization failed.' as const;
export const AuthSuccess = ' successfully authorized.' as const;
export const CannotDispense = 'Cannot dispense the amount: $' as const;
export const AccountOverdrawn = 'Your account is overdrawn! You may not make withdrawals at this time.' as const;
export const EmptyMachine = 'Unable to process your withdrawal at this time.' as const;
export const PoorMachine = 'Unable to dispense full amount requested at this time.' as const;
export const AmtDispensed = 'Amount dispensed: $' as const;
export const Balance = 'Current balance: $' as const;
export const BalanceWithOverdraft = 'You have been charged an overdraft fee of $5. Current balance: $' as const;
export const CannotDeposit = 'Cannot deposit the amount: $' as const;
export const NoHistory = 'No history found' as const;
export const NoAuth = 'No account is currently authorized.' as const;
}

export interface AutomatedTellerMachine {
authorize(accountId: string, pin: string): void;
withdraw(value: number): void;
deposit(value: number): void;
balance(): void;
history(): void;
logout(): void;
}
173 changes: 173 additions & 0 deletions src/atm/InMemoryAutomatedTellerMachine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/* eslint-disable no-console */
import { parse } from 'csv-parse/sync';
import { DateTime } from 'luxon';
import { v5 as uuidv5 } from 'uuid';

import { ATMError } from './ATMError';
import { Account, InitialAccount, Transaction } from './Account';
import { AtmMessages, AutomatedTellerMachine } from "./AutomatedTellerMachine";

export const PIN_HASH_NAMESPACE = '1c22bcfe-baa1-468a-a7b3-25e4aa51dc5b';

const OVERDRAFT_FEE = 5;

export class InMemoryAutomatedTellerMachine implements AutomatedTellerMachine {
private authorizationTimeout?: NodeJS.Timeout;
private authorizedAccountId?: string;

constructor(
initialAccountsCsv?: Buffer,
private cashOnHand: number = 10000.00,
private readonly accountMap: Map<string, Account> = new Map()) {
if (initialAccountsCsv) {
const initialAccounts: InitialAccount[] = parse(initialAccountsCsv, {columns: true});
for (const account of initialAccounts) {
const newAccount: Account = {
accountId: account.ACCOUNT_ID,
pinHash: uuidv5(account.PIN, PIN_HASH_NAMESPACE),
balance: parseFloat(account.BALANCE),
transactions: [],
overdrawn: false,
};
this.accountMap.set(newAccount.accountId, newAccount);
}
}
}

private handleTimeout(accountId: string) {
console.log(`${accountId} Timed out!`);
this.authorizedAccountId = undefined;
console.log(`> `);
}

private refreshAuthorization(accountId: string) {
this.authorizedAccountId = accountId;

if (this.authorizationTimeout) {
clearTimeout(this.authorizationTimeout);
}
// 2 minutes = 2minutes * 60s * 1000ms = 120000ms
this.authorizationTimeout = setTimeout(this.handleTimeout.bind(this), 120000, accountId);
}

private assertAuthorized() {
if (this.authorizedAccountId === undefined) {
throw new ATMError(AtmMessages.AuthRequired);
}

this.refreshAuthorization(this.authorizedAccountId);
}

authorize(accountId: string, pin: string): void {
if (!this.accountMap.has(accountId)) {
throw new ATMError(AtmMessages.AuthFailed);
}

const account = this.accountMap.get(accountId)!;
if (account.pinHash !== uuidv5(pin, PIN_HASH_NAMESPACE)) {
throw new ATMError(AtmMessages.AuthFailed);
}

this.refreshAuthorization(accountId);
console.log(`${accountId}${AtmMessages.AuthSuccess}`)
return;
}

withdraw(intendedValue: number): void {
this.assertAuthorized();
let actualValue = intendedValue;
if (intendedValue <= 0 || intendedValue % 20 !== 0) {
throw new ATMError(`${AtmMessages.CannotDispense}${intendedValue}`);
}

const account = this.accountMap.get(this.authorizedAccountId!)!;
if (account.overdrawn) {
throw new ATMError(AtmMessages.AccountOverdrawn);
}

if (this.cashOnHand === 0) {
throw new ATMError(AtmMessages.EmptyMachine);
}

if (intendedValue >= this.cashOnHand) {
console.log(AtmMessages.PoorMachine);
actualValue = this.cashOnHand;
}

const transactions: Transaction[] = [];
const resultingBalance = account.balance - actualValue;
transactions.push({
happenedAt: new Date(),
amount: -1 * actualValue,
balance: resultingBalance,
});
account.balance -= actualValue;
this.cashOnHand -= actualValue;

console.log(AtmMessages.AmtDispensed + actualValue.toFixed(2));

if (resultingBalance < 0) {
transactions.push({
happenedAt: new Date(),
amount: -1 * OVERDRAFT_FEE,
balance: resultingBalance - OVERDRAFT_FEE,
});
account.balance -= OVERDRAFT_FEE;
account.overdrawn = true;
console.log(AtmMessages.BalanceWithOverdraft + account.balance);
} else {
console.log(AtmMessages.Balance + account.balance.toFixed(2));
}

account.transactions.push(...transactions);
}

deposit(value: number): void {
this.assertAuthorized();
if (value <= 0) {
throw new ATMError(AtmMessages.CannotDeposit + value);
}

const account = this.accountMap.get(this.authorizedAccountId!)!;
const newBalance: number = account.balance + value;
account.transactions.push({
happenedAt: new Date(),
amount: value,
balance: newBalance,
});
account.balance = newBalance;

console.log(AtmMessages.Balance + newBalance.toFixed(2));
}

balance(): void {
this.assertAuthorized();
const account = this.accountMap.get(this.authorizedAccountId!)!;
console.log(AtmMessages.Balance + account.balance);
}

history(): void {
this.assertAuthorized();
const account = this.accountMap.get(this.authorizedAccountId!)!;
if (!account.transactions.length) {
console.log(AtmMessages.NoHistory);
}

const transactions = new Array(...account.transactions);
transactions.reverse();
for (const transaction of transactions) {
DateTime.fromJSDate(transaction.happenedAt).toFormat('yyyy-mm-dd HH:mm:ss')
console.log(`${DateTime.fromJSDate(transaction.happenedAt).toFormat('yyyy-mm-dd HH:mm:ss')} ${transaction.amount.toFixed(2)} ${transaction.balance.toFixed(2)}`)
}
}

logout(): void {
if (!this.authorizedAccountId) {
console.log(AtmMessages.NoAuth);
} else {
console.log(`Account ${this.authorizedAccountId} logged out.`);
this.authorizedAccountId = undefined;
clearTimeout(this.authorizationTimeout);
}
}
}
5 changes: 5 additions & 0 deletions src/atm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from "./ATMError";
export * from "./Account";
export * from "./AutomatedTellerMachine";
export * from "./InMemoryAutomatedTellerMachine";

5 changes: 5 additions & 0 deletions src/initial_accounts.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ACCOUNT_ID,PIN,BALANCE
2859459814,7386,10.24
1434597300,4557,90000.55
7089382418,0075,0.00
2001377812,5950,60.00
Loading