Skip to content

Commit

Permalink
feat: Add investments tx controller and more tests
Browse files Browse the repository at this point in the history
  • Loading branch information
letehaha committed Sep 29, 2024
1 parent 9dc983c commit a769d32
Show file tree
Hide file tree
Showing 7 changed files with 158 additions and 46 deletions.
2 changes: 1 addition & 1 deletion shared-types/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ export interface InvestmentTransactionModel {
* A descriptive name or title for the investment transaction, providing a
* quick overview of the transaction's nature. Same as `note` in `Transactions`
*/
name: string;
name: string | null;
/**
* The monetary value involved in the transaction. Depending on the context,
* this could represent the cost, sale proceeds, or other financial values
Expand Down
54 changes: 54 additions & 0 deletions src/controllers/investments/transactions/create-transaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { z } from 'zod';
import { API_RESPONSE_STATUS, TRANSACTION_TYPES } from 'shared-types';
import { CustomRequest, CustomResponse } from '@common/types';
import { errorHandler } from '@controllers/helpers';

import * as investmentTransactionsService from '@services/investments/transactions';

export const createInvestmentTransaction = async (
req: CustomRequest<typeof createInvestmentTransactionSchema>,
res: CustomResponse,
) => {
try {
const { id: userId } = req.user;
const { accountId, securityId, transactionType, date, name, quantity, price, fees } =
req.validated.body;

const data = await investmentTransactionsService.createInvestmentTransaction({
userId,
params: {
accountId,
securityId,
transactionType,
date,
name,
quantity: String(quantity),
price: String(price),
fees: String(fees),
},
});

return res.status(200).json({
status: API_RESPONSE_STATUS.success,
response: data,
});
} catch (err) {
errorHandler(res, err);
}
};

const recordId = () => z.number().int().positive().finite();
export const createInvestmentTransactionBodySchema = z.object({
accountId: recordId(),
securityId: recordId(),
transactionType: z.nativeEnum(TRANSACTION_TYPES),
date: z.string().datetime({ message: 'Invalid ISO date string' }),
name: z.string().max(1000, 'The string must not exceed 1000 characters.').nullish(),
quantity: z.number().positive('Quantity must be greater than 0').finite(),
price: z.number().positive('Price must be greater than 0').finite(),
fees: z.number().positive('Fees must be greater than 0').finite(),
});

export const createInvestmentTransactionSchema = z.object({
body: createInvestmentTransactionBodySchema,
});
36 changes: 10 additions & 26 deletions src/routes/investments.route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { Router } from 'express';
import { API_RESPONSE_STATUS } from 'shared-types';
import { authenticateJwt } from '@middlewares/passport';
import { validateEndpoint } from '@middlewares/validations';
// import { marketDataService } from '@services/investments/market-data.service';

import { checkSecuritiesSyncingStatus } from '@controllers/investments/securities/check-syncing-status';
import { syncSecuritiesData } from '@controllers/investments/securities/sync-data';
import { loadSecuritiesList } from '@services/investments/securities/get-securities-list';
import { getInvestmentTransactions } from '@services/investments/transactions';
import {
createInvestmentTransaction,
getInvestmentTransactions,
} from '@services/investments/transactions';
createInvestmentTransactionSchema,
} from '@controllers/investments/transactions/create-transaction';

import { createHolding, loadHoldings } from '@controllers/investments/holdings';

Expand All @@ -21,30 +23,12 @@ const router = Router({});
router.get('/holdings', authenticateJwt, loadHoldings);
router.post('/holdings', authenticateJwt, createHolding);

router.post('/transaction', authenticateJwt, async (req, res) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { id: userId } = req.user as any;
const { accountId, securityId, transactionType, date, name, quantity, price, fees } = req.body;

const data = await createInvestmentTransaction({
userId,
params: {
accountId,
securityId,
transactionType,
date,
name,
quantity,
price,
fees,
},
});

return res.status(200).json({
status: API_RESPONSE_STATUS.success,
response: data,
});
});
router.post(
'/transaction',
authenticateJwt,
validateEndpoint(createInvestmentTransactionSchema),
createInvestmentTransaction,
);

router.get('/transactions', authenticateJwt, async (req, res) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
82 changes: 65 additions & 17 deletions src/services/investments/transactions/create.service.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { TRANSACTION_TYPES } from 'shared-types';
import { isSameDay, isBefore } from 'date-fns';
import { format } from 'date-fns';
import * as helpers from '@tests/helpers';
import { ERROR_CODES } from '@js/errors';

jest.setTimeout(30_000);

describe('Create investment transaction service', () => {
it.skip(`
it(`
- creates income transaction;
- updates related holding;
- updates related account balance;
Expand Down Expand Up @@ -39,12 +40,10 @@ describe('Create investment transaction service', () => {
quantity: 10,
price: 25.1,
fees: 0.25,
date: new Date('2024-05-25'),
date: new Date('2024-05-25').toISOString(),
};

await helpers.makeRequest({
method: 'post',
url: '/investing/transaction',
await helpers.createInvestmentTransaction({
payload: {
accountId: account.id,
securityId: mockedSecurity.id,
Expand Down Expand Up @@ -99,14 +98,13 @@ describe('Create investment transaction service', () => {
raw: true,
});

console.log('updatedBalances', updatedBalances);
expect(updatedBalances.length).toBe(2);
expect(
updatedBalances.find((item) => isSameDay(new Date(item.date), transactionValues.date)).amount,
).toBe(expectedAccountBalance);
expect(
updatedBalances.find((item) => isBefore(new Date(item.date), transactionValues.date)).amount,
).toBe(0);
expect(updatedBalances.length).toBe(3);
expect(updatedBalances).toStrictEqual([
{ date: '2024-05-24', amount: 0 },
{ date: '2024-05-25', amount: expectedAccountBalance },
// Since account was created today, the balance should be updated for today too
{ date: format(new Date(), 'yyyy-MM-dd'), amount: expectedAccountBalance },
]);
});
it.todo(
'correctly works for non-base currency (ref values are correct for tx, holdings, and account balance)',
Expand All @@ -115,8 +113,58 @@ describe('Create investment transaction service', () => {
it.todo('after creation, statistics are updated correctly');

describe('failure cases', () => {
it.todo('throws when trying to create transaction when holding does not exist');
it.todo('throws when trying to create transaction when account does not exist');
it.todo('throws when trying to create transaction when security does not exist');
it(`throws when trying to create transaction when:
- holding does not exist
- account does not exist
- security does not exist
`, async () => {
await helpers.syncSecuritiesData();
const securities = await helpers.getSecuritiesList({ raw: true });
const mockedSecurity = securities[0]!;
const account = await helpers.createAccount({ raw: true });
const basePayload = {
accountId: account.id,
securityId: mockedSecurity.id,
transactionType: TRANSACTION_TYPES.income,
quantity: 10,
date: new Date().toISOString(),
price: 25.1,
fees: 0.25,
};

expect(
(
await helpers.createInvestmentTransaction({
payload: {
...basePayload,
accountId: account.id,
securityId: mockedSecurity.id,
},
})
).statusCode,
).toBe(ERROR_CODES.ValidationError);

expect(
(
await helpers.createInvestmentTransaction({
payload: {
...basePayload,
accountId: 10101010101,
},
})
).statusCode,
).toBe(ERROR_CODES.ValidationError);

expect(
(
await helpers.createInvestmentTransaction({
payload: {
...basePayload,
securityId: 10000000,
},
})
).statusCode,
).toBe(ERROR_CODES.ValidationError);
});
});
});
10 changes: 8 additions & 2 deletions src/services/investments/transactions/create.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import Accounts from '@models/Accounts.model';

type CreationParams = Pick<
InvestmentTransactionModel,
'accountId' | 'securityId' | 'transactionType' | 'date' | 'name' | 'quantity' | 'price' | 'fees'
'accountId' | 'securityId' | 'transactionType' | 'date' | 'quantity' | 'price' | 'fees'
>;

/**
Expand All @@ -27,7 +27,13 @@ type CreationParams = Pick<
* 4. Update Balances table
*/
export const createInvestmentTransaction = withTransaction(
async ({ params, userId }: { params: CreationParams; userId: number }) => {
async ({
params,
userId,
}: {
params: CreationParams & { name?: string | null };
userId: number;
}) => {
try {
const security = await Security.findOne({
where: {
Expand Down
1 change: 1 addition & 0 deletions src/tests/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './transactions';
export * from './exchange-rates';
export * from './investments/holdings';
export * from './investments/securities';
export * from './investments/transactions';
19 changes: 19 additions & 0 deletions src/tests/helpers/investments/transactions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { makeRequest } from '../common';
import { z } from 'zod';
import * as investmentService from '@services/investments/transactions';
import { createInvestmentTransactionBodySchema } from '@controllers/investments/transactions/create-transaction';

export function createInvestmentTransaction<R extends boolean | undefined = undefined>({
raw,
payload,
}: {
raw?: R;
payload: z.infer<typeof createInvestmentTransactionBodySchema>;
}) {
return makeRequest<Awaited<ReturnType<typeof investmentService.createInvestmentTransaction>>, R>({
method: 'post',
url: '/investing/transaction',
payload,
raw,
});
}

0 comments on commit a769d32

Please sign in to comment.