From 274d13d68b678178ab3397df0c3d1ddcc0aa4632 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Mon, 22 Jan 2024 10:38:59 -0300 Subject: [PATCH 01/19] Add In-Out transaction creation --- .../components/fields/transaction-name.yaml | 7 ++ openapi/openapi.yaml | 2 + openapi/paths/transactions/in-out.yaml | 61 ++++++++++++++++ openapi/paths/transactions/transfer.yaml | 8 +- src/delivery/dtos/transaction.ts | 38 +++++++++- src/delivery/transaction.controller.ts | 15 +++- src/delivery/validators/internal.ts | 21 ++++++ src/models/bank.ts | 19 ++++- src/models/category.ts | 8 ++ src/models/transaction.ts | 29 ++++++++ .../postgres/bank/bank-repository.service.ts | 6 +- .../category/category-repository.service.ts | 20 ++++- .../transaction-repository.service.ts | 46 ++++++++++++ src/usecases/bank/bank.service.ts | 46 +++++++++++- .../transaction/transaction.module.ts | 2 + .../transaction/transaction.service.ts | 73 +++++++++++++++++++ 16 files changed, 383 insertions(+), 18 deletions(-) create mode 100644 openapi/components/fields/transaction-name.yaml create mode 100644 openapi/paths/transactions/in-out.yaml diff --git a/openapi/components/fields/transaction-name.yaml b/openapi/components/fields/transaction-name.yaml new file mode 100644 index 0000000..35229e3 --- /dev/null +++ b/openapi/components/fields/transaction-name.yaml @@ -0,0 +1,7 @@ +description: | + Transaction name +type: string +minLength: 1 +maxLength: 30 +examples: + - "Foo" diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 128d5dc..485343a 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -122,6 +122,8 @@ paths: $ref: paths/transactions/salary.yaml /transactions/transfer: $ref: paths/transactions/transfer.yaml + /transactions/in-out: + $ref: paths/transactions/in-out.yaml /wallet/balance: $ref: paths/wallet/balance.yaml diff --git a/openapi/paths/transactions/in-out.yaml b/openapi/paths/transactions/in-out.yaml new file mode 100644 index 0000000..1c659ce --- /dev/null +++ b/openapi/paths/transactions/in-out.yaml @@ -0,0 +1,61 @@ +post: + tags: + - Transaction + summary: Create IN or OUT transaction + description: | + Create a IN or OUT transaction, to add or remove money from + one of the user's bank accounts + operationId: transaction-in-out + security: + - bearer: [] + requestBody: + content: + application/json: + schema: + type: object + required: + - type + - name + - amount + - bankAccountId + - budgetDateId + - categoryId + - description + - createdAt + properties: + type: + description: | + Transaction type + type: string + enum: + - IN + - OUT + name: + $ref: ../../components/fields/transaction-name.yaml + description: + $ref: ../../components/fields/description.yaml + amount: + $ref: ../../components/fields/amount.yaml + bankAccountId: + description: ID of the bank account to add/remove money + type: string + format: uuid + categoryId: + description: Category ID + type: string + format: uuid + budgetDateId: + description: BudgetDate ID + type: string + format: uuid + createdAt: + $ref: ../../components/fields/created-at.yaml + required: true + responses: + "201": + description: | + Transaction created + "400": + $ref: ../../components/responses/bad-request.yaml + "401": + $ref: ../../components/responses/unauthorized.yaml diff --git a/openapi/paths/transactions/transfer.yaml b/openapi/paths/transactions/transfer.yaml index 9dd014a..a2c5ee5 100644 --- a/openapi/paths/transactions/transfer.yaml +++ b/openapi/paths/transactions/transfer.yaml @@ -1,9 +1,9 @@ post: tags: - Transaction - summary: Create transfer transaction + summary: Create TRANSFER transaction description: | - Create a transfer transaction, to transfer money from + Create a TRANSFER transaction, to transfer money from one of the user's bank accounts to another operationId: transaction-transfer security: @@ -23,7 +23,9 @@ post: - createdAt properties: name: - $ref: ../../components/fields/name.yaml + $ref: ../../components/fields/transaction-name.yaml + description: + $ref: ../../components/fields/description.yaml amount: $ref: ../../components/fields/amount.yaml bankAccountFromId: diff --git a/src/delivery/dtos/transaction.ts b/src/delivery/dtos/transaction.ts index 4aa3923..f5a47d8 100644 --- a/src/delivery/dtos/transaction.ts +++ b/src/delivery/dtos/transaction.ts @@ -1,6 +1,12 @@ -import { IsAmount, IsDescription, IsID, IsName } from '../validators/internal'; +import { + IsAmount, + IsDescription, + IsID, + IsTransactionName, +} from '../validators/internal'; import { IsMonth, IsYear } from '../validators/date'; -import { IsDate } from 'class-validator'; +import { IsDate, IsIn } from 'class-validator'; +import { TransactionTypeEnum } from '@prisma/client'; export class GetListDto { @IsID() @@ -14,9 +20,12 @@ export class GetListDto { } export class TransferDto { - @IsName() + @IsTransactionName() name: string; + @IsDescription() + description: string; + @IsAmount() amount: number; @@ -29,9 +38,32 @@ export class TransferDto { @IsID() budgetDateId: string; + @IsDate() + createdAt: Date; +} + +export class InOutDto { + @IsIn([TransactionTypeEnum.IN, TransactionTypeEnum.OUT]) + type: typeof TransactionTypeEnum.IN | typeof TransactionTypeEnum.OUT; + + @IsTransactionName() + name: string; + @IsDescription() description: string; + @IsAmount() + amount: number; + + @IsID() + bankAccountId: string; + + @IsID() + budgetDateId: string; + + @IsID() + categoryId: string; + @IsDate() createdAt: Date; } diff --git a/src/delivery/transaction.controller.ts b/src/delivery/transaction.controller.ts index 0b4171a..a3bab44 100644 --- a/src/delivery/transaction.controller.ts +++ b/src/delivery/transaction.controller.ts @@ -1,7 +1,7 @@ import { Controller, Inject, Get, Query, Body, Post } from '@nestjs/common'; import { UserData } from './decorators/user-data'; import { UserDataDto } from './dtos'; -import { GetListDto, TransferDto } from './dtos/transaction'; +import { GetListDto, InOutDto, TransferDto } from './dtos/transaction'; import { TransactionService } from 'usecases/transaction/transaction.service'; import { TransactionUseCase } from 'models/transaction'; @@ -37,4 +37,17 @@ export class TransactionController { accountId: userData.accountId, }); } + + @Post('/in-out') + async inOut( + @UserData() + userData: UserDataDto, + @Body() + body: InOutDto, + ) { + return this.transactionService.inOut({ + ...body, + accountId: userData.accountId, + }); + } } diff --git a/src/delivery/validators/internal.ts b/src/delivery/validators/internal.ts index 330a87c..e003ba3 100644 --- a/src/delivery/validators/internal.ts +++ b/src/delivery/validators/internal.ts @@ -87,6 +87,27 @@ export function IsDescription() { }; } +export function IsTransactionName() { + // eslint-disable-next-line @typescript-eslint/ban-types + return function (object: Object, propertyName: string) { + registerDecorator({ + name: 'IsName', + target: object.constructor, + propertyName: propertyName, + constraints: [], + options: { + message: `${propertyName} must be a valid transaction name`, + }, + validator: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + validate(value: any, _args: ValidationArguments) { + return typeof value === 'string' && /^[\w\W\d\s]{1,30}$/i.test(value); + }, + }, + }); + }; +} + export function IsAmount() { // eslint-disable-next-line @typescript-eslint/ban-types return function (object: Object, propertyName: string) { diff --git a/src/models/bank.ts b/src/models/bank.ts index 5cf50c6..7671051 100644 --- a/src/models/bank.ts +++ b/src/models/bank.ts @@ -1,4 +1,8 @@ -import type { BankAccount, BankProvider } from '@prisma/client'; +import type { + BankAccount, + BankProvider, + TransactionTypeEnum, +} from '@prisma/client'; import type { Paginated, PaginatedItems, @@ -40,7 +44,7 @@ export interface GetManyByIdInput { accountId: string; } -export interface UpdateBalanceInput { +export interface IncrementBalanceInput { bankAccountId: string; accountId: string; amount: number; @@ -62,7 +66,7 @@ export abstract class BankRepository { /** * Increment or decrement the balance based on the amount */ - abstract updateBalance(i: UpdateBalanceInput): Promise; + abstract incrementBalance(i: IncrementBalanceInput): Promise; } /** @@ -84,6 +88,13 @@ export interface TransferInput { amount: number; } +export interface InOutInput { + type: typeof TransactionTypeEnum.IN | typeof TransactionTypeEnum.OUT; + accountId: string; + bankAccountId: string; + amount: number; +} + export abstract class BankUseCase { abstract getProviders(i: Paginated): Promise>; @@ -92,4 +103,6 @@ export abstract class BankUseCase { abstract list(i: ListInput): Promise>; abstract transfer(i: TransferInput): Promise; + + abstract inOut(i: InOutInput): Promise; } diff --git a/src/models/category.ts b/src/models/category.ts index 34fe0a0..633eba3 100644 --- a/src/models/category.ts +++ b/src/models/category.ts @@ -28,12 +28,20 @@ export interface GetByUserInput extends PaginatedRepository { onlyActive?: boolean; } +export interface GetByIdInput { + categoryId: string; + accountId: string; + active?: boolean; +} + export abstract class CategoryRepository { abstract getDefault(i: PaginatedRepository): Promise>; abstract createMany(i: CreateManyInput): Promise; abstract getByUser(i: GetByUserInput): Promise>; + + abstract getById(i: GetByIdInput): Promise; } /** diff --git a/src/models/transaction.ts b/src/models/transaction.ts index 5783800..995ed70 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -59,6 +59,19 @@ export interface CreateTransferInput { isSystemManaged: boolean; } +export interface CreateInOutInput { + type: typeof TransactionTypeEnum.IN | typeof TransactionTypeEnum.OUT; + accountId: string; + name: string; + amount: number; + categoryId: string; + bankAccountId: string; + budgetDateId: string; + description: string; + createdAt: Date; + isSystemManaged: boolean; +} + export abstract class TransactionRepository { abstract getMonthlyAmountByCategory( i: GetMonthlyAmountByCategoryInput, @@ -67,6 +80,8 @@ export abstract class TransactionRepository { abstract getByBudget(i: GetByBudgetInput): Promise>; abstract createTransfer(i: CreateTransferInput): Promise; + + abstract createInOut(i: CreateInOutInput): Promise; } /** @@ -95,8 +110,22 @@ export interface TransferInput { createdAt: Date; } +export interface InOutInput { + type: typeof TransactionTypeEnum.IN | typeof TransactionTypeEnum.OUT; + accountId: string; + categoryId: string; + bankAccountId: string; + budgetDateId: string; + name: string; + amount: number; + description: string; + createdAt: Date; +} + export abstract class TransactionUseCase { abstract getList(i: GetListInput): Promise>; abstract transfer(i: TransferInput): Promise; + + abstract inOut(i: InOutInput): Promise; } diff --git a/src/repositories/postgres/bank/bank-repository.service.ts b/src/repositories/postgres/bank/bank-repository.service.ts index 9930f5e..ed76ca0 100644 --- a/src/repositories/postgres/bank/bank-repository.service.ts +++ b/src/repositories/postgres/bank/bank-repository.service.ts @@ -14,7 +14,7 @@ import type { GetByIdInput, GetByUserInput, GetManyByIdInput, - UpdateBalanceInput, + IncrementBalanceInput, } from 'models/bank'; import { BankRepository } from 'models/bank'; import { IdAdapter } from 'adapters/id'; @@ -143,11 +143,11 @@ export class BankRepositoryService extends BankRepository { }); } - async updateBalance({ + async incrementBalance({ bankAccountId, accountId, amount, - }: UpdateBalanceInput): Promise { + }: IncrementBalanceInput): Promise { await this.bankAccountRepository.update({ where: { id: bankAccountId, diff --git a/src/repositories/postgres/category/category-repository.service.ts b/src/repositories/postgres/category/category-repository.service.ts index 7c4ef0d..35b7811 100644 --- a/src/repositories/postgres/category/category-repository.service.ts +++ b/src/repositories/postgres/category/category-repository.service.ts @@ -1,7 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository, Repository } from '..'; import type { Category, DefaultCategory } from '@prisma/client'; -import type { CreateManyInput, GetByUserInput } from 'models/category'; +import type { + CreateManyInput, + GetByIdInput, + GetByUserInput, +} from 'models/category'; import { CategoryRepository } from 'models/category'; import type { PaginatedRepository } from 'types/paginated-items'; import { IdAdapter } from 'adapters/id'; @@ -66,4 +70,18 @@ export class CategoryRepositoryService extends CategoryRepository { skip: offset, }); } + + getById({ categoryId, accountId, active }: GetByIdInput): Promise { + return this.categoryRepository.findFirst({ + where: { + id: categoryId, + accountId, + ...(typeof active !== 'undefined' + ? { + active, + } + : {}), + }, + }); + } } diff --git a/src/repositories/postgres/transaction/transaction-repository.service.ts b/src/repositories/postgres/transaction/transaction-repository.service.ts index ab82306..fbe88ba 100644 --- a/src/repositories/postgres/transaction/transaction-repository.service.ts +++ b/src/repositories/postgres/transaction/transaction-repository.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository, Repository } from '..'; import type { + CreateInOutInput, CreateTransferInput, GetByBudgetInput, GetByBudgetOutput, @@ -166,4 +167,49 @@ export class TransactionRepositoryService extends TransactionRepository { }, }); } + + async createInOut({ + type, + accountId, + name, + amount, + categoryId, + bankAccountId, + budgetDateId, + description, + createdAt, + isSystemManaged, + }: CreateInOutInput): Promise { + await this.transactionRepository.create({ + data: { + id: this.idAdapter.genId(), + name, + amount, + description, + createdAt, + isSystemManaged, + type, + account: { + connect: { + id: accountId, + }, + }, + bankAccount: { + connect: { + id: bankAccountId, + }, + }, + budgetDate: { + connect: { + id: budgetDateId, + }, + }, + category: { + connect: { + id: categoryId, + }, + }, + }, + }); + } } diff --git a/src/usecases/bank/bank.service.ts b/src/usecases/bank/bank.service.ts index 780b17c..d98289b 100644 --- a/src/usecases/bank/bank.service.ts +++ b/src/usecases/bank/bank.service.ts @@ -4,10 +4,19 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import type { BankAccount, BankProvider } from '@prisma/client'; +import { + TransactionTypeEnum, + type BankAccount, + type BankProvider, +} from '@prisma/client'; import { UtilsAdapterService } from 'adapters/implementations/utils/utils.service'; import { UtilsAdapter } from 'adapters/utils'; -import type { CreateInput, ListInput, TransferInput } from 'models/bank'; +import type { + CreateInput, + InOutInput, + ListInput, + TransferInput, +} from 'models/bank'; import { BankUseCase } from 'models/bank'; import { BankRepositoryService } from 'repositories/postgres/bank/bank-repository.service'; @@ -86,16 +95,45 @@ export class BankService extends BankUseCase { } await Promise.all([ - this.bankRepository.updateBalance({ + this.bankRepository.incrementBalance({ bankAccountId: bankAccountFromId, accountId, amount: amount * -1, }), - this.bankRepository.updateBalance({ + this.bankRepository.incrementBalance({ bankAccountId: bankAccountToId, accountId, amount, }), ]); } + + async inOut({ + type, + accountId, + bankAccountId, + amount, + }: InOutInput): Promise { + /** + * This is a double validation, because it's extremely + * important that this value is ONLY positive + */ + if (amount <= 0) { + throw new BadRequestException('amount must be bigger than 0'); + } + + if (type === TransactionTypeEnum.IN) { + await this.bankRepository.incrementBalance({ + bankAccountId, + accountId, + amount, + }); + } else { + await this.bankRepository.incrementBalance({ + bankAccountId, + accountId, + amount: amount * -1, + }); + } + } } diff --git a/src/usecases/transaction/transaction.module.ts b/src/usecases/transaction/transaction.module.ts index e48fad9..3bab886 100644 --- a/src/usecases/transaction/transaction.module.ts +++ b/src/usecases/transaction/transaction.module.ts @@ -6,6 +6,7 @@ import { UtilsAdapterModule } from 'adapters/implementations/utils/utils.module' import { BankModule } from 'usecases/bank/bank.module'; import { BankRepositoryModule } from 'repositories/postgres/bank/bank-repository.module'; import { BudgetRepositoryModule } from 'repositories/postgres/budget/budget-repository.module'; +import { CategoryRepositoryModule } from 'repositories/postgres/category/category-repository.module'; @Module({ controllers: [TransactionController], @@ -13,6 +14,7 @@ import { BudgetRepositoryModule } from 'repositories/postgres/budget/budget-repo TransactionRepositoryModule, BankRepositoryModule, BudgetRepositoryModule, + CategoryRepositoryModule, BankModule, UtilsAdapterModule, ], diff --git a/src/usecases/transaction/transaction.service.ts b/src/usecases/transaction/transaction.service.ts index 74fe5bb..9fe0dcf 100644 --- a/src/usecases/transaction/transaction.service.ts +++ b/src/usecases/transaction/transaction.service.ts @@ -3,14 +3,17 @@ import { UtilsAdapterService } from 'adapters/implementations/utils/utils.servic import { UtilsAdapter } from 'adapters/utils'; import { BankRepository, BankUseCase } from 'models/bank'; import { BudgetRepository } from 'models/budget'; +import { CategoryRepository } from 'models/category'; import type { GetByBudgetOutput, GetListInput, + InOutInput, TransferInput, } from 'models/transaction'; import { TransactionRepository, TransactionUseCase } from 'models/transaction'; import { BankRepositoryService } from 'repositories/postgres/bank/bank-repository.service'; import { BudgetRepositoryService } from 'repositories/postgres/budget/budget-repository.service'; +import { CategoryRepositoryService } from 'repositories/postgres/category/category-repository.service'; import { TransactionRepositoryService } from 'repositories/postgres/transaction/transaction-repository.service'; import type { PaginatedItems } from 'types/paginated-items'; import { BankService } from 'usecases/bank/bank.service'; @@ -24,6 +27,8 @@ export class TransactionService extends TransactionUseCase { private readonly bankRepository: BankRepository, @Inject(BudgetRepositoryService) private readonly budgetRepository: BudgetRepository, + @Inject(CategoryRepositoryService) + private readonly categoryRepository: CategoryRepository, @Inject(BankService) private readonly bankService: BankUseCase, @@ -126,4 +131,72 @@ export class TransactionService extends TransactionUseCase { isSystemManaged: false, }); } + + async inOut({ + type, + accountId, + name, + amount, + categoryId, + bankAccountId, + budgetDateId, + description, + createdAt, + }: InOutInput): Promise { + const [bankAccount, category, budgetDate] = await Promise.all([ + this.bankRepository.getById({ + bankAccountId, + accountId, + }), + this.categoryRepository.getById({ + categoryId, + accountId, + active: true, + }), + this.budgetRepository.getBudgetDateById({ + budgetDateId, + accountId, + }), + ]); + + if (!bankAccount) { + throw new BadRequestException('Invalid bankAccount'); + } + + if (!category) { + throw new BadRequestException('Invalid category'); + } + + if (!budgetDate) { + throw new BadRequestException('Invalid budgetDate'); + } + + /** + * 1- First adds/removes the funds to the bank account, + * to ensure that the user has the necessary + * requirements to do this transaction + */ + await this.bankService.inOut({ + type, + accountId, + bankAccountId, + amount, + }); + + /** + * 2- Then, creates the transaction + */ + await this.transactionRepository.createInOut({ + type, + accountId, + name, + amount, + categoryId, + bankAccountId, + budgetDateId, + description, + createdAt, + isSystemManaged: false, + }); + } } From 5ac8a573143fe890631205bdd85ecfe2bff65e60 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Tue, 23 Jan 2024 21:19:11 -0300 Subject: [PATCH 02/19] Add method to create cardBills --- src/adapters/date.ts | 73 +++++++-- .../implementations/dayjs/dayjs.service.ts | 100 ++++++++++++- .../implementations/google/google.service.ts | 2 +- src/models/card.ts | 32 ++++ .../postgres/card/card-repository.module.ts | 2 + .../postgres/card/card-repository.service.ts | 138 +++++++++++------- src/usecases/card/card.service.ts | 83 ++++++++++- 7 files changed, 361 insertions(+), 69 deletions(-) diff --git a/src/adapters/date.ts b/src/adapters/date.ts index 065464b..32f03f3 100644 --- a/src/adapters/date.ts +++ b/src/adapters/date.ts @@ -1,22 +1,77 @@ import type { TimezoneEnum } from 'types/enums/timezone'; -export interface GetTodayInfoOutput { +export interface TodayOutput { day: number; month: number; year: number; } -export type DateManipulationUnit = - | 'seconds' - | 'days' - | 'weeks' - | 'months' - | 'years'; +export type DateManipulationUnit = 'second' | 'day' | 'week' | 'month' | 'year'; + +export type YearMonth = `${number}-${number}`; +export type YearMonthDay = `${number}-${number}-${number}`; export abstract class DateAdapter { - abstract getTodayInfo(timezone?: TimezoneEnum): GetTodayInfoOutput; + /** + * + * Info + * + */ + + abstract today(timezone?: TimezoneEnum): TodayOutput; + + /** + * Return an array of dates ('year-month') in the space + * between the start and end dates (including the start + * and end dates itself). + * + * @returns Array of dates: ['2023-01'] + */ + abstract getMonthsBetween( + startDate: YearMonth, + endDate: YearMonth, + ): Array; + + /** + * + * Comparison + * + */ + + abstract isSameMonth( + date: Date | YearMonth, + anotherDate: Date | YearMonth, + ): boolean; + + /** + * + * Modifiers + * + */ abstract nowPlus(amount: number, unit: DateManipulationUnit): Date; - abstract endOfMonth(date: Date, timezone?: TimezoneEnum): Date; + abstract add( + date: Date | string, + amount: number, + unit: DateManipulationUnit, + ): Date; + + abstract sub( + date: Date | string, + amount: number, + unit: DateManipulationUnit, + ): Date; + + abstract startOf( + date: Date | string, + unit: DateManipulationUnit, + timezone?: TimezoneEnum, + ): Date; + + abstract endOf( + date: Date | string, + unit: DateManipulationUnit, + timezone?: TimezoneEnum, + ): Date; } diff --git a/src/adapters/implementations/dayjs/dayjs.service.ts b/src/adapters/implementations/dayjs/dayjs.service.ts index 40b7e37..47cad44 100644 --- a/src/adapters/implementations/dayjs/dayjs.service.ts +++ b/src/adapters/implementations/dayjs/dayjs.service.ts @@ -1,18 +1,26 @@ import { Injectable } from '@nestjs/common'; -import type { DateManipulationUnit, GetTodayInfoOutput } from '../../date'; +import type { DateManipulationUnit, TodayOutput, YearMonth } from '../../date'; import { DateAdapter } from '../../date'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; +import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import type { TimezoneEnum } from 'types/enums/timezone'; dayjs.extend(utc); dayjs.extend(timezone); +dayjs.extend(isSameOrAfter); @Injectable() export class DayjsAdapterService extends DateAdapter { - getTodayInfo(timezone?: TimezoneEnum): GetTodayInfoOutput { + /** + * + * Info + * + */ + + today(timezone?: TimezoneEnum): TodayOutput { const today = dayjs.tz(timezone); return { @@ -22,11 +30,95 @@ export class DayjsAdapterService extends DateAdapter { }; } + getMonthsBetween(startDate: YearMonth, endDate: YearMonth): YearMonth[] { + if (this.isSameMonth(startDate, endDate)) { + return [startDate]; + } + + const startDateDayjs = dayjs(startDate); + + if (startDateDayjs.isAfter(endDate)) { + throw new Error('startDate must be before endDate'); + } + + const difference = startDateDayjs.diff(endDate, 'months'); + const THIRTY_FIVE_YEARS_IN_MONTHS = 35 * 12; + if (difference > THIRTY_FIVE_YEARS_IN_MONTHS) { + throw new Error( + 'Difference between startDate and endDate must be less than 35 years', + ); + } + + const dates: Array = [ + startDateDayjs.format('YYYY-MM') as YearMonth, + ]; + + let reached = false; + const curDate = startDateDayjs; + do { + curDate.add(1, 'months'); + dates.push(curDate.format('YYYY-MM') as YearMonth); + + if (curDate.isSameOrAfter(endDate)) { + reached = true; + } + } while (reached); + + return dates; + } + + /** + * + * Comparison + * + */ + + isSameMonth(date: Date | YearMonth, anotherDate: Date | YearMonth): boolean { + const d1 = dayjs(date); + const d2 = dayjs(anotherDate); + + const d1Month = d1.get('month'); + const d1Year = d1.get('year'); + const d2Month = d2.get('month'); + const d2Year = d2.get('year'); + + const isSameYear = d1Year === d2Year; + const isSameMonth = d1Month === d2Month; + + return isSameYear && isSameMonth; + } + + /** + * + * Modifiers + * + */ + nowPlus(amount: number, unit: DateManipulationUnit): Date { return dayjs.utc().add(amount, unit).toDate(); } - endOfMonth(date: Date, timezone?: TimezoneEnum): Date { - return dayjs.tz(date, timezone).endOf('month').endOf('day').toDate(); + add(date: string | Date, amount: number, unit: DateManipulationUnit): Date { + return dayjs(date).add(amount, unit).toDate(); + } + + sub(date: string | Date, amount: number, unit: DateManipulationUnit): Date { + return this.add(date, amount * -1, unit); + } + + startOf( + date: string | Date, + unit: DateManipulationUnit, + timezone?: TimezoneEnum, + ): Date { + return dayjs.tz(date, timezone).startOf(unit).toDate(); + } + + endOf( + date: string | Date, + unit: DateManipulationUnit, + timezone?: TimezoneEnum, + ): Date { + return dayjs.tz(date, timezone).endOf(unit).toDate(); } } diff --git a/src/adapters/implementations/google/google.service.ts b/src/adapters/implementations/google/google.service.ts index e60be42..4c6c46b 100644 --- a/src/adapters/implementations/google/google.service.ts +++ b/src/adapters/implementations/google/google.service.ts @@ -70,7 +70,7 @@ export class GoogleAdapterService extends GoogleAdapter { refreshToken: result.refresh_token, scopes: result.scope.split(' '), // eslint-disable-next-line @typescript-eslint/no-magic-numbers - expiresAt: this.dateAdapter.nowPlus(result.expires_in - 60, 'seconds'), + expiresAt: this.dateAdapter.nowPlus(result.expires_in - 60, 'second'), }; } diff --git a/src/models/card.ts b/src/models/card.ts index 62d4f16..6b60c48 100644 --- a/src/models/card.ts +++ b/src/models/card.ts @@ -5,6 +5,7 @@ import type { CardTypeEnum, PayAtEnum, } from '@prisma/client'; +import type { YearMonth } from 'adapters/date'; import type { Paginated, PaginatedItems, @@ -112,6 +113,23 @@ export interface GetBillsToBePaidOutput { }; } +export interface GetByIdInput { + cardId: string; +} + +export interface GetByIdOutput extends Card { + cardProvider: CardProvider; +} + +export interface UpsertManyBillsInput { + cardId: string; + month: string; + startAt: Date; + endAt: Date; + statementDate: Date; + dueDate: Date; +} + export abstract class CardRepository { // Card provider @@ -133,6 +151,12 @@ export abstract class CardRepository { abstract getPrepaid(i: GetPrepaidInput): Promise>; + abstract getById(i: GetByIdInput): Promise; + + // CardBill + + abstract upsertManyBills(i: Array): Promise; + abstract getBillsToBePaid( i: GetBillsToBePaidInput, ): Promise>; @@ -160,6 +184,12 @@ export interface GetCardBillsToBePaidInput extends Paginated { date: Date; } +export interface UpsertCardBillsInput { + cardId: string; + startDate: YearMonth; + endDate: YearMonth; +} + export abstract class CardUseCase { abstract getProviders(i: Paginated): Promise>; @@ -176,4 +206,6 @@ export abstract class CardUseCase { abstract getBillsToBePaid( i: GetCardBillsToBePaidInput, ): Promise>; + + abstract upsertCardBills(i: UpsertCardBillsInput): Promise; } diff --git a/src/repositories/postgres/card/card-repository.module.ts b/src/repositories/postgres/card/card-repository.module.ts index 1c38694..1bc6f56 100644 --- a/src/repositories/postgres/card/card-repository.module.ts +++ b/src/repositories/postgres/card/card-repository.module.ts @@ -2,12 +2,14 @@ import { Module } from '@nestjs/common'; import { PostgresModule } from '..'; import { CardRepositoryService } from './card-repository.service'; import { UIDAdapterModule } from 'adapters/implementations/uid/uid.module'; +import { DayJsAdapterModule } from 'adapters/implementations/dayjs/dayjs.module'; @Module({ imports: [ PostgresModule.forFeature(['cardProvider', 'card']), PostgresModule.raw(), UIDAdapterModule, + DayJsAdapterModule, ], providers: [CardRepositoryService], exports: [CardRepositoryService], diff --git a/src/repositories/postgres/card/card-repository.service.ts b/src/repositories/postgres/card/card-repository.service.ts index 4c47ca6..be18d0b 100644 --- a/src/repositories/postgres/card/card-repository.service.ts +++ b/src/repositories/postgres/card/card-repository.service.ts @@ -13,18 +13,23 @@ import type { GetBalanceByUserOutput, GetBillsToBePaidInput, GetBillsToBePaidOutput, + GetByIdInput, + GetByIdOutput, GetPostpaidInput, GetPostpaidOutput, GetPrepaidInput, GetPrepaidOutput, GetProviderInput, UpdateBalanceInput, + UpsertManyBillsInput, } from 'models/card'; import { CardRepository } from 'models/card'; import type { Card, CardNetworkEnum, CardProvider } from '@prisma/client'; import { CardTypeEnum, PayAtEnum } from '@prisma/client'; import { IdAdapter } from 'adapters/id'; import { UIDAdapterService } from 'adapters/implementations/uid/uid.service'; +import { DateAdapter } from 'adapters/date'; +import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; @Injectable() export class CardRepositoryService extends CardRepository { @@ -33,11 +38,15 @@ export class CardRepositoryService extends CardRepository { private readonly cardProviderRepository: Repository<'cardProvider'>, @InjectRepository('card') private readonly cardRepository: Repository<'card'>, + @InjectRepository('cardBill') + private readonly cardBillRepository: Repository<'cardBill'>, @InjectRaw() private readonly rawPostgres: RawPostgres, @Inject(UIDAdapterService) private readonly idAdapter: IdAdapter, + @Inject(DayjsAdapterService) + private readonly dateAdapter: DateAdapter, ) { super(); } @@ -60,63 +69,13 @@ export class CardRepositoryService extends CardRepository { }); } - async create({ - accountId, - cardProviderId, - name, - lastFourDigits, - dueDay, - limit, - balance, - }: CreateInput): Promise { - try { - const cardAccount = await this.cardRepository.create({ - data: { - id: this.idAdapter.genId(), - accountId, - cardProviderId, - name, - lastFourDigits, - dueDay, - limit, - balance, - }, - }); - - return cardAccount; - } catch (err) { - // https://www.prisma.io/docs/reference/api-reference/error-reference#p2003 - if (err.code === 'P2003') { - throw new NotFoundException("Card provider doesn't exists"); - } - // https://www.prisma.io/docs/reference/api-reference/error-reference#p2004 - if (err.code === 'P2004') { - throw new ConflictException('Card already exists'); - } - - throw new InternalServerErrorException( - `Fail to create card: ${err.message}`, - ); - } - } - - async updateBalance({ - cardId, - increment, - }: UpdateBalanceInput): Promise { - await this.cardRepository.update({ + getById({ cardId }: GetByIdInput): Promise { + return this.cardRepository.findUnique({ where: { id: cardId, - cardProvider: { - type: { - in: [CardTypeEnum.VA, CardTypeEnum.VR, CardTypeEnum.VT], - }, - }, }, - data: { - balance: { - increment, - }, + include: { + cardProvider: true, }, }); } @@ -357,4 +316,75 @@ export class CardRepositoryService extends CardRepository { }, })); } + + async create({ + accountId, + cardProviderId, + name, + lastFourDigits, + dueDay, + limit, + balance, + }: CreateInput): Promise { + try { + const cardAccount = await this.cardRepository.create({ + data: { + id: this.idAdapter.genId(), + accountId, + cardProviderId, + name, + lastFourDigits, + dueDay, + limit, + balance, + }, + }); + + return cardAccount; + } catch (err) { + // https://www.prisma.io/docs/reference/api-reference/error-reference#p2003 + if (err.code === 'P2003') { + throw new NotFoundException("Card provider doesn't exists"); + } + // https://www.prisma.io/docs/reference/api-reference/error-reference#p2004 + if (err.code === 'P2004') { + throw new ConflictException('Card already exists'); + } + + throw new InternalServerErrorException( + `Fail to create card: ${err.message}`, + ); + } + } + + async updateBalance({ + cardId, + increment, + }: UpdateBalanceInput): Promise { + await this.cardRepository.update({ + where: { + id: cardId, + cardProvider: { + type: { + in: [CardTypeEnum.VA, CardTypeEnum.VR, CardTypeEnum.VT], + }, + }, + }, + data: { + balance: { + increment, + }, + }, + }); + } + + async upsertManyBills(i: UpsertManyBillsInput[]): Promise { + await this.cardBillRepository.createMany({ + data: i.map((cardBill) => ({ + ...cardBill, + id: this.idAdapter.genId(), + })), + skipDuplicates: true, + }); + } } diff --git a/src/usecases/card/card.service.ts b/src/usecases/card/card.service.ts index d9bb0a6..f4382dc 100644 --- a/src/usecases/card/card.service.ts +++ b/src/usecases/card/card.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import type { CardProvider } from '@prisma/client'; import { CardTypeEnum } from '@prisma/client'; +import type { YearMonth } from 'adapters/date'; import { DateAdapter } from 'adapters/date'; import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; import { UtilsAdapterService } from 'adapters/implementations/utils/utils.service'; @@ -18,11 +19,18 @@ import type { GetPostpaidOutput, GetPrepaidCardsInput, GetPrepaidOutput, + UpsertCardBillsInput, } from 'models/card'; import { CardRepository, CardUseCase } from 'models/card'; import { CardRepositoryService } from 'repositories/postgres/card/card-repository.service'; import type { Paginated, PaginatedItems } from 'types/paginated-items'; +interface GetBillStartDateInput { + month: YearMonth; + dueDay: number; + statementDays: number; +} + @Injectable() export class CardService extends CardUseCase { constructor( @@ -146,7 +154,7 @@ export class CardService extends CardUseCase { > { const { limit, offset, paging } = this.utilsAdapter.pagination(pagination); - const endDate = this.dateAdapter.endOfMonth(date); + const endDate = this.dateAdapter.endOf(date, 'month'); const data = await this.cardRepository.getBillsToBePaid({ accountId, @@ -162,6 +170,43 @@ export class CardService extends CardUseCase { }; } + async upsertCardBills({ + cardId, + startDate, + endDate, + }: UpsertCardBillsInput): Promise { + const card = await this.cardRepository.getById({ + cardId, + }); + + if (!card) { + throw new NotFoundException('Card not found'); + } + + const monthsBetween = this.dateAdapter.getMonthsBetween(startDate, endDate); + + await this.cardRepository.upsertManyBills( + monthsBetween.map((month) => { + const date = `${month}-01`; + + const { startAt, endAt, statementDate, dueDate } = this.getBillDates({ + month, + statementDays: card.cardProvider.statementDays, + dueDay: card.dueDay, + }); + + return { + cardId, + month: date, + startAt, + endAt, + statementDate, + dueDate, + }; + }), + ); + } + // Private private isPostpaid(type: CardTypeEnum) { @@ -173,4 +218,40 @@ export class CardService extends CardUseCase { type as any, ); } + + private getBillDates({ + month, + dueDay, + statementDays, + }: GetBillStartDateInput) { + const curDueDate = `${month}-${dueDay}`; + + return { + // startOfDay: curDueDate - statementDays + statementDate: this.dateAdapter.startOf( + this.dateAdapter.sub(curDueDate, statementDays, 'day'), + 'day', + ), + + // endOfDay: curDueDate + dueDate: this.dateAdapter.endOf(curDueDate, 'day'), + + // startOfDay: prevDueDate - statementDays + startAt: this.dateAdapter.startOf( + this.dateAdapter.sub( + this.dateAdapter.sub(curDueDate, 1, 'month'), + statementDays, + 'day', + ), + 'day', + ), + + // endOfDay: curDueDate - (statementDays + 1) + // *: Because when statementDate, cardBill is already closed + endAt: this.dateAdapter.endOf( + this.dateAdapter.sub(curDueDate, statementDays + 1, 'day'), + 'day', + ), + }; + } } From fd985d6e64b1abaa8d1b014f44de905af42e7919 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Wed, 24 Jan 2024 00:10:48 -0300 Subject: [PATCH 03/19] Add method to create CREDIT transactions --- openapi/openapi.yaml | 2 + openapi/paths/transactions/credit.yaml | 64 +++++++++ package.json | 1 + .../migration.sql | 8 ++ prisma/schema.prisma | 13 +- src/adapters/date.ts | 47 +++---- .../implementations/dayjs/dayjs.service.ts | 85 ++++++------ src/delivery/dtos/transaction.ts | 28 +++- src/delivery/transaction.controller.ts | 20 ++- src/models/budget.ts | 20 +++ src/models/card.ts | 19 ++- src/models/transaction.ts | 44 +++++- .../budget/budget-repository.module.ts | 2 + .../budget/budget-repository.service.ts | 56 ++++++++ .../postgres/card/card-repository.module.ts | 2 +- .../postgres/card/card-repository.service.ts | 62 +++++++-- .../transaction-repository.service.ts | 17 +++ src/usecases/budget/budget.module.ts | 2 + src/usecases/budget/budget.service.ts | 24 +++- src/usecases/card/card.service.ts | 127 ++++++++++++------ .../transaction/transaction.module.ts | 10 ++ .../transaction/transaction.service.ts | 114 +++++++++++++++- 22 files changed, 630 insertions(+), 137 deletions(-) create mode 100644 openapi/paths/transactions/credit.yaml create mode 100644 prisma/migrations/20240124030836_add_date_to_budget_date/migration.sql diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 485343a..0252d1d 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -124,6 +124,8 @@ paths: $ref: paths/transactions/transfer.yaml /transactions/in-out: $ref: paths/transactions/in-out.yaml + /transactions/credit: + $ref: paths/transactions/credit.yaml /wallet/balance: $ref: paths/wallet/balance.yaml diff --git a/openapi/paths/transactions/credit.yaml b/openapi/paths/transactions/credit.yaml new file mode 100644 index 0000000..690c783 --- /dev/null +++ b/openapi/paths/transactions/credit.yaml @@ -0,0 +1,64 @@ +post: + tags: + - Transaction + summary: Create CREDIT transaction + description: | + Create a CREDIT transaction, to add or remove money from + one of the user's bank accounts only when the card bill + is paid + operationId: transaction-credit + security: + - bearer: [] + requestBody: + content: + application/json: + schema: + type: object + required: + - name + - description + - amount + - installments + - categoryId + - cardId + - budgetDateId + - createdAt + properties: + name: + $ref: ../../components/fields/transaction-name.yaml + description: + $ref: ../../components/fields/description.yaml + amount: + $ref: ../../components/fields/amount.yaml + installments: + description: | + Amount of installments for the transaction + type: integer + minimum: 1 + examples: + - 1 + - 12 + - 420 + cardId: + description: ID of the card used to pay the transaction + type: string + format: uuid + categoryId: + description: Category ID + type: string + format: uuid + budgetDateId: + description: BudgetDate ID + type: string + format: uuid + createdAt: + $ref: ../../components/fields/created-at.yaml + required: true + responses: + "201": + description: | + Transaction created + "400": + $ref: ../../components/responses/bad-request.yaml + "401": + $ref: ../../components/responses/unauthorized.yaml diff --git a/package.json b/package.json index 8c4624f..66fb70e 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "start:db": "docker compose up postgres", "start:prod": "node main", "clean:docker": "docker container rm econominhas-api && docker image rm econominhas-api", + "lint:all": "yarn lint:ts && yarn lint:code && yarn lint:prisma && yarn lint:openapi", "lint:ts": "tsc --project tsconfig.lint.json", "lint:code": "eslint \"src/**/*.ts\" --fix --quiet", "lint:prisma": "prisma-case-format --file prisma/schema.prisma --map-table-case snake,plural --map-field-case snake --map-enum-case snake -p", diff --git a/prisma/migrations/20240124030836_add_date_to_budget_date/migration.sql b/prisma/migrations/20240124030836_add_date_to_budget_date/migration.sql new file mode 100644 index 0000000..6cfc08f --- /dev/null +++ b/prisma/migrations/20240124030836_add_date_to_budget_date/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `date` to the `budget_dates` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "budget_dates" ADD COLUMN "date" DATE NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 278e1e0..ad2f1c7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -438,10 +438,11 @@ model Budget { /// Contains the budgets information by date (month-year) model BudgetDate { - id String @id @db.Char(16) - budgetId String @map("budget_id") @db.Char(16) - month Int @db.SmallInt - year Int @db.SmallInt + id String @id @db.Char(16) + budgetId String @map("budget_id") @db.Char(16) + month Int @db.SmallInt + year Int @db.SmallInt + date DateTime @db.Date /// The first day of the month relative to the budgetDate budget Budget @relation(fields: [budgetId], references: [id], onDelete: Cascade) budgetItems BudgetItem[] @@ -493,8 +494,10 @@ model Transaction { recurrentTransactionId String? @map("recurrent_transaction_id") @db.Char(16) // Transaction type=IN,OUT,CREDIT categoryId String? @map("category_id") @db.Char(16) /// Only type=IN,OUT,CREDIT transactions have this column - cardId String? @map("card_id") @db.Char(16) /// Only type=IN,OUT,CREDIT transactions have this column + // Transaction type=IN,OUT bankAccountId String? @map("bank_account_id") @db.Char(16) /// Only type=IN,OUT,CREDIT transactions have this column + // Transaction type=CREDIT + cardId String? @map("card_id") @db.Char(16) /// Only type=IN,OUT,CREDIT transactions have this column // Transaction type=TRANSFER bankAccountFromId String? @map("bank_account_from_id") @db.Char(16) /// Only type=TRANSFER transactions have this column bankAccountToId String? @map("bank_account_to_id") @db.Char(16) /// Only type=TRANSFER transactions have this column diff --git a/src/adapters/date.ts b/src/adapters/date.ts index 32f03f3..adb1115 100644 --- a/src/adapters/date.ts +++ b/src/adapters/date.ts @@ -6,7 +6,8 @@ export interface TodayOutput { year: number; } -export type DateManipulationUnit = 'second' | 'day' | 'week' | 'month' | 'year'; +export type DateUnit = 'second' | 'day' | 'week' | 'month' | 'year'; +export type DateUnitExceptWeek = 'second' | 'day' | 'month' | 'year'; export type YearMonth = `${number}-${number}`; export type YearMonthDay = `${number}-${number}-${number}`; @@ -20,17 +21,19 @@ export abstract class DateAdapter { abstract today(timezone?: TimezoneEnum): TodayOutput; - /** - * Return an array of dates ('year-month') in the space - * between the start and end dates (including the start - * and end dates itself). - * - * @returns Array of dates: ['2023-01'] - */ - abstract getMonthsBetween( - startDate: YearMonth, - endDate: YearMonth, - ): Array; + abstract newDate(date: string, timezone?: TimezoneEnum): Date; + + abstract get(date: Date | string, unit: DateUnitExceptWeek): number; + + abstract getNextMonths(startDate: Date | string, amount: number): Array; + + abstract statementDate( + dueDay: number, + statementDays: number, + monthsToAdd?: number, + ): Date; + + abstract dueDate(dueDay: number, monthsToAdd?: number): Date; /** * @@ -43,35 +46,29 @@ export abstract class DateAdapter { anotherDate: Date | YearMonth, ): boolean; + abstract isAfterToday(date: Date | string): boolean; + /** * * Modifiers * */ - abstract nowPlus(amount: number, unit: DateManipulationUnit): Date; + abstract nowPlus(amount: number, unit: DateUnit): Date; - abstract add( - date: Date | string, - amount: number, - unit: DateManipulationUnit, - ): Date; + abstract add(date: Date | string, amount: number, unit: DateUnit): Date; - abstract sub( - date: Date | string, - amount: number, - unit: DateManipulationUnit, - ): Date; + abstract sub(date: Date | string, amount: number, unit: DateUnit): Date; abstract startOf( date: Date | string, - unit: DateManipulationUnit, + unit: DateUnit, timezone?: TimezoneEnum, ): Date; abstract endOf( date: Date | string, - unit: DateManipulationUnit, + unit: DateUnit, timezone?: TimezoneEnum, ): Date; } diff --git a/src/adapters/implementations/dayjs/dayjs.service.ts b/src/adapters/implementations/dayjs/dayjs.service.ts index 47cad44..c0020cd 100644 --- a/src/adapters/implementations/dayjs/dayjs.service.ts +++ b/src/adapters/implementations/dayjs/dayjs.service.ts @@ -1,5 +1,10 @@ import { Injectable } from '@nestjs/common'; -import type { DateManipulationUnit, TodayOutput, YearMonth } from '../../date'; +import type { + DateUnit, + DateUnitExceptWeek, + TodayOutput, + YearMonth, +} from '../../date'; import { DateAdapter } from '../../date'; import dayjs from 'dayjs'; @@ -30,41 +35,41 @@ export class DayjsAdapterService extends DateAdapter { }; } - getMonthsBetween(startDate: YearMonth, endDate: YearMonth): YearMonth[] { - if (this.isSameMonth(startDate, endDate)) { - return [startDate]; - } + newDate(date: string, timezone?: TimezoneEnum): Date { + return dayjs.tz(date, timezone).toDate(); + } - const startDateDayjs = dayjs(startDate); + get(date: string | Date, unit: DateUnitExceptWeek): number { + return dayjs(date).get(unit); + } - if (startDateDayjs.isAfter(endDate)) { - throw new Error('startDate must be before endDate'); - } + getNextMonths(startDate: string | Date, amount: number): Date[] { + const months: Array = []; - const difference = startDateDayjs.diff(endDate, 'months'); - const THIRTY_FIVE_YEARS_IN_MONTHS = 35 * 12; - if (difference > THIRTY_FIVE_YEARS_IN_MONTHS) { - throw new Error( - 'Difference between startDate and endDate must be less than 35 years', - ); - } + let curDate = dayjs(startDate); + do { + months.push(curDate.toDate()); - const dates: Array = [ - startDateDayjs.format('YYYY-MM') as YearMonth, - ]; + curDate = curDate.add(1, 'month'); + } while (months.length < amount); - let reached = false; - const curDate = startDateDayjs; - do { - curDate.add(1, 'months'); - dates.push(curDate.format('YYYY-MM') as YearMonth); + return months; + } - if (curDate.isSameOrAfter(endDate)) { - reached = true; - } - } while (reached); + statementDate( + dueDay: number, + statementDays: number, + monthsToAdd: number = 0, + ): Date { + return dayjs() + .set('day', dueDay) + .add(monthsToAdd, 'months') + .add(statementDays * -1, 'days') + .toDate(); + } - return dates; + dueDate(dueDay: number, monthsToAdd: number = 0): Date { + return dayjs().set('day', dueDay).add(monthsToAdd, 'months').toDate(); } /** @@ -88,37 +93,33 @@ export class DayjsAdapterService extends DateAdapter { return isSameYear && isSameMonth; } + isAfterToday(date: string | Date): boolean { + return dayjs(date).isAfter(dayjs()); + } + /** * * Modifiers * */ - nowPlus(amount: number, unit: DateManipulationUnit): Date { + nowPlus(amount: number, unit: DateUnit): Date { return dayjs.utc().add(amount, unit).toDate(); } - add(date: string | Date, amount: number, unit: DateManipulationUnit): Date { + add(date: string | Date, amount: number, unit: DateUnit): Date { return dayjs(date).add(amount, unit).toDate(); } - sub(date: string | Date, amount: number, unit: DateManipulationUnit): Date { + sub(date: string | Date, amount: number, unit: DateUnit): Date { return this.add(date, amount * -1, unit); } - startOf( - date: string | Date, - unit: DateManipulationUnit, - timezone?: TimezoneEnum, - ): Date { + startOf(date: string | Date, unit: DateUnit, timezone?: TimezoneEnum): Date { return dayjs.tz(date, timezone).startOf(unit).toDate(); } - endOf( - date: string | Date, - unit: DateManipulationUnit, - timezone?: TimezoneEnum, - ): Date { + endOf(date: string | Date, unit: DateUnit, timezone?: TimezoneEnum): Date { return dayjs.tz(date, timezone).endOf(unit).toDate(); } } diff --git a/src/delivery/dtos/transaction.ts b/src/delivery/dtos/transaction.ts index f5a47d8..ebf6666 100644 --- a/src/delivery/dtos/transaction.ts +++ b/src/delivery/dtos/transaction.ts @@ -5,7 +5,7 @@ import { IsTransactionName, } from '../validators/internal'; import { IsMonth, IsYear } from '../validators/date'; -import { IsDate, IsIn } from 'class-validator'; +import { IsDate, IsIn, IsPositive } from 'class-validator'; import { TransactionTypeEnum } from '@prisma/client'; export class GetListDto { @@ -67,3 +67,29 @@ export class InOutDto { @IsDate() createdAt: Date; } + +export class CreditDto { + @IsTransactionName() + name: string; + + @IsDescription() + description: string; + + @IsAmount() + amount: number; + + @IsPositive() + installments: number; + + @IsID() + categoryId: string; + + @IsID() + cardId: string; + + @IsID() + budgetDateId: string; + + @IsDate() + createdAt: Date; +} diff --git a/src/delivery/transaction.controller.ts b/src/delivery/transaction.controller.ts index a3bab44..1448f59 100644 --- a/src/delivery/transaction.controller.ts +++ b/src/delivery/transaction.controller.ts @@ -1,7 +1,12 @@ import { Controller, Inject, Get, Query, Body, Post } from '@nestjs/common'; import { UserData } from './decorators/user-data'; import { UserDataDto } from './dtos'; -import { GetListDto, InOutDto, TransferDto } from './dtos/transaction'; +import { + CreditDto, + GetListDto, + InOutDto, + TransferDto, +} from './dtos/transaction'; import { TransactionService } from 'usecases/transaction/transaction.service'; import { TransactionUseCase } from 'models/transaction'; @@ -50,4 +55,17 @@ export class TransactionController { accountId: userData.accountId, }); } + + @Post('/credit') + async credit( + @UserData() + userData: UserDataDto, + @Body() + body: CreditDto, + ) { + return this.transactionService.credit({ + ...body, + accountId: userData.accountId, + }); + } } diff --git a/src/models/budget.ts b/src/models/budget.ts index ab71b12..190051c 100644 --- a/src/models/budget.ts +++ b/src/models/budget.ts @@ -39,6 +39,13 @@ export interface GetBudgetDateByIdInput { accountId: string; } +export interface UpsertManyBudgetDatesInput { + budgetId: string; + month: number; + year: number; + date: Date; +} + export abstract class BudgetRepository { abstract createWithItems(i: CreateWithItemsInput): Promise; @@ -49,6 +56,10 @@ export abstract class BudgetRepository { abstract getBudgetDateById( i: GetBudgetDateByIdInput, ): Promise; + + abstract upsertManyBudgetDates( + i: Array, + ): Promise>; } /** @@ -104,10 +115,19 @@ export interface OverviewOutput { >; } +export interface CreateNextBudgetDatesInput { + startFrom: BudgetDate; + amount: number; +} + export abstract class BudgetUseCase { abstract create(i: CreateInput): Promise; abstract createBasic(i: CreateBasicInput): Promise; abstract overview(i: OverviewInput): Promise; + + abstract createNextBudgetDates( + i: CreateNextBudgetDatesInput, + ): Promise>; } diff --git a/src/models/card.ts b/src/models/card.ts index 6b60c48..87cd401 100644 --- a/src/models/card.ts +++ b/src/models/card.ts @@ -1,11 +1,11 @@ import type { Card, + CardBill, CardNetworkEnum, CardProvider, CardTypeEnum, PayAtEnum, } from '@prisma/client'; -import type { YearMonth } from 'adapters/date'; import type { Paginated, PaginatedItems, @@ -115,6 +115,7 @@ export interface GetBillsToBePaidOutput { export interface GetByIdInput { cardId: string; + accountId: string; } export interface GetByIdOutput extends Card { @@ -123,7 +124,7 @@ export interface GetByIdOutput extends Card { export interface UpsertManyBillsInput { cardId: string; - month: string; + month: Date; startAt: Date; endAt: Date; statementDate: Date; @@ -155,7 +156,9 @@ export abstract class CardRepository { // CardBill - abstract upsertManyBills(i: Array): Promise; + abstract upsertManyBills( + i: Array, + ): Promise>; abstract getBillsToBePaid( i: GetBillsToBePaidInput, @@ -184,10 +187,10 @@ export interface GetCardBillsToBePaidInput extends Paginated { date: Date; } -export interface UpsertCardBillsInput { +export interface CreateNextCardBillsInput { cardId: string; - startDate: YearMonth; - endDate: YearMonth; + accountId: string; + amount: number; } export abstract class CardUseCase { @@ -207,5 +210,7 @@ export abstract class CardUseCase { i: GetCardBillsToBePaidInput, ): Promise>; - abstract upsertCardBills(i: UpsertCardBillsInput): Promise; + abstract createNextCardBills( + i: CreateNextCardBillsInput, + ): Promise>; } diff --git a/src/models/transaction.ts b/src/models/transaction.ts index 995ed70..ccb7fe5 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -72,6 +72,30 @@ export interface CreateInOutInput { isSystemManaged: boolean; } +export interface CreateCreditInput { + common: { + accountId: string; + name: string; + amount: number; + categoryId: string; + cardId: string; + description: string; + createdAt: Date; + isSystemManaged: boolean; + installment: { + installmentGroupId: string; + total: number; + }; + }; + unique: Array<{ + budgetDateId: string; + installment: { + current: number; + cardBillId: string; + }; + }>; +} + export abstract class TransactionRepository { abstract getMonthlyAmountByCategory( i: GetMonthlyAmountByCategoryInput, @@ -82,6 +106,8 @@ export abstract class TransactionRepository { abstract createTransfer(i: CreateTransferInput): Promise; abstract createInOut(i: CreateInOutInput): Promise; + + abstract createCredit(i: CreateCreditInput): Promise; } /** @@ -102,23 +128,35 @@ export interface GetListInput extends Paginated { export interface TransferInput { accountId: string; name: string; + description: string; amount: number; bankAccountFromId: string; bankAccountToId: string; budgetDateId: string; - description: string; createdAt: Date; } export interface InOutInput { type: typeof TransactionTypeEnum.IN | typeof TransactionTypeEnum.OUT; accountId: string; + name: string; + description: string; + amount: number; categoryId: string; bankAccountId: string; budgetDateId: string; + createdAt: Date; +} + +export interface CreditInput { + accountId: string; name: string; - amount: number; description: string; + amount: number; + installments: number; + categoryId: string; + cardId: string; + budgetDateId: string; createdAt: Date; } @@ -128,4 +166,6 @@ export abstract class TransactionUseCase { abstract transfer(i: TransferInput): Promise; abstract inOut(i: InOutInput): Promise; + + abstract credit(i: CreditInput): Promise; } diff --git a/src/repositories/postgres/budget/budget-repository.module.ts b/src/repositories/postgres/budget/budget-repository.module.ts index 10836f4..643adcf 100644 --- a/src/repositories/postgres/budget/budget-repository.module.ts +++ b/src/repositories/postgres/budget/budget-repository.module.ts @@ -2,10 +2,12 @@ import { Module } from '@nestjs/common'; import { PostgresModule } from '..'; import { BudgetRepositoryService } from './budget-repository.service'; import { UIDAdapterModule } from 'adapters/implementations/uid/uid.module'; +import { DayJsAdapterModule } from 'adapters/implementations/dayjs/dayjs.module'; @Module({ imports: [ PostgresModule.forFeature(['budget', 'budgetDate']), + DayJsAdapterModule, UIDAdapterModule, ], providers: [BudgetRepositoryService], diff --git a/src/repositories/postgres/budget/budget-repository.service.ts b/src/repositories/postgres/budget/budget-repository.service.ts index 9b70b7f..6d21709 100644 --- a/src/repositories/postgres/budget/budget-repository.service.ts +++ b/src/repositories/postgres/budget/budget-repository.service.ts @@ -5,11 +5,14 @@ import type { GetBudgetDateByIdInput, GetMonthlyByCategoryInput, GetMonthlyByCategoryOutput, + UpsertManyBudgetDatesInput, } from 'models/budget'; import { BudgetRepository } from 'models/budget'; import type { Budget, BudgetDate } from '@prisma/client'; import { IdAdapter } from 'adapters/id'; import { UIDAdapterService } from 'adapters/implementations/uid/uid.service'; +import { DateAdapter } from 'adapters/date'; +import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; @Injectable() export class BudgetRepositoryService extends BudgetRepository { @@ -19,6 +22,8 @@ export class BudgetRepositoryService extends BudgetRepository { @InjectRepository('budgetDate') private readonly budgetDateRepository: Repository<'budgetDate'>, + @Inject(DayjsAdapterService) + private readonly dateAdapter: DateAdapter, @Inject(UIDAdapterService) private readonly idAdapter: IdAdapter, ) { @@ -48,6 +53,7 @@ export class BudgetRepositoryService extends BudgetRepository { budgetId, month, year, + date: this.dateAdapter.newDate(`${year}-${month}-01`), budgetItems: { create: items.map(({ categoryId, amount }) => ({ budgetDateId: this.idAdapter.genId(), @@ -111,4 +117,54 @@ export class BudgetRepositoryService extends BudgetRepository { }, }); } + + async upsertManyBudgetDates( + i: UpsertManyBudgetDatesInput[], + ): Promise { + const budgetId = i[0]?.budgetId; + + if (!budgetId) { + return []; + } + + const alreadyExistentDates = await this.budgetDateRepository + .findMany({ + where: { + budgetId, + date: { + in: i.map((bd) => bd.date), + }, + }, + select: { + date: true, + }, + }) + .then((r) => r.map((bd) => bd.date.toISOString())); + + const budgetDates = i.filter( + (bd) => !alreadyExistentDates.includes(bd.date.toISOString()), + ); + + await this.budgetDateRepository.createMany({ + data: budgetDates.map((budgetDate) => { + return { + ...budgetDate, + id: this.idAdapter.genId(), + }; + }), + skipDuplicates: true, + }); + + return this.budgetDateRepository.findMany({ + where: { + budgetId, + date: { + in: i.map((cb) => cb.date), + }, + }, + orderBy: { + date: 'asc', + }, + }); + } } diff --git a/src/repositories/postgres/card/card-repository.module.ts b/src/repositories/postgres/card/card-repository.module.ts index 1bc6f56..ed3ceac 100644 --- a/src/repositories/postgres/card/card-repository.module.ts +++ b/src/repositories/postgres/card/card-repository.module.ts @@ -6,7 +6,7 @@ import { DayJsAdapterModule } from 'adapters/implementations/dayjs/dayjs.module' @Module({ imports: [ - PostgresModule.forFeature(['cardProvider', 'card']), + PostgresModule.forFeature(['cardProvider', 'card', 'cardBill']), PostgresModule.raw(), UIDAdapterModule, DayJsAdapterModule, diff --git a/src/repositories/postgres/card/card-repository.service.ts b/src/repositories/postgres/card/card-repository.service.ts index be18d0b..5fb8fe7 100644 --- a/src/repositories/postgres/card/card-repository.service.ts +++ b/src/repositories/postgres/card/card-repository.service.ts @@ -24,12 +24,15 @@ import type { UpsertManyBillsInput, } from 'models/card'; import { CardRepository } from 'models/card'; -import type { Card, CardNetworkEnum, CardProvider } from '@prisma/client'; +import type { + Card, + CardBill, + CardNetworkEnum, + CardProvider, +} from '@prisma/client'; import { CardTypeEnum, PayAtEnum } from '@prisma/client'; import { IdAdapter } from 'adapters/id'; import { UIDAdapterService } from 'adapters/implementations/uid/uid.service'; -import { DateAdapter } from 'adapters/date'; -import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; @Injectable() export class CardRepositoryService extends CardRepository { @@ -45,8 +48,6 @@ export class CardRepositoryService extends CardRepository { @Inject(UIDAdapterService) private readonly idAdapter: IdAdapter, - @Inject(DayjsAdapterService) - private readonly dateAdapter: DateAdapter, ) { super(); } @@ -69,10 +70,11 @@ export class CardRepositoryService extends CardRepository { }); } - getById({ cardId }: GetByIdInput): Promise { + getById({ cardId, accountId }: GetByIdInput): Promise { return this.cardRepository.findUnique({ where: { id: cardId, + accountId, }, include: { cardProvider: true, @@ -378,13 +380,51 @@ export class CardRepositoryService extends CardRepository { }); } - async upsertManyBills(i: UpsertManyBillsInput[]): Promise { + async upsertManyBills(i: UpsertManyBillsInput[]): Promise { + const cardId = i[0]?.cardId; + + if (!cardId) { + return []; + } + + const alreadyExistentMonths = await this.cardBillRepository + .findMany({ + where: { + cardId, + month: { + in: i.map((cb) => cb.month), + }, + }, + select: { + month: true, + }, + }) + .then((r) => r.map((cb) => cb.month.toISOString())); + + const cardBills = i.filter( + (cb) => !alreadyExistentMonths.includes(cb.month.toISOString()), + ); + await this.cardBillRepository.createMany({ - data: i.map((cardBill) => ({ - ...cardBill, - id: this.idAdapter.genId(), - })), + data: cardBills.map((cardBill) => { + return { + ...cardBill, + id: this.idAdapter.genId(), + }; + }), skipDuplicates: true, }); + + return this.cardBillRepository.findMany({ + where: { + cardId, + month: { + in: i.map((cb) => cb.month), + }, + }, + orderBy: { + month: 'asc', + }, + }); } } diff --git a/src/repositories/postgres/transaction/transaction-repository.service.ts b/src/repositories/postgres/transaction/transaction-repository.service.ts index fbe88ba..903e7d6 100644 --- a/src/repositories/postgres/transaction/transaction-repository.service.ts +++ b/src/repositories/postgres/transaction/transaction-repository.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository, Repository } from '..'; import type { + CreateCreditInput, CreateInOutInput, CreateTransferInput, GetByBudgetInput, @@ -212,4 +213,20 @@ export class TransactionRepositoryService extends TransactionRepository { }, }); } + + async createCredit({ common, unique }: CreateCreditInput): Promise { + this.transactionRepository.createMany({ + data: unique.map((u) => ({ + ...common, + ...u, + installment: { + ...common.installment, + ...u.installment, + }, + type: TransactionTypeEnum.CREDIT, + id: this.idAdapter.genId(), + })), + skipDuplicates: true, + }); + } } diff --git a/src/usecases/budget/budget.module.ts b/src/usecases/budget/budget.module.ts index e21cf84..ea481e4 100644 --- a/src/usecases/budget/budget.module.ts +++ b/src/usecases/budget/budget.module.ts @@ -5,6 +5,7 @@ import { BudgetController } from 'delivery/budget.controller'; import { AccountModule } from '../account/account.module'; import { TransactionRepositoryModule } from 'repositories/postgres/transaction/transaction-repository.module'; import { CategoryRepositoryModule } from 'repositories/postgres/category/category-repository.module'; +import { DayJsAdapterModule } from 'adapters/implementations/dayjs/dayjs.module'; @Module({ controllers: [BudgetController], @@ -13,6 +14,7 @@ import { CategoryRepositoryModule } from 'repositories/postgres/category/categor CategoryRepositoryModule, TransactionRepositoryModule, AccountModule, + DayJsAdapterModule, ], providers: [BudgetService], exports: [BudgetService], diff --git a/src/usecases/budget/budget.service.ts b/src/usecases/budget/budget.service.ts index 91b3277..4e361b2 100644 --- a/src/usecases/budget/budget.service.ts +++ b/src/usecases/budget/budget.service.ts @@ -2,18 +2,21 @@ import { Inject, Injectable } from '@nestjs/common'; import type { CreateBasicInput, CreateInput, + CreateNextBudgetDatesInput, OverviewInput, OverviewOutput, } from 'models/budget'; import { BudgetRepository, BudgetUseCase } from 'models/budget'; import { BudgetRepositoryService } from 'repositories/postgres/budget/budget-repository.service'; import { AccountService } from '../account/account.service'; -import type { Budget } from '@prisma/client'; +import type { Budget, BudgetDate } from '@prisma/client'; import { AccountUseCase } from 'models/account'; import { TransactionRepository } from 'models/transaction'; import { TransactionRepositoryService } from 'repositories/postgres/transaction/transaction-repository.service'; import { CategoryRepository } from 'models/category'; import { CategoryRepositoryService } from 'repositories/postgres/category/category-repository.service'; +import { DateAdapter } from 'adapters/date'; +import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; @Injectable() export class BudgetService extends BudgetUseCase { @@ -27,6 +30,9 @@ export class BudgetService extends BudgetUseCase { @Inject(AccountService) private readonly accountService: AccountUseCase, + + @Inject(DayjsAdapterService) + private readonly dateAdapter: DateAdapter, ) { super(); } @@ -132,4 +138,20 @@ export class BudgetService extends BudgetUseCase { })), }; } + + async createNextBudgetDates({ + startFrom, + amount, + }: CreateNextBudgetDatesInput): Promise { + const dates = this.dateAdapter.getNextMonths(startFrom.date, amount); + + return this.budgetRepository.upsertManyBudgetDates( + dates.map((date) => ({ + budgetId: startFrom.budgetId, + month: this.dateAdapter.get(date, 'month'), + year: this.dateAdapter.get(date, 'year'), + date, + })), + ); + } } diff --git a/src/usecases/card/card.service.ts b/src/usecases/card/card.service.ts index f4382dc..0316f5e 100644 --- a/src/usecases/card/card.service.ts +++ b/src/usecases/card/card.service.ts @@ -4,33 +4,50 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import type { CardProvider } from '@prisma/client'; +import type { CardBill, CardProvider } from '@prisma/client'; import { CardTypeEnum } from '@prisma/client'; -import type { YearMonth } from 'adapters/date'; import { DateAdapter } from 'adapters/date'; import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; import { UtilsAdapterService } from 'adapters/implementations/utils/utils.service'; import { UtilsAdapter } from 'adapters/utils'; import type { CreateInput, + CreateNextCardBillsInput, GetBillsToBePaidOutput, GetCardBillsToBePaidInput, GetPostpaidCardsInput, GetPostpaidOutput, GetPrepaidCardsInput, GetPrepaidOutput, - UpsertCardBillsInput, } from 'models/card'; import { CardRepository, CardUseCase } from 'models/card'; import { CardRepositoryService } from 'repositories/postgres/card/card-repository.service'; import type { Paginated, PaginatedItems } from 'types/paginated-items'; -interface GetBillStartDateInput { - month: YearMonth; +interface GetCurBillDataInput { dueDay: number; statementDays: number; } +interface GetBillDatesInput { + dueDate: Date; + statementDays: number; +} + +interface GetBillDatesOutput { + month: Date; + statementDate: Date; + dueDate: Date; + startAt: Date; + endAt: Date; +} + +interface GetNextBillsDatesInput { + initialDueDate: Date; + statementDays: number; + amount: number; +} + @Injectable() export class CardService extends CardUseCase { constructor( @@ -170,40 +187,36 @@ export class CardService extends CardUseCase { }; } - async upsertCardBills({ + async createNextCardBills({ cardId, - startDate, - endDate, - }: UpsertCardBillsInput): Promise { + accountId, + amount, + }: CreateNextCardBillsInput): Promise { const card = await this.cardRepository.getById({ cardId, + accountId, }); if (!card) { - throw new NotFoundException('Card not found'); + throw new BadRequestException('Invalid card'); } - const monthsBetween = this.dateAdapter.getMonthsBetween(startDate, endDate); - - await this.cardRepository.upsertManyBills( - monthsBetween.map((month) => { - const date = `${month}-01`; - - const { startAt, endAt, statementDate, dueDate } = this.getBillDates({ - month, - statementDays: card.cardProvider.statementDays, - dueDay: card.dueDay, - }); - - return { - cardId, - month: date, - startAt, - endAt, - statementDate, - dueDate, - }; - }), + const curBillDueDate = this.getCurBillDueDate({ + dueDay: card.dueDay, + statementDays: card.cardProvider.statementDays, + }); + + const allBillsData = this.getNextBillsDates({ + initialDueDate: curBillDueDate, + statementDays: card.cardProvider.statementDays, + amount, + }); + + return this.cardRepository.upsertManyBills( + allBillsData.map((billDates) => ({ + ...billDates, + cardId, + })), ); } @@ -219,27 +232,63 @@ export class CardService extends CardUseCase { ); } - private getBillDates({ - month, + private getCurBillDueDate({ dueDay, statementDays, - }: GetBillStartDateInput) { - const curDueDate = `${month}-${dueDay}`; + }: GetCurBillDataInput): Date { + const statementDate = this.dateAdapter.statementDate(dueDay, statementDays); + + // If the statementDay is after the current day + if (this.dateAdapter.isAfterToday(statementDate)) { + return this.dateAdapter.dueDate(dueDay); + } + + // If the statementDay is BEFORE the current day, + // the dueDate is in the next month + return this.dateAdapter.dueDate(dueDay, 1); + } + + private getNextBillsDates({ + initialDueDate, + statementDays, + amount, + }: GetNextBillsDatesInput) { + const billsDates: Array = []; + const curDueDate = initialDueDate; + do { + const billDates = this.getBillDates({ + dueDate: curDueDate, + statementDays, + }); + + billsDates.push(billDates); + } while (billsDates.length < amount); + + return billsDates; + } + + private getBillDates({ + dueDate, + statementDays, + }: GetBillDatesInput): GetBillDatesOutput { return { + // First day of the month + month: this.dateAdapter.startOf(dueDate, 'month'), + // startOfDay: curDueDate - statementDays statementDate: this.dateAdapter.startOf( - this.dateAdapter.sub(curDueDate, statementDays, 'day'), + this.dateAdapter.sub(dueDate, statementDays, 'day'), 'day', ), // endOfDay: curDueDate - dueDate: this.dateAdapter.endOf(curDueDate, 'day'), + dueDate: this.dateAdapter.endOf(dueDate, 'day'), // startOfDay: prevDueDate - statementDays startAt: this.dateAdapter.startOf( this.dateAdapter.sub( - this.dateAdapter.sub(curDueDate, 1, 'month'), + this.dateAdapter.sub(dueDate, 1, 'month'), statementDays, 'day', ), @@ -249,7 +298,7 @@ export class CardService extends CardUseCase { // endOfDay: curDueDate - (statementDays + 1) // *: Because when statementDate, cardBill is already closed endAt: this.dateAdapter.endOf( - this.dateAdapter.sub(curDueDate, statementDays + 1, 'day'), + this.dateAdapter.sub(dueDate, statementDays + 1, 'day'), 'day', ), }; diff --git a/src/usecases/transaction/transaction.module.ts b/src/usecases/transaction/transaction.module.ts index 3bab886..496c35e 100644 --- a/src/usecases/transaction/transaction.module.ts +++ b/src/usecases/transaction/transaction.module.ts @@ -7,6 +7,10 @@ import { BankModule } from 'usecases/bank/bank.module'; import { BankRepositoryModule } from 'repositories/postgres/bank/bank-repository.module'; import { BudgetRepositoryModule } from 'repositories/postgres/budget/budget-repository.module'; import { CategoryRepositoryModule } from 'repositories/postgres/category/category-repository.module'; +import { CardRepositoryModule } from 'repositories/postgres/card/card-repository.module'; +import { BudgetModule } from 'usecases/budget/budget.module'; +import { CardModule } from 'usecases/card/card.module'; +import { UIDAdapterModule } from 'adapters/implementations/uid/uid.module'; @Module({ controllers: [TransactionController], @@ -15,7 +19,13 @@ import { CategoryRepositoryModule } from 'repositories/postgres/category/categor BankRepositoryModule, BudgetRepositoryModule, CategoryRepositoryModule, + CardRepositoryModule, + BankModule, + BudgetModule, + CardModule, + + UIDAdapterModule, UtilsAdapterModule, ], providers: [TransactionService], diff --git a/src/usecases/transaction/transaction.service.ts b/src/usecases/transaction/transaction.service.ts index 9fe0dcf..4e27d04 100644 --- a/src/usecases/transaction/transaction.service.ts +++ b/src/usecases/transaction/transaction.service.ts @@ -1,10 +1,19 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { + BadRequestException, + InternalServerErrorException, + Inject, + Injectable, +} from '@nestjs/common'; +import { IdAdapter } from 'adapters/id'; +import { UIDAdapterService } from 'adapters/implementations/uid/uid.service'; import { UtilsAdapterService } from 'adapters/implementations/utils/utils.service'; import { UtilsAdapter } from 'adapters/utils'; import { BankRepository, BankUseCase } from 'models/bank'; -import { BudgetRepository } from 'models/budget'; +import { BudgetRepository, BudgetUseCase } from 'models/budget'; +import { CardRepository, CardUseCase } from 'models/card'; import { CategoryRepository } from 'models/category'; import type { + CreditInput, GetByBudgetOutput, GetListInput, InOutInput, @@ -13,10 +22,13 @@ import type { import { TransactionRepository, TransactionUseCase } from 'models/transaction'; import { BankRepositoryService } from 'repositories/postgres/bank/bank-repository.service'; import { BudgetRepositoryService } from 'repositories/postgres/budget/budget-repository.service'; +import { CardRepositoryService } from 'repositories/postgres/card/card-repository.service'; import { CategoryRepositoryService } from 'repositories/postgres/category/category-repository.service'; import { TransactionRepositoryService } from 'repositories/postgres/transaction/transaction-repository.service'; import type { PaginatedItems } from 'types/paginated-items'; import { BankService } from 'usecases/bank/bank.service'; +import { BudgetService } from 'usecases/budget/budget.service'; +import { CardService } from 'usecases/card/card.service'; @Injectable() export class TransactionService extends TransactionUseCase { @@ -27,12 +39,20 @@ export class TransactionService extends TransactionUseCase { private readonly bankRepository: BankRepository, @Inject(BudgetRepositoryService) private readonly budgetRepository: BudgetRepository, + @Inject(CardRepositoryService) + private readonly cardRepository: CardRepository, @Inject(CategoryRepositoryService) private readonly categoryRepository: CategoryRepository, @Inject(BankService) private readonly bankService: BankUseCase, + @Inject(BudgetService) + private readonly budgetService: BudgetUseCase, + @Inject(CardService) + private readonly cardService: CardUseCase, + @Inject(UIDAdapterService) + private readonly idAdapter: IdAdapter, @Inject(UtilsAdapterService) private readonly utilsAdapter: UtilsAdapter, ) { @@ -199,4 +219,94 @@ export class TransactionService extends TransactionUseCase { isSystemManaged: false, }); } + + async credit({ + accountId, + name, + description, + amount, + installments, + categoryId, + cardId, + budgetDateId, + createdAt, + }: CreditInput): Promise { + const [card, category, budgetDate] = await Promise.all([ + this.cardRepository.getById({ + cardId, + accountId, + }), + this.categoryRepository.getById({ + categoryId, + accountId, + active: true, + }), + this.budgetRepository.getBudgetDateById({ + budgetDateId, + accountId, + }), + ]); + + if (!card) { + throw new BadRequestException('Invalid card'); + } + + if (!category) { + throw new BadRequestException('Invalid category'); + } + + if (!budgetDate) { + throw new BadRequestException('Invalid budgetDate'); + } + + // Create credit card bills and budgetDates if they + // don't exist + // OBS: this should be done elsewhere, but since we + // are poor and a very small team, we do it here + const [cardBills, budgetDates] = await Promise.all([ + this.cardService.createNextCardBills({ + cardId, + accountId, + amount: installments, + }), + this.budgetService.createNextBudgetDates({ + startFrom: budgetDate, + amount: installments, + }), + ]); + + if (cardBills.length !== installments) { + throw new InternalServerErrorException('Fail to create card bills'); + } + + if (budgetDates.length !== installments) { + throw new InternalServerErrorException('Fail to create budgets'); + } + + // Create multiple transactions with their installment + const installmentGroupId = this.idAdapter.genId(); + await this.transactionRepository.createCredit({ + common: { + accountId, + name, + amount, + categoryId, + cardId, + description, + createdAt, + isSystemManaged: false, + installment: { + installmentGroupId, + total: installments, + }, + }, + unique: cardBills.map(({ id }, idx) => ({ + budgetDateId: budgetDates[idx].id, + installment: { + current: idx + 1, + cardBillId: id, + }, + })), + }); + } } From 0ea8bd89d7f5852e543053c083ac9831b32d3e4d Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Wed, 24 Jan 2024 11:43:16 -0300 Subject: [PATCH 04/19] Add tests for dayjs module --- .lintstagedrc | 6 +- jest.config.json | 19 +- package.json | 2 +- src/adapters/date.ts | 12 +- .../implementations/dayjs/dayjs.service.ts | 118 +++- .../mocks/repositories/postgres/repository.ts | 36 + tests/setup.ts | 0 tests/src/adapters/dayjs.spec.ts | 617 ++++++++++++++++++ yarn.lock | 8 +- 9 files changed, 780 insertions(+), 38 deletions(-) create mode 100644 tests/mocks/repositories/postgres/repository.ts create mode 100644 tests/setup.ts create mode 100644 tests/src/adapters/dayjs.spec.ts diff --git a/.lintstagedrc b/.lintstagedrc index 3f3a764..af0f1f2 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -4,5 +4,9 @@ "bash -c \"yarn openapi:postman\"" ], "prisma/**/*": "bash -c \"yarn lint:prisma\"", - "src/**/*": ["bash -c \"yarn lint:ts\"", "bash -c \"yarn lint:code\""] + "src/**/*": [ + "bash -c \"yarn lint:ts\"", + "bash -c \"yarn lint:code\"", + "bash -c \"yarn test\"" + ] } diff --git a/jest.config.json b/jest.config.json index edf6c72..761a7b2 100644 --- a/jest.config.json +++ b/jest.config.json @@ -1,11 +1,22 @@ { "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": "src", + "rootDir": ".", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "collectCoverageFrom": ["**/*.(t|j)s"], - "coverageDirectory": "../coverage", - "testEnvironment": "node" + "collectCoverageFrom": [ + "src/**/*.(t|j)s", + "!src/app.module.ts", + "!src/config.ts", + "!src/main.ts", + "!src/models/*.ts", + "!src/adapters/*.ts", + "!src/repositories/postgres/*.ts", + "!src/types/*.ts" + ], + "coverageDirectory": "./coverage", + "testEnvironment": "node", + "moduleDirectories": ["node_modules", "src"], + "setupFiles": ["./tests/setup.ts"] } diff --git a/package.json b/package.json index 66fb70e..538fd0c 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "devDependencies": { "@nestjs/cli": "^10.2.1", "@nestjs/schematics": "^10.0.3", - "@nestjs/testing": "^10.2.10", + "@nestjs/testing": "^10.3.1", "@redocly/cli": "^1.4.1", "@types/express": "^4.17.21", "@types/jest": "^29.5.10", diff --git a/src/adapters/date.ts b/src/adapters/date.ts index adb1115..0190e8d 100644 --- a/src/adapters/date.ts +++ b/src/adapters/date.ts @@ -4,10 +4,10 @@ export interface TodayOutput { day: number; month: number; year: number; + date: Date; } -export type DateUnit = 'second' | 'day' | 'week' | 'month' | 'year'; -export type DateUnitExceptWeek = 'second' | 'day' | 'month' | 'year'; +export type DateUnit = 'second' | 'day' | 'month' | 'year'; export type YearMonth = `${number}-${number}`; export type YearMonthDay = `${number}-${number}-${number}`; @@ -21,9 +21,9 @@ export abstract class DateAdapter { abstract today(timezone?: TimezoneEnum): TodayOutput; - abstract newDate(date: string, timezone?: TimezoneEnum): Date; + abstract newDate(date: string | Date, timezone?: TimezoneEnum): Date; - abstract get(date: Date | string, unit: DateUnitExceptWeek): number; + abstract get(date: Date | string, unit: DateUnit): number; abstract getNextMonths(startDate: Date | string, amount: number): Array; @@ -42,8 +42,8 @@ export abstract class DateAdapter { */ abstract isSameMonth( - date: Date | YearMonth, - anotherDate: Date | YearMonth, + date: Date | string, + anotherDate: Date | string, ): boolean; abstract isAfterToday(date: Date | string): boolean; diff --git a/src/adapters/implementations/dayjs/dayjs.service.ts b/src/adapters/implementations/dayjs/dayjs.service.ts index c0020cd..b1dc155 100644 --- a/src/adapters/implementations/dayjs/dayjs.service.ts +++ b/src/adapters/implementations/dayjs/dayjs.service.ts @@ -1,10 +1,5 @@ import { Injectable } from '@nestjs/common'; -import type { - DateUnit, - DateUnitExceptWeek, - TodayOutput, - YearMonth, -} from '../../date'; +import type { DateUnit, TodayOutput } from '../../date'; import { DateAdapter } from '../../date'; import dayjs from 'dayjs'; @@ -17,6 +12,8 @@ dayjs.extend(utc); dayjs.extend(timezone); dayjs.extend(isSameOrAfter); +dayjs.tz.setDefault('UTC'); + @Injectable() export class DayjsAdapterService extends DateAdapter { /** @@ -32,21 +29,30 @@ export class DayjsAdapterService extends DateAdapter { day: today.date(), month: today.month() + 1, year: today.year(), + date: today.toDate(), }; } - newDate(date: string, timezone?: TimezoneEnum): Date { + newDate(date: string | Date, timezone?: TimezoneEnum): Date { return dayjs.tz(date, timezone).toDate(); } - get(date: string | Date, unit: DateUnitExceptWeek): number { - return dayjs(date).get(unit); + get(date: string | Date, unit: DateUnit): number { + if (unit === 'day') { + return dayjs.tz(date).get('date'); + } + + if (unit === 'month') { + return dayjs.tz(date).get(unit) + 1; + } + + return dayjs.tz(date).get(unit); } getNextMonths(startDate: string | Date, amount: number): Date[] { const months: Array = []; - let curDate = dayjs(startDate); + let curDate = dayjs.tz(startDate); do { months.push(curDate.toDate()); @@ -59,17 +65,29 @@ export class DayjsAdapterService extends DateAdapter { statementDate( dueDay: number, statementDays: number, - monthsToAdd: number = 0, + monthsToAdd?: number, ): Date { - return dayjs() - .set('day', dueDay) - .add(monthsToAdd, 'months') - .add(statementDays * -1, 'days') + let date = dayjs.tz(); + + if (monthsToAdd) { + date = date.add(monthsToAdd, 'months'); + } + + return date + .set('date', dueDay) + .startOf('day') + .subtract(statementDays, 'days') .toDate(); } dueDate(dueDay: number, monthsToAdd: number = 0): Date { - return dayjs().set('day', dueDay).add(monthsToAdd, 'months').toDate(); + let date = dayjs.tz(); + + if (monthsToAdd) { + date = date.add(monthsToAdd, 'months'); + } + + return date.set('date', dueDay).startOf('day').toDate(); } /** @@ -78,9 +96,9 @@ export class DayjsAdapterService extends DateAdapter { * */ - isSameMonth(date: Date | YearMonth, anotherDate: Date | YearMonth): boolean { - const d1 = dayjs(date); - const d2 = dayjs(anotherDate); + isSameMonth(date: Date | string, anotherDate: Date | string): boolean { + const d1 = dayjs.tz(date); + const d2 = dayjs.tz(anotherDate); const d1Month = d1.get('month'); const d1Year = d1.get('year'); @@ -94,7 +112,7 @@ export class DayjsAdapterService extends DateAdapter { } isAfterToday(date: string | Date): boolean { - return dayjs(date).isAfter(dayjs()); + return dayjs.tz(date).isAfter(dayjs.tz()); } /** @@ -104,14 +122,70 @@ export class DayjsAdapterService extends DateAdapter { */ nowPlus(amount: number, unit: DateUnit): Date { - return dayjs.utc().add(amount, unit).toDate(); + return dayjs.tz().add(amount, unit).toDate(); } add(date: string | Date, amount: number, unit: DateUnit): Date { - return dayjs(date).add(amount, unit).toDate(); + if (unit === 'month') { + const dateDate = this.newDate(date); + + const day = this.get(dateDate, 'day'); + + if (day > 28) { + const year = this.get(dateDate, 'year'); + const month = this.get(dateDate, 'month'); + + const result = dayjs + .tz(`${year}-${month}-28`) + .add(amount, unit) + .toDate(); + const resultEndOfMonth = this.startOf( + this.endOf(result, 'month'), + 'day', + ); + const lastDayOfMonth = this.get(resultEndOfMonth, 'day'); + + // If the expected day exceeds the last day of the month, + // returns the last day of the month + if (day > lastDayOfMonth) { + return resultEndOfMonth; + } + + return this.newDate( + [this.get(result, 'year'), this.get(result, 'month'), day].join('-'), + ); + } + + return dayjs.tz(date).add(amount, unit).set('date', day).toDate(); + } + + return dayjs.tz(date).add(amount, unit).toDate(); } sub(date: string | Date, amount: number, unit: DateUnit): Date { + const dateDate = this.newDate(date); + + const day = this.get(dateDate, 'day'); + + if (day > 28) { + const year = this.get(dateDate, 'year'); + const month = this.get(dateDate, 'month'); + + const result = this.add(`${year}-${month}-28`, amount * -1, unit); + const resultEndOfMonth = this.startOf(this.endOf(result, 'month'), 'day'); + const lastDayOfMonth = this.get(resultEndOfMonth, 'day'); + + // If the expected day exceeds the last day of the month, + // returns the last day of the month + if (day > lastDayOfMonth) { + return resultEndOfMonth; + } + + return this.newDate( + [this.get(result, 'year'), this.get(result, 'month'), day].join('-'), + ); + } + return this.add(date, amount * -1, unit); } diff --git a/tests/mocks/repositories/postgres/repository.ts b/tests/mocks/repositories/postgres/repository.ts new file mode 100644 index 0000000..15064eb --- /dev/null +++ b/tests/mocks/repositories/postgres/repository.ts @@ -0,0 +1,36 @@ +export interface MockRepository { + find: jest.Mock; + findOne: jest.Mock; + findAndCount: jest.Mock; + save: jest.Mock; + insert: jest.Mock; + update: jest.Mock; + delete: jest.Mock; +} + +export const makeMockRepository = () => { + const mock = { + find: jest.fn(), + findOne: jest.fn(), + findAndCount: jest.fn(), + save: jest.fn(), + insert: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + const resetMock = () => { + mock.find.mockReset(); + mock.findOne.mockReset(); + mock.findAndCount.mockReset(); + mock.save.mockReset(); + mock.insert.mockReset(); + mock.update.mockReset(); + mock.delete.mockReset(); + }; + + return { + resetMock, + ...mock, + }; +}; diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..e69de29 diff --git a/tests/src/adapters/dayjs.spec.ts b/tests/src/adapters/dayjs.spec.ts new file mode 100644 index 0000000..a50d3f3 --- /dev/null +++ b/tests/src/adapters/dayjs.spec.ts @@ -0,0 +1,617 @@ +import type { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { DayJsAdapterModule } from 'adapters/implementations/dayjs/dayjs.module'; +import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; + +const removeMillis = (date?: Date) => date?.toISOString().split('.')[0]; + +describe('Adapters > DayJs', () => { + let service: DayjsAdapterService; + let module: INestApplication; + + beforeAll(async () => { + try { + const moduleForService = await Test.createTestingModule({ + providers: [DayjsAdapterService], + }).compile(); + + service = moduleForService.get(DayjsAdapterService); + + const moduleForModule = await Test.createTestingModule({ + imports: [DayJsAdapterModule], + }).compile(); + + module = moduleForModule.createNestApplication(); + } catch (err) { + console.error(err); + } + }); + + describe('definitions', () => { + it('should initialize Service', () => { + expect(service).toBeDefined(); + }); + + it('should initialize Module', async () => { + expect(module).toBeDefined(); + }); + }); + + /** + * + * Info + * + */ + + describe('> today', () => { + it('should get todays info', async () => { + let result; + try { + result = service.today(); + } catch (err) { + result = err; + } + + const todayDate = new Date(); + + expect(result).toMatchObject({ + day: todayDate.getDate(), + month: todayDate.getMonth() + 1, + year: todayDate.getFullYear(), + }); + // Removes the milliseconds so it don't create false positives + expect(removeMillis(result.date)).toBe(removeMillis(todayDate)); + }); + }); + + describe('> newDate', () => { + it('should create a new UTC date', async () => { + const date = '2023-01-01'; + + let result; + try { + result = service.newDate(date).toISOString(); + } catch (err) { + result = err; + } + + expect(result).toBe(`${date}T00:00:00.000Z`); + }); + }); + + describe('> get', () => { + it('should get the day', async () => { + const date = '2023-01-01'; + + let result; + try { + result = service.get(date, 'day'); + } catch (err) { + result = err; + } + + expect(result).toBe(1); + }); + + it('should get the month', async () => { + const date = '2023-01-01'; + + let result; + try { + result = service.get(date, 'month'); + } catch (err) { + result = err; + } + + expect(result).toBe(1); + }); + + it('should get the year', async () => { + const date = '2023-01-01'; + + let result; + try { + result = service.get(date, 'year'); + } catch (err) { + result = err; + } + + expect(result).toBe(2023); + }); + }); + + describe('> getNextMonths', () => { + it('should get a list of the next months (same year)', async () => { + let result; + try { + result = service.getNextMonths('2023-01-01', 5); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual([ + new Date('2023-01-01T00:00:00.000Z'), + new Date('2023-02-01T00:00:00.000Z'), + new Date('2023-03-01T00:00:00.000Z'), + new Date('2023-04-01T00:00:00.000Z'), + new Date('2023-05-01T00:00:00.000Z'), + ]); + }); + + it('should get a list of the next months (different years)', async () => { + let result; + try { + result = service.getNextMonths('2023-12-01', 5); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual([ + new Date('2023-12-01T00:00:00.000Z'), + new Date('2024-01-01T00:00:00.000Z'), + new Date('2024-02-01T00:00:00.000Z'), + new Date('2024-03-01T00:00:00.000Z'), + new Date('2024-04-01T00:00:00.000Z'), + ]); + }); + }); + + describe('> statementDate', () => { + it('should get card statement date of current month', async () => { + let result; + try { + result = service.statementDate(15, 7); + } catch (err) { + result = err; + } + + const today = service.today(); + + const expectedResult = service.newDate( + [today.year, today.month, 8] + .map((v) => v.toString().padStart(2, '0')) + .join('-'), + ); + + expect(result).toStrictEqual(expectedResult); + }); + + it('should get card statement date of prev month', async () => { + const dueDay = 15; + const statementDays = 7; + const monthsToAdd = -1; + + let result; + try { + result = service.statementDate(dueDay, statementDays, monthsToAdd); + } catch (err) { + result = err; + } + + const today = service.today(); + + const nextMonth = service.sub( + [today.year, today.month, dueDay].join('-'), + monthsToAdd * -1, + 'month', + ); + + const expectedResult = service.sub(nextMonth, statementDays, 'day'); + + expect(result).toStrictEqual(expectedResult); + }); + + it('should get card statement date of next month', async () => { + const dueDay = 15; + const statementDays = 7; + const monthsToAdd = 1; + + let result; + try { + result = service.statementDate(dueDay, statementDays, monthsToAdd); + } catch (err) { + result = err; + } + + const today = service.today(); + + const nextMonth = service.add( + [today.year, today.month, dueDay] + .map((v) => v.toString().padStart(2, '0')) + .join('-'), + monthsToAdd, + 'month', + ); + const expectedResult = service.sub(nextMonth, statementDays, 'day'); + + expect(result).toStrictEqual(expectedResult); + }); + }); + + describe('> dueDate', () => { + it('should get card due date of current month', async () => { + let result; + try { + result = service.dueDate(15); + } catch (err) { + result = err; + } + + const today = service.today(); + + const expectedResult = service.newDate( + [today.year, today.month, 15] + .map((v) => v.toString().padStart(2, '0')) + .join('-'), + ); + + expect(result).toStrictEqual(expectedResult); + }); + + it('should get card due date of prev month', async () => { + const dueDay = 15; + const monthsToAdd = -1; + + let result; + try { + result = service.dueDate(dueDay, monthsToAdd); + } catch (err) { + result = err; + } + + const today = service.today(); + + const expectedResult = service.add( + [today.year, today.month, dueDay] + .map((v) => v.toString().padStart(2, '0')) + .join('-'), + monthsToAdd, + 'month', + ); + + expect(result).toStrictEqual(expectedResult); + }); + + it('should get card due date of next month', async () => { + const dueDay = 15; + const monthsToAdd = 1; + + let result; + try { + result = service.dueDate(dueDay, monthsToAdd); + } catch (err) { + result = err; + } + + const today = service.today(); + + const expectedResult = service.add( + [today.year, today.month, dueDay] + .map((v) => v.toString().padStart(2, '0')) + .join('-'), + monthsToAdd, + 'month', + ); + + expect(result).toStrictEqual(expectedResult); + }); + }); + + /** + * + * Comparison + * + */ + + describe('> isSameMonth', () => { + it('should check if two dates are in the same year and month (true)', async () => { + const date1 = '2023-01-01'; + const date2 = '2023-01-07'; + + let result; + try { + result = service.isSameMonth(date1, date2); + } catch (err) { + result = err; + } + + expect(result).toBeTruthy(); + }); + + it('should check if two dates are in the same year and month (false - different years)', async () => { + const date1 = '2023-01-01'; + const date2 = '2024-01-01'; + + let result; + try { + result = service.isSameMonth(date1, date2); + } catch (err) { + result = err; + } + + expect(result).toBeFalsy(); + }); + + it('should check if two dates are in the same year and month (false - different months)', async () => { + const date1 = '2023-01-01'; + const date2 = '2023-02-01'; + + let result; + try { + result = service.isSameMonth(date1, date2); + } catch (err) { + result = err; + } + + expect(result).toBeFalsy(); + }); + }); + + describe('> isAfterToday', () => { + it('should check if a date is after today (true)', async () => { + const tomorrow = service.add(service.today().date, 1, 'day'); + + let result; + try { + result = service.isAfterToday(tomorrow); + } catch (err) { + result = err; + } + + expect(result).toBeTruthy(); + }); + + it('should check if a date is after today (false = is today)', async () => { + let result; + try { + result = service.isAfterToday(service.today().date); + } catch (err) { + result = err; + } + + expect(result).toBeFalsy(); + }); + + it('should check if a date is after today (false - yesterday)', async () => { + const yesterday = service.sub(service.today().date, 1, 'day'); + + let result; + try { + result = service.isAfterToday(yesterday); + } catch (err) { + result = err; + } + + expect(result).toBeFalsy(); + }); + }); + + /** + * + * Modifiers + * + */ + + describe('> nowPlus', () => { + it('should add 1 day', async () => { + const tomorrow = service.add(service.today().date, 1, 'day'); + + let result; + try { + result = service.nowPlus(1, 'day'); + } catch (err) { + result = err; + } + + expect(removeMillis(result)).toBe(removeMillis(tomorrow)); + }); + + it('should subtract 1 day', async () => { + const yesterday = service.add(service.today().date, -1, 'day'); + + let result; + try { + result = service.nowPlus(-1, 'day'); + } catch (err) { + result = err; + } + + expect(removeMillis(result)).toBe(removeMillis(yesterday)); + }); + }); + + describe('> add', () => { + it('should add 1 day', async () => { + let result; + try { + result = service.add('2024-01-01', 1, 'day'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-01-02')); + }); + + it('should add 1 month', async () => { + let result; + try { + result = service.add('2024-01-01', 1, 'month'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-02-01')); + }); + + it('should add 1 year', async () => { + let result; + try { + result = service.add('2023-01-01', 1, 'year'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-01-01')); + }); + + it('should add 1 month (february)', async () => { + let result; + try { + result = service.add('2024-01-30', 1, 'month'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-02-29')); + }); + + it('should add 1 month (february - leap)', async () => { + let result; + try { + result = service.add('2024-01-29', 1, 'month'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-02-29')); + }); + }); + + describe('> sub', () => { + it('should subtract 1 day', async () => { + let result; + try { + result = service.sub('2024-01-02', 1, 'day'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-01-01')); + }); + + it('should subtract 1 month', async () => { + let result; + try { + result = service.sub('2024-02-01', 1, 'month'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-01-01')); + }); + + it('should subtract 2 months', async () => { + let result; + try { + result = service.sub('2024-02-01', 2, 'month'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2023-12-01')); + }); + + it('should subtract 1 year', async () => { + let result; + try { + result = service.sub('2024-01-01', 1, 'year'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2023-01-01')); + }); + + it('should subtract 1 month (february)', async () => { + let result; + try { + result = service.sub('2024-03-30', 1, 'month'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-02-29')); + }); + + it('should subtract 1 month (february - leap)', async () => { + let result; + try { + result = service.sub('2024-03-29', 1, 'month'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-02-29')); + }); + }); + + describe('> startOf', () => { + it('should get the start of the day', async () => { + let result; + try { + result = service.startOf('2024-01-01T16:00:00.000Z', 'day'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-01-01T00:00:00.000Z')); + }); + + it('should get the start of the month', async () => { + let result; + try { + result = service.startOf('2024-01-15', 'month'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-01-01')); + }); + + it('should get the start of the year', async () => { + let result; + try { + result = service.startOf('2024-01-15', 'year'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-01-01')); + }); + }); + + describe('> endOf', () => { + it('should get the end of the day', async () => { + let result; + try { + result = service.endOf('2024-01-01T16:00:00.000Z', 'day'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-01-01T23:59:59.999Z')); + }); + + it('should get the end of the month', async () => { + let result; + try { + result = service.endOf('2024-01-15', 'month'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-01-31T23:59:59.999Z')); + }); + + it('should get the end of the year', async () => { + let result; + try { + result = service.endOf('2024-01-15', 'year'); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual(service.newDate('2024-12-31T23:59:59.999Z')); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 7861066..26fcd1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1783,10 +1783,10 @@ jsonc-parser "3.2.0" pluralize "8.0.0" -"@nestjs/testing@^10.2.10": - version "10.2.10" - resolved "https://registry.yarnpkg.com/@nestjs/testing/-/testing-10.2.10.tgz#bd0a1e86622f7aa8cf6d8804af0f23b9444c3099" - integrity sha512-IVLUnPz/+fkBtPATYfqTIP+phN9yjkXejmj+JyhmcfPJZpxBmD1i9VSMqa4u54l37j0xkGPscQ0IXpbhqMYUKw== +"@nestjs/testing@^10.3.1": + version "10.3.1" + resolved "https://registry.yarnpkg.com/@nestjs/testing/-/testing-10.3.1.tgz#ea28a7d29122dd3a2df1542842e741a52dd7c474" + integrity sha512-74aSAugWT31jSPnStyRWDXgjHXWO3GYaUfAZ2T7Dml88UGkGy95iwaWgYy7aYM8/xVFKcDYkfL5FAYqZYce/yg== dependencies: tslib "2.6.2" From af7d500a161f43da5ec98ff82abffd839c6eafd8 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Wed, 24 Jan 2024 13:48:24 -0300 Subject: [PATCH 05/19] Add GoogleAdapter tests --- .../implementations/google/google.module.ts | 9 +- .../implementations/google/google.service.ts | 25 +- tests/mocks/config.ts | 13 + tests/mocks/libs/axios.ts | 16 ++ tests/src/adapters/dayjs.spec.ts | 3 +- tests/src/adapters/google.spec.ts | 272 ++++++++++++++++++ tests/utils.ts | 1 + 7 files changed, 324 insertions(+), 15 deletions(-) create mode 100644 tests/mocks/config.ts create mode 100644 tests/mocks/libs/axios.ts create mode 100644 tests/src/adapters/google.spec.ts create mode 100644 tests/utils.ts diff --git a/src/adapters/implementations/google/google.module.ts b/src/adapters/implementations/google/google.module.ts index 0586369..2944aca 100644 --- a/src/adapters/implementations/google/google.module.ts +++ b/src/adapters/implementations/google/google.module.ts @@ -2,10 +2,17 @@ import { Module } from '@nestjs/common'; import { GoogleAdapterService } from './google.service'; import { DayJsAdapterModule } from '../dayjs/dayjs.module'; import { ConfigModule } from '@nestjs/config'; +import axios from 'axios'; @Module({ imports: [DayJsAdapterModule, ConfigModule], - providers: [GoogleAdapterService], + providers: [ + { + provide: 'axios', + useValue: axios, + }, + GoogleAdapterService, + ], exports: [GoogleAdapterService], }) export class GoogleAdapterModule {} diff --git a/src/adapters/implementations/google/google.service.ts b/src/adapters/implementations/google/google.service.ts index 4c6c46b..2de657c 100644 --- a/src/adapters/implementations/google/google.service.ts +++ b/src/adapters/implementations/google/google.service.ts @@ -9,7 +9,7 @@ import { DateAdapter } from 'adapters/date'; import { DayjsAdapterService } from '../dayjs/dayjs.service'; import { AppConfig } from 'config'; import { ConfigService } from '@nestjs/config'; -import axios from 'axios'; +import { Axios } from 'axios'; interface ExchangeCodeAPIOutput { access_token: string; @@ -29,6 +29,9 @@ interface GetUserDataAPIOutput { @Injectable() export class GoogleAdapterService extends GoogleAdapter { constructor( + @Inject('axios') + protected readonly axios: Axios, + @Inject(DayjsAdapterService) protected readonly dateAdapter: DateAdapter, @@ -53,7 +56,7 @@ export class GoogleAdapterService extends GoogleAdapter { body.append('grant_type', 'authorization_code'); // ALERT: The order of the properties is important, don't change it! - const result = await axios + const result = await this.axios .post('https://oauth2.googleapis.com/token', body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -62,14 +65,13 @@ export class GoogleAdapterService extends GoogleAdapter { }) .then((r) => r.data as ExchangeCodeAPIOutput) .catch((err) => { - throw err?.response?.data; + throw new Error(JSON.stringify(err?.response?.data || {})); }); return { accessToken: result.access_token, refreshToken: result.refresh_token, scopes: result.scope.split(' '), - // eslint-disable-next-line @typescript-eslint/no-magic-numbers expiresAt: this.dateAdapter.nowPlus(result.expires_in - 60, 'second'), }; } @@ -77,17 +79,16 @@ export class GoogleAdapterService extends GoogleAdapter { async getAuthenticatedUserData( accessToken: string, ): Promise { - const result = await fetch( - 'https://openidconnect.googleapis.com/v1/userinfo', - { - method: 'GET', + const result = await this.axios + .get('https://openidconnect.googleapis.com/v1/userinfo', { headers: { Authorization: `Bearer ${accessToken}`, }, - }, - ) - .then((r) => r.json()) - .then((r) => r as GetUserDataAPIOutput); + }) + .then((r) => r.data as GetUserDataAPIOutput) + .catch((err) => { + throw new Error(JSON.stringify(err?.response?.data || {})); + }); return { id: result.sub, diff --git a/tests/mocks/config.ts b/tests/mocks/config.ts new file mode 100644 index 0000000..064f071 --- /dev/null +++ b/tests/mocks/config.ts @@ -0,0 +1,13 @@ +import { ConfigService } from '@nestjs/config'; + +const configMock = new Map(); + +configMock.set('GOOGLE_CLIENT_ID', 'foo'); +configMock.set('GOOGLE_CLIENT_SECRET', 'bar'); + +const configMockModule = { + provide: ConfigService, + useValue: configMock, +}; + +export { configMock, configMockModule }; diff --git a/tests/mocks/libs/axios.ts b/tests/mocks/libs/axios.ts new file mode 100644 index 0000000..4a91445 --- /dev/null +++ b/tests/mocks/libs/axios.ts @@ -0,0 +1,16 @@ +export const makeAxiosMock = () => { + const mock = { + post: jest.fn(), + get: jest.fn(), + }; + + const resetMock = () => { + mock.post.mockReset(); + mock.get.mockReset(); + }; + + return { + resetMock, + ...mock, + }; +}; diff --git a/tests/src/adapters/dayjs.spec.ts b/tests/src/adapters/dayjs.spec.ts index a50d3f3..6f74939 100644 --- a/tests/src/adapters/dayjs.spec.ts +++ b/tests/src/adapters/dayjs.spec.ts @@ -2,8 +2,7 @@ import type { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { DayJsAdapterModule } from 'adapters/implementations/dayjs/dayjs.module'; import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; - -const removeMillis = (date?: Date) => date?.toISOString().split('.')[0]; +import { removeMillis } from '../../utils'; describe('Adapters > DayJs', () => { let service: DayjsAdapterService; diff --git a/tests/src/adapters/google.spec.ts b/tests/src/adapters/google.spec.ts new file mode 100644 index 0000000..0bde8da --- /dev/null +++ b/tests/src/adapters/google.spec.ts @@ -0,0 +1,272 @@ +import type { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { GoogleAdapterModule } from 'adapters/implementations/google/google.module'; +import { GoogleAdapterService } from 'adapters/implementations/google/google.service'; +import { makeAxiosMock } from '../../mocks/libs/axios'; +import { DayJsAdapterModule } from 'adapters/implementations/dayjs/dayjs.module'; +import { removeMillis } from '../../utils'; +import { configMock, configMockModule } from '../../mocks/config'; + +const googleTokenUrl = 'https://oauth2.googleapis.com/token'; +const googleUserDataUrl = 'https://openidconnect.googleapis.com/v1/userinfo'; + +describe('Adapters > Google', () => { + let service: GoogleAdapterService; + let module: INestApplication; + + const axiosMock = makeAxiosMock(); + + beforeAll(async () => { + try { + const moduleForService = await Test.createTestingModule({ + imports: [DayJsAdapterModule], + providers: [ + { + provide: 'axios', + useValue: axiosMock, + }, + configMockModule, + GoogleAdapterService, + ], + }).compile(); + + service = + moduleForService.get(GoogleAdapterService); + + const moduleForModule = await Test.createTestingModule({ + imports: [GoogleAdapterModule], + }).compile(); + + module = moduleForModule.createNestApplication(); + } catch (err) { + console.error(err); + } + }); + + beforeEach(() => { + axiosMock.resetMock(); + }); + + describe('definitions', () => { + it('should initialize Service', () => { + expect(service).toBeDefined(); + }); + + it('should initialize Module', async () => { + expect(module).toBeDefined(); + }); + }); + + describe('> exchangeCode', () => { + it('should return auth data', async () => { + const body = { + code: 'foo', + client_id: configMock.get('GOOGLE_CLIENT_ID'), + client_secret: configMock.get('GOOGLE_CLIENT_SECRET'), + grant_type: 'authorization_code', + }; + + const axiosResponse = { + access_token: 'foo', + refresh_token: 'bar', + scope: 'foo bar fooBar', + expires_in: 120, + }; + + axiosMock.post.mockResolvedValue({ + data: axiosResponse, + }); + + const date = new Date(); + let result; + try { + result = await service.exchangeCode({ + code: body.code, + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + accessToken: axiosResponse.access_token, + refreshToken: axiosResponse.refresh_token, + scopes: axiosResponse.scope.split(' '), + }); + expect(removeMillis(result.expiresAt)).toBe( + removeMillis(new Date(date.getTime() + 60 * 1000)), + ); + expect(axiosMock.post).toHaveBeenCalled(); + expect(axiosMock.post).toHaveBeenCalledWith( + googleTokenUrl, + new URLSearchParams(body), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + }, + ); + }); + + it('should return auth data (with originUrl)', async () => { + const body = { + code: 'foo', + client_id: configMock.get('GOOGLE_CLIENT_ID'), + client_secret: configMock.get('GOOGLE_CLIENT_SECRET'), + redirect_uri: 'originUrl', + grant_type: 'authorization_code', + }; + + const axiosResponse = { + access_token: 'foo', + refresh_token: 'bar', + scope: 'foo bar fooBar', + expires_in: 120, + }; + + axiosMock.post.mockResolvedValue({ + data: axiosResponse, + }); + + const date = new Date(); + let result; + try { + result = await service.exchangeCode({ + code: body.code, + originUrl: body.redirect_uri, + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + accessToken: axiosResponse.access_token, + refreshToken: axiosResponse.refresh_token, + scopes: axiosResponse.scope.split(' '), + }); + expect(removeMillis(result.expiresAt)).toBe( + removeMillis(new Date(date.getTime() + 60 * 1000)), + ); + expect(axiosMock.post).toHaveBeenCalled(); + expect(axiosMock.post).toHaveBeenCalledWith( + googleTokenUrl, + new URLSearchParams(body), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + }, + ); + }); + + it('should fail if google rejects request', async () => { + axiosMock.post.mockRejectedValue({ + response: { + data: { + error: 'error', + }, + }, + }); + + let result; + try { + result = await service.exchangeCode({ + code: 'code', + originUrl: 'originUrl', + }); + } catch (err) { + result = err; + } + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('{"error":"error"}'); + }); + + it('should fail if google rejects request (undefined response)', async () => { + axiosMock.post.mockRejectedValue({}); + + let result; + try { + result = await service.exchangeCode({ + code: 'code', + originUrl: 'originUrl', + }); + } catch (err) { + result = err; + } + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('{}'); + }); + }); + + describe('> getAuthenticatedUserData', () => { + it('should return user data', async () => { + const axiosResponse = { + sub: 'sub', + given_name: 'given_name', + email: 'email', + email_verified: 'email_verified', + }; + + axiosMock.get.mockResolvedValue({ + data: axiosResponse, + }); + + let result; + try { + result = await service.getAuthenticatedUserData('accessToken'); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + id: axiosResponse.sub, + name: axiosResponse.given_name, + email: axiosResponse.email, + isEmailVerified: axiosResponse.email_verified, + }); + expect(axiosMock.get).toHaveBeenCalled(); + expect(axiosMock.get).toHaveBeenCalledWith(googleUserDataUrl, { + headers: { + Authorization: `Bearer accessToken`, + }, + }); + }); + + it('should fail if google rejects request', async () => { + axiosMock.get.mockRejectedValue({ + response: { + data: { + error: 'error', + }, + }, + }); + + let result; + try { + result = await service.getAuthenticatedUserData('accessToken'); + } catch (err) { + result = err; + } + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('{"error":"error"}'); + }); + + it('should fail if google rejects request (undefined response)', async () => { + axiosMock.get.mockRejectedValue({}); + + let result; + try { + result = await service.getAuthenticatedUserData('accessToken'); + } catch (err) { + result = err; + } + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('{}'); + }); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 0000000..88394d3 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1 @@ +export const removeMillis = (date?: Date) => date?.toISOString().split('.')[0]; From c5e20c419e88c0d049b17e281c858a96b6234f8f Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Wed, 24 Jan 2024 22:00:24 -0300 Subject: [PATCH 06/19] Add tests to JWTAdapter and UIDAdapter --- jest.config.json | 3 + .../implementations/jwt/token.module.ts | 9 +- .../implementations/jwt/token.service.ts | 9 +- .../implementations/uid/uid.module.ts | 9 +- .../implementations/uid/uid.service.ts | 14 +- tests/mocks/config.ts | 5 +- tests/mocks/libs/jsonwebtoken.ts | 16 ++ tests/src/adapters/dayjs.spec.ts | 6 +- tests/src/adapters/jwt.spec.ts | 138 ++++++++++++++++++ tests/src/adapters/uid.spec.ts | 89 +++++++++++ 10 files changed, 283 insertions(+), 15 deletions(-) create mode 100644 tests/mocks/libs/jsonwebtoken.ts create mode 100644 tests/src/adapters/jwt.spec.ts create mode 100644 tests/src/adapters/uid.spec.ts diff --git a/jest.config.json b/jest.config.json index 761a7b2..01afb6d 100644 --- a/jest.config.json +++ b/jest.config.json @@ -12,6 +12,9 @@ "!src/main.ts", "!src/models/*.ts", "!src/adapters/*.ts", + "!src/adapters/implementations/s3/*.ts", + "!src/adapters/implementations/ses/*.ts", + "!src/adapters/implementations/sns/*.ts", "!src/repositories/postgres/*.ts", "!src/types/*.ts" ], diff --git a/src/adapters/implementations/jwt/token.module.ts b/src/adapters/implementations/jwt/token.module.ts index da8e9b8..68e8edc 100644 --- a/src/adapters/implementations/jwt/token.module.ts +++ b/src/adapters/implementations/jwt/token.module.ts @@ -2,10 +2,17 @@ import { Module } from '@nestjs/common'; import { JWTAdapterService } from './token.service'; import { UIDAdapterModule } from '../uid/uid.module'; import { ConfigModule } from '@nestjs/config'; +import * as Jwt from 'jsonwebtoken'; @Module({ imports: [UIDAdapterModule, ConfigModule], - providers: [JWTAdapterService], + providers: [ + JWTAdapterService, + { + provide: 'jsonwebtoken', + useValue: Jwt, + }, + ], exports: [JWTAdapterService], }) export class JWTAdapterModule {} diff --git a/src/adapters/implementations/jwt/token.service.ts b/src/adapters/implementations/jwt/token.service.ts index 63ebeda..7d1235b 100644 --- a/src/adapters/implementations/jwt/token.service.ts +++ b/src/adapters/implementations/jwt/token.service.ts @@ -7,7 +7,7 @@ import type { ValidateAccessInput, } from '../../token'; import { TokensAdapter } from '../../token'; -import { sign, verify } from 'jsonwebtoken'; +import type * as Jwt from 'jsonwebtoken'; import { SecretAdapter } from 'adapters/secret'; import { UIDAdapterService } from '../uid/uid.service'; import { AppConfig } from 'config'; @@ -16,6 +16,9 @@ import { ConfigService } from '@nestjs/config'; @Injectable() export class JWTAdapterService extends TokensAdapter { constructor( + @Inject('jsonwebtoken') + protected readonly jwt: typeof Jwt, + @Inject(UIDAdapterService) protected readonly secretAdapter: SecretAdapter, @@ -36,7 +39,7 @@ export class JWTAdapterService extends TokensAdapter { const expiresAt = ''; - const accessToken = sign(payload, this.config.get('JWT_SECRET')); + const accessToken = this.jwt.sign(payload, this.config.get('JWT_SECRET')); return { accessToken, @@ -46,7 +49,7 @@ export class JWTAdapterService extends TokensAdapter { validateAccess({ accessToken }: ValidateAccessInput): TokenPayload { try { - const payload = verify( + const payload = this.jwt.verify( accessToken, this.config.get('JWT_SECRET'), ) as TokenPayload; diff --git a/src/adapters/implementations/uid/uid.module.ts b/src/adapters/implementations/uid/uid.module.ts index 1f8c7f1..5a5b98a 100644 --- a/src/adapters/implementations/uid/uid.module.ts +++ b/src/adapters/implementations/uid/uid.module.ts @@ -1,8 +1,15 @@ import { Module } from '@nestjs/common'; import { UIDAdapterService } from './uid.service'; +import { uid } from 'uid/secure'; @Module({ - providers: [UIDAdapterService], + providers: [ + UIDAdapterService, + { + provide: 'uid/secure', + useValue: uid, + }, + ], exports: [UIDAdapterService], }) export class UIDAdapterModule {} diff --git a/src/adapters/implementations/uid/uid.service.ts b/src/adapters/implementations/uid/uid.service.ts index 48f1a0c..dddfcad 100644 --- a/src/adapters/implementations/uid/uid.service.ts +++ b/src/adapters/implementations/uid/uid.service.ts @@ -1,19 +1,23 @@ -import { uid } from 'uid/secure'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import type { IdAdapter } from '../../id'; import type { SecretAdapter } from '../../secret'; @Injectable() export class UIDAdapterService implements IdAdapter, SecretAdapter { + constructor( + @Inject('uid/secure') + protected uid: (length?: number) => string, + ) {} + genId(): string { - return uid(16); + return this.uid(16); } genSecret(): string { - return uid(32); + return this.uid(32); } genSuperSecret(): string { - return uid(64); + return this.uid(64); } } diff --git a/tests/mocks/config.ts b/tests/mocks/config.ts index 064f071..e900ba8 100644 --- a/tests/mocks/config.ts +++ b/tests/mocks/config.ts @@ -2,8 +2,9 @@ import { ConfigService } from '@nestjs/config'; const configMock = new Map(); -configMock.set('GOOGLE_CLIENT_ID', 'foo'); -configMock.set('GOOGLE_CLIENT_SECRET', 'bar'); +configMock.set('GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_ID'); +configMock.set('GOOGLE_CLIENT_SECRET', 'GOOGLE_CLIENT_SECRET'); +configMock.set('JWT_SECRET', 'JWT_SECRET'); const configMockModule = { provide: ConfigService, diff --git a/tests/mocks/libs/jsonwebtoken.ts b/tests/mocks/libs/jsonwebtoken.ts new file mode 100644 index 0000000..55f8adf --- /dev/null +++ b/tests/mocks/libs/jsonwebtoken.ts @@ -0,0 +1,16 @@ +export const makeJwtMock = () => { + const mock = { + sign: jest.fn(), + verify: jest.fn(), + }; + + const resetMock = () => { + mock.sign.mockReset(); + mock.verify.mockReset(); + }; + + return { + resetMock, + ...mock, + }; +}; diff --git a/tests/src/adapters/dayjs.spec.ts b/tests/src/adapters/dayjs.spec.ts index 6f74939..7029248 100644 --- a/tests/src/adapters/dayjs.spec.ts +++ b/tests/src/adapters/dayjs.spec.ts @@ -54,9 +54,9 @@ describe('Adapters > DayJs', () => { const todayDate = new Date(); expect(result).toMatchObject({ - day: todayDate.getDate(), - month: todayDate.getMonth() + 1, - year: todayDate.getFullYear(), + day: todayDate.getUTCDate(), + month: todayDate.getUTCMonth() + 1, + year: todayDate.getUTCFullYear(), }); // Removes the milliseconds so it don't create false positives expect(removeMillis(result.date)).toBe(removeMillis(todayDate)); diff --git a/tests/src/adapters/jwt.spec.ts b/tests/src/adapters/jwt.spec.ts new file mode 100644 index 0000000..a5463e2 --- /dev/null +++ b/tests/src/adapters/jwt.spec.ts @@ -0,0 +1,138 @@ +import { Test } from '@nestjs/testing'; +import { JWTAdapterService } from 'adapters/implementations/jwt/token.service'; +import { makeJwtMock } from '../../mocks/libs/jsonwebtoken'; +import { UIDAdapterModule } from 'adapters/implementations/uid/uid.module'; +import { configMock, configMockModule } from '../../mocks/config'; +import { JWTAdapterModule } from 'adapters/implementations/jwt/token.module'; +import type { INestApplication } from '@nestjs/common'; + +describe('Adapters > JWT', () => { + let service: JWTAdapterService; + let module: INestApplication; + + const jwtMock = makeJwtMock(); + + beforeAll(async () => { + try { + const moduleForService = await Test.createTestingModule({ + imports: [UIDAdapterModule], + providers: [ + { + provide: 'jsonwebtoken', + useValue: jwtMock, + }, + configMockModule, + JWTAdapterService, + ], + }).compile(); + + service = moduleForService.get(JWTAdapterService); + + const moduleForModule = await Test.createTestingModule({ + imports: [JWTAdapterModule], + }).compile(); + + module = moduleForModule.createNestApplication(); + } catch (err) { + console.error(err); + } + }); + + beforeEach(() => { + jwtMock.resetMock(); + }); + + describe('definitions', () => { + it('should initialize Service', () => { + expect(service).toBeDefined(); + }); + + it('should initialize Module', async () => { + expect(module).toBeDefined(); + }); + }); + + describe('> genAccess', () => { + it('should generate access token', async () => { + jwtMock.sign.mockImplementation( + (data: Record, secret: string) => + `${JSON.stringify(data)}${secret}`, + ); + + let result; + try { + result = await service.genAccess({ + accountId: 'accountId', + hasAcceptedLatestTerms: true, + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + expiresAt: '', + accessToken: `{"sub":"accountId","terms":true}${configMock.get( + 'JWT_SECRET', + )}`, + }); + }); + }); + + describe('> validateAccess', () => { + it('should return payload from access token', async () => { + jwtMock.verify.mockImplementation( + (dataStringified: string, secret: string) => + JSON.parse(dataStringified.replace(secret, '')), + ); + + let result; + try { + result = await service.validateAccess({ + accessToken: `{"sub":"accountId","terms":true}${configMock.get( + 'JWT_SECRET', + )}`, + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + sub: 'accountId', + terms: true, + }); + }); + + it('should return undefined if error', async () => { + jwtMock.verify.mockImplementation(() => { + throw new Error(); + }); + + let result; + try { + result = await service.validateAccess({ + accessToken: 'foo', + }); + } catch (err) { + result = err; + } + + expect(result).toBeUndefined(); + }); + }); + + describe('> genRefresh', () => { + it('should generate refresh token', async () => { + let result; + try { + result = await service.genRefresh(); + } catch (err) { + result = err; + } + + expect(result).not.toBeInstanceOf(Error); + expect(result).toHaveProperty('refreshToken'); + expect(result.refreshToken).toHaveLength(64); + expect(result.refreshToken).toMatch(/^[a-fA-F0-9]*$/); + }); + }); +}); diff --git a/tests/src/adapters/uid.spec.ts b/tests/src/adapters/uid.spec.ts new file mode 100644 index 0000000..9f73852 --- /dev/null +++ b/tests/src/adapters/uid.spec.ts @@ -0,0 +1,89 @@ +import { Test } from '@nestjs/testing'; +import { UIDAdapterModule } from 'adapters/implementations/uid/uid.module'; +import type { INestApplication } from '@nestjs/common'; +import { UIDAdapterService } from 'adapters/implementations/uid/uid.service'; +import { uid } from 'uid/secure'; + +describe('Adapters > UID', () => { + let service: UIDAdapterService; + let module: INestApplication; + + beforeAll(async () => { + try { + const moduleForService = await Test.createTestingModule({ + providers: [ + { + provide: 'uid/secure', + useValue: uid, + }, + UIDAdapterService, + ], + }).compile(); + + service = moduleForService.get(UIDAdapterService); + + const moduleForModule = await Test.createTestingModule({ + imports: [UIDAdapterModule], + }).compile(); + + module = moduleForModule.createNestApplication(); + } catch (err) { + console.error(err); + } + }); + + describe('definitions', () => { + it('should initialize Service', () => { + expect(service).toBeDefined(); + }); + + it('should initialize Module', async () => { + expect(module).toBeDefined(); + }); + }); + + describe('> genId', () => { + it('should generate ID', async () => { + let result; + try { + result = service.genId(); + } catch (err) { + result = err; + } + + expect(typeof result).toBe('string'); + expect(result).toHaveLength(16); + expect(result).toMatch(/^[a-fA-F0-9]*$/); + }); + }); + + describe('> genSecret', () => { + it('should generate Secret', async () => { + let result; + try { + result = service.genSecret(); + } catch (err) { + result = err; + } + + expect(typeof result).toBe('string'); + expect(result).toHaveLength(32); + expect(result).toMatch(/^[a-fA-F0-9]*$/); + }); + }); + + describe('> genSuperSecret', () => { + it('should generate Super Secret', async () => { + let result; + try { + result = service.genSuperSecret(); + } catch (err) { + result = err; + } + + expect(typeof result).toBe('string'); + expect(result).toHaveLength(64); + expect(result).toMatch(/^[a-fA-F0-9]*$/); + }); + }); +}); From a8fd9e7579ab4817146f6a46d3b7c63dfb637d19 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Wed, 24 Jan 2024 22:13:46 -0300 Subject: [PATCH 07/19] Add tests to UtilsAdapter --- .../implementations/utils/utils.service.ts | 15 +- tests/src/adapters/utils.spec.ts | 180 ++++++++++++++++++ 2 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 tests/src/adapters/utils.spec.ts diff --git a/src/adapters/implementations/utils/utils.service.ts b/src/adapters/implementations/utils/utils.service.ts index b6455e3..1aea272 100644 --- a/src/adapters/implementations/utils/utils.service.ts +++ b/src/adapters/implementations/utils/utils.service.ts @@ -33,14 +33,11 @@ export class UtilsAdapterService extends UtilsAdapter { currency: 'BRL', }); - return formatter.format( - parseFloat( - [ - value.substring(0, decimalsStart), - '.', - value.substring(decimalsStart), - ].join(''), - ), - ); + const integer = value.length >= 2 ? value.substring(0, decimalsStart) : '0'; + const cents = value.length >= 2 ? value.substring(decimalsStart) : value; + + return formatter + .format(parseFloat([integer, '.', cents.padStart(2, '0')].join(''))) + .replaceAll(' ', ' '); } } diff --git a/tests/src/adapters/utils.spec.ts b/tests/src/adapters/utils.spec.ts new file mode 100644 index 0000000..c569fd9 --- /dev/null +++ b/tests/src/adapters/utils.spec.ts @@ -0,0 +1,180 @@ +import { Test } from '@nestjs/testing'; +import type { INestApplication } from '@nestjs/common'; +import { UtilsAdapterService } from 'adapters/implementations/utils/utils.service'; +import { UtilsAdapterModule } from 'adapters/implementations/utils/utils.module'; + +describe('Adapters > Utils', () => { + let service: UtilsAdapterService; + let module: INestApplication; + + beforeAll(async () => { + try { + const moduleForService = await Test.createTestingModule({ + providers: [UtilsAdapterService], + }).compile(); + + service = moduleForService.get(UtilsAdapterService); + + const moduleForModule = await Test.createTestingModule({ + imports: [UtilsAdapterModule], + }).compile(); + + module = moduleForModule.createNestApplication(); + } catch (err) { + console.error(err); + } + }); + + describe('definitions', () => { + it('should initialize Service', () => { + expect(service).toBeDefined(); + }); + + it('should initialize Module', async () => { + expect(module).toBeDefined(); + }); + }); + + describe('> pagination', () => { + it('should get pagination data (empty params)', async () => { + let result; + try { + result = service.pagination({}); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + paging: { + curPage: 1, + nextPage: 2, + limit: 15, + }, + limit: 15, + offset: 0, + }); + }); + + it('should get pagination data (with page)', async () => { + let result; + try { + result = service.pagination({ + page: 10, + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + paging: { + curPage: 10, + nextPage: 11, + prevPage: 9, + limit: 15, + }, + limit: 15, + offset: 135, + }); + }); + + it('should get pagination data (with limit)', async () => { + let result; + try { + result = service.pagination({ + limit: 10, + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + paging: { + curPage: 1, + nextPage: 2, + limit: 10, + }, + limit: 10, + offset: 0, + }); + }); + + it('should get pagination data (with page and limit)', async () => { + let result; + try { + result = service.pagination({ + page: 10, + limit: 10, + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + paging: { + curPage: 10, + nextPage: 11, + limit: 10, + }, + limit: 10, + offset: 90, + }); + }); + }); + + describe('> formatMoney', () => { + it('should get money formatted (R$0,01)', async () => { + let result; + try { + result = service.formatMoney(1); + } catch (err) { + result = err; + } + + expect(result).toBe('R$ 0,01'); + }); + + it('should get money formatted (R$0,10)', async () => { + let result; + try { + result = service.formatMoney(10); + } catch (err) { + result = err; + } + + expect(result).toBe('R$ 0,10'); + }); + + it('should get money formatted (R$1,00)', async () => { + let result; + try { + result = service.formatMoney(100); + } catch (err) { + result = err; + } + + expect(result).toBe('R$ 1,00'); + }); + + it('should get money formatted (R$10,00)', async () => { + let result; + try { + result = service.formatMoney(1000); + } catch (err) { + result = err; + } + + expect(result).toBe('R$ 10,00'); + }); + + it('should get money formatted (R$99,09)', async () => { + let result; + try { + result = service.formatMoney(9909); + } catch (err) { + result = err; + } + + expect(result).toBe('R$ 99,09'); + }); + }); +}); From cbde4432148b092d27876b3f17be734561396c12 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Wed, 24 Jan 2024 22:21:23 -0300 Subject: [PATCH 08/19] Add tests pipeline --- .github/workflows/tests-validate.yml | 26 ++++++++++++++++++++++++++ .lintstagedrc | 2 +- jest.config.json | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/tests-validate.yml diff --git a/.github/workflows/tests-validate.yml b/.github/workflows/tests-validate.yml new file mode 100644 index 0000000..87a5d0a --- /dev/null +++ b/.github/workflows/tests-validate.yml @@ -0,0 +1,26 @@ +name: tests-validate + +on: + pull_request: + branches: + - master + paths: + - 'src/*' + - 'tests/*' + +jobs: + tests-validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install dependencies + run: yarn install --ignore-scripts + + - name: Run tests + run: yarn test:cov diff --git a/.lintstagedrc b/.lintstagedrc index af0f1f2..f723222 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -4,7 +4,7 @@ "bash -c \"yarn openapi:postman\"" ], "prisma/**/*": "bash -c \"yarn lint:prisma\"", - "src/**/*": [ + "(src|tests)/**/*": [ "bash -c \"yarn lint:ts\"", "bash -c \"yarn lint:code\"", "bash -c \"yarn test\"" diff --git a/jest.config.json b/jest.config.json index 01afb6d..9f98b57 100644 --- a/jest.config.json +++ b/jest.config.json @@ -16,7 +16,7 @@ "!src/adapters/implementations/ses/*.ts", "!src/adapters/implementations/sns/*.ts", "!src/repositories/postgres/*.ts", - "!src/types/*.ts" + "!src/types/**/*.ts" ], "coverageDirectory": "./coverage", "testEnvironment": "node", From 1aad71dcf1dab16e0cc90bdb943c1b5b2b1c1ed8 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Wed, 24 Jan 2024 22:54:32 -0300 Subject: [PATCH 09/19] Add delivery Decorators and Guards tests --- src/delivery/decorators/user-data.ts | 31 ++- .../src/delivery/decorators/user-data.spec.ts | 64 +++++ tests/src/delivery/guards/auth.spec.ts | 251 ++++++++++++++++++ 3 files changed, 332 insertions(+), 14 deletions(-) create mode 100644 tests/src/delivery/decorators/user-data.spec.ts create mode 100644 tests/src/delivery/guards/auth.spec.ts diff --git a/src/delivery/decorators/user-data.ts b/src/delivery/decorators/user-data.ts index d41900d..96033a8 100644 --- a/src/delivery/decorators/user-data.ts +++ b/src/delivery/decorators/user-data.ts @@ -4,21 +4,24 @@ import type { Request } from 'express'; import { decode } from 'jsonwebtoken'; import type { TokenPayload, UserData as UserDataType } from 'adapters/token'; -export const UserData = createParamDecorator( - (data: undefined, ctx: ExecutionContext): UserDataType => { - const request = ctx.switchToHttp().getRequest(); +export const validate = ( + data: undefined, + ctx: ExecutionContext, +): UserDataType => { + const request = ctx.switchToHttp().getRequest(); - const [token] = request.headers.authorization?.split(' ') ?? []; + const [, token] = request.headers.authorization?.split(' ') ?? []; - const payload = decode(token) as TokenPayload | undefined; + const payload = decode(token) as TokenPayload | undefined; - if (!payload) { - return {} as UserDataType; - } + if (!payload) { + return {} as UserDataType; + } - return { - accountId: payload.sub, - hasAcceptedLatestTerms: payload.terms, - }; - }, -); + return { + accountId: payload.sub, + hasAcceptedLatestTerms: payload.terms, + }; +}; + +export const UserData = createParamDecorator(validate); diff --git a/tests/src/delivery/decorators/user-data.spec.ts b/tests/src/delivery/decorators/user-data.spec.ts new file mode 100644 index 0000000..359d018 --- /dev/null +++ b/tests/src/delivery/decorators/user-data.spec.ts @@ -0,0 +1,64 @@ +import { validate } from 'delivery/decorators/user-data'; + +describe('Delivery > Decorators', () => { + describe('> UserData', () => { + it('should get user data', () => { + let result; + try { + result = validate(undefined, { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { + authorization: + 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhY2NvdW50SWQiLCJ0ZXJtcyI6dHJ1ZX0._Xha--8lvBSdfY8cQ3_Kup1eEM12N4nHceflRXtuZHY', + }, + }), + }), + } as any); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual({ + accountId: 'accountId', + hasAcceptedLatestTerms: true, + }); + }); + + it('should return empty object if no auth header', () => { + let result; + try { + result = validate(undefined, { + switchToHttp: () => ({ + getRequest: () => ({ + headers: {}, + }), + }), + } as any); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual({}); + }); + + it('should return undefined if fail to decode', () => { + let result; + try { + result = validate(undefined, { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { + authorization: 'Bearer foo', + }, + }), + }), + } as any); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({}); + }); + }); +}); diff --git a/tests/src/delivery/guards/auth.spec.ts b/tests/src/delivery/guards/auth.spec.ts new file mode 100644 index 0000000..b31c1b8 --- /dev/null +++ b/tests/src/delivery/guards/auth.spec.ts @@ -0,0 +1,251 @@ +import { Reflector } from '@nestjs/core'; +import { + AuthGuard, + IgnoreTermsCheck, + Public, +} from 'delivery/guards/auth.guard'; + +describe('Delivery > Guards', () => { + const reflectorMock = { + get: jest.fn(), + }; + const tokenMock = { + validateAccess: jest.fn(), + }; + const makeContextMock = () => { + const requestMock = jest.fn(); + + return { + getHandler: jest.fn(), + requestMock, + switchToHttp: () => ({ + getRequest: requestMock, + }), + }; + }; + + const guard = new AuthGuard(reflectorMock as any, tokenMock as any); + + beforeEach(() => { + reflectorMock.get.mockReset(); + }); + + describe('> Auth', () => { + it('should return true if public', async () => { + reflectorMock.get.mockReturnValue(true); + + const contextMock = makeContextMock(); + + let result; + try { + result = await guard.canActivate(contextMock as any); + } catch (err) { + result = err; + } + + expect(result).toBeTruthy(); + }); + + it('should return false without auth header', async () => { + reflectorMock.get.mockReturnValue(false); + + const contextMock = makeContextMock(); + contextMock.requestMock.mockReturnValue({ + headers: {}, + }); + + let result; + try { + result = await guard.canActivate(contextMock as any); + } catch (err) { + result = err; + } + + expect(result).toBeFalsy(); + }); + + it('should return false if token is not Bearer', async () => { + reflectorMock.get.mockReturnValue(false); + + const contextMock = makeContextMock(); + contextMock.requestMock.mockReturnValue({ + headers: { + authorization: 'foo bar', + }, + }); + + let result; + try { + result = await guard.canActivate(contextMock as any); + } catch (err) { + result = err; + } + + expect(result).toBeFalsy(); + }); + + it("should return false if don't have token", async () => { + reflectorMock.get.mockReturnValue(false); + + const contextMock = makeContextMock(); + contextMock.requestMock.mockReturnValue({ + headers: { + authorization: 'Bearer', + }, + }); + + let result; + try { + result = await guard.canActivate(contextMock as any); + } catch (err) { + result = err; + } + + expect(result).toBeFalsy(); + }); + + it('should return false if invalid token', async () => { + reflectorMock.get.mockReturnValue(false); + + const contextMock = makeContextMock(); + contextMock.requestMock.mockReturnValue({ + headers: { + authorization: 'Bearer foo', + }, + }); + + tokenMock.validateAccess.mockReturnValue(undefined); + + let result; + try { + result = await guard.canActivate(contextMock as any); + } catch (err) { + result = err; + } + + expect(result).toBeFalsy(); + }); + + it('should return false if terms flag and terms not accepted', async () => { + reflectorMock.get.mockReturnValueOnce(false); + + const contextMock = makeContextMock(); + contextMock.requestMock.mockReturnValue({ + headers: { + authorization: 'Bearer foo', + }, + }); + + tokenMock.validateAccess.mockReturnValue({ + sub: 'accountId', + terms: false, + }); + + reflectorMock.get.mockReturnValueOnce(false); + + let result; + try { + result = await guard.canActivate(contextMock as any); + } catch (err) { + result = err; + } + + expect(result).toBeFalsy(); + }); + + it('should return false if fail to validate', async () => { + reflectorMock.get.mockReturnValueOnce(false); + + const contextMock = makeContextMock(); + contextMock.requestMock.mockReturnValue({ + headers: { + authorization: 'Bearer foo', + }, + }); + + tokenMock.validateAccess.mockImplementation(() => { + throw new Error(); + }); + + reflectorMock.get.mockReturnValueOnce(false); + + let result; + try { + result = await guard.canActivate(contextMock as any); + } catch (err) { + result = err; + } + + expect(result).toBeFalsy(); + }); + + it('should return true if terms flag and terms accepted', async () => { + reflectorMock.get.mockReturnValueOnce(false); + + const contextMock = makeContextMock(); + contextMock.requestMock.mockReturnValue({ + headers: { + authorization: 'Bearer foo', + }, + }); + + tokenMock.validateAccess.mockReturnValue({ + sub: 'accountId', + terms: true, + }); + + reflectorMock.get.mockReturnValueOnce(false); + + let result; + try { + result = await guard.canActivate(contextMock as any); + } catch (err) { + result = err; + } + + expect(result).toBeTruthy(); + }); + }); + + describe('> Public', () => { + const reflector = new Reflector(); + + it('should set metadata', () => { + let result; + try { + class Foo { + @Public() + bar() {} + } + + result = reflector.get('isPublic', new Foo().bar); + } catch (err) { + result = err; + } + + expect(result).toBeDefined(); + expect(result).toBeTruthy(); + }); + }); + + describe('> IgnoreTermsCheck', () => { + const reflector = new Reflector(); + + it('should set metadata', () => { + let result; + try { + class Foo { + @IgnoreTermsCheck() + bar() {} + } + + result = reflector.get('ignoreTermsCheck', new Foo().bar); + } catch (err) { + result = err; + } + + expect(result).toBeDefined(); + expect(result).toBeTruthy(); + }); + }); +}); From fedf75fb512fbb2ec814e31ed92c625b439b3d16 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Thu, 25 Jan 2024 10:59:52 -0300 Subject: [PATCH 10/19] Add tests to AccountService --- jest.config.json | 3 +- src/usecases/account/account.service.ts | 2 +- tests/mocks/libs/axios.ts | 20 +-- tests/mocks/libs/jsonwebtoken.ts | 20 +-- tests/mocks/repositories/postgres/account.ts | 30 ++++ tests/mocks/repositories/postgres/core.ts | 5 + .../mocks/repositories/postgres/repository.ts | 37 ++--- tests/mocks/types.ts | 1 + tests/src/adapters/google.spec.ts | 4 - tests/src/adapters/jwt.spec.ts | 4 - .../src/delivery/decorators/user-data.spec.ts | 2 +- tests/src/usecases/account.spec.ts | 148 ++++++++++++++++++ tests/utils.ts | 30 ++++ 13 files changed, 236 insertions(+), 70 deletions(-) create mode 100644 tests/mocks/repositories/postgres/account.ts create mode 100644 tests/mocks/repositories/postgres/core.ts create mode 100644 tests/mocks/types.ts create mode 100644 tests/src/usecases/account.spec.ts diff --git a/jest.config.json b/jest.config.json index 9f98b57..23bb724 100644 --- a/jest.config.json +++ b/jest.config.json @@ -21,5 +21,6 @@ "coverageDirectory": "./coverage", "testEnvironment": "node", "moduleDirectories": ["node_modules", "src"], - "setupFiles": ["./tests/setup.ts"] + "setupFiles": ["./tests/setup.ts"], + "resetMocks": true } diff --git a/src/usecases/account/account.service.ts b/src/usecases/account/account.service.ts index bfc456e..15ca5d7 100644 --- a/src/usecases/account/account.service.ts +++ b/src/usecases/account/account.service.ts @@ -34,7 +34,7 @@ export class AccountService extends AccountUseCase { return { id: accountId, - googleId: google.providerId, + googleId: google?.providerId, }; } diff --git a/tests/mocks/libs/axios.ts b/tests/mocks/libs/axios.ts index 4a91445..74f9bf4 100644 --- a/tests/mocks/libs/axios.ts +++ b/tests/mocks/libs/axios.ts @@ -1,16 +1,4 @@ -export const makeAxiosMock = () => { - const mock = { - post: jest.fn(), - get: jest.fn(), - }; - - const resetMock = () => { - mock.post.mockReset(); - mock.get.mockReset(); - }; - - return { - resetMock, - ...mock, - }; -}; +export const makeAxiosMock = () => ({ + post: jest.fn(), + get: jest.fn(), +}); diff --git a/tests/mocks/libs/jsonwebtoken.ts b/tests/mocks/libs/jsonwebtoken.ts index 55f8adf..58de17d 100644 --- a/tests/mocks/libs/jsonwebtoken.ts +++ b/tests/mocks/libs/jsonwebtoken.ts @@ -1,16 +1,4 @@ -export const makeJwtMock = () => { - const mock = { - sign: jest.fn(), - verify: jest.fn(), - }; - - const resetMock = () => { - mock.sign.mockReset(); - mock.verify.mockReset(); - }; - - return { - resetMock, - ...mock, - }; -}; +export const makeJwtMock = () => ({ + sign: jest.fn(), + verify: jest.fn(), +}); diff --git a/tests/mocks/repositories/postgres/account.ts b/tests/mocks/repositories/postgres/account.ts new file mode 100644 index 0000000..6c3afad --- /dev/null +++ b/tests/mocks/repositories/postgres/account.ts @@ -0,0 +1,30 @@ +import type { AccountRepository } from 'models/account'; +import type { RepositoryMock } from '../../types'; +import { AccountRepositoryService } from 'repositories/postgres/account/account-repository.service'; +import type { Account } from '@prisma/client'; + +export const makeAccountRepositoryMock = () => { + const baseAccount: Account = { + id: 'accountId', + email: 'foo@bar', + phone: null, + createdAt: new Date(), + }; + + const accountRepositoryMock: RepositoryMock = { + getById: jest.fn(), + getByIdWithProviders: jest.fn(), + updateConfig: jest.fn(), + }; + + const accountRepositoryMockModule = { + provide: AccountRepositoryService, + useValue: accountRepositoryMock, + }; + + return { + baseAccount, + accountRepositoryMock, + accountRepositoryMockModule, + }; +}; diff --git a/tests/mocks/repositories/postgres/core.ts b/tests/mocks/repositories/postgres/core.ts new file mode 100644 index 0000000..09a8014 --- /dev/null +++ b/tests/mocks/repositories/postgres/core.ts @@ -0,0 +1,5 @@ +export const postgresConnectionMock = { + $connect: () => {}, + $disconnect: () => {}, + $queryRaw: () => {}, +}; diff --git a/tests/mocks/repositories/postgres/repository.ts b/tests/mocks/repositories/postgres/repository.ts index 15064eb..884848e 100644 --- a/tests/mocks/repositories/postgres/repository.ts +++ b/tests/mocks/repositories/postgres/repository.ts @@ -1,4 +1,4 @@ -export interface MockRepository { +export interface PostgresMock { find: jest.Mock; findOne: jest.Mock; findAndCount: jest.Mock; @@ -8,29 +8,12 @@ export interface MockRepository { delete: jest.Mock; } -export const makeMockRepository = () => { - const mock = { - find: jest.fn(), - findOne: jest.fn(), - findAndCount: jest.fn(), - save: jest.fn(), - insert: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - }; - - const resetMock = () => { - mock.find.mockReset(); - mock.findOne.mockReset(); - mock.findAndCount.mockReset(); - mock.save.mockReset(); - mock.insert.mockReset(); - mock.update.mockReset(); - mock.delete.mockReset(); - }; - - return { - resetMock, - ...mock, - }; -}; +export const makePostgresMock = () => ({ + find: jest.fn(), + findOne: jest.fn(), + findAndCount: jest.fn(), + save: jest.fn(), + insert: jest.fn(), + update: jest.fn(), + delete: jest.fn(), +}); diff --git a/tests/mocks/types.ts b/tests/mocks/types.ts new file mode 100644 index 0000000..9794676 --- /dev/null +++ b/tests/mocks/types.ts @@ -0,0 +1 @@ +export type RepositoryMock = Record; diff --git a/tests/src/adapters/google.spec.ts b/tests/src/adapters/google.spec.ts index 0bde8da..6a0f3e2 100644 --- a/tests/src/adapters/google.spec.ts +++ b/tests/src/adapters/google.spec.ts @@ -43,10 +43,6 @@ describe('Adapters > Google', () => { } }); - beforeEach(() => { - axiosMock.resetMock(); - }); - describe('definitions', () => { it('should initialize Service', () => { expect(service).toBeDefined(); diff --git a/tests/src/adapters/jwt.spec.ts b/tests/src/adapters/jwt.spec.ts index a5463e2..0b33b69 100644 --- a/tests/src/adapters/jwt.spec.ts +++ b/tests/src/adapters/jwt.spec.ts @@ -38,10 +38,6 @@ describe('Adapters > JWT', () => { } }); - beforeEach(() => { - jwtMock.resetMock(); - }); - describe('definitions', () => { it('should initialize Service', () => { expect(service).toBeDefined(); diff --git a/tests/src/delivery/decorators/user-data.spec.ts b/tests/src/delivery/decorators/user-data.spec.ts index 359d018..dd21027 100644 --- a/tests/src/delivery/decorators/user-data.spec.ts +++ b/tests/src/delivery/decorators/user-data.spec.ts @@ -58,7 +58,7 @@ describe('Delivery > Decorators', () => { result = err; } - expect(result).toMatchObject({}); + expect(result).toStrictEqual({}); }); }); }); diff --git a/tests/src/usecases/account.spec.ts b/tests/src/usecases/account.spec.ts new file mode 100644 index 0000000..afd0a11 --- /dev/null +++ b/tests/src/usecases/account.spec.ts @@ -0,0 +1,148 @@ +import type { INestApplication } from '@nestjs/common'; +import { AccountModule } from 'usecases/account/account.module'; +import { AccountService } from 'usecases/account/account.service'; +import { makeAccountRepositoryMock } from '../../mocks/repositories/postgres/account'; +import { SignInProviderEnum } from '@prisma/client'; +import { createTestModule, createTestService } from '../../utils'; + +describe('Usecases > Account', () => { + let service: AccountService; + let module: INestApplication; + + const { baseAccount, accountRepositoryMock, accountRepositoryMockModule } = + makeAccountRepositoryMock(); + + beforeAll(async () => { + try { + service = await createTestService(AccountService, { + providers: [accountRepositoryMockModule], + }); + + module = await createTestModule(AccountModule); + } catch (err) { + console.error(err); + } + }); + + describe('definitions', () => { + it('should initialize Service', () => { + expect(service).toBeDefined(); + }); + + it('should initialize Module', async () => { + expect(module).toBeDefined(); + }); + }); + + describe('> iam', () => { + it("should return the user's IDs", async () => { + const account = { + ...baseAccount, + signInProviders: [], + }; + + accountRepositoryMock.getByIdWithProviders.mockResolvedValue(account); + + let result; + try { + result = await service.iam({ + accountId: account.id, + }); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual({ + id: account.id, + googleId: undefined, + }); + }); + + it("should return the user's IDs (with google)", async () => { + const account = { + ...baseAccount, + signInProviders: [ + { + accountId: baseAccount.id, + provider: SignInProviderEnum.GOOGLE, + providerId: 'providerId', + accessToken: 'accessToken', + refreshToken: 'refreshToken', + expiresAt: new Date(), + }, + ], + }; + + accountRepositoryMock.getByIdWithProviders.mockResolvedValue(account); + + let result; + try { + result = await service.iam({ + accountId: account.id, + }); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual({ + id: account.id, + googleId: account.signInProviders[0].providerId, + }); + }); + + it('should fail id account not found', async () => { + accountRepositoryMock.getByIdWithProviders.mockResolvedValue(undefined); + + let result; + try { + result = await service.iam({ + accountId: 'accountId', + }); + } catch (err) { + result = err; + } + + expect(result).toBeInstanceOf(Error); + expect(result.status).toBe(401); + expect(result.message).toBe('User not found'); + }); + }); + + describe('> updateName', () => { + it("should update user's name", async () => { + accountRepositoryMock.updateConfig.mockResolvedValue(undefined); + + let result; + try { + result = await service.updateName({ + accountId: 'accountId', + name: 'Foo Bar', + }); + } catch (err) { + result = err; + } + + expect(result).toBeUndefined(); + expect(accountRepositoryMock.updateConfig).toHaveBeenCalled(); + }); + }); + + describe('> setBudget', () => { + it("should update user's budget", async () => { + accountRepositoryMock.updateConfig.mockResolvedValue(undefined); + + let result; + try { + result = await service.setBudget({ + accountId: 'accountId', + budgetId: 'budgetId', + }); + } catch (err) { + result = err; + } + + expect(result).toBeUndefined(); + expect(accountRepositoryMock.updateConfig).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index 88394d3..993f8b0 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1 +1,31 @@ +import { Test } from '@nestjs/testing'; +import { POSTGRES_CONNECTION_NAME } from 'repositories/postgres/core'; +import { postgresConnectionMock } from './mocks/repositories/postgres/core'; +import type { INestApplication, ModuleMetadata } from '@nestjs/common'; + export const removeMillis = (date?: Date) => date?.toISOString().split('.')[0]; + +export const createTestService = ( + service: any, + { providers, imports }: ModuleMetadata, +): Promise => { + return Test.createTestingModule({ + providers: [service as any, ...providers], + imports, + }) + .compile() + .then((r) => r.get(service)); +}; + +export const createTestModule = (module: any): Promise => { + return Test.createTestingModule({ + imports: [module], + }) + .useMocker((token) => { + if (token === POSTGRES_CONNECTION_NAME) { + return postgresConnectionMock; + } + }) + .compile() + .then((r) => r.createNestApplication()); +}; From eb102b1c5be60ad201e9ce1d0e79b8ccefb54bf1 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Thu, 25 Jan 2024 11:06:45 -0300 Subject: [PATCH 11/19] Improve services and modules initialization testing --- tests/src/adapters/dayjs.spec.ts | 16 ++++---------- tests/src/adapters/google.spec.ts | 36 +++++++++++++------------------ tests/src/adapters/jwt.spec.ts | 15 ++++--------- tests/src/adapters/uid.spec.ts | 15 ++++--------- tests/src/adapters/utils.spec.ts | 15 ++++--------- tests/utils.ts | 4 ++-- 6 files changed, 33 insertions(+), 68 deletions(-) diff --git a/tests/src/adapters/dayjs.spec.ts b/tests/src/adapters/dayjs.spec.ts index 7029248..4f4bcee 100644 --- a/tests/src/adapters/dayjs.spec.ts +++ b/tests/src/adapters/dayjs.spec.ts @@ -1,8 +1,7 @@ import type { INestApplication } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; import { DayJsAdapterModule } from 'adapters/implementations/dayjs/dayjs.module'; import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; -import { removeMillis } from '../../utils'; +import { createTestModule, createTestService, removeMillis } from '../../utils'; describe('Adapters > DayJs', () => { let service: DayjsAdapterService; @@ -10,17 +9,10 @@ describe('Adapters > DayJs', () => { beforeAll(async () => { try { - const moduleForService = await Test.createTestingModule({ - providers: [DayjsAdapterService], - }).compile(); + service = + await createTestService(DayjsAdapterService); - service = moduleForService.get(DayjsAdapterService); - - const moduleForModule = await Test.createTestingModule({ - imports: [DayJsAdapterModule], - }).compile(); - - module = moduleForModule.createNestApplication(); + module = await createTestModule(DayJsAdapterModule); } catch (err) { console.error(err); } diff --git a/tests/src/adapters/google.spec.ts b/tests/src/adapters/google.spec.ts index 6a0f3e2..3fb4ca0 100644 --- a/tests/src/adapters/google.spec.ts +++ b/tests/src/adapters/google.spec.ts @@ -1,10 +1,9 @@ import type { INestApplication } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; import { GoogleAdapterModule } from 'adapters/implementations/google/google.module'; import { GoogleAdapterService } from 'adapters/implementations/google/google.service'; import { makeAxiosMock } from '../../mocks/libs/axios'; import { DayJsAdapterModule } from 'adapters/implementations/dayjs/dayjs.module'; -import { removeMillis } from '../../utils'; +import { createTestModule, createTestService, removeMillis } from '../../utils'; import { configMock, configMockModule } from '../../mocks/config'; const googleTokenUrl = 'https://oauth2.googleapis.com/token'; @@ -18,26 +17,21 @@ describe('Adapters > Google', () => { beforeAll(async () => { try { - const moduleForService = await Test.createTestingModule({ - imports: [DayJsAdapterModule], - providers: [ - { - provide: 'axios', - useValue: axiosMock, - }, - configMockModule, - GoogleAdapterService, - ], - }).compile(); - - service = - moduleForService.get(GoogleAdapterService); - - const moduleForModule = await Test.createTestingModule({ - imports: [GoogleAdapterModule], - }).compile(); + service = await createTestService( + GoogleAdapterService, + { + imports: [DayJsAdapterModule], + providers: [ + { + provide: 'axios', + useValue: axiosMock, + }, + configMockModule, + ], + }, + ); - module = moduleForModule.createNestApplication(); + module = await createTestModule(GoogleAdapterModule); } catch (err) { console.error(err); } diff --git a/tests/src/adapters/jwt.spec.ts b/tests/src/adapters/jwt.spec.ts index 0b33b69..8ffae58 100644 --- a/tests/src/adapters/jwt.spec.ts +++ b/tests/src/adapters/jwt.spec.ts @@ -1,10 +1,10 @@ -import { Test } from '@nestjs/testing'; import { JWTAdapterService } from 'adapters/implementations/jwt/token.service'; import { makeJwtMock } from '../../mocks/libs/jsonwebtoken'; import { UIDAdapterModule } from 'adapters/implementations/uid/uid.module'; import { configMock, configMockModule } from '../../mocks/config'; import { JWTAdapterModule } from 'adapters/implementations/jwt/token.module'; import type { INestApplication } from '@nestjs/common'; +import { createTestModule, createTestService } from '../../utils'; describe('Adapters > JWT', () => { let service: JWTAdapterService; @@ -14,7 +14,7 @@ describe('Adapters > JWT', () => { beforeAll(async () => { try { - const moduleForService = await Test.createTestingModule({ + service = await createTestService(JWTAdapterService, { imports: [UIDAdapterModule], providers: [ { @@ -22,17 +22,10 @@ describe('Adapters > JWT', () => { useValue: jwtMock, }, configMockModule, - JWTAdapterService, ], - }).compile(); - - service = moduleForService.get(JWTAdapterService); - - const moduleForModule = await Test.createTestingModule({ - imports: [JWTAdapterModule], - }).compile(); + }); - module = moduleForModule.createNestApplication(); + module = await createTestModule(JWTAdapterModule); } catch (err) { console.error(err); } diff --git a/tests/src/adapters/uid.spec.ts b/tests/src/adapters/uid.spec.ts index 9f73852..98ae5b5 100644 --- a/tests/src/adapters/uid.spec.ts +++ b/tests/src/adapters/uid.spec.ts @@ -1,8 +1,8 @@ -import { Test } from '@nestjs/testing'; import { UIDAdapterModule } from 'adapters/implementations/uid/uid.module'; import type { INestApplication } from '@nestjs/common'; import { UIDAdapterService } from 'adapters/implementations/uid/uid.service'; import { uid } from 'uid/secure'; +import { createTestModule, createTestService } from '../../utils'; describe('Adapters > UID', () => { let service: UIDAdapterService; @@ -10,23 +10,16 @@ describe('Adapters > UID', () => { beforeAll(async () => { try { - const moduleForService = await Test.createTestingModule({ + service = await createTestService(UIDAdapterService, { providers: [ { provide: 'uid/secure', useValue: uid, }, - UIDAdapterService, ], - }).compile(); + }); - service = moduleForService.get(UIDAdapterService); - - const moduleForModule = await Test.createTestingModule({ - imports: [UIDAdapterModule], - }).compile(); - - module = moduleForModule.createNestApplication(); + module = await createTestModule(UIDAdapterModule); } catch (err) { console.error(err); } diff --git a/tests/src/adapters/utils.spec.ts b/tests/src/adapters/utils.spec.ts index c569fd9..4c7297d 100644 --- a/tests/src/adapters/utils.spec.ts +++ b/tests/src/adapters/utils.spec.ts @@ -1,7 +1,7 @@ -import { Test } from '@nestjs/testing'; import type { INestApplication } from '@nestjs/common'; import { UtilsAdapterService } from 'adapters/implementations/utils/utils.service'; import { UtilsAdapterModule } from 'adapters/implementations/utils/utils.module'; +import { createTestModule, createTestService } from '../../utils'; describe('Adapters > Utils', () => { let service: UtilsAdapterService; @@ -9,17 +9,10 @@ describe('Adapters > Utils', () => { beforeAll(async () => { try { - const moduleForService = await Test.createTestingModule({ - providers: [UtilsAdapterService], - }).compile(); + service = + await createTestService(UtilsAdapterService); - service = moduleForService.get(UtilsAdapterService); - - const moduleForModule = await Test.createTestingModule({ - imports: [UtilsAdapterModule], - }).compile(); - - module = moduleForModule.createNestApplication(); + module = await createTestModule(UtilsAdapterModule); } catch (err) { console.error(err); } diff --git a/tests/utils.ts b/tests/utils.ts index 993f8b0..1e3c491 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -7,10 +7,10 @@ export const removeMillis = (date?: Date) => date?.toISOString().split('.')[0]; export const createTestService = ( service: any, - { providers, imports }: ModuleMetadata, + { providers, imports }: ModuleMetadata = {}, ): Promise => { return Test.createTestingModule({ - providers: [service as any, ...providers], + providers: [...(providers || []), service as any], imports, }) .compile() From be59f9c042f85db9630f900804756377bd7f9f7e Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Fri, 26 Jan 2024 08:18:11 -0300 Subject: [PATCH 12/19] Add tests to AuthService --- .../implementations/jwt/token.service.ts | 4 +- src/adapters/token.ts | 2 +- src/delivery/guards/auth.guard.ts | 4 +- .../refresh-token-repository.service.ts | 4 +- src/usecases/auth/auth.service.ts | 63 ++- tests/mocks/adapters/email.ts | 19 + tests/mocks/adapters/google.ts | 55 ++ tests/mocks/adapters/sms.ts | 19 + tests/mocks/adapters/token.ts | 31 ++ tests/mocks/repositories/postgres/account.ts | 16 +- tests/mocks/repositories/postgres/auth.ts | 81 +++ .../repositories/postgres/magic-link-code.ts | 47 ++ .../repositories/postgres/refresh-token.ts | 42 ++ tests/mocks/types.ts | 2 +- tests/mocks/usecases/terms.ts | 21 + tests/src/usecases/account.spec.ts | 25 +- tests/src/usecases/auth.spec.ts | 509 ++++++++++++++++++ 17 files changed, 888 insertions(+), 56 deletions(-) create mode 100644 tests/mocks/adapters/email.ts create mode 100644 tests/mocks/adapters/google.ts create mode 100644 tests/mocks/adapters/sms.ts create mode 100644 tests/mocks/adapters/token.ts create mode 100644 tests/mocks/repositories/postgres/auth.ts create mode 100644 tests/mocks/repositories/postgres/magic-link-code.ts create mode 100644 tests/mocks/repositories/postgres/refresh-token.ts create mode 100644 tests/mocks/usecases/terms.ts create mode 100644 tests/src/usecases/auth.spec.ts diff --git a/src/adapters/implementations/jwt/token.service.ts b/src/adapters/implementations/jwt/token.service.ts index 7d1235b..65125af 100644 --- a/src/adapters/implementations/jwt/token.service.ts +++ b/src/adapters/implementations/jwt/token.service.ts @@ -6,7 +6,7 @@ import type { TokenPayload, ValidateAccessInput, } from '../../token'; -import { TokensAdapter } from '../../token'; +import { TokenAdapter } from '../../token'; import type * as Jwt from 'jsonwebtoken'; import { SecretAdapter } from 'adapters/secret'; import { UIDAdapterService } from '../uid/uid.service'; @@ -14,7 +14,7 @@ import { AppConfig } from 'config'; import { ConfigService } from '@nestjs/config'; @Injectable() -export class JWTAdapterService extends TokensAdapter { +export class JWTAdapterService extends TokenAdapter { constructor( @Inject('jsonwebtoken') protected readonly jwt: typeof Jwt, diff --git a/src/adapters/token.ts b/src/adapters/token.ts index b433f1c..6382fbc 100644 --- a/src/adapters/token.ts +++ b/src/adapters/token.ts @@ -23,7 +23,7 @@ export interface GenRefreshOutput { refreshToken: string; } -export abstract class TokensAdapter { +export abstract class TokenAdapter { abstract genAccess(i: GenAccessInput): GenAccessOutput; abstract validateAccess(i: ValidateAccessInput): TokenPayload | undefined; diff --git a/src/delivery/guards/auth.guard.ts b/src/delivery/guards/auth.guard.ts index 6fe7879..6d064b6 100644 --- a/src/delivery/guards/auth.guard.ts +++ b/src/delivery/guards/auth.guard.ts @@ -1,7 +1,7 @@ import type { CanActivate, ExecutionContext } from '@nestjs/common'; import { Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { TokensAdapter } from 'adapters/token'; +import { TokenAdapter } from 'adapters/token'; import { SetMetadata } from '@nestjs/common'; @Injectable() @@ -9,7 +9,7 @@ export class AuthGuard implements CanActivate { constructor( private readonly reflector: Reflector, - private readonly tokenAdapter: TokensAdapter, + private readonly tokenAdapter: TokenAdapter, ) {} async canActivate(context: ExecutionContext): Promise { diff --git a/src/repositories/postgres/refresh-token/refresh-token-repository.service.ts b/src/repositories/postgres/refresh-token/refresh-token-repository.service.ts index 969dd4a..7407700 100644 --- a/src/repositories/postgres/refresh-token/refresh-token-repository.service.ts +++ b/src/repositories/postgres/refresh-token/refresh-token-repository.service.ts @@ -7,7 +7,7 @@ import type { import { RefreshTokenRepository } from 'models/refresh-token'; import { InjectRepository, Repository } from '..'; import type { RefreshToken } from '@prisma/client'; -import { TokensAdapter } from 'adapters/token'; +import { TokenAdapter } from 'adapters/token'; import { JWTAdapterService } from 'adapters/implementations/jwt/token.service'; @Injectable() @@ -17,7 +17,7 @@ export class RefreshTokenRepositoryService extends RefreshTokenRepository { private readonly refreshTokenRepository: Repository<'refreshToken'>, @Inject(JWTAdapterService) - private readonly tokenAdapter: TokensAdapter, + private readonly tokenAdapter: TokenAdapter, ) { super(); } diff --git a/src/usecases/auth/auth.service.ts b/src/usecases/auth/auth.service.ts index 28d05b3..b26368f 100644 --- a/src/usecases/auth/auth.service.ts +++ b/src/usecases/auth/auth.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, + ForbiddenException, Inject, Injectable, Logger, @@ -27,7 +28,7 @@ import { TermsAndPoliciesUseCase } from 'models/terms-and-policies'; import { MagicLinkCodeRepository } from 'models/magic-link-code'; import { RefreshTokenRepository } from 'models/refresh-token'; import { GoogleAdapter } from 'adapters/google'; -import { TokensAdapter } from 'adapters/token'; +import { TokenAdapter } from 'adapters/token'; import { EmailAdapter } from 'adapters/email'; import { SmsAdapter } from 'adapters/sms'; import { GoogleAdapterService } from 'adapters/implementations/google/google.service'; @@ -38,16 +39,11 @@ import { SNSAdapterService } from 'adapters/implementations/sns/sns.service'; interface GenTokensInput { accountId: string; isFirstAccess: boolean; - refresh?: boolean; + refresh: boolean; } @Injectable() export class AuthService extends AuthUseCase { - private readonly requiredGoogleScopes = [ - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/userinfo.email', - ]; - constructor( @Inject(AuthRepositoryService) private readonly authRepository: AuthRepository, @@ -62,7 +58,7 @@ export class AuthService extends AuthUseCase { @Inject(GoogleAdapterService) private readonly googleAdapter: GoogleAdapter, @Inject(JWTAdapterService) - private readonly tokenAdapter: TokensAdapter, + private readonly tokenAdapter: TokenAdapter, @Inject(SESAdapterService) private readonly emailAdapter: EmailAdapter, @Inject(SNSAdapterService) @@ -83,7 +79,7 @@ export class AuthService extends AuthUseCase { throw new BadRequestException('Invalid code'); }); - const missingScopes = this.requiredGoogleScopes.filter( + const missingScopes = this.googleAdapter.requiredScopes.filter( (s) => !scopes.includes(s), ); @@ -97,6 +93,10 @@ export class AuthService extends AuthUseCase { providerTokens.accessToken, ); + if (!providerData.isEmailVerified) { + throw new ForbiddenException('Unverified provider email'); + } + const relatedAccounts = await this.authRepository.getManyByProvider({ providerId: providerData.id, email: providerData.email, @@ -104,11 +104,11 @@ export class AuthService extends AuthUseCase { }); let account: Account; - let isFirstAccess: true; + let isFirstAccess = false; if (relatedAccounts.length > 0) { - const sameProviderId = relatedAccounts.find( - (a) => a.signInProviders[0].providerId === providerData.id, + const sameProviderId = relatedAccounts.find((a) => + a.signInProviders.find((p) => p.providerId === providerData.id), ); const sameEmail = relatedAccounts.find( (a) => a.email === providerData.email, @@ -116,24 +116,27 @@ export class AuthService extends AuthUseCase { // Has an account with the same email, and it // isn't linked with another provider account - // or it has only one account if ( sameEmail && !sameProviderId && - (!sameEmail.signInProviders[0].providerId || - sameEmail.signInProviders[0].providerId === - sameProviderId.signInProviders[0].providerId) + !sameEmail.signInProviders.find( + (p) => p.provider === SignInProviderEnum.GOOGLE, + ) ) { account = sameEmail; } + // Account with same provider id (it can have a different email, - // in case that the user updated it in provider) - if ((sameProviderId && !sameEmail) || (sameProviderId && sameEmail)) { + // in case that the user updated it in provider or on our platform) + // More descriptive IF: + // if ((sameProviderId && !sameEmail) || (sameProviderId && sameEmail)) { + if (sameProviderId) { account = sameProviderId; } + if (!account) { throw new ConflictException( - `Error finding account, please contact support`, + 'Error finding account, please contact support', ); } @@ -172,7 +175,7 @@ export class AuthService extends AuthUseCase { let account = await this.authRepository.getByEmail({ email: i.email, }); - let isFirstAccess: true = null; + let isFirstAccess = false; if (!account) { account = await this.authRepository.create({ @@ -203,7 +206,7 @@ export class AuthService extends AuthUseCase { let account = await this.authRepository.getByPhone({ phone: i.phone, }); - let isFirstAccess: true = null; + let isFirstAccess = false; if (!account) { account = await this.authRepository.create({ @@ -259,17 +262,23 @@ export class AuthService extends AuthUseCase { throw new NotFoundException('Refresh token not found'); } - const { isFirstAccess: _, ...authOutput } = await this.genAuthOutput({ + return this.genAuthOutput({ accountId: refreshTokenData.accountId, isFirstAccess: false, + refresh: false, }); - - return authOutput; } - // Private + /** + * Private + * + * We set their accessibility to public so we can test it + */ - private async genAuthOutput({ + /** + * @private + */ + public async genAuthOutput({ accountId, isFirstAccess, refresh, @@ -283,7 +292,7 @@ export class AuthService extends AuthUseCase { }), ); } else { - promises.push({ refreshToken: '' }); + promises.push({ refreshToken: undefined }); } if (!isFirstAccess) { diff --git a/tests/mocks/adapters/email.ts b/tests/mocks/adapters/email.ts new file mode 100644 index 0000000..4570476 --- /dev/null +++ b/tests/mocks/adapters/email.ts @@ -0,0 +1,19 @@ +import type { EmailAdapter } from 'adapters/email'; +import { SESAdapterService } from 'adapters/implementations/ses/ses.service'; +import type { Mock } from '../types'; + +export const makeEmailAdapterMock = () => { + const mock: Mock = { + send: jest.fn(), + }; + + const module = { + provide: SESAdapterService, + useValue: mock, + }; + + return { + mock, + module, + }; +}; diff --git a/tests/mocks/adapters/google.ts b/tests/mocks/adapters/google.ts new file mode 100644 index 0000000..7b05203 --- /dev/null +++ b/tests/mocks/adapters/google.ts @@ -0,0 +1,55 @@ +import type { GoogleAdapter } from 'adapters/google'; +import { GoogleAdapterService } from 'adapters/implementations/google/google.service'; +import type { Mock } from '../types'; + +export const makeGoogleAdapterMock = () => { + const mock: Mock> & { + requiredScopes: Array; + } = { + requiredScopes: ['email', 'openid', 'email'], + exchangeCode: jest.fn(), + getAuthenticatedUserData: jest.fn(), + }; + + const module = { + provide: GoogleAdapterService, + useValue: mock, + }; + + const outputs = { + exchangeCode: { + success: { + scopes: mock.requiredScopes, + accessToken: 'accessToken', + refreshToken: 'refreshToken', + expiresAt: new Date(), + }, + noScopes: { + scopes: [], + accessToken: 'accessToken', + refreshToken: 'refreshToken', + expiresAt: new Date(), + }, + }, + getAuthenticatedUserData: { + success: { + id: 'providerId', + name: 'Foo Bar', + email: 'foo@bar.com', + isEmailVerified: true, + }, + unverified: { + id: 'providerId', + name: 'Foo Bar', + email: 'foo@bar.com', + isEmailVerified: false, + }, + }, + }; + + return { + mock, + module, + outputs, + }; +}; diff --git a/tests/mocks/adapters/sms.ts b/tests/mocks/adapters/sms.ts new file mode 100644 index 0000000..0cfc2c6 --- /dev/null +++ b/tests/mocks/adapters/sms.ts @@ -0,0 +1,19 @@ +import { SNSAdapterService } from 'adapters/implementations/sns/sns.service'; +import type { SmsAdapter } from 'adapters/sms'; +import type { Mock } from '../types'; + +export const makeSmsAdapterMock = () => { + const mock: Mock = { + send: jest.fn(), + }; + + const module = { + provide: SNSAdapterService, + useValue: mock, + }; + + return { + mock, + module, + }; +}; diff --git a/tests/mocks/adapters/token.ts b/tests/mocks/adapters/token.ts new file mode 100644 index 0000000..d395bad --- /dev/null +++ b/tests/mocks/adapters/token.ts @@ -0,0 +1,31 @@ +import { JWTAdapterService } from 'adapters/implementations/jwt/token.service'; +import type { TokenAdapter } from 'adapters/token'; +import type { Mock } from '../types'; + +export const makeTokenAdapterMock = () => { + const mock: Mock = { + genAccess: jest.fn(), + validateAccess: jest.fn(), + genRefresh: jest.fn(), + }; + + const module = { + provide: JWTAdapterService, + useValue: mock, + }; + + const outputs = { + genAccess: { + success: { + accessToken: 'accessToken', + expiresAt: new Date().toISOString(), + }, + }, + }; + + return { + mock, + module, + outputs, + }; +}; diff --git a/tests/mocks/repositories/postgres/account.ts b/tests/mocks/repositories/postgres/account.ts index 6c3afad..c8d3aca 100644 --- a/tests/mocks/repositories/postgres/account.ts +++ b/tests/mocks/repositories/postgres/account.ts @@ -1,30 +1,30 @@ import type { AccountRepository } from 'models/account'; -import type { RepositoryMock } from '../../types'; +import type { Mock } from '../../types'; import { AccountRepositoryService } from 'repositories/postgres/account/account-repository.service'; import type { Account } from '@prisma/client'; export const makeAccountRepositoryMock = () => { - const baseAccount: Account = { + const base: Account = { id: 'accountId', email: 'foo@bar', phone: null, createdAt: new Date(), }; - const accountRepositoryMock: RepositoryMock = { + const mock: Mock = { getById: jest.fn(), getByIdWithProviders: jest.fn(), updateConfig: jest.fn(), }; - const accountRepositoryMockModule = { + const module = { provide: AccountRepositoryService, - useValue: accountRepositoryMock, + useValue: mock, }; return { - baseAccount, - accountRepositoryMock, - accountRepositoryMockModule, + base, + mock, + module, }; }; diff --git a/tests/mocks/repositories/postgres/auth.ts b/tests/mocks/repositories/postgres/auth.ts new file mode 100644 index 0000000..7b010ba --- /dev/null +++ b/tests/mocks/repositories/postgres/auth.ts @@ -0,0 +1,81 @@ +import { SignInProviderEnum } from '@prisma/client'; +import type { Mock } from '../../types'; +import type { AuthRepository } from 'models/auth'; +import { AuthRepositoryService } from 'repositories/postgres/auth/auth-repository.service'; + +export const makeAuthRepositoryMock = () => { + const mock: Mock = { + create: jest.fn(), + getByEmail: jest.fn(), + getByPhone: jest.fn(), + getByProvider: jest.fn(), + getManyByProvider: jest.fn(), + updateProvider: jest.fn(), + }; + + const module = { + provide: AuthRepositoryService, + useValue: mock, + }; + + const outputs = { + getManyByProvider: { + empty: [], + email: [ + { + id: 'accountId', + email: 'foo@bar.com', + createdAt: new Date(), + signInProviders: [], + }, + ], + google: [ + { + id: 'accountId', + email: 'foo@bar.com', + createdAt: new Date(), + signInProviders: [ + { + accountId: 'accountId', + provider: SignInProviderEnum.GOOGLE, + providerId: 'providerId', + accessToken: 'accessToken', + refreshToken: 'refreshToken', + expiresAt: new Date(), + }, + ], + }, + ], + sameEmailDifferentGoogle: [ + { + id: 'accountId', + email: 'foo@bar.com', + createdAt: new Date(), + signInProviders: [ + { + accountId: 'accountId', + provider: SignInProviderEnum.GOOGLE, + providerId: 'differentProviderId', + accessToken: 'accessToken', + refreshToken: 'refreshToken', + expiresAt: new Date(), + }, + ], + }, + ], + }, + create: { + successGoogle: { + id: 'accountId', + email: 'foo@bar.com', + createdAt: new Date(), + }, + }, + }; + + return { + mock, + module, + outputs, + }; +}; diff --git a/tests/mocks/repositories/postgres/magic-link-code.ts b/tests/mocks/repositories/postgres/magic-link-code.ts new file mode 100644 index 0000000..bcf6be0 --- /dev/null +++ b/tests/mocks/repositories/postgres/magic-link-code.ts @@ -0,0 +1,47 @@ +import type { MagicLinkCode } from '@prisma/client'; +import type { Mock } from '../../types'; +import type { MagicLinkCodeRepository } from 'models/magic-link-code'; +import { MagicLinkCodeRepositoryService } from 'repositories/postgres/magic-link-code/magic-link-code-repository.service'; + +export const makeMagicLinkCodeRepositoryMock = () => { + const base: MagicLinkCode = { + accountId: 'accountId', + code: 'code', + isFirstAccess: false, + createdAt: new Date(), + }; + + const mock: Mock = { + upsert: jest.fn(), + get: jest.fn(), + }; + + const module = { + provide: MagicLinkCodeRepositoryService, + useValue: mock, + }; + + const outputs = { + get: { + success: { + accountId: 'accountId', + code: 'code', + isFirstAccess: false, + createdAt: new Date(), + }, + firstAccess: { + accountId: 'accountId', + code: 'code', + isFirstAccess: true, + createdAt: new Date(), + }, + }, + }; + + return { + base, + mock, + module, + outputs, + }; +}; diff --git a/tests/mocks/repositories/postgres/refresh-token.ts b/tests/mocks/repositories/postgres/refresh-token.ts new file mode 100644 index 0000000..1dc82fb --- /dev/null +++ b/tests/mocks/repositories/postgres/refresh-token.ts @@ -0,0 +1,42 @@ +import type { RefreshToken } from '@prisma/client'; +import type { Mock } from '../../types'; +import type { RefreshTokenRepository } from 'models/refresh-token'; +import { RefreshTokenRepositoryService } from 'repositories/postgres/refresh-token/refresh-token-repository.service'; + +export const makeRefreshTokenRepositoryMock = () => { + const base: RefreshToken = { + accountId: 'accountId', + refreshToken: 'refreshToken', + createdAt: new Date(), + }; + + const mock: Mock = { + create: jest.fn(), + get: jest.fn(), + }; + + const module = { + provide: RefreshTokenRepositoryService, + useValue: mock, + }; + + const outputs = { + create: { + success: base, + }, + get: { + success: { + accountId: 'accountId', + refreshToken: 'refreshToken', + createdAt: new Date(), + }, + }, + }; + + return { + base, + mock, + module, + outputs, + }; +}; diff --git a/tests/mocks/types.ts b/tests/mocks/types.ts index 9794676..5783e4e 100644 --- a/tests/mocks/types.ts +++ b/tests/mocks/types.ts @@ -1 +1 @@ -export type RepositoryMock = Record; +export type Mock = Record; diff --git a/tests/mocks/usecases/terms.ts b/tests/mocks/usecases/terms.ts new file mode 100644 index 0000000..a09202a --- /dev/null +++ b/tests/mocks/usecases/terms.ts @@ -0,0 +1,21 @@ +import type { TermsAndPoliciesUseCase } from 'models/terms-and-policies'; +import { TermsAndPoliciesService } from 'usecases/terms-and-policies/terms-and-policies.service'; +import type { Mock } from '../types'; + +export const makeTermsServiceMock = () => { + const mock: Mock = { + accept: jest.fn(), + hasAcceptedLatest: jest.fn(), + getLatest: jest.fn(), + }; + + const module = { + provide: TermsAndPoliciesService, + useValue: mock, + }; + + return { + mock, + module, + }; +}; diff --git a/tests/src/usecases/account.spec.ts b/tests/src/usecases/account.spec.ts index afd0a11..98c745c 100644 --- a/tests/src/usecases/account.spec.ts +++ b/tests/src/usecases/account.spec.ts @@ -9,13 +9,12 @@ describe('Usecases > Account', () => { let service: AccountService; let module: INestApplication; - const { baseAccount, accountRepositoryMock, accountRepositoryMockModule } = - makeAccountRepositoryMock(); + const accountRepository = makeAccountRepositoryMock(); beforeAll(async () => { try { service = await createTestService(AccountService, { - providers: [accountRepositoryMockModule], + providers: [accountRepository.module], }); module = await createTestModule(AccountModule); @@ -37,11 +36,11 @@ describe('Usecases > Account', () => { describe('> iam', () => { it("should return the user's IDs", async () => { const account = { - ...baseAccount, + ...accountRepository.base, signInProviders: [], }; - accountRepositoryMock.getByIdWithProviders.mockResolvedValue(account); + accountRepository.mock.getByIdWithProviders.mockResolvedValue(account); let result; try { @@ -60,10 +59,10 @@ describe('Usecases > Account', () => { it("should return the user's IDs (with google)", async () => { const account = { - ...baseAccount, + ...accountRepository.base, signInProviders: [ { - accountId: baseAccount.id, + accountId: accountRepository.base.id, provider: SignInProviderEnum.GOOGLE, providerId: 'providerId', accessToken: 'accessToken', @@ -73,7 +72,7 @@ describe('Usecases > Account', () => { ], }; - accountRepositoryMock.getByIdWithProviders.mockResolvedValue(account); + accountRepository.mock.getByIdWithProviders.mockResolvedValue(account); let result; try { @@ -91,7 +90,7 @@ describe('Usecases > Account', () => { }); it('should fail id account not found', async () => { - accountRepositoryMock.getByIdWithProviders.mockResolvedValue(undefined); + accountRepository.mock.getByIdWithProviders.mockResolvedValue(undefined); let result; try { @@ -110,7 +109,7 @@ describe('Usecases > Account', () => { describe('> updateName', () => { it("should update user's name", async () => { - accountRepositoryMock.updateConfig.mockResolvedValue(undefined); + accountRepository.mock.updateConfig.mockResolvedValue(undefined); let result; try { @@ -123,13 +122,13 @@ describe('Usecases > Account', () => { } expect(result).toBeUndefined(); - expect(accountRepositoryMock.updateConfig).toHaveBeenCalled(); + expect(accountRepository.mock.updateConfig).toHaveBeenCalled(); }); }); describe('> setBudget', () => { it("should update user's budget", async () => { - accountRepositoryMock.updateConfig.mockResolvedValue(undefined); + accountRepository.mock.updateConfig.mockResolvedValue(undefined); let result; try { @@ -142,7 +141,7 @@ describe('Usecases > Account', () => { } expect(result).toBeUndefined(); - expect(accountRepositoryMock.updateConfig).toHaveBeenCalled(); + expect(accountRepository.mock.updateConfig).toHaveBeenCalled(); }); }); }); diff --git a/tests/src/usecases/auth.spec.ts b/tests/src/usecases/auth.spec.ts new file mode 100644 index 0000000..5c80fa4 --- /dev/null +++ b/tests/src/usecases/auth.spec.ts @@ -0,0 +1,509 @@ +import type { INestApplication } from '@nestjs/common'; +import { createTestModule, createTestService } from '../../utils'; +import { AuthService } from 'usecases/auth/auth.service'; +import { AuthModule } from 'usecases/auth/auth.module'; +import { makeMagicLinkCodeRepositoryMock } from '../../mocks/repositories/postgres/magic-link-code'; +import { makeRefreshTokenRepositoryMock } from '../../mocks/repositories/postgres/refresh-token'; +import { makeTermsServiceMock } from '../../mocks/usecases/terms'; +import { makeGoogleAdapterMock } from '../../mocks/adapters/google'; +import { makeTokenAdapterMock } from '../../mocks/adapters/token'; +import { makeEmailAdapterMock } from '../../mocks/adapters/email'; +import { makeSmsAdapterMock } from '../../mocks/adapters/sms'; +import { makeAuthRepositoryMock } from '../../mocks/repositories/postgres/auth'; + +describe('Usecases > Auth', () => { + let service: AuthService; + let module: INestApplication; + + const authRepository = makeAuthRepositoryMock(); + const magicLinkCodeRepository = makeMagicLinkCodeRepositoryMock(); + const refreshTokenRepository = makeRefreshTokenRepositoryMock(); + + const termsService = makeTermsServiceMock(); + + const googleAdapter = makeGoogleAdapterMock(); + const tokenAdapter = makeTokenAdapterMock(); + const emailAdapter = makeEmailAdapterMock(); + const smsAdapter = makeSmsAdapterMock(); + + beforeAll(async () => { + try { + service = await createTestService(AuthService, { + providers: [ + authRepository.module, + magicLinkCodeRepository.module, + refreshTokenRepository.module, + termsService.module, + googleAdapter.module, + tokenAdapter.module, + emailAdapter.module, + smsAdapter.module, + ], + }); + + module = await createTestModule(AuthModule); + } catch (err) { + console.error(err); + } + }); + + describe('definitions', () => { + it('should initialize Service', () => { + expect(service).toBeDefined(); + }); + + it('should initialize Module', async () => { + expect(module).toBeDefined(); + }); + }); + + describe('> createFromGoogleProvider', () => { + it('should sign in user', async () => { + googleAdapter.mock.exchangeCode.mockResolvedValue( + googleAdapter.outputs.exchangeCode.success, + ); + googleAdapter.mock.getAuthenticatedUserData.mockResolvedValue( + googleAdapter.outputs.getAuthenticatedUserData.success, + ); + authRepository.mock.getManyByProvider.mockResolvedValue( + authRepository.outputs.getManyByProvider.empty, + ); + authRepository.mock.create.mockResolvedValue( + authRepository.outputs.create.successGoogle, + ); + refreshTokenRepository.mock.create.mockResolvedValue( + refreshTokenRepository.outputs.create.success, + ); + tokenAdapter.mock.genAccess.mockReturnValue( + tokenAdapter.outputs.genAccess.success, + ); + + let result; + try { + result = await service.createFromGoogleProvider({ + code: 'code', + }); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual({ + accessToken: tokenAdapter.outputs.genAccess.success.accessToken, + expiresAt: tokenAdapter.outputs.genAccess.success.expiresAt, + refreshToken: + refreshTokenRepository.outputs.create.success.refreshToken, + isFirstAccess: true, + }); + expect(googleAdapter.mock.exchangeCode).toHaveBeenCalled(); + expect(googleAdapter.mock.getAuthenticatedUserData).toHaveBeenCalled(); + expect(authRepository.mock.getManyByProvider).toHaveBeenCalled(); + expect(authRepository.mock.create).toHaveBeenCalled(); + expect(refreshTokenRepository.mock.create).toHaveBeenCalled(); + expect(tokenAdapter.mock.genAccess).toHaveBeenCalled(); + }); + + it('should sign up user (same providerId)', async () => { + googleAdapter.mock.exchangeCode.mockResolvedValue( + googleAdapter.outputs.exchangeCode.success, + ); + googleAdapter.mock.getAuthenticatedUserData.mockResolvedValue( + googleAdapter.outputs.getAuthenticatedUserData.success, + ); + authRepository.mock.getManyByProvider.mockResolvedValue( + authRepository.outputs.getManyByProvider.google, + ); + authRepository.mock.updateProvider.mockResolvedValue(undefined); + refreshTokenRepository.mock.create.mockResolvedValue( + refreshTokenRepository.outputs.create.success, + ); + termsService.mock.hasAcceptedLatest.mockResolvedValue(true); + tokenAdapter.mock.genAccess.mockReturnValue( + tokenAdapter.outputs.genAccess.success, + ); + + let result; + try { + result = await service.createFromGoogleProvider({ + code: 'code', + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + accessToken: tokenAdapter.outputs.genAccess.success.accessToken, + expiresAt: tokenAdapter.outputs.genAccess.success.expiresAt, + refreshToken: + refreshTokenRepository.outputs.create.success.refreshToken, + isFirstAccess: false, + }); + expect(googleAdapter.mock.exchangeCode).toHaveBeenCalled(); + expect(googleAdapter.mock.getAuthenticatedUserData).toHaveBeenCalled(); + expect(authRepository.mock.getManyByProvider).toHaveBeenCalled(); + expect(authRepository.mock.updateProvider).toHaveBeenCalled(); + expect(refreshTokenRepository.mock.create).toHaveBeenCalled(); + expect(termsService.mock.hasAcceptedLatest).toHaveBeenCalled(); + expect(tokenAdapter.mock.genAccess).toHaveBeenCalled(); + }); + + it('should sign up user (same email)', async () => { + googleAdapter.mock.exchangeCode.mockResolvedValue( + googleAdapter.outputs.exchangeCode.success, + ); + googleAdapter.mock.getAuthenticatedUserData.mockResolvedValue( + googleAdapter.outputs.getAuthenticatedUserData.success, + ); + authRepository.mock.getManyByProvider.mockResolvedValue( + authRepository.outputs.getManyByProvider.email, + ); + authRepository.mock.updateProvider.mockResolvedValue(undefined); + refreshTokenRepository.mock.create.mockResolvedValue( + refreshTokenRepository.outputs.create.success, + ); + termsService.mock.hasAcceptedLatest.mockResolvedValue(true); + tokenAdapter.mock.genAccess.mockReturnValue( + tokenAdapter.outputs.genAccess.success, + ); + + let result; + try { + result = await service.createFromGoogleProvider({ + code: 'code', + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + accessToken: tokenAdapter.outputs.genAccess.success.accessToken, + expiresAt: tokenAdapter.outputs.genAccess.success.expiresAt, + refreshToken: + refreshTokenRepository.outputs.create.success.refreshToken, + isFirstAccess: false, + }); + expect(googleAdapter.mock.exchangeCode).toHaveBeenCalled(); + expect(googleAdapter.mock.getAuthenticatedUserData).toHaveBeenCalled(); + expect(authRepository.mock.getManyByProvider).toHaveBeenCalled(); + expect(authRepository.mock.updateProvider).toHaveBeenCalled(); + expect(refreshTokenRepository.mock.create).toHaveBeenCalled(); + expect(termsService.mock.hasAcceptedLatest).toHaveBeenCalled(); + expect(tokenAdapter.mock.genAccess).toHaveBeenCalled(); + }); + + it('should fail if missing scopes', async () => { + googleAdapter.mock.exchangeCode.mockResolvedValue( + googleAdapter.outputs.exchangeCode.noScopes, + ); + + let result; + try { + result = await service.createFromGoogleProvider({ + code: 'code', + }); + } catch (err) { + result = err; + } + + expect(result).toBeInstanceOf(Error); + expect(result.status).toBe(400); + expect(result.message).toBe( + `Missing required scopes: ${googleAdapter.mock.requiredScopes.join( + ' ', + )}`, + ); + expect(googleAdapter.mock.exchangeCode).toHaveBeenCalled(); + expect( + googleAdapter.mock.getAuthenticatedUserData, + ).not.toHaveBeenCalled(); + }); + + it('should fail if unverified provider email', async () => { + googleAdapter.mock.exchangeCode.mockResolvedValue( + googleAdapter.outputs.exchangeCode.success, + ); + googleAdapter.mock.getAuthenticatedUserData.mockResolvedValue( + googleAdapter.outputs.getAuthenticatedUserData.unverified, + ); + + let result; + try { + result = await service.createFromGoogleProvider({ + code: 'code', + }); + } catch (err) { + result = err; + } + + expect(result).toBeInstanceOf(Error); + expect(result.status).toBe(403); + expect(result.message).toBe('Unverified provider email'); + expect(googleAdapter.mock.exchangeCode).toHaveBeenCalled(); + expect(googleAdapter.mock.getAuthenticatedUserData).toHaveBeenCalled(); + expect(authRepository.mock.getManyByProvider).not.toHaveBeenCalled(); + }); + + it('should fail if find account by email related to another google account', async () => { + googleAdapter.mock.exchangeCode.mockResolvedValue( + googleAdapter.outputs.exchangeCode.success, + ); + googleAdapter.mock.getAuthenticatedUserData.mockResolvedValue( + googleAdapter.outputs.getAuthenticatedUserData.success, + ); + authRepository.mock.getManyByProvider.mockResolvedValue( + authRepository.outputs.getManyByProvider.sameEmailDifferentGoogle, + ); + + let result; + try { + result = await service.createFromGoogleProvider({ + code: 'code', + }); + } catch (err) { + result = err; + } + + expect(result).toBeInstanceOf(Error); + expect(result.status).toBe(409); + expect(result.message).toBe( + 'Error finding account, please contact support', + ); + expect(googleAdapter.mock.exchangeCode).toHaveBeenCalled(); + expect(googleAdapter.mock.getAuthenticatedUserData).toHaveBeenCalled(); + expect(authRepository.mock.getManyByProvider).toHaveBeenCalled(); + expect(authRepository.mock.updateProvider).not.toHaveBeenCalled(); + }); + }); + + describe('> createFromEmailProvider', () => { + it.todo('should'); + }); + + describe('> createFromPhoneProvider', () => { + it.todo('should'); + }); + + describe('> exchangeCode', () => { + it('should return auth tokens', async () => { + magicLinkCodeRepository.mock.get.mockResolvedValue( + magicLinkCodeRepository.outputs.get.success, + ); + refreshTokenRepository.mock.create.mockResolvedValue( + refreshTokenRepository.outputs.create.success, + ); + tokenAdapter.mock.genAccess.mockReturnValue( + tokenAdapter.outputs.genAccess.success, + ); + + let result; + try { + result = await service.exchangeCode({ + accountId: 'accountId', + code: 'code', + }); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual({ + accessToken: tokenAdapter.outputs.genAccess.success.accessToken, + expiresAt: tokenAdapter.outputs.genAccess.success.expiresAt, + refreshToken: + refreshTokenRepository.outputs.create.success.refreshToken, + isFirstAccess: false, + }); + expect(magicLinkCodeRepository.mock.get).toHaveBeenCalled(); + expect(refreshTokenRepository.mock.create).toHaveBeenCalled(); + expect(tokenAdapter.mock.genAccess).toHaveBeenCalled(); + }); + + it('should throw error if magic link code not found', async () => { + magicLinkCodeRepository.mock.get.mockResolvedValue(undefined); + + let result; + try { + result = await service.exchangeCode({ + accountId: 'accountId', + code: 'code', + }); + } catch (err) { + result = err; + } + + expect(result).toBeInstanceOf(Error); + expect(result.status).toBe(404); + expect(result.message).toBe('Invalid code'); + expect(magicLinkCodeRepository.mock.get).toHaveBeenCalled(); + expect(refreshTokenRepository.mock.create).not.toHaveBeenCalled(); + expect(tokenAdapter.mock.genAccess).not.toHaveBeenCalled(); + }); + }); + + describe('> refreshToken', () => { + it('should regen access token', async () => { + const expiresAt = new Date(); + + refreshTokenRepository.mock.get.mockResolvedValue( + refreshTokenRepository.outputs.get.success, + ); + termsService.mock.hasAcceptedLatest.mockResolvedValue(true); + tokenAdapter.mock.genAccess.mockReturnValue({ + accessToken: 'accessToken', + expiresAt, + }); + + let result; + try { + result = await service.refreshToken({ + refreshToken: 'refreshToken', + }); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual({ + accessToken: 'accessToken', + expiresAt, + isFirstAccess: false, + refreshToken: undefined, + }); + }); + + it('should throw error if refresh token not found', async () => { + refreshTokenRepository.mock.get.mockResolvedValue(undefined); + + let result; + try { + result = await service.refreshToken({ + refreshToken: 'refreshToken', + }); + } catch (err) { + result = err; + } + + expect(result).toBeInstanceOf(Error); + expect(result.status).toBe(404); + expect(result.message).toBe('Refresh token not found'); + expect(refreshTokenRepository.mock.get).toHaveBeenCalled(); + expect(tokenAdapter.mock.genAccess).not.toHaveBeenCalled(); + }); + }); + + describe('> genAuthOutput', () => { + it('should return access and refresh token', async () => { + const expiresAt = new Date(); + + refreshTokenRepository.mock.create.mockResolvedValue( + refreshTokenRepository.outputs.create.success, + ); + termsService.mock.hasAcceptedLatest.mockResolvedValue(true); + tokenAdapter.mock.genAccess.mockReturnValue({ + accessToken: 'accessToken', + expiresAt, + }); + + let result; + try { + result = await service.genAuthOutput({ + accountId: 'accountId', + isFirstAccess: true, + refresh: true, + }); + } catch (err) { + result = err; + } + + expect(result).toStrictEqual({ + accessToken: 'accessToken', + expiresAt, + refreshToken: + refreshTokenRepository.outputs.create.success.refreshToken, + isFirstAccess: true, + }); + }); + + it('should return only access token', async () => { + const expiresAt = new Date(); + + termsService.mock.hasAcceptedLatest.mockResolvedValue(true); + tokenAdapter.mock.genAccess.mockReturnValue({ + accessToken: 'accessToken', + expiresAt, + }); + + let result; + try { + result = await service.genAuthOutput({ + accountId: 'accountId', + isFirstAccess: true, + refresh: false, + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + accessToken: 'accessToken', + expiresAt, + isFirstAccess: true, + }); + expect(result.refreshToken).toBeUndefined(); + expect(refreshTokenRepository.mock.create).not.toHaveBeenCalled(); + }); + + it("should return terms from database if it's not first access (true)", async () => { + const terms = true; + + termsService.mock.hasAcceptedLatest.mockResolvedValue(terms); + tokenAdapter.mock.genAccess.mockImplementation((i: any) => ({ + accessToken: JSON.stringify(i), + expiresAt: new Date(), + })); + + let result; + try { + result = await service.genAuthOutput({ + accountId: 'accountId', + isFirstAccess: false, + refresh: false, + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + accessToken: JSON.stringify({ + accountId: 'accountId', + hasAcceptedLatestTerms: terms, + }), + }); + }); + + it("should return terms from database if it's not first access (false)", async () => { + const terms = false; + + termsService.mock.hasAcceptedLatest.mockResolvedValue(terms); + tokenAdapter.mock.genAccess.mockImplementation((i: any) => ({ + accessToken: JSON.stringify(i), + expiresAt: new Date(), + })); + + let result; + try { + result = await service.genAuthOutput({ + accountId: 'accountId', + isFirstAccess: false, + refresh: false, + }); + } catch (err) { + result = err; + } + + expect(result).toMatchObject({ + accessToken: JSON.stringify({ + accountId: 'accountId', + hasAcceptedLatestTerms: terms, + }), + }); + }); + }); +}); From 2b2f132538d743a74103baaaf9a64d0c19f2cf4f Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Fri, 26 Jan 2024 13:13:59 -0300 Subject: [PATCH 13/19] Add Onboarding --- openapi/openapi.yaml | 2 + openapi/paths/accounts/onboarding.yaml | 41 +++++++++++++ .../20240126161003_onboarding/migration.sql | 19 ++++++ prisma/schema.prisma | 17 ++++++ src/adapters/date.ts | 2 +- .../implementations/dayjs/dayjs.service.ts | 2 +- src/delivery/account.controller.ts | 26 +++++++- src/delivery/dtos/account.ts | 28 +++++++++ src/delivery/dtos/bank.ts | 8 ++- src/delivery/dtos/card.ts | 12 +++- src/delivery/validators/miscellaneous.ts | 34 ++--------- src/models/account.ts | 49 ++++++++++++++- .../account/account-repository.module.ts | 2 +- .../account/account-repository.service.ts | 29 ++++++++- .../postgres/auth/auth-repository.service.ts | 5 ++ .../budget/budget-repository.service.ts | 2 +- src/usecases/account/account.module.ts | 3 +- src/usecases/account/account.service.ts | 60 ++++++++++++++++++- tests/mocks/repositories/postgres/account.ts | 2 + tests/src/usecases/account.spec.ts | 9 ++- 20 files changed, 306 insertions(+), 46 deletions(-) create mode 100644 openapi/paths/accounts/onboarding.yaml create mode 100644 prisma/migrations/20240126161003_onboarding/migration.sql diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 0252d1d..ff411e0 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -78,6 +78,8 @@ paths: $ref: paths/accounts/iam.yaml /accounts/name: $ref: paths/accounts/name.yaml + /accounts/onboarding: + $ref: paths/accounts/onboarding.yaml /banks/providers: $ref: paths/banks/providers.yaml diff --git a/openapi/paths/accounts/onboarding.yaml b/openapi/paths/accounts/onboarding.yaml new file mode 100644 index 0000000..cb86413 --- /dev/null +++ b/openapi/paths/accounts/onboarding.yaml @@ -0,0 +1,41 @@ +patch: + tags: + - Account + summary: Update user's onboarding progress + description: | + Update user's onboarding progress + operationId: account-update-onboarding + security: + - bearer: [] + requestBody: + content: + application/json: + schema: + type: object + title: Update user's onboarding progress + properties: + name: + type: boolean + categories: + type: boolean + bankAccounts: + type: boolean + creditCards: + type: boolean + budget: + type: boolean + salary: + type: boolean + required: true + responses: + "204": + description: | + Onboarding progress updated + + "400": + description: | + Invalid name + + "401": + description: | + Unauthorized diff --git a/prisma/migrations/20240126161003_onboarding/migration.sql b/prisma/migrations/20240126161003_onboarding/migration.sql new file mode 100644 index 0000000..6707c34 --- /dev/null +++ b/prisma/migrations/20240126161003_onboarding/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "onboardings" ( + "id" CHAR(16) NOT NULL, + "account_id" CHAR(16) NOT NULL, + "name" TIMESTAMP, + "categories" TIMESTAMP, + "bank_accounts" TIMESTAMP, + "credit_cards" TIMESTAMP, + "budget" TIMESTAMP, + "salary" TIMESTAMP, + + CONSTRAINT "onboardings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "onboardings_account_id_key" ON "onboardings"("account_id"); + +-- AddForeignKey +ALTER TABLE "onboardings" ADD CONSTRAINT "onboardings_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ad2f1c7..8443be5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,6 +39,7 @@ model Account { createdAt DateTime @default(now()) @map("created_at") config Config? + onboarding Onboarding? magicLinkCode MagicLinkCode? signInProviders SignInProvider[] refreshTokens RefreshToken[] @@ -84,6 +85,22 @@ model Config { @@map("configs") } +/// Contains data about the user's onboarding and the steps concluded +model Onboarding { + id String @id @db.Char(16) /// Same as accountId + accountId String @unique @map("account_id") @db.Char(16) + name DateTime? @db.Timestamp + categories DateTime? @db.Timestamp + bankAccounts DateTime? @map("bank_accounts") @db.Timestamp + creditCards DateTime? @map("credit_cards") @db.Timestamp + budget DateTime? @db.Timestamp + salary DateTime? @db.Timestamp + + account Account? @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@map("onboardings") +} + /// Contains codes to be used by the users to login model MagicLinkCode { accountId String @id @map("account_id") @db.Char(16) diff --git a/src/adapters/date.ts b/src/adapters/date.ts index 0190e8d..346fe78 100644 --- a/src/adapters/date.ts +++ b/src/adapters/date.ts @@ -21,7 +21,7 @@ export abstract class DateAdapter { abstract today(timezone?: TimezoneEnum): TodayOutput; - abstract newDate(date: string | Date, timezone?: TimezoneEnum): Date; + abstract newDate(date?: string | Date, timezone?: TimezoneEnum): Date; abstract get(date: Date | string, unit: DateUnit): number; diff --git a/src/adapters/implementations/dayjs/dayjs.service.ts b/src/adapters/implementations/dayjs/dayjs.service.ts index b1dc155..7420ab4 100644 --- a/src/adapters/implementations/dayjs/dayjs.service.ts +++ b/src/adapters/implementations/dayjs/dayjs.service.ts @@ -33,7 +33,7 @@ export class DayjsAdapterService extends DateAdapter { }; } - newDate(date: string | Date, timezone?: TimezoneEnum): Date { + newDate(date?: string | Date, timezone?: TimezoneEnum): Date { return dayjs.tz(date, timezone).toDate(); } diff --git a/src/delivery/account.controller.ts b/src/delivery/account.controller.ts index 0116116..bdc5847 100644 --- a/src/delivery/account.controller.ts +++ b/src/delivery/account.controller.ts @@ -10,7 +10,7 @@ import { import { AccountService } from 'usecases/account/account.service'; import { IgnoreTermsCheck } from './guards/auth.guard'; import { UserData } from './decorators/user-data'; -import { NameDto } from './dtos/account'; +import { NameDto, UpdateOnboardingDto } from './dtos/account'; import { UserDataDto } from './dtos'; import { AccountUseCase } from 'models/account'; @@ -45,4 +45,28 @@ export class AccountController { name: body.name, }); } + + @Get('/onboarding') + getOnboarding( + @UserData() + userData: UserDataDto, + ) { + return this.accountService.getOnboarding({ + accountId: userData.accountId, + }); + } + + @HttpCode(HttpStatus.NO_CONTENT) + @Patch('/onboarding') + updateOnboarding( + @UserData() + userData: UserDataDto, + @Body() + body: UpdateOnboardingDto, + ) { + return this.accountService.updateOnboarding({ + ...body, + accountId: userData.accountId, + }); + } } diff --git a/src/delivery/dtos/account.ts b/src/delivery/dtos/account.ts index 587a205..05d59d2 100644 --- a/src/delivery/dtos/account.ts +++ b/src/delivery/dtos/account.ts @@ -1,6 +1,34 @@ +import { IsOptional } from 'class-validator'; import { IsName } from '../validators/internal'; +import { IsTrue } from 'delivery/validators/miscellaneous'; export class NameDto { @IsName() name: string; } + +export class UpdateOnboardingDto { + @IsOptional() + @IsTrue() + name?: true; + + @IsOptional() + @IsTrue() + categories?: true; + + @IsOptional() + @IsTrue() + bankAccounts?: true; + + @IsOptional() + @IsTrue() + creditCards?: true; + + @IsOptional() + @IsTrue() + budget?: true; + + @IsOptional() + @IsTrue() + salary?: true; +} diff --git a/src/delivery/dtos/bank.ts b/src/delivery/dtos/bank.ts index 516b9f4..a524607 100644 --- a/src/delivery/dtos/bank.ts +++ b/src/delivery/dtos/bank.ts @@ -1,5 +1,5 @@ +import { IsNumberString, Length } from 'class-validator'; import { IsAmount, IsID, IsName } from '../validators/internal'; -import { IsNumberString } from '../validators/miscellaneous'; export class CreateDto { @IsName() @@ -8,10 +8,12 @@ export class CreateDto { @IsID() bankProviderId: string; - @IsNumberString(6) + @IsNumberString({ no_symbols: true }) + @Length(6) accountNumber: string; - @IsNumberString(3) + @IsNumberString({ no_symbols: true }) + @Length(3) branch: string; @IsAmount() diff --git a/src/delivery/dtos/card.ts b/src/delivery/dtos/card.ts index b2e6293..589fbb7 100644 --- a/src/delivery/dtos/card.ts +++ b/src/delivery/dtos/card.ts @@ -1,7 +1,12 @@ -import { IsDate, IsEnum, IsOptional } from 'class-validator'; +import { + IsDate, + IsEnum, + IsNumberString, + IsOptional, + Length, +} from 'class-validator'; import { IsAmount, IsID, IsName } from '../validators/internal'; import { IsDay } from '../validators/date'; -import { IsNumberString } from '../validators/miscellaneous'; import { PayAtEnum } from '@prisma/client'; import { PaginatedDto } from '.'; @@ -12,7 +17,8 @@ export class CreateDto { @IsName() name: string; - @IsNumberString(4) + @IsNumberString({ no_symbols: true }) + @Length(4) lastFourDigits: string; @IsOptional() diff --git a/src/delivery/validators/miscellaneous.ts b/src/delivery/validators/miscellaneous.ts index a7cc1ff..fd7450f 100644 --- a/src/delivery/validators/miscellaneous.ts +++ b/src/delivery/validators/miscellaneous.ts @@ -1,29 +1,5 @@ import type { ValidationArguments } from 'class-validator'; import { registerDecorator } from 'class-validator'; -import { isDateYMD } from '@techmmunity/utils'; - -export function IsDateYYYYMMDD() { - // eslint-disable-next-line @typescript-eslint/ban-types - return function (object: Object, propertyName: string) { - registerDecorator({ - name: 'isDateYYYYMMDD', - target: object.constructor, - propertyName: propertyName, - constraints: [], - options: { - message: `${propertyName} must be a valid birth date`, - }, - validator: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - validate(value: any, _args: ValidationArguments) { - if (typeof value !== 'string') return false; - - return isDateYMD(value); - }, - }, - }); - }; -} interface IsURLValidationOptions { acceptLocalhost: boolean; @@ -84,23 +60,21 @@ export function IsPhone() { }; } -export function IsNumberString(length: number) { +export function IsTrue() { // eslint-disable-next-line @typescript-eslint/ban-types return function (object: Object, propertyName: string) { registerDecorator({ - name: 'IsNumberString', + name: 'IsTrue', target: object.constructor, propertyName: propertyName, constraints: [], options: { - message: `${propertyName} must be a valid number string with length of ${length}.`, + message: `${propertyName} must be true.`, }, validator: { // eslint-disable-next-line @typescript-eslint/no-unused-vars validate(value: any, _args: ValidationArguments) { - if (typeof value !== 'string') return false; - - return /^[0-9]*$/.test(value) && value.length === length; + return value === true; }, }, }); diff --git a/src/models/account.ts b/src/models/account.ts index 7c4b16a..10b083b 100644 --- a/src/models/account.ts +++ b/src/models/account.ts @@ -1,4 +1,4 @@ -import type { Account, SignInProvider } from '@prisma/client'; +import type { Account, Onboarding, SignInProvider } from '@prisma/client'; /** * @@ -27,6 +27,20 @@ export interface UpdateConfigInput { salaryId?: string; } +export interface GetOnboardingRecordInput { + accountId: string; +} + +export interface UpdateOnboardingRecordInput { + accountId: string; + name?: Date; + categories?: Date; + bankAccounts?: Date; + creditCards?: Date; + budget?: Date; + salary?: Date; +} + export abstract class AccountRepository { abstract getById(i: GetByIdInput): Promise; @@ -35,6 +49,12 @@ export abstract class AccountRepository { ): Promise; abstract updateConfig(i: UpdateConfigInput): Promise; + + abstract getOnboarding( + i: GetOnboardingRecordInput, + ): Promise; + + abstract updateOnboarding(i: UpdateOnboardingRecordInput): Promise; } /** @@ -69,6 +89,29 @@ export interface SetSalaryInput { salaryId: string; } +export interface GetOnboardingInput { + accountId: string; +} + +export interface GetOnboardingOutput { + name?: true; + categories?: true; + bankAccounts?: true; + creditCards?: true; + budget?: true; + salary?: true; +} + +export interface UpdateOnboardingInput { + accountId: string; + name?: true; + categories?: true; + bankAccounts?: true; + creditCards?: true; + budget?: true; + salary?: true; +} + export abstract class AccountUseCase { abstract iam(i: IamInput): Promise; @@ -77,4 +120,8 @@ export abstract class AccountUseCase { abstract setBudget(i: SetBudgetInput): Promise; abstract setSalary(i: SetSalaryInput): Promise; + + abstract getOnboarding(i: GetOnboardingInput): Promise; + + abstract updateOnboarding(i: UpdateOnboardingInput): Promise; } diff --git a/src/repositories/postgres/account/account-repository.module.ts b/src/repositories/postgres/account/account-repository.module.ts index 719b838..091bbca 100644 --- a/src/repositories/postgres/account/account-repository.module.ts +++ b/src/repositories/postgres/account/account-repository.module.ts @@ -3,7 +3,7 @@ import { AccountRepositoryService } from './account-repository.service'; import { PostgresModule } from '..'; @Module({ - imports: [PostgresModule.forFeature(['account', 'config'])], + imports: [PostgresModule.forFeature(['account', 'config', 'onboarding'])], providers: [AccountRepositoryService], exports: [AccountRepositoryService], }) diff --git a/src/repositories/postgres/account/account-repository.service.ts b/src/repositories/postgres/account/account-repository.service.ts index f75f698..b61faeb 100644 --- a/src/repositories/postgres/account/account-repository.service.ts +++ b/src/repositories/postgres/account/account-repository.service.ts @@ -3,11 +3,14 @@ import type { GetByIdInput, GetByIdWithProvidersInput, GetByIdWithProvidersOutput, + GetOnboardingRecordInput, UpdateConfigInput, + UpdateOnboardingRecordInput, } from 'models/account'; import { AccountRepository } from 'models/account'; import { InjectRepository, Repository } from '..'; -import type { Account } from '@prisma/client'; +import type { Account, Onboarding } from '@prisma/client'; +import { cleanObj } from '@techmmunity/utils'; @Injectable() export class AccountRepositoryService extends AccountRepository { @@ -16,6 +19,8 @@ export class AccountRepositoryService extends AccountRepository { private readonly accountRepository: Repository<'account'>, @InjectRepository('config') private readonly configRepository: Repository<'config'>, + @InjectRepository('onboarding') + private readonly onboardingRepository: Repository<'onboarding'>, ) { super(); } @@ -60,4 +65,26 @@ export class AccountRepositoryService extends AccountRepository { }, }); } + + async getOnboarding({ + accountId, + }: GetOnboardingRecordInput): Promise { + return this.onboardingRepository.findUnique({ + where: { + id: accountId, + }, + }); + } + + async updateOnboarding({ + accountId, + ...data + }: UpdateOnboardingRecordInput): Promise { + this.onboardingRepository.update({ + where: { + id: accountId, + }, + data: cleanObj(data), + }); + } } diff --git a/src/repositories/postgres/auth/auth-repository.service.ts b/src/repositories/postgres/auth/auth-repository.service.ts index a7bd49e..865d3c5 100644 --- a/src/repositories/postgres/auth/auth-repository.service.ts +++ b/src/repositories/postgres/auth/auth-repository.service.ts @@ -46,6 +46,11 @@ export class AuthRepositoryService extends AuthRepository { id: accountId, }, }, + onboarding: { + create: { + id: accountId, + }, + }, }; const iAsGoogle = i as CreateWithGoogle; diff --git a/src/repositories/postgres/budget/budget-repository.service.ts b/src/repositories/postgres/budget/budget-repository.service.ts index 6d21709..e07213a 100644 --- a/src/repositories/postgres/budget/budget-repository.service.ts +++ b/src/repositories/postgres/budget/budget-repository.service.ts @@ -74,7 +74,7 @@ export class BudgetRepositoryService extends BudgetRepository { month, year, }: GetMonthlyByCategoryInput): Promise { - const budget = await this.budgetRepository.findUnique({ + const budget = await this.budgetRepository.findFirst({ select: { budgetDates: { select: { diff --git a/src/usecases/account/account.module.ts b/src/usecases/account/account.module.ts index 97c7373..d3ea22d 100644 --- a/src/usecases/account/account.module.ts +++ b/src/usecases/account/account.module.ts @@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'; import { AccountService } from './account.service'; import { AccountRepositoryModule } from 'repositories/postgres/account/account-repository.module'; import { AccountController } from 'delivery/account.controller'; +import { DayJsAdapterModule } from 'adapters/implementations/dayjs/dayjs.module'; @Module({ controllers: [AccountController], - imports: [AccountRepositoryModule], + imports: [AccountRepositoryModule, DayJsAdapterModule], providers: [AccountService], exports: [AccountService], }) diff --git a/src/usecases/account/account.service.ts b/src/usecases/account/account.service.ts index 15ca5d7..e9c516b 100644 --- a/src/usecases/account/account.service.ts +++ b/src/usecases/account/account.service.ts @@ -1,20 +1,34 @@ import { AccountRepositoryService } from 'repositories/postgres/account/account-repository.service'; -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { + Inject, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import type { + GetOnboardingInput, + GetOnboardingOutput, IamInput, IamOutput, SetBudgetInput, SetSalaryInput, UpdateNameInput, + UpdateOnboardingInput, + UpdateOnboardingRecordInput, } from 'models/account'; import { AccountUseCase } from 'models/account'; import { SignInProviderEnum } from '@prisma/client'; +import { DateAdapter } from 'adapters/date'; +import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; @Injectable() export class AccountService extends AccountUseCase { constructor( @Inject(AccountRepositoryService) private readonly accountRepository: AccountRepositoryService, + + @Inject(DayjsAdapterService) + private readonly dateAdapter: DateAdapter, ) { super(); } @@ -55,4 +69,48 @@ export class AccountService extends AccountUseCase { salaryId, }); } + + async getOnboarding({ + accountId, + }: GetOnboardingInput): Promise { + const onboarding = await this.accountRepository.getOnboarding({ + accountId, + }); + + if (!onboarding) { + throw new NotFoundException('User not found'); + } + + const entries = Object.entries(onboarding); + + return entries.reduce((acc, [key, value]) => { + if (value) { + acc[key] = true; + } + + return acc; + }, {} as GetOnboardingOutput); + } + + async updateOnboarding({ + accountId, + ...data + }: UpdateOnboardingInput): Promise { + const entries = Object.entries(data); + + const date = this.dateAdapter.newDate(); + + const toUpdate = entries.reduce((acc, [key, value]) => { + if (value) { + acc[key] = date; + } + + return acc; + }, {} as UpdateOnboardingRecordInput); + + await this.accountRepository.updateOnboarding({ + accountId, + ...toUpdate, + }); + } } diff --git a/tests/mocks/repositories/postgres/account.ts b/tests/mocks/repositories/postgres/account.ts index c8d3aca..3394bc7 100644 --- a/tests/mocks/repositories/postgres/account.ts +++ b/tests/mocks/repositories/postgres/account.ts @@ -15,6 +15,8 @@ export const makeAccountRepositoryMock = () => { getById: jest.fn(), getByIdWithProviders: jest.fn(), updateConfig: jest.fn(), + getOnboarding: jest.fn(), + updateOnboarding: jest.fn(), }; const module = { diff --git a/tests/src/usecases/account.spec.ts b/tests/src/usecases/account.spec.ts index 98c745c..b719cfd 100644 --- a/tests/src/usecases/account.spec.ts +++ b/tests/src/usecases/account.spec.ts @@ -4,6 +4,7 @@ import { AccountService } from 'usecases/account/account.service'; import { makeAccountRepositoryMock } from '../../mocks/repositories/postgres/account'; import { SignInProviderEnum } from '@prisma/client'; import { createTestModule, createTestService } from '../../utils'; +import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; describe('Usecases > Account', () => { let service: AccountService; @@ -14,7 +15,13 @@ describe('Usecases > Account', () => { beforeAll(async () => { try { service = await createTestService(AccountService, { - providers: [accountRepository.module], + providers: [ + accountRepository.module, + { + provide: DayjsAdapterService, + useValue: DayjsAdapterService, + }, + ], }); module = await createTestModule(AccountModule); From 084cb16c7da01966232efb64fb3a72326f213359 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Fri, 26 Jan 2024 14:24:32 -0300 Subject: [PATCH 14/19] Update README --- README.md | 52 +++++++++++----------------------------------------- 1 file changed, 11 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 25f1cdd..b5e3782 100644 --- a/README.md +++ b/README.md @@ -35,49 +35,19 @@ This project use lot's of tools to be as efficient as possible, here's the list - [API](https://wise-bulldog-88.redoc.ly/) - [Database](https://dbdocs.io/henriqueleite42/Econominhas?view=relationships) -## Third party Urls - -### Dev - -- [Google](https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount?response_type=code&client_id=489785083174-0rqt9bc7l9t09luor3fc16h21kdf57q7.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A8081&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile&access_type=offline&state=1234_purpleGoogle&prompt=consent&authuser=1&service=lso&o2v=2&theme=glif&flowName=GeneralOAuthFlow) - ## Useful commands -| Command | Description | -| --------------- | --------------------------------------------------------------------------------------------- | -| `start:dev` | Run the project with all it's dependencies locally | -| `openapi:serve` | Serve the API docs locally so you can validate your changes | -| `db:prisma` | Update the ORM types (You need to run this every time that you change `prisma/schema.prisma`) | - -## Manual Deploy - -1. Connect to the EC2 instance trough [the console](https://us-east-1.console.aws.amazon.com/ec2/home?region=us-east-1#InstanceDetails:instanceId=i-058e2ca9b6b405219) -2. [EC2] Stop the current execution: - -```sh -pm2 stop econominhas -pm2 delete econominhas -``` - -3. [EC2] Delete the old files: - -``` -rm -rf dist -``` - -4. [Locally] Send the files to the EC2: - -```sh -scp -i ~/Desktop/default.pem -r dist ubuntu@ec2-54-226-253-54.compute-1.amazonaws.com:/home/ubuntu -``` - -5. [EC2] Execute the API: - -```sh -cd dist -yarn -pm2 start main.js --name econominhas -``` +| Command | Description | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `start:dev` | Run the project with all it's dependencies locally | +| `openapi:serve` | Serve the API docs locally so you can validate your changes | +| `openapi:postman` | Generate Postman json file (at `openapi/postman.json`) | +| `lint:prisma` | Lint prisma schema | +| `db:prisma` | Update the ORM types (You need to run this every time that you change `prisma/schema.prisma`) | +| `test` | Run tests | +| `test:cov` | Run tests and collect coverage | +| `db:migrate` | Run the migrations | +| `db:gen-migration ` | Generates a new migration based on the schema-database difference (you must run `start:dev` and `db:migrate` before run this!) | ## Process to develop a new feature From 6c97b9c6f53d7b528d13d88b260cd4bbb848afe1 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Mon, 29 Jan 2024 21:53:16 -0300 Subject: [PATCH 15/19] Refact RecurrentTransactions --- package.json | 1 + .../migration.sql | 55 ++++ .../migration.sql | 78 +++++ prisma/schema.prisma | 160 ++++++---- src/adapters/date.ts | 33 +- .../implementations/dayjs/dayjs.service.ts | 32 +- src/app.module.ts | 2 + .../recurrent-transaction.controller.ts | 26 +- src/models/budget.ts | 8 + src/models/recurrent-transaction.ts | 52 ++-- src/models/transaction.ts | 9 + ...ecurrent-transaction-repository.service.ts | 79 +++-- .../transaction-repository.service.ts | 4 + src/usecases/budget/budget.service.ts | 15 + .../recurrent-transaction.module.ts | 19 +- .../recurrent-transaction.service.ts | 292 +++++++++++++++--- .../utils/formula/formula.module.ts | 10 + .../utils/formula/formula.service.ts | 114 +++++++ .../matching-dates/matching-dates.module.ts | 10 + .../matching-dates/matching-dates.service.ts | 292 ++++++++++++++++++ .../transaction/transaction.service.ts | 15 +- yarn.lock | 31 ++ 22 files changed, 1138 insertions(+), 199 deletions(-) create mode 100644 prisma/migrations/20240129021502_delete_recurrent_transactions/migration.sql create mode 100644 prisma/migrations/20240129023712_refact_recurrent_transaction/migration.sql create mode 100644 src/usecases/recurrent-transaction/utils/formula/formula.module.ts create mode 100644 src/usecases/recurrent-transaction/utils/formula/formula.service.ts create mode 100644 src/usecases/recurrent-transaction/utils/matching-dates/matching-dates.module.ts create mode 100644 src/usecases/recurrent-transaction/utils/matching-dates/matching-dates.service.ts diff --git a/package.json b/package.json index 538fd0c..255d792 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.2.10", "@nestjs/platform-express": "^10.2.10", + "@nestjs/schedule": "^4.0.0", "@prisma/client": "^5.7.0", "@techmmunity/utils": "^1.10.1", "axios": "^1.6.5", diff --git a/prisma/migrations/20240129021502_delete_recurrent_transactions/migration.sql b/prisma/migrations/20240129021502_delete_recurrent_transactions/migration.sql new file mode 100644 index 0000000..e456858 --- /dev/null +++ b/prisma/migrations/20240129021502_delete_recurrent_transactions/migration.sql @@ -0,0 +1,55 @@ +/* + Warnings: + + - You are about to drop the column `salary_id` on the `configs` table. All the data in the column will be lost. + - You are about to drop the `recurrent_transaction_rules` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `recurrent_transactions` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "configs" DROP CONSTRAINT "configs_salary_id_fkey"; + +-- DropForeignKey +ALTER TABLE "recurrent_transaction_rules" DROP CONSTRAINT "recurrent_transaction_rules_recurrent_transaction_id_fkey"; + +-- DropForeignKey +ALTER TABLE "recurrent_transactions" DROP CONSTRAINT "recurrent_transactions_account_id_fkey"; + +-- DropForeignKey +ALTER TABLE "recurrent_transactions" DROP CONSTRAINT "recurrent_transactions_bank_account_from_id_fkey"; + +-- DropForeignKey +ALTER TABLE "recurrent_transactions" DROP CONSTRAINT "recurrent_transactions_bank_account_id_fkey"; + +-- DropForeignKey +ALTER TABLE "recurrent_transactions" DROP CONSTRAINT "recurrent_transactions_bank_account_to_id_fkey"; + +-- DropForeignKey +ALTER TABLE "recurrent_transactions" DROP CONSTRAINT "recurrent_transactions_budget_id_fkey"; + +-- DropForeignKey +ALTER TABLE "recurrent_transactions" DROP CONSTRAINT "recurrent_transactions_card_id_fkey"; + +-- DropForeignKey +ALTER TABLE "recurrent_transactions" DROP CONSTRAINT "recurrent_transactions_category_id_fkey"; + +-- DropIndex +DROP INDEX "configs_salary_id_key"; + +-- AlterTable +ALTER TABLE "configs" DROP COLUMN "salary_id"; + +-- DropTable +DROP TABLE "recurrent_transaction_rules"; + +-- DropTable +DROP TABLE "recurrent_transactions"; + +-- DropEnum +DROP TYPE "ca_formula_enum"; + +-- DropEnum +DROP TYPE "recurrence_conditions_enum"; + +-- DropEnum +DROP TYPE "recurrence_frequency_enum"; diff --git a/prisma/migrations/20240129023712_refact_recurrent_transaction/migration.sql b/prisma/migrations/20240129023712_refact_recurrent_transaction/migration.sql new file mode 100644 index 0000000..fb6da99 --- /dev/null +++ b/prisma/migrations/20240129023712_refact_recurrent_transaction/migration.sql @@ -0,0 +1,78 @@ +/* + Warnings: + + - A unique constraint covering the columns `[salary_id]` on the table `configs` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "recurrence_frequency_enum" AS ENUM ('DAILY', 'MONTHLY', 'YEARLY'); + +-- CreateEnum +CREATE TYPE "recurrence_formula_enum" AS ENUM ('RAW_AMOUNT', 'PM_1WG', 'PM_2WG'); + +-- CreateEnum +CREATE TYPE "recurrence_create_enum" AS ENUM ('DAY_1', 'DAY_2', 'DAY_3', 'DAY_4', 'DAY_5', 'DAY_6', 'DAY_7', 'DAY_8', 'DAY_9', 'DAY_10', 'DAY_11', 'DAY_12', 'DAY_13', 'DAY_14', 'DAY_15', 'DAY_16', 'DAY_17', 'DAY_18', 'DAY_19', 'DAY_20', 'DAY_21', 'DAY_22', 'DAY_23', 'DAY_24', 'DAY_25', 'DAY_26', 'DAY_27', 'DAY_28', 'DAY_29_OR_LAST_DAY_OF_MONTH', 'DAY_30_OR_LAST_DAY_OF_MONTH', 'DAY_31_OR_LAST_DAY_OF_MONTH', 'FIFTH_BUSINESS_DAY', 'FIRST_DAY_OF_MONTH', 'LAST_DAY_OF_MONTH'); + +-- CreateEnum +CREATE TYPE "recurrence_exclude_enum" AS ENUM ('IN_BUSINESS_DAY', 'IN_WEEKEND', 'IN_HOLIDAY', 'NOT_IN_HOLIDAY'); + +-- CreateEnum +CREATE TYPE "recurrence_try_again_enum" AS ENUM ('IF_NOT_BEFORE', 'IF_NOT_AFTER'); + +-- AlterTable +ALTER TABLE "configs" ADD COLUMN "salary_id" CHAR(16); + +-- CreateTable +CREATE TABLE "recurrent_transactions" ( + "id" CHAR(16) NOT NULL, + "account_id" CHAR(16) NOT NULL, + "budget_id" CHAR(16) NOT NULL, + "is_system_managed" BOOLEAN NOT NULL, + "frequency" "recurrence_frequency_enum" NOT NULL, + "formula_to_use" "recurrence_formula_enum" NOT NULL, + "start_at" TIMESTAMP(3), + "end_at" TIMESTAMP(3), + "base_amounts" INTEGER[], + "c_creates" "recurrence_create_enum"[], + "c_excludes" "recurrence_exclude_enum"[], + "c_try_agains" "recurrence_try_again_enum"[], + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "type" "transaction_type_enum" NOT NULL, + "name" VARCHAR(30) NOT NULL, + "description" VARCHAR(300) NOT NULL, + "is_system_managed_t" BOOLEAN NOT NULL, + "category_id" CHAR(16), + "card_id" CHAR(16), + "bank_account_id" CHAR(16), + "bank_account_from_id" CHAR(16), + "bank_account_to_id" CHAR(16), + + CONSTRAINT "recurrent_transactions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "configs_salary_id_key" ON "configs"("salary_id"); + +-- AddForeignKey +ALTER TABLE "configs" ADD CONSTRAINT "configs_salary_id_fkey" FOREIGN KEY ("salary_id") REFERENCES "recurrent_transactions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recurrent_transactions" ADD CONSTRAINT "recurrent_transactions_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recurrent_transactions" ADD CONSTRAINT "recurrent_transactions_budget_id_fkey" FOREIGN KEY ("budget_id") REFERENCES "budgets"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recurrent_transactions" ADD CONSTRAINT "recurrent_transactions_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recurrent_transactions" ADD CONSTRAINT "recurrent_transactions_card_id_fkey" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recurrent_transactions" ADD CONSTRAINT "recurrent_transactions_bank_account_id_fkey" FOREIGN KEY ("bank_account_id") REFERENCES "bank_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recurrent_transactions" ADD CONSTRAINT "recurrent_transactions_bank_account_from_id_fkey" FOREIGN KEY ("bank_account_from_id") REFERENCES "bank_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recurrent_transactions" ADD CONSTRAINT "recurrent_transactions_bank_account_to_id_fkey" FOREIGN KEY ("bank_account_to_id") REFERENCES "bank_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8443be5..9b93005 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -556,91 +556,121 @@ model Installment { // // -enum CaFormulaEnum { - EXACT_AMOUNT - MBWOPM /// MULTIPLY_BY_WEEKS_OF_PREV_MONTH, the amount is multiplied by the amount of weeks of the previous month - MBDOPM /// MULTIPLY_BY_DAYS_OF_PREV_MONTH, the amount is multiplied by the amount of days of the previous month - DPFET // DIFFERENT_PERCENTAGES_FOR_EACH_TRANSACTION, the "params" prop is an array of percentages matching each of the transactions that should be created. Using these parameters, the amount of the transaction will be multiplied by the percentage to get the final amount - CCB // CREDIT_CARD_BILL, sum the value of the transactions to get the amount - - @@map("ca_formula_enum") -} - enum RecurrenceFrequencyEnum { DAILY /// Every day - WEEKLY /// Every week MONTHLY /// Once a month - SEMI_MONTHLY /// One month yes, another month no - QUARTERLY /// Once every 3 months - ANNUALLY /// Once a year @map("ANNUALLY") - SEMI_ANNUALLY /// Once every 6 months + YEARLY /// Once a year @@map("recurrence_frequency_enum") } -enum RecurrenceConditionsEnum { - IN_WEEKDAY /// Mon-Fri - IN_WEEKEND /// Sat-Sun - IS_EVEN_DAY /// Like 2, 4, 6 - IS_ODD_DAY /// Like 1, 3, 5 - NOT_HOLIDAY - IF_NOT_BEFORE /// If the day doesn't match the conditions, try previuoius days - IF_NOT_AFTER /// If the day doesn't match the conditions, try following days +enum RecurrenceFormulaEnum { + RAW_AMOUNT /// amount * 1 + PM_1WG /// PreviousMonth_1WeekGap: amount * amountOfDaysThatMatchConditions + PM_2WG /// PreviousMonth_2WeekGap: amount * amountOfDaysThatMatchConditions + + @@map("recurrence_formula_enum") +} + +enum RecurrenceCreateEnum { + DAY_1 + DAY_2 + DAY_3 + DAY_4 + DAY_5 + DAY_6 + DAY_7 + DAY_8 + DAY_9 + DAY_10 + DAY_11 + DAY_12 + DAY_13 + DAY_14 + DAY_15 + DAY_16 + DAY_17 + DAY_18 + DAY_19 + DAY_20 + DAY_21 + DAY_22 + DAY_23 + DAY_24 + DAY_25 + DAY_26 + DAY_27 + DAY_28 + DAY_29_OR_LAST_DAY_OF_MONTH + DAY_30_OR_LAST_DAY_OF_MONTH + DAY_31_OR_LAST_DAY_OF_MONTH + + FIFTH_BUSINESS_DAY + + FIRST_DAY_OF_MONTH + LAST_DAY_OF_MONTH + + @@map("recurrence_create_enum") +} + +enum RecurrenceExcludeEnum { + IN_BUSINESS_DAY + IN_WEEKEND - @@map("recurrence_conditions_enum") + IN_HOLIDAY + NOT_IN_HOLIDAY + + @@map("recurrence_exclude_enum") +} + +enum RecurrenceTryAgainEnum { + IF_NOT_BEFORE + IF_NOT_AFTER + + @@map("recurrence_try_again_enum") } /// Contains all the user's recurrent transactions. /// The recurrent transactions are linked to the budget, this way the user can have a better control of which transactions he wants to execute. model RecurrentTransaction { - id String @id @db.Char(16) - accountId String @map("account_id") @db.Char(16) - budgetId String @map("budget_id") @db.Char(16) - isSystemManaged Boolean @map("is_system_managed") /// Define if the recurrent transaction is automatic controlled by the system, or if it\'s created and controled by the user + id String @id @db.Char(16) + accountId String @map("account_id") @db.Char(16) + budgetId String @map("budget_id") @db.Char(16) + isSystemManaged Boolean @map("is_system_managed") /// Define if the recurrent transaction is automatic controlled by the system, or if it\'s created and controled by the user + frequency RecurrenceFrequencyEnum + formulaToUse RecurrenceFormulaEnum @map("formula_to_use") + startAt DateTime? @map("start_at") + endAt DateTime? @map("end_at") + baseAmounts Int[] @map("base_amounts") + cCreates RecurrenceCreateEnum[] @map("c_creates") + cExcludes RecurrenceExcludeEnum[] @map("c_excludes") + cTryAgains RecurrenceTryAgainEnum[] @map("c_try_agains") + createdAt DateTime @default(now()) @map("created_at") // Data to create the transaction type TransactionTypeEnum - name String @db.VarChar(30) - description String @db.VarChar(300) - amount Int /// Can only be POSITIVE, the real amount is determined by the type OUT/CREDIT, then amount * -1 - createdAt DateTime @default(now()) @map("created_at") - isSystemManagedT Boolean @map("is_system_managed_t") /// Same as "isSystemManaged". It exists because some RecurrentTransactions may be system managed, but the Trasactions created by this RecurrentTransaction aren't. An example of it it's the salary, we have a specific interface for the user to managed it, and it shouldn't be managed directly by the user like other RecurrentTransactions, but the transactions created by it are normal Transactions, that the user can edit freely. + name String @db.VarChar(30) + description String @db.VarChar(300) + isSystemManagedT Boolean @map("is_system_managed_t") /// Same as "isSystemManaged". It exists because some RecurrentTransactions may be system managed, but the Trasactions created by this RecurrentTransaction aren't. An example of it it's the salary, we have a specific interface for the user to managed it, and it shouldn't be managed directly by the user like other RecurrentTransactions, but the transactions created by it are normal Transactions, that the user can edit freely. // Transaction type=IN,OUT,CREDIT - categoryId String? @map("category_id") @db.Char(16) /// Only type=IN,OUT,CREDIT transactions have this column - cardId String? @map("card_id") @db.Char(16) /// Only type=IN,OUT,CREDIT transactions have this column - bankAccountId String? @map("bank_account_id") @db.Char(16) /// Only type=IN,OUT,CREDIT transactions have this column + categoryId String? @map("category_id") @db.Char(16) /// Only type=IN,OUT,CREDIT transactions have this column + // Transaction type=CREDIT + cardId String? @map("card_id") @db.Char(16) /// Only type=IN,OUT,CREDIT transactions have this column + // Transaction type=IN,OUT + bankAccountId String? @map("bank_account_id") @db.Char(16) /// Only type=IN,OUT,CREDIT transactions have this column // Transaction type=TRANSFER - bankAccountFromId String? @map("bank_account_from_id") @db.Char(16) /// Only type=TRANSFER transactions have this column - bankAccountToId String? @map("bank_account_to_id") @db.Char(16) /// Only type=TRANSFER transactions have this column + bankAccountFromId String? @map("bank_account_from_id") @db.Char(16) /// Only type=TRANSFER transactions have this column + bankAccountToId String? @map("bank_account_to_id") @db.Char(16) /// Only type=TRANSFER transactions have this column - account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) - budget Budget @relation(fields: [budgetId], references: [id], onDelete: Restrict) - recurrentTransactionRules RecurrentTransactionRule[] - config Config? + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + budget Budget @relation(fields: [budgetId], references: [id], onDelete: Restrict) + config Config? // Transaction type=IN,OUT,CREDIT - category Category? @relation(fields: [categoryId], references: [id], onDelete: Restrict) - card Card? @relation(fields: [cardId], references: [id], onDelete: Restrict) - bankAccount BankAccount? @relation(name: "RecurrentTransactionBankAccount", fields: [bankAccountId], references: [id], onDelete: Restrict) + category Category? @relation(fields: [categoryId], references: [id], onDelete: Restrict) + card Card? @relation(fields: [cardId], references: [id], onDelete: Restrict) + bankAccount BankAccount? @relation(name: "RecurrentTransactionBankAccount", fields: [bankAccountId], references: [id], onDelete: Restrict) // Transaction type=TRANSFER - bankAccountFrom BankAccount? @relation(name: "RecurrentTransactionBankAccountFrom", fields: [bankAccountFromId], references: [id], onDelete: Restrict) - bankAccountTo BankAccount? @relation(name: "RecurrentTransactionBankAccountTo", fields: [bankAccountToId], references: [id], onDelete: Restrict) + bankAccountFrom BankAccount? @relation(name: "RecurrentTransactionBankAccountFrom", fields: [bankAccountFromId], references: [id], onDelete: Restrict) + bankAccountTo BankAccount? @relation(name: "RecurrentTransactionBankAccountTo", fields: [bankAccountToId], references: [id], onDelete: Restrict) @@map("recurrent_transactions") } - -/// Contains the recurrent transactions rules to be executed -model RecurrentTransactionRule { - id String @id @db.Char(16) - recurrentTransactionId String @map("recurrent_transaction_id") @db.Char(16) - - caFormula CaFormulaEnum @map("ca_formula") - caParams String @map("ca_params") @db.VarChar /// JSON stringified prop to pass the params to calculate the amount - caConditions RecurrenceConditionsEnum[] @map("ca_conditions") - - frequency RecurrenceFrequencyEnum - fParams String @map("f_params") @db.VarChar /// JSON stringified prop to pass the params to calculate the frequency - fConditions RecurrenceConditionsEnum[] @map("f_conditions") - - recurrentTransaction RecurrentTransaction @relation(fields: [recurrentTransactionId], references: [id]) - - @@map("recurrent_transaction_rules") -} diff --git a/src/adapters/date.ts b/src/adapters/date.ts index 346fe78..6f38e1d 100644 --- a/src/adapters/date.ts +++ b/src/adapters/date.ts @@ -12,6 +12,15 @@ export type DateUnit = 'second' | 'day' | 'month' | 'year'; export type YearMonth = `${number}-${number}`; export type YearMonthDay = `${number}-${number}-${number}`; +export type WeekDays = + | 'sunday' + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday'; + export abstract class DateAdapter { /** * @@ -25,8 +34,18 @@ export abstract class DateAdapter { abstract get(date: Date | string, unit: DateUnit): number; + abstract diff( + startDate: Date | string, + endDate: Date | string, + unit: DateUnit, + ): number; + + abstract getDayOfWeek(date: string | Date): WeekDays; + abstract getNextMonths(startDate: Date | string, amount: number): Array; + abstract format(date: Date | string): string; + abstract statementDate( dueDay: number, statementDays: number, @@ -56,9 +75,19 @@ export abstract class DateAdapter { abstract nowPlus(amount: number, unit: DateUnit): Date; - abstract add(date: Date | string, amount: number, unit: DateUnit): Date; + abstract setDay(date: Date | string, amount: number): Date; - abstract sub(date: Date | string, amount: number, unit: DateUnit): Date; + abstract add( + date: Date | string, + amount: number, + unit: DateUnit | 'week', + ): Date; + + abstract sub( + date: Date | string, + amount: number, + unit: DateUnit | 'week', + ): Date; abstract startOf( date: Date | string, diff --git a/src/adapters/implementations/dayjs/dayjs.service.ts b/src/adapters/implementations/dayjs/dayjs.service.ts index 7420ab4..3cf4977 100644 --- a/src/adapters/implementations/dayjs/dayjs.service.ts +++ b/src/adapters/implementations/dayjs/dayjs.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import type { DateUnit, TodayOutput } from '../../date'; +import type { DateUnit, WeekDays, TodayOutput } from '../../date'; import { DateAdapter } from '../../date'; import dayjs from 'dayjs'; @@ -16,6 +16,16 @@ dayjs.tz.setDefault('UTC'); @Injectable() export class DayjsAdapterService extends DateAdapter { + private weekDays: Array = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + ]; + /** * * Info @@ -49,6 +59,18 @@ export class DayjsAdapterService extends DateAdapter { return dayjs.tz(date).get(unit); } + diff( + startDate: string | Date, + endDate: string | Date, + unit: DateUnit, + ): number { + return dayjs(endDate).diff(startDate, unit, true); + } + + getDayOfWeek(date: string | Date): WeekDays { + return this.weekDays[dayjs(date).get('day')]; + } + getNextMonths(startDate: string | Date, amount: number): Date[] { const months: Array = []; @@ -62,6 +84,10 @@ export class DayjsAdapterService extends DateAdapter { return months; } + format(date: string | Date): string { + return dayjs.tz(date).format('YYYY-MM-DD'); + } + statementDate( dueDay: number, statementDays: number, @@ -125,6 +151,10 @@ export class DayjsAdapterService extends DateAdapter { return dayjs.tz().add(amount, unit).toDate(); } + setDay(date: string | Date, amount: number): Date { + return dayjs(date).set('date', amount).toDate(); + } + add(date: string | Date, amount: number, unit: DateUnit): Date { if (unit === 'month') { const dateDate = this.newDate(date); diff --git a/src/app.module.ts b/src/app.module.ts index 4b05b37..e63a709 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,7 @@ import { WalletModule } from './usecases/wallet/wallet.module'; import { TransactionModule } from './usecases/transaction/transaction.module'; import { ConfigModule } from '@nestjs/config'; import { validateConfig } from './config'; +import { ScheduleModule } from '@nestjs/schedule'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { validateConfig } from './config'; validate: validateConfig, isGlobal: true, }), + ScheduleModule.forRoot(), PostgresModule.forRoot(), AuthModule, AccountModule, diff --git a/src/delivery/recurrent-transaction.controller.ts b/src/delivery/recurrent-transaction.controller.ts index 07d9560..ad1481f 100644 --- a/src/delivery/recurrent-transaction.controller.ts +++ b/src/delivery/recurrent-transaction.controller.ts @@ -1,16 +1,6 @@ -import { - Controller, - Inject, - HttpCode, - HttpStatus, - Post, - Body, -} from '@nestjs/common'; +import { Controller, Inject } from '@nestjs/common'; import { RecurrentTransactionUseCase } from 'models/recurrent-transaction'; import { RecurrentTransactionService } from 'usecases/recurrent-transaction/recurrent-transaction.service'; -import { UserData } from './decorators/user-data'; -import { UserDataDto } from './dtos'; -import { CreateSalaryDto } from './dtos/recurrent-transaction'; @Controller('transactions') export class RecurrentTransactionController { @@ -18,18 +8,4 @@ export class RecurrentTransactionController { @Inject(RecurrentTransactionService) private readonly recurrentTransactionService: RecurrentTransactionUseCase, ) {} - - @HttpCode(HttpStatus.CREATED) - @Post('/salary') - async createSalary( - @UserData() - userData: UserDataDto, - @Body() - body: CreateSalaryDto, - ) { - return this.recurrentTransactionService.createSalary({ - ...body, - accountId: userData.accountId, - }); - } } diff --git a/src/models/budget.ts b/src/models/budget.ts index 190051c..9468948 100644 --- a/src/models/budget.ts +++ b/src/models/budget.ts @@ -115,6 +115,12 @@ export interface OverviewOutput { >; } +export interface GetOrCreateManyInput { + accountId: string; + budgetId: string; + dates: Array; +} + export interface CreateNextBudgetDatesInput { startFrom: BudgetDate; amount: number; @@ -127,6 +133,8 @@ export abstract class BudgetUseCase { abstract overview(i: OverviewInput): Promise; + abstract getOrCreateMany(i: GetOrCreateManyInput): Promise>; + abstract createNextBudgetDates( i: CreateNextBudgetDatesInput, ): Promise>; diff --git a/src/models/recurrent-transaction.ts b/src/models/recurrent-transaction.ts index 7e2388a..41bf3be 100644 --- a/src/models/recurrent-transaction.ts +++ b/src/models/recurrent-transaction.ts @@ -1,10 +1,13 @@ import type { - CaFormulaEnum, - RecurrenceConditionsEnum, + RecurrenceCreateEnum, + RecurrenceExcludeEnum, + RecurrenceFormulaEnum, RecurrenceFrequencyEnum, + RecurrenceTryAgainEnum, RecurrentTransaction, TransactionTypeEnum, } from '@prisma/client'; +import type { PaginatedRepository } from 'types/paginated-items'; /** * @@ -18,33 +21,40 @@ export interface CreateInput { accountId: string; budgetId: string; isSystemManaged: boolean; + frequency: RecurrenceFrequencyEnum; + formulaToUse: RecurrenceFormulaEnum; + startAt: Date; + endAt: Date; + baseAmounts: Array; + cCreates: Array; + cExcludes: Array; + cTryAgains: Array; // Data to create the transaction type: TransactionTypeEnum; name: string; description: string; - amount: number; isSystemManagedT: boolean; // Transaction type=IN,OUT,CREDIT categoryId?: string; + // Transaction type=CREDIT cardId?: string; + // Transaction type=IN,OUT bankAccountId?: string; // Transaction type=TRANSFER bankAccountFromId?: string; bankAccountToId?: string; - - rules: Array<{ - caFormula: CaFormulaEnum; - caParams: Record; - caConditions: RecurrenceConditionsEnum[]; - - frequency: RecurrenceFrequencyEnum; - fParams: Record; - fConditions: RecurrenceConditionsEnum[]; - }>; } export abstract class RecurrentTransactionRepository { abstract create(i: CreateInput): Promise; + + abstract findMonthly( + i: PaginatedRepository, + ): Promise>; + + abstract findYearly( + i: PaginatedRepository, + ): Promise>; } /** @@ -55,18 +65,8 @@ export abstract class RecurrentTransactionRepository { * */ -export interface CreateSalaryInput { - accountId: string; - bankAccountId: string; - budgetId: string; - categoryId: string; - amount: number; - installments: Array<{ - dayOfTheMonth: number; - percentage: number; - }>; -} - export abstract class RecurrentTransactionUseCase { - abstract createSalary(i: CreateSalaryInput): Promise; + abstract execMonthly(): Promise; + + abstract execYearly(): Promise; } diff --git a/src/models/transaction.ts b/src/models/transaction.ts index ccb7fe5..2727f33 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -57,6 +57,7 @@ export interface CreateTransferInput { description: string; createdAt: Date; isSystemManaged: boolean; + recurrentTransactionId?: string; } export interface CreateInOutInput { @@ -70,6 +71,7 @@ export interface CreateInOutInput { description: string; createdAt: Date; isSystemManaged: boolean; + recurrentTransactionId?: string; } export interface CreateCreditInput { @@ -82,6 +84,7 @@ export interface CreateCreditInput { description: string; createdAt: Date; isSystemManaged: boolean; + recurrentTransactionId?: string; installment: { installmentGroupId: string; total: number; @@ -134,6 +137,8 @@ export interface TransferInput { bankAccountToId: string; budgetDateId: string; createdAt: Date; + isSystemManaged?: boolean; + recurrentTransactionId?: string; } export interface InOutInput { @@ -146,6 +151,8 @@ export interface InOutInput { bankAccountId: string; budgetDateId: string; createdAt: Date; + isSystemManaged?: boolean; + recurrentTransactionId?: string; } export interface CreditInput { @@ -158,6 +165,8 @@ export interface CreditInput { cardId: string; budgetDateId: string; createdAt: Date; + isSystemManaged?: boolean; + recurrentTransactionId?: string; } export abstract class TransactionUseCase { diff --git a/src/repositories/postgres/recurrent-transaction/recurrent-transaction-repository.service.ts b/src/repositories/postgres/recurrent-transaction/recurrent-transaction-repository.service.ts index 58ae62a..d5f075a 100644 --- a/src/repositories/postgres/recurrent-transaction/recurrent-transaction-repository.service.ts +++ b/src/repositories/postgres/recurrent-transaction/recurrent-transaction-repository.service.ts @@ -1,10 +1,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository, Repository } from '..'; -import type { RecurrentTransaction } from '@prisma/client'; +import { + RecurrenceFrequencyEnum, + type RecurrentTransaction, +} from '@prisma/client'; import type { CreateInput } from 'models/recurrent-transaction'; import { RecurrentTransactionRepository } from 'models/recurrent-transaction'; import { IdAdapter } from 'adapters/id'; import { UIDAdapterService } from 'adapters/implementations/uid/uid.service'; +import type { PaginatedRepository } from 'types/paginated-items'; @Injectable() export class RecurrentTransactionRepositoryService extends RecurrentTransactionRepository { @@ -22,21 +26,28 @@ export class RecurrentTransactionRepositoryService extends RecurrentTransactionR accountId, budgetId, isSystemManaged, + frequency, + formulaToUse, + startAt, + endAt, + baseAmounts, + cCreates, + cExcludes, + cTryAgains, // Data to create the transaction type, name, description, - amount, isSystemManagedT, // Transaction type=IN,OUT,CREDIT categoryId, + // Transaction type=CREDIT cardId, + // Transaction type=IN,OUT bankAccountId, // Transaction type=TRANSFER bankAccountFromId, bankAccountToId, - - rules, }: CreateInput): Promise { const recurrentTransactionId = this.idAdapter.genId(); @@ -46,47 +57,55 @@ export class RecurrentTransactionRepositoryService extends RecurrentTransactionR accountId, budgetId, isSystemManaged, + frequency, + formulaToUse, + startAt, + endAt, + baseAmounts, + cCreates, + cExcludes, + cTryAgains, // Data to create the transaction type, name, description, - amount, isSystemManagedT, // Transaction type=IN,OUT,CREDIT categoryId, + // Transaction type=CREDIT cardId, + // Transaction type=IN,OUT bankAccountId, // Transaction type=TRANSFER bankAccountFromId, bankAccountToId, + }, + }); + } - recurrentTransactionRules: { - createMany: { - data: rules.map( - ({ - caFormula, - caParams, - caConditions, - - frequency, - fParams, - fConditions, - }) => ({ - id: this.idAdapter.genId(), - recurrentTransactionId, - - caFormula, - caParams: JSON.stringify(caParams), - caConditions, + findMonthly({ + limit, + offset, + }: PaginatedRepository): Promise { + return this.recurrentTransactionRepository.findMany({ + where: { + frequency: RecurrenceFrequencyEnum.MONTHLY, + }, + skip: offset, + take: limit, + }); + } - frequency, - fParams: JSON.stringify(fParams), - fConditions, - }), - ), - }, - }, + findYearly({ + limit, + offset, + }: PaginatedRepository): Promise { + return this.recurrentTransactionRepository.findMany({ + where: { + frequency: RecurrenceFrequencyEnum.YEARLY, }, + skip: offset, + take: limit, }); } } diff --git a/src/repositories/postgres/transaction/transaction-repository.service.ts b/src/repositories/postgres/transaction/transaction-repository.service.ts index 903e7d6..5a65086 100644 --- a/src/repositories/postgres/transaction/transaction-repository.service.ts +++ b/src/repositories/postgres/transaction/transaction-repository.service.ts @@ -135,6 +135,7 @@ export class TransactionRepositoryService extends TransactionRepository { description, createdAt, isSystemManaged, + recurrentTransactionId, }: CreateTransferInput): Promise { await this.transactionRepository.create({ data: { @@ -144,6 +145,7 @@ export class TransactionRepositoryService extends TransactionRepository { description, createdAt, isSystemManaged, + recurrentTransactionId, type: TransactionTypeEnum.TRANSFER, account: { connect: { @@ -180,6 +182,7 @@ export class TransactionRepositoryService extends TransactionRepository { description, createdAt, isSystemManaged, + recurrentTransactionId, }: CreateInOutInput): Promise { await this.transactionRepository.create({ data: { @@ -189,6 +192,7 @@ export class TransactionRepositoryService extends TransactionRepository { description, createdAt, isSystemManaged, + recurrentTransactionId, type, account: { connect: { diff --git a/src/usecases/budget/budget.service.ts b/src/usecases/budget/budget.service.ts index 4e361b2..709fa8d 100644 --- a/src/usecases/budget/budget.service.ts +++ b/src/usecases/budget/budget.service.ts @@ -3,6 +3,7 @@ import type { CreateBasicInput, CreateInput, CreateNextBudgetDatesInput, + GetOrCreateManyInput, OverviewInput, OverviewOutput, } from 'models/budget'; @@ -63,6 +64,20 @@ export class BudgetService extends BudgetUseCase { return budget; } + async getOrCreateMany({ + budgetId, + dates, + }: GetOrCreateManyInput): Promise { + return this.budgetRepository.upsertManyBudgetDates( + dates.map((date) => ({ + budgetId, + month: this.dateAdapter.get(date, 'month'), + year: this.dateAdapter.get(date, 'year'), + date: this.dateAdapter.startOf(date, 'month'), + })), + ); + } + async createBasic({ accountId, name, diff --git a/src/usecases/recurrent-transaction/recurrent-transaction.module.ts b/src/usecases/recurrent-transaction/recurrent-transaction.module.ts index 96c30b7..4839c66 100644 --- a/src/usecases/recurrent-transaction/recurrent-transaction.module.ts +++ b/src/usecases/recurrent-transaction/recurrent-transaction.module.ts @@ -2,10 +2,27 @@ import { Module } from '@nestjs/common'; import { RecurrentTransactionService } from './recurrent-transaction.service'; import { RecurrentTransactionRepositoryModule } from 'repositories/postgres/recurrent-transaction/recurrent-transaction-repository.module'; import { RecurrentTransactionController } from 'delivery/recurrent-transaction.controller'; +import { DayJsAdapterModule } from 'adapters/implementations/dayjs/dayjs.module'; +import { FormulasModule } from './utils/formula/formula.module'; +import { MatchingDatesModule } from './utils/matching-dates/matching-dates.module'; +import { BudgetModule } from 'usecases/budget/budget.module'; +import { UtilsAdapterModule } from 'adapters/implementations/utils/utils.module'; +import { TransactionModule } from 'usecases/transaction/transaction.module'; @Module({ controllers: [RecurrentTransactionController], - imports: [RecurrentTransactionRepositoryModule], + imports: [ + RecurrentTransactionRepositoryModule, + + TransactionModule, + BudgetModule, + + MatchingDatesModule, + FormulasModule, + + DayJsAdapterModule, + UtilsAdapterModule, + ], providers: [RecurrentTransactionService], exports: [RecurrentTransactionService], }) diff --git a/src/usecases/recurrent-transaction/recurrent-transaction.service.ts b/src/usecases/recurrent-transaction/recurrent-transaction.service.ts index 129b26a..bccd286 100644 --- a/src/usecases/recurrent-transaction/recurrent-transaction.service.ts +++ b/src/usecases/recurrent-transaction/recurrent-transaction.service.ts @@ -1,70 +1,270 @@ import { Inject, Injectable } from '@nestjs/common'; -import { - CaFormulaEnum, - RecurrenceConditionsEnum, - RecurrenceFrequencyEnum, - TransactionTypeEnum, -} from '@prisma/client'; +import { TransactionTypeEnum, type RecurrentTransaction } from '@prisma/client'; +import { DateAdapter } from 'adapters/date'; +import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; -import type { CreateSalaryInput } from 'models/recurrent-transaction'; import { RecurrentTransactionRepository, RecurrentTransactionUseCase, } from 'models/recurrent-transaction'; import { RecurrentTransactionRepositoryService } from 'repositories/postgres/recurrent-transaction/recurrent-transaction-repository.service'; +import { MatchingDates } from './utils/matching-dates/matching-dates.service'; +import { Formulas } from './utils/formula/formula.service'; +import { TransactionUseCase } from 'models/transaction'; +import { BudgetUseCase } from 'models/budget'; +import { BudgetService } from 'usecases/budget/budget.service'; +import { Cron } from '@nestjs/schedule'; +import { UtilsAdapter } from 'adapters/utils'; +import { UtilsAdapterService } from 'adapters/implementations/utils/utils.service'; +import { TransactionService } from 'usecases/transaction/transaction.service'; + +interface ReplaceVarsInput { + text: string; + date: Date; +} @Injectable() export class RecurrentTransactionService extends RecurrentTransactionUseCase { constructor( @Inject(RecurrentTransactionRepositoryService) private readonly recurrentTransactionRepository: RecurrentTransactionRepository, + + @Inject(BudgetService) + private readonly budgetService: BudgetUseCase, + @Inject(TransactionService) + private readonly transactionService: TransactionUseCase, + + @Inject(MatchingDates) + private readonly matchingDates: MatchingDates, + @Inject(Formulas) + private readonly formulas: Formulas, + + @Inject(DayjsAdapterService) + private readonly dateAdapter: DateAdapter, + @Inject(UtilsAdapterService) + private readonly utilsAdapter: UtilsAdapter, ) { super(); } - async createSalary({ + // Every first day of the month + @Cron('0 0 1 * *') + /** + * @private + */ + async execMonthly(): Promise { + let recurrentTransactions: Array = []; + const limit = 1000; + + let page = 1; + do { + const { offset } = this.utilsAdapter.pagination({ + page, + limit, + }); + + recurrentTransactions = + await this.recurrentTransactionRepository.findMonthly({ + offset, + limit, + }); + + if (recurrentTransactions.length === 0) { + break; + } + + await Promise.all(recurrentTransactions.map((rt) => this.create(rt))); + + page++; + } while (recurrentTransactions.length === limit); + } + + // Every year, on the first day of January + @Cron('0 0 1 1 *') + /** + * @private + */ + async execYearly(): Promise { + let recurrentTransactions: Array = []; + const limit = 1000; + + let page = 1; + do { + const { offset } = this.utilsAdapter.pagination({ + page, + limit, + }); + + recurrentTransactions = + await this.recurrentTransactionRepository.findYearly({ + offset, + limit, + }); + + if (recurrentTransactions.length === 0) { + break; + } + + await Promise.all(recurrentTransactions.map((rt) => this.create(rt))); + + page++; + } while (recurrentTransactions.length === limit); + } + + /** + * @private + */ + async create({ + id, accountId, - bankAccountId, budgetId, + /** + * Define if the recurrent transaction is automatic controlled by the system, or if it\'s created and controled by the user + */ + formulaToUse, + startAt, + baseAmounts, + cCreates, + cExcludes, + cTryAgains, + type, + name, + description, + isSystemManagedT, categoryId, - amount, - installments, - }: CreateSalaryInput): Promise { - await this.recurrentTransactionRepository.create({ - accountId, + cardId, + bankAccountId, + bankAccountFromId, + bankAccountToId, + }: RecurrentTransaction) { + const dates = this.matchingDates.getDates({ + cCreates, + cExcludes, + cTryAgains, + }); + + if (dates.length === 0) return; + + /** + * As the transactions always are in the same month, + * we only need to get/create 1 budgetDate + */ + const budgetDates = await this.budgetService.getOrCreateMany({ + dates: dates, budgetId, - isSystemManaged: true, - type: TransactionTypeEnum.IN, - name: 'Salário', - description: 'Valor recebido mensalmente pelo trabalho feito.', - amount, - isSystemManagedT: false, - categoryId, - bankAccountId, - - rules: [ - { - caFormula: CaFormulaEnum.DPFET, - caParams: { - percentages: installments.map((r) => r.percentage), - }, - caConditions: [ - RecurrenceConditionsEnum.IN_WEEKDAY, - RecurrenceConditionsEnum.NOT_HOLIDAY, - RecurrenceConditionsEnum.IF_NOT_BEFORE, - ], - - frequency: RecurrenceFrequencyEnum.MONTHLY, - fParams: { - daysOfTheMonth: installments.map((r) => String(r.dayOfTheMonth)), - }, - fConditions: [ - RecurrenceConditionsEnum.IN_WEEKDAY, - RecurrenceConditionsEnum.NOT_HOLIDAY, - RecurrenceConditionsEnum.IF_NOT_BEFORE, - ], - }, - ], + accountId, + }); + + const transactions: Array> = dates.map((date, idx) => { + const amount = this.formulas.calcAmount({ + formulaToUse, + amount: this.getAmount(baseAmounts, idx), + startAt, + }); + + const budgetDate = budgetDates.find( + (bd) => + bd.month === this.dateAdapter.get(date, 'month') && + bd.year === this.dateAdapter.get(date, 'year'), + ); + + if (type === TransactionTypeEnum.CREDIT) { + return this.transactionService.credit({ + recurrentTransactionId: id, + accountId, + name: this.replaceVars({ + text: name, + date, + }), + description: this.replaceVars({ + text: description, + date, + }), + categoryId, + cardId, + amount, + budgetDateId: budgetDate.id, + createdAt: date, + installments: 1, + isSystemManaged: isSystemManagedT, + }); + } + + if (type === TransactionTypeEnum.IN || type === TransactionTypeEnum.OUT) { + return this.transactionService.inOut({ + recurrentTransactionId: id, + type, + accountId, + name: this.replaceVars({ + text: name, + date, + }), + description: this.replaceVars({ + text: description, + date, + }), + categoryId, + amount, + budgetDateId: budgetDate.id, + createdAt: date, + bankAccountId, + isSystemManaged: isSystemManagedT, + }); + } + + if (type === TransactionTypeEnum.TRANSFER) { + return this.transactionService.transfer({ + recurrentTransactionId: id, + accountId, + name: this.replaceVars({ + text: name, + date, + }), + description: this.replaceVars({ + text: description, + date, + }), + bankAccountFromId, + bankAccountToId, + amount, + budgetDateId: budgetDate.id, + createdAt: date, + isSystemManaged: isSystemManagedT, + }); + } }); + + await Promise.allSettled(transactions); + } + + /** + * @private + */ + getAmount(baseAmounts: Array, idx: number) { + const value = baseAmounts[idx]; + + return typeof value === 'number' ? value : baseAmounts[0]; + } + + /** + * @private + */ + replaceVars({ text, date }: ReplaceVarsInput): string { + let finalText = text; + + finalText = finalText.replaceAll( + '${DAY}', + this.dateAdapter.get(date, 'day').toString(), + ); + finalText = finalText.replaceAll( + '${MONTH}', + this.dateAdapter.get(date, 'month').toString(), + ); + finalText = finalText.replaceAll( + '${YEAR}', + this.dateAdapter.get(date, 'year').toString(), + ); + + return finalText; } } diff --git a/src/usecases/recurrent-transaction/utils/formula/formula.module.ts b/src/usecases/recurrent-transaction/utils/formula/formula.module.ts new file mode 100644 index 0000000..03d7f53 --- /dev/null +++ b/src/usecases/recurrent-transaction/utils/formula/formula.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { Formulas } from './formula.service'; +import { DayJsAdapterModule } from 'adapters/implementations/dayjs/dayjs.module'; + +@Module({ + imports: [DayJsAdapterModule], + providers: [Formulas], + exports: [Formulas], +}) +export class FormulasModule {} diff --git a/src/usecases/recurrent-transaction/utils/formula/formula.service.ts b/src/usecases/recurrent-transaction/utils/formula/formula.service.ts new file mode 100644 index 0000000..6de1c2d --- /dev/null +++ b/src/usecases/recurrent-transaction/utils/formula/formula.service.ts @@ -0,0 +1,114 @@ +import { + Inject, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; +import { RecurrenceFormulaEnum } from '@prisma/client'; +import { DateAdapter } from 'adapters/date'; +import type { DateUnit } from 'adapters/date'; +import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; + +interface CalcAmountInput { + formulaToUse: RecurrenceFormulaEnum; + amount: number; + startAt?: Date; +} + +interface FormulaRawAmountInput { + baseAmount: number; +} + +interface FormulaPMGInput { + baseAmount: number; + startAt: Date; + gapAmount: number; + gapUnit: DateUnit | 'week'; +} + +@Injectable() +export class Formulas { + constructor( + @Inject(DayjsAdapterService) + private readonly dateAdapter: DateAdapter, + ) {} + + calcAmount({ formulaToUse, amount, startAt }: CalcAmountInput) { + if (formulaToUse === RecurrenceFormulaEnum.RAW_AMOUNT) { + return this.formulaRawAmount({ + baseAmount: amount, + }); + } + + if (formulaToUse === RecurrenceFormulaEnum.PM_1WG) { + if (!startAt) { + throw new InternalServerErrorException( + `Formula "${formulaToUse}" requires startAt`, + ); + } + + return this.formulaPMG({ + baseAmount: amount, + startAt, + gapAmount: 1, + gapUnit: 'week', + }); + } + + if (formulaToUse === RecurrenceFormulaEnum.PM_2WG) { + if (!startAt) { + throw new InternalServerErrorException( + `Formula "${formulaToUse}" requires startAt`, + ); + } + + return this.formulaPMG({ + baseAmount: amount, + startAt, + gapAmount: 2, + gapUnit: 'week', + }); + } + + throw new InternalServerErrorException( + `Formula "${formulaToUse}" not implemented`, + ); + } + + /** + * @private + */ + formulaRawAmount({ baseAmount }: FormulaRawAmountInput): number { + return baseAmount; + } + + /** + * @private + */ + formulaPMG({ + baseAmount, + startAt, + gapAmount, + gapUnit, + }: FormulaPMGInput): number { + const prevMonth = this.dateAdapter.startOf( + this.dateAdapter.sub(this.dateAdapter.newDate(), 1, 'month'), + 'month', + ); + const diff = Math.ceil(this.dateAdapter.diff(startAt, prevMonth, 'month')); + const firstDay = this.dateAdapter.add(startAt, diff, 'month'); + + const startOfCurMonthTime = this.dateAdapter + .startOf(this.dateAdapter.newDate(), 'month') + .getTime(); + + let amountOfDays = 0; + let curDate = firstDay; + while (curDate.getTime() < startOfCurMonthTime) { + amountOfDays++; + + curDate = this.dateAdapter.add(curDate, gapAmount, gapUnit); + } + + return baseAmount * amountOfDays; + } +} diff --git a/src/usecases/recurrent-transaction/utils/matching-dates/matching-dates.module.ts b/src/usecases/recurrent-transaction/utils/matching-dates/matching-dates.module.ts new file mode 100644 index 0000000..76797a9 --- /dev/null +++ b/src/usecases/recurrent-transaction/utils/matching-dates/matching-dates.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MatchingDates } from './matching-dates.service'; +import { DayJsAdapterModule } from 'adapters/implementations/dayjs/dayjs.module'; + +@Module({ + imports: [DayJsAdapterModule], + providers: [MatchingDates], + exports: [MatchingDates], +}) +export class MatchingDatesModule {} diff --git a/src/usecases/recurrent-transaction/utils/matching-dates/matching-dates.service.ts b/src/usecases/recurrent-transaction/utils/matching-dates/matching-dates.service.ts new file mode 100644 index 0000000..fce60cc --- /dev/null +++ b/src/usecases/recurrent-transaction/utils/matching-dates/matching-dates.service.ts @@ -0,0 +1,292 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + RecurrenceCreateEnum, + RecurrenceExcludeEnum, + RecurrenceTryAgainEnum, +} from '@prisma/client'; +import type { WeekDays } from 'adapters/date'; +import { DateAdapter } from 'adapters/date'; +import { DayjsAdapterService } from 'adapters/implementations/dayjs/dayjs.service'; + +interface GetDatesInput { + cCreates: Array; + cExcludes: Array; + cTryAgains: Array; +} + +interface DatesFound { + valid: boolean; + cCreates: RecurrenceCreateEnum; + date: Date; + invalidBecause?: string; + replaceBy?: Date; // If invalid but found alternative +} + +interface GetMatchingDatesInput { + date: Date; + cCreates: Array; +} + +interface FilterDatesInput { + matchingDates: Array; + cExcludes: Array; + cTryAgains: Array; +} + +interface FindAlternativeInput { + date: Date; + cExcludes: Array; + cTryAgains: Array; +} + +const HOLIDAYS = [ + '2024-01-01', // Ano Novo + '2024-02-12', // Carnaval + '2024-02-13', // Carnaval + '2024-02-14', // Carnaval + '2024-03-29', // Sexta-Feira Santa + '2024-04-21', // Dia de Tiradentes + '2024-05-01', // Dia do Trabalho + '2024-05-30', // Corpus Christi + '2024-09-07', // Independência do Brasil + '2024-10-12', // Nossa Senhora Aparecida + '2024-10-15', // Dia do Professor + '2024-10-28', // Dia do Servidor Público + '2024-11-02', // Dia de Finados + '2024-11-15', // Proclamação da República + '2024-12-25', // Natal +]; + +@Injectable() +export class MatchingDates { + constructor( + @Inject(DayjsAdapterService) + private readonly dateAdapter: DateAdapter, + ) {} + + getDates({ cCreates, cExcludes, cTryAgains }: GetDatesInput) { + const matchingDates = this.getMatchingDates({ + date: this.dateAdapter.newDate(), + cCreates, + }); + + const filteredDates = this.filterDates({ + matchingDates, + cExcludes, + cTryAgains, + }); + + return filteredDates + .map(({ valid, date, replaceBy }) => + valid ? replaceBy || date : undefined, + ) + .filter(Boolean); + } + + /** + * @private + */ + getMatchingDates({ + date, + cCreates, + }: GetMatchingDatesInput): Array { + const startOfTheMonth = this.dateAdapter.startOf(date, 'month'); + + return cCreates.map((param) => { + if (param === RecurrenceCreateEnum.FIFTH_BUSINESS_DAY) { + /** + * Days to add to get to the fifth business day, + * if the month start at the following week days + */ + const daysToAdd: Record = { + sunday: 5, + monday: 4, + tuesday: 6, + wednesday: 6, + thursday: 6, + friday: 6, + saturday: 6, + }; + + const fifthBusinessDay = this.dateAdapter.add( + startOfTheMonth, + daysToAdd[this.dateAdapter.getDayOfWeek(date)], + 'day', + ); + + return { + valid: true, + cCreates: param, + date: fifthBusinessDay, + }; + } + + if (param === RecurrenceCreateEnum.FIRST_DAY_OF_MONTH) { + return { + valid: true, + cCreates: param, + date: startOfTheMonth, + }; + } + + if (param === RecurrenceCreateEnum.LAST_DAY_OF_MONTH) { + return { + valid: true, + cCreates: param, + date: this.dateAdapter.startOf( + this.dateAdapter.endOf(startOfTheMonth, 'month'), + 'day', + ), + }; + } + + if (/^DAY_[\d]{1,2}$/.test(param)) { + const day = parseInt(param.replace('DAY_', ''), 10); + + return { + valid: true, + cCreates: param, + date: this.dateAdapter.setDay(startOfTheMonth, day), + }; + } + + if (/^DAY_[\d]{1,2}_OR_LAST_DAY_OF_MONTH$/.test(param)) { + const day = parseInt(param.replace('DAY_', ''), 10); + + return { + valid: true, + cCreates: param, + date: this.dateAdapter.setDay(startOfTheMonth, day), + }; + } + }); + } + + /** + * @private + */ + filterDates({ + matchingDates, + cExcludes, + cTryAgains, + }: FilterDatesInput): Array { + return matchingDates.map((dateData) => { + if (this.isDateValid(dateData.date, cExcludes)) { + return { + ...dateData, + valid: true, + }; + } + + const alternativeDate = cTryAgains.reduce( + (acc, cur) => { + if (acc) return acc; + + if (cur === RecurrenceTryAgainEnum.IF_NOT_BEFORE) { + return this.ifNotBefore(dateData.date, cExcludes); + } + + if (cur === RecurrenceTryAgainEnum.IF_NOT_AFTER) { + return this.ifNotAfter(dateData.date, cExcludes); + } + }, + undefined, + ); + + return { + ...dateData, + replaceBy: alternativeDate, + valid: Boolean(alternativeDate), + }; + }); + } + + /** + * @private + */ + isDateValid(date: Date, conditions: Array) { + return conditions.every((condition) => { + if (condition === RecurrenceExcludeEnum.IN_BUSINESS_DAY) { + const weekDay = this.dateAdapter.getDayOfWeek(date); + + return !['sunday', 'saturday'].includes(weekDay); + } + + if (condition === RecurrenceExcludeEnum.IN_WEEKEND) { + const weekDay = this.dateAdapter.getDayOfWeek(date); + + return ['sunday', 'saturday'].includes(weekDay); + } + + if (condition === RecurrenceExcludeEnum.IN_HOLIDAY) { + return this.isHoliday(date); + } + + if (condition === RecurrenceExcludeEnum.NOT_IN_HOLIDAY) { + return !this.isHoliday(date); + } + }); + } + + /** + * @private + */ + findAlternative({ date, cTryAgains, cExcludes }: FindAlternativeInput) { + return cTryAgains.reduce((acc, cur) => { + if (acc) return acc; + + if (cur === RecurrenceTryAgainEnum.IF_NOT_BEFORE) { + return this.ifNotBefore(date, cExcludes); + } + + if (cur === RecurrenceTryAgainEnum.IF_NOT_AFTER) { + return this.ifNotAfter(date, cExcludes); + } + }, undefined); + } + + /** + * @private + */ + ifNotBefore( + date: Date, + conditions: Array, + ): Date | undefined { + const month = this.dateAdapter.get(date, 'month'); + let curDate = date; + + while (this.dateAdapter.get(curDate, 'month') === month) { + curDate = this.dateAdapter.sub(curDate, 1, 'day'); + + if (this.isDateValid(curDate, conditions)) { + return curDate; + } + } + } + + /** + * @private + */ + ifNotAfter( + date: Date, + conditions: Array, + ): Date | undefined { + const month = this.dateAdapter.get(date, 'month'); + let curDate = date; + + while (this.dateAdapter.get(curDate, 'month') === month) { + curDate = this.dateAdapter.add(curDate, 1, 'day'); + + if (this.isDateValid(curDate, conditions)) { + return curDate; + } + } + } + + /** + * @private + */ + isHoliday(date: Date): boolean { + return HOLIDAYS.includes(this.dateAdapter.format(date)); + } +} diff --git a/src/usecases/transaction/transaction.service.ts b/src/usecases/transaction/transaction.service.ts index 4e27d04..b3ceaea 100644 --- a/src/usecases/transaction/transaction.service.ts +++ b/src/usecases/transaction/transaction.service.ts @@ -95,6 +95,8 @@ export class TransactionService extends TransactionUseCase { budgetDateId, description, createdAt, + isSystemManaged = false, + recurrentTransactionId, }: TransferInput): Promise { const [bankAccounts, budgetDate] = await Promise.all([ this.bankRepository.getManyById({ @@ -148,7 +150,8 @@ export class TransactionService extends TransactionUseCase { budgetDateId, description, createdAt, - isSystemManaged: false, + isSystemManaged, + recurrentTransactionId, }); } @@ -162,6 +165,8 @@ export class TransactionService extends TransactionUseCase { budgetDateId, description, createdAt, + isSystemManaged = false, + recurrentTransactionId, }: InOutInput): Promise { const [bankAccount, category, budgetDate] = await Promise.all([ this.bankRepository.getById({ @@ -216,7 +221,8 @@ export class TransactionService extends TransactionUseCase { budgetDateId, description, createdAt, - isSystemManaged: false, + isSystemManaged, + recurrentTransactionId, }); } @@ -230,6 +236,8 @@ export class TransactionService extends TransactionUseCase { cardId, budgetDateId, createdAt, + isSystemManaged = false, + recurrentTransactionId, }: CreditInput): Promise { const [card, category, budgetDate] = await Promise.all([ this.cardRepository.getById({ @@ -294,7 +302,8 @@ export class TransactionService extends TransactionUseCase { cardId, description, createdAt, - isSystemManaged: false, + isSystemManaged, + recurrentTransactionId, installment: { installmentGroupId, total: installments, diff --git a/yarn.lock b/yarn.lock index 26fcd1b..b046737 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1772,6 +1772,14 @@ multer "1.4.4-lts.1" tslib "2.6.2" +"@nestjs/schedule@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@nestjs/schedule/-/schedule-4.0.0.tgz#522fa0c79a2b44a66aab16a46bdf4c11ae73f3c3" + integrity sha512-zz4h54m/F/1qyQKvMJCRphmuwGqJltDAkFxUXCVqJBXEs5kbPt93Pza3heCQOcMH22MZNhGlc9DmDMLXVHmgVQ== + dependencies: + cron "3.1.3" + uuid "9.0.1" + "@nestjs/schematics@^10.0.1", "@nestjs/schematics@^10.0.3": version "10.0.3" resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-10.0.3.tgz#0f48af0a20983ffecabcd8763213a3e53d43f270" @@ -2996,6 +3004,11 @@ dependencies: "@types/node" "*" +"@types/luxon@~3.3.0": + version "3.3.8" + resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.3.8.tgz#84dbf2d020a9209a272058725e168f21d331a67e" + integrity sha512-jYvz8UMLDgy3a5SkGJne8H7VA7zPV2Lwohjx0V8V31+SqAjNmurWMkk9cQhfvlcnXWudBpK9xPM1n4rljOcHYQ== + "@types/mime@*": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.4.tgz#2198ac274de6017b44d941e00261d5bc6a0e0a45" @@ -4506,6 +4519,14 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cron@3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/cron/-/cron-3.1.3.tgz#4eac8f6691ce7e24c8e89b5317b8097d6f2d0053" + integrity sha512-KVxeKTKYj2eNzN4ElnT6nRSbjbfhyxR92O/Jdp6SH3pc05CDJws59jBrZWEMQlxevCiE6QUTrXy+Im3vC3oD3A== + dependencies: + "@types/luxon" "~3.3.0" + luxon "~3.4.0" + cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -7045,6 +7066,11 @@ lunr@^2.3.9: resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1" integrity sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow== +luxon@~3.4.0: + version "3.4.4" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.4.4.tgz#cf20dc27dc532ba41a169c43fdcc0063601577af" + integrity sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA== + macos-release@^2.5.0: version "2.5.1" resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.5.1.tgz#bccac4a8f7b93163a8d163b8ebf385b3c5f55bf9" @@ -9477,6 +9503,11 @@ uuid@9.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== +uuid@9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== + uuid@^3.3.2: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" From 420233da92b17ae34e50ff7ce9ccf1a61d4cf339 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Wed, 31 Jan 2024 08:38:21 -0300 Subject: [PATCH 16/19] Add BankProvider & CardProviders seeds --- .vscode/settings.json | 2 + appspec.yml | 8 +- package.json | 5 +- .../migration.sql | 41 + prisma/schema.prisma | 43 +- prisma/seeds/bank-providers.ts | 34 + prisma/seeds/card-providers.ts | 84 ++ prisma/seeds/data/bank-providers/banks.ts | 109 ++ .../data/bank-providers/digital-wallet.ts | 11 + .../card-providers/credit/banco-do-brasil.ts | 220 ++++ .../data/card-providers/credit/bradesco.ts | 130 +++ .../seeds/data/card-providers/credit/btg.ts | 27 + prisma/seeds/data/card-providers/credit/c6.ts | 49 + .../seeds/data/card-providers/credit/caixa.ts | 218 ++++ .../seeds/data/card-providers/credit/inter.ts | 49 + .../seeds/data/card-providers/credit/itau.ts | 960 ++++++++++++++++++ .../data/card-providers/credit/nubank.ts | 27 + .../data/card-providers/credit/original.ts | 49 + .../data/card-providers/credit/picpay.ts | 38 + .../seeds/data/card-providers/credit/safra.ts | 27 + .../data/card-providers/credit/santander.ts | 130 +++ .../data/card-providers/credit/sicoob.ts | 107 ++ .../data/card-providers/credit/votorantim.ts | 84 ++ prisma/seeds/data/card-providers/va/alelo.ts | 27 + prisma/seeds/data/card-providers/va/pluxee.ts | 27 + prisma/seeds/data/card-providers/va/ticket.ts | 27 + prisma/seeds/data/card-providers/va/vr.ts | 27 + prisma/seeds/index.ts | 22 + scripts/{ => ci-cd}/build.sh | 0 scripts/{cd-prepare.sh => ci-cd/prepare.sh} | 0 scripts/{cd-start.sh => ci-cd/start.sh} | 0 scripts/{cd-stop.sh => ci-cd/stop.sh} | 0 scripts/{cd-validate.sh => ci-cd/validate.sh} | 0 scripts/id.ts | 27 + src/models/card.ts | 8 +- .../postgres/card/card-repository.service.ts | 28 +- src/usecases/card/card.service.ts | 4 +- src/usecases/wallet/wallet.service.ts | 8 +- 38 files changed, 2616 insertions(+), 41 deletions(-) create mode 100644 prisma/migrations/20240131183647_improve_providers/migration.sql create mode 100644 prisma/seeds/bank-providers.ts create mode 100644 prisma/seeds/card-providers.ts create mode 100644 prisma/seeds/data/bank-providers/banks.ts create mode 100644 prisma/seeds/data/bank-providers/digital-wallet.ts create mode 100644 prisma/seeds/data/card-providers/credit/banco-do-brasil.ts create mode 100644 prisma/seeds/data/card-providers/credit/bradesco.ts create mode 100644 prisma/seeds/data/card-providers/credit/btg.ts create mode 100644 prisma/seeds/data/card-providers/credit/c6.ts create mode 100644 prisma/seeds/data/card-providers/credit/caixa.ts create mode 100644 prisma/seeds/data/card-providers/credit/inter.ts create mode 100644 prisma/seeds/data/card-providers/credit/itau.ts create mode 100644 prisma/seeds/data/card-providers/credit/nubank.ts create mode 100644 prisma/seeds/data/card-providers/credit/original.ts create mode 100644 prisma/seeds/data/card-providers/credit/picpay.ts create mode 100644 prisma/seeds/data/card-providers/credit/safra.ts create mode 100644 prisma/seeds/data/card-providers/credit/santander.ts create mode 100644 prisma/seeds/data/card-providers/credit/sicoob.ts create mode 100644 prisma/seeds/data/card-providers/credit/votorantim.ts create mode 100644 prisma/seeds/data/card-providers/va/alelo.ts create mode 100644 prisma/seeds/data/card-providers/va/pluxee.ts create mode 100644 prisma/seeds/data/card-providers/va/ticket.ts create mode 100644 prisma/seeds/data/card-providers/va/vr.ts create mode 100644 prisma/seeds/index.ts rename scripts/{ => ci-cd}/build.sh (100%) rename scripts/{cd-prepare.sh => ci-cd/prepare.sh} (100%) rename scripts/{cd-start.sh => ci-cd/start.sh} (100%) rename scripts/{cd-stop.sh => ci-cd/stop.sh} (100%) rename scripts/{cd-validate.sh => ci-cd/validate.sh} (100%) create mode 100644 scripts/id.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index e1382cc..9936126 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -73,8 +73,10 @@ "dtos", "Econominhas", "medkit", + "NANQUIM", "nestjs", "openapi", + "Personnalité", "redocly", "Usecase", "usecases" diff --git a/appspec.yml b/appspec.yml index 65a631a..6d84f5e 100644 --- a/appspec.yml +++ b/appspec.yml @@ -19,21 +19,21 @@ permissions: hooks: ApplicationStop: - - location: scripts/cd-stop.sh + - location: scripts/ci-cd/stop.sh timeout: 300 runas: ubuntu AfterInstall: - - location: scripts/cd-prepare.sh + - location: scripts/ci-cd/prepare.sh timeout: 300 runas: ubuntu ApplicationStart: - - location: scripts/cd-start.sh + - location: scripts/ci-cd/start.sh timeout: 300 runas: ubuntu ValidateService: - - location: scripts/cd-validate.sh + - location: scripts/ci-cd/validate.sh timeout: 300 runas: ubuntu diff --git a/package.json b/package.json index 255d792..946512f 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "db:migrate": "prisma migrate deploy", "openapi:serve": "redocly preview-docs", "openapi:postman": "yarn lint:openapi && redocly bundle -o openapi/bundle.yaml && openapi2postmanv2 -s openapi/bundle.yaml -o openapi/postman.json -O folderStrategy=Tags,requestParametersResolution=Example", - "build": "./scripts/build.sh", + "build": "./scripts/ci-cd/build.sh", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", "start:docker": "nest start --watch", @@ -99,6 +99,7 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "script:id": "ts-node -r tsconfig-paths/register scripts/id.ts" } } diff --git a/prisma/migrations/20240131183647_improve_providers/migration.sql b/prisma/migrations/20240131183647_improve_providers/migration.sql new file mode 100644 index 0000000..0dfd174 --- /dev/null +++ b/prisma/migrations/20240131183647_improve_providers/migration.sql @@ -0,0 +1,41 @@ +/* + Warnings: + + - The values [VA,VR,VT] on the enum `card_type_enum` will be removed. If these variants are still used in the database, this will fail. + - Added the required column `variant` to the `card_providers` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "card_variant_enum" AS ENUM ('ENTRY_LEVEL', 'MID_MARKET', 'PREMIUM', 'SUPER_PREMIUM', 'ULTRA_PREMIUM', 'VA', 'VR', 'VT'); + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "card_network_enum" ADD VALUE 'AMEX'; +ALTER TYPE "card_network_enum" ADD VALUE 'ALELO'; +ALTER TYPE "card_network_enum" ADD VALUE 'PLUXEE'; +ALTER TYPE "card_network_enum" ADD VALUE 'TICKET'; +ALTER TYPE "card_network_enum" ADD VALUE 'VR'; + +-- AlterEnum +BEGIN; +CREATE TYPE "card_type_enum_new" AS ENUM ('CREDIT', 'BENEFIT'); +ALTER TABLE "card_providers" ALTER COLUMN "type" TYPE "card_type_enum_new" USING ("type"::text::"card_type_enum_new"); +ALTER TYPE "card_type_enum" RENAME TO "card_type_enum_old"; +ALTER TYPE "card_type_enum_new" RENAME TO "card_type_enum"; +DROP TYPE "card_type_enum_old"; +COMMIT; + +-- AlterTable +ALTER TABLE "bank_providers" ADD COLUMN "last_reviewed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ALTER COLUMN "code" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "card_providers" ADD COLUMN "last_reviewed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "variant" "card_variant_enum" NOT NULL, +ALTER COLUMN "bank_provider_id" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9b93005..ef085e7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -216,11 +216,12 @@ model TermsAndPoliciesAccepted { /// Contains the record of all the existent banks model BankProvider { - id String @id @db.Char(16) - name String @db.VarChar(30) - code String @db.Char(3) - iconUrl String @map("icon_url") @db.VarChar(200) - color String @db.Char(7) + id String @id @db.Char(16) + name String @db.VarChar(30) + code String? @db.Char(3) /// We don't have an unique constraint because some "banks" may not have codes (ex: 99pay) + iconUrl String @map("icon_url") @db.VarChar(200) + color String @db.Char(7) + lastReviewedAt DateTime @default(now()) @updatedAt @map("last_reviewed_at") bankAccounts BankAccount[] cardProviders CardProvider[] @@ -261,9 +262,7 @@ model BankAccount { enum CardTypeEnum { CREDIT - VA - VR - VT + BENEFIT @@map("card_type_enum") } @@ -276,26 +275,50 @@ enum PayAtEnum { } enum CardNetworkEnum { + // Credit VISA MASTERCARD ELO SODEXO + AMEX + // VA, VR + ALELO + PLUXEE + TICKET + VR @@map("card_network_enum") } +enum CardVariantEnum { + // Credit + ENTRY_LEVEL // Mastercard=Standard Visa=Classic + MID_MARKET // Mastercard=Gold Visa=Gold Elo=Mais + PREMIUM // Mastercard=Platinum Visa=Platinum Elo=Grafite + SUPER_PREMIUM // Visa=Signature + ULTRA_PREMIUM // Mastercard=Black Visa=Infinite Elo=Nanquim + // Benefit + VA + VR + VT + + @@map("card_variant_enum") +} + /// Contains the record of all the existent cards model CardProvider { id String @id @db.Char(16) - bankProviderId String @map("bank_provider_id") @db.Char(16) + bankProviderId String? @map("bank_provider_id") @db.Char(16) name String @db.VarChar(30) iconUrl String @map("icon_url") @db.VarChar(200) color String @db.Char(7) type CardTypeEnum network CardNetworkEnum + variant CardVariantEnum + lastReviewedAt DateTime @default(now()) @updatedAt @map("last_reviewed_at") statementDays Int @map("statement_days") @db.SmallInt /// Only postpaid cards have this column - bankProvider BankProvider @relation(fields: [bankProviderId], references: [id], onDelete: Restrict) + bankProvider BankProvider? @relation(fields: [bankProviderId], references: [id], onDelete: Restrict) cards Card[] @@map("card_providers") diff --git a/prisma/seeds/bank-providers.ts b/prisma/seeds/bank-providers.ts new file mode 100644 index 0000000..f62efe2 --- /dev/null +++ b/prisma/seeds/bank-providers.ts @@ -0,0 +1,34 @@ +import type { BankProvider as BankProviderPrisma } from '@prisma/client'; +import type { PrismaClient } from '@prisma/client'; +import { BANKS } from './data/bank-providers/banks'; +import { DIGITAL_WALLETS } from './data/bank-providers/digital-wallet'; + +export type BankProvider = Omit; + +const PROVIDERS: Array = [...BANKS, ...DIGITAL_WALLETS]; + +export const bankProviders = async (prisma: PrismaClient) => { + const codes = PROVIDERS.map((p) => p.code).filter(Boolean); + + const uniqueCodes = [...new Set(codes)]; + + if (uniqueCodes.length !== codes.length) { + throw new Error('There are duplicated codes in the bank providers'); + } + + await Promise.allSettled( + PROVIDERS.map(({ id, name, code, iconUrl, color }) => + prisma.bankProvider.upsert({ + where: { id }, + update: {}, + create: { + id, + name, + code, + iconUrl, + color, + }, + }), + ), + ); +}; diff --git a/prisma/seeds/card-providers.ts b/prisma/seeds/card-providers.ts new file mode 100644 index 0000000..72562ad --- /dev/null +++ b/prisma/seeds/card-providers.ts @@ -0,0 +1,84 @@ +import type { CardProvider as CardProviderPrisma } from '@prisma/client'; +import type { PrismaClient } from '@prisma/client'; + +import { ITAU } from './data/card-providers/credit/itau'; +import { BANCO_DO_BRASIL } from './data/card-providers/credit/banco-do-brasil'; +import { CAIXA } from './data/card-providers/credit/caixa'; +import { INTER } from './data/card-providers/credit/inter'; +import { PICPAY } from './data/card-providers/credit/picpay'; +import { C6 } from './data/card-providers/credit/c6'; +import { ORIGINAL } from './data/card-providers/credit/original'; +import { NUBANK } from './data/card-providers/credit/nubank'; +import { BRADESCO } from './data/card-providers/credit/bradesco'; +import { SANTANDER } from './data/card-providers/credit/santander'; +import { BTG } from './data/card-providers/credit/btg'; +import { SAFRA } from './data/card-providers/credit/safra'; +import { SICOOB } from './data/card-providers/credit/sicoob'; +import { VOTORANTIM } from './data/card-providers/credit/votorantim'; +import { ALELO } from './data/card-providers/va/alelo'; +import { PLUXEE } from './data/card-providers/va/pluxee'; +import { TICKET } from './data/card-providers/va/ticket'; +import { VR } from './data/card-providers/va/vr'; + +export type CardProvider = Omit; + +const PROVIDERS: Array = [ + // Credit + ...NUBANK, + ...ITAU, + ...BANCO_DO_BRASIL, + ...CAIXA, + ...INTER, + ...PICPAY, + ...C6, + ...ORIGINAL, + ...BRADESCO, + ...SANTANDER, + ...BTG, + ...SAFRA, + ...SICOOB, + ...VOTORANTIM, + + // Benefit + ...ALELO, + ...PLUXEE, + ...TICKET, + ...VR, +]; + +export const cardProviders = async (prisma: PrismaClient) => { + await Promise.allSettled( + PROVIDERS.map( + ({ + id, + bankProviderId, + name, + iconUrl, + color, + type, + network, + variant, + statementDays, + }) => + prisma.cardProvider.upsert({ + where: { id }, + update: {}, + create: { + id, + name, + iconUrl, + color, + type, + network, + variant, + statementDays, + bankProvider: { + connect: { + id: bankProviderId, + }, + }, + }, + }), + ), + ); +}; diff --git a/prisma/seeds/data/bank-providers/banks.ts b/prisma/seeds/data/bank-providers/banks.ts new file mode 100644 index 0000000..37fdc52 --- /dev/null +++ b/prisma/seeds/data/bank-providers/banks.ts @@ -0,0 +1,109 @@ +import type { BankProvider } from '../../bank-providers'; + +export const BANKS: Array = [ + { + id: '0353c5b72dfd1851', + name: 'Nubank', + code: '260', + iconUrl: '', + color: '#820ad1', + }, + { + id: '2a3d7a70b7423102', + name: 'Itaú Unibanco', + code: '341', + iconUrl: '', + color: '#ff6200', + }, + { + id: 'b9e1557c47aa9a57', + name: 'Banco do Brasil', + code: '001', + iconUrl: '', + color: '#fcfc30', + }, + { + id: 'c931015b6f053501', + name: 'Caixa Econômica Federal', + code: '104', + iconUrl: '', + color: '#005CA9', + }, + { + id: '219c7ea11698644a', + name: 'Bradesco', + code: '237', + iconUrl: '', + color: '#cc092f', + }, + { + id: '39a26422a0b1e9d5', + name: 'Santander', + code: '033', + iconUrl: '', + color: '#c00c00', + }, + { + id: 'c85c33210416e7fd', + name: 'BTG Pactual', + code: '208', + iconUrl: '', + color: '#05132a', + }, + { + id: '964e9df18f268cc4', + name: 'Banco Safra', + code: '422', + iconUrl: '', + color: '#00003c', + }, + { + id: 'b279e36d7fefd59a', + name: 'Sicoob', + code: '756', + iconUrl: '', + color: '#003641', + }, + { + id: 'ccae54b0e3265b6d', + name: 'Banco Votorantim', + code: '655', + iconUrl: '', + color: '#223ad2', + }, + { + id: '454b42246725ba61', + name: 'Banco Original', + code: '212', + iconUrl: '', + color: '#00a857', + }, + { + id: '6a1bd35be594a1e6', + name: 'Banco Inter', + code: '077', + iconUrl: '', + color: '#ff9d42', + }, + { + id: '8e8d3c0762eda53c', + name: 'C6 Bank', + code: '336', + iconUrl: '', + color: '#000000', + }, + { + id: '96d09014832bbf46', + name: 'PicPay', + code: '380', + iconUrl: '', + color: '#11C76F', + }, + { + id: '96d09014832bbf46', + name: '99Pay', + code: null, + iconUrl: '', + color: '#FFDD00', + }, +]; diff --git a/prisma/seeds/data/bank-providers/digital-wallet.ts b/prisma/seeds/data/bank-providers/digital-wallet.ts new file mode 100644 index 0000000..f3b384f --- /dev/null +++ b/prisma/seeds/data/bank-providers/digital-wallet.ts @@ -0,0 +1,11 @@ +import type { BankProvider } from '../../bank-providers'; + +export const DIGITAL_WALLETS: Array = [ + { + id: '96d09014832bbf46', + name: '99Pay', + code: null, + iconUrl: '', + color: '#FFDD00', + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/banco-do-brasil.ts b/prisma/seeds/data/card-providers/credit/banco-do-brasil.ts new file mode 100644 index 0000000..457977c --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/banco-do-brasil.ts @@ -0,0 +1,220 @@ +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; +import type { CardProvider } from '../../../card-providers'; + +export const BANCO_DO_BRASIL: Array = [ + // Ourocard + { + id: '2f5ec6bfa2b71017', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Ourocard', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '615330a7525d4c5b', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Ourocard Mais', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '0d2181ea09bc2db3', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Ourocard Grafite', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'ebaa3bbdf7350702', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Ourocard Nanquim', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + { + id: '10a6e483517c021b', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Ourocard Fácil', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: 'e25020ec64b9661c', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Ourocard Universitário', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '185d1e93608f9a6c', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Ourocard Gold', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '4ab87f3af8423cfb', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Ourocard Platinum', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '17b29c0497502ec4', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Ourocard Platinum', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '05c4c798fca9d875', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Ourocard Black', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + { + id: '91cc6b6ccfcc1826', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Ourocard Infinite', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + // GOL Smiles + { + id: '6a37d54862fb0fed', + bankProviderId: 'b9e1557c47aa9a57', + name: 'GOL Smiles Gold', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: 'f218e6c4b8ae60c4', + bankProviderId: 'b9e1557c47aa9a57', + name: 'GOL Smiles Platinum', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '9e64fd285bc783e2', + bankProviderId: 'b9e1557c47aa9a57', + name: 'GOL Smiles Infinite', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + // Saraiva + { + id: 'dcd9da0138058736', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Saraiva', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + // Petrobras + { + id: '39878533220cd36e', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Petrobras', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + // Dtoz + { + id: '94aa2245102f159f', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Dtoz', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '838e49852c48732b', + bankProviderId: 'b9e1557c47aa9a57', + name: 'Dtoz Platinum', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + // AME + { + id: '4f2ee2ef7331c4a5', + bankProviderId: 'b9e1557c47aa9a57', + name: 'AME', + iconUrl: '', + color: '#fcfc30', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/bradesco.ts b/prisma/seeds/data/card-providers/credit/bradesco.ts new file mode 100644 index 0000000..4730876 --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/bradesco.ts @@ -0,0 +1,130 @@ +import type { CardProvider } from '../../../card-providers'; +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; + +export const BRADESCO: Array = [ + { + id: 'eed7c96dd219dce1', + bankProviderId: '219c7ea11698644a', + name: 'Bradesco Like', + iconUrl: '', + color: '#cc092f', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + + { + id: '6c4a294a563a8566', + bankProviderId: '219c7ea11698644a', + name: 'Bradesco Elo Mais', + iconUrl: '', + color: '#cc092f', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '2ba4ab3835336ee0', + bankProviderId: '219c7ea11698644a', + name: 'Bradesco Amex Green', + iconUrl: '', + color: '#cc092f', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.AMEX, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + + { + id: '8d0ba80cbbae956d', + bankProviderId: '219c7ea11698644a', + name: 'Bradesco Neo Platinum', + iconUrl: '', + color: '#cc092f', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '1f8f4ca607f33990', + bankProviderId: '219c7ea11698644a', + name: 'Bradesco Grafite', + iconUrl: '', + color: '#cc092f', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'c6f7dfa204e69897', + bankProviderId: '219c7ea11698644a', + name: 'Bradesco Amex Gold', + iconUrl: '', + color: '#cc092f', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.AMEX, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'ad9f955963f61520', + bankProviderId: '219c7ea11698644a', + name: 'Bradesco Platinum', + iconUrl: '', + color: '#cc092f', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + + { + id: '831f27494aac24bb', + bankProviderId: '219c7ea11698644a', + name: 'Bradesco Signature', + iconUrl: '', + color: '#cc092f', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.SUPER_PREMIUM, + statementDays: 7, + }, + + { + id: 'b17d44c6e1d9c3c5', + bankProviderId: '219c7ea11698644a', + name: 'Bradesco Infinite', + iconUrl: '', + color: '#cc092f', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + { + id: '4e504741ed6a59e5', + bankProviderId: '219c7ea11698644a', + name: 'Bradesco Nanquim', + iconUrl: '', + color: '#cc092f', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + { + id: 'e4c21f32e05d517f', + bankProviderId: '219c7ea11698644a', + name: 'Bradesco Amex Platinum', + iconUrl: '', + color: '#cc092f', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.AMEX, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/btg.ts b/prisma/seeds/data/card-providers/credit/btg.ts new file mode 100644 index 0000000..3b144da --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/btg.ts @@ -0,0 +1,27 @@ +import type { CardProvider } from '../../../card-providers'; +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; + +export const BTG: Array = [ + { + id: '178138ca95b415f0', + bankProviderId: 'c85c33210416e7fd', + name: 'Opção Avançada', + iconUrl: '', + color: '#05132a', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '6ba3fe1e8602903c', + bankProviderId: 'c85c33210416e7fd', + name: 'Black', + iconUrl: '', + color: '#05132a', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/c6.ts b/prisma/seeds/data/card-providers/credit/c6.ts new file mode 100644 index 0000000..4f30a79 --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/c6.ts @@ -0,0 +1,49 @@ +import type { CardProvider } from '../../../card-providers'; +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; + +export const C6: Array = [ + { + id: '83ff3f16cb3c5aa2', + bankProviderId: '8e8d3c0762eda53c', + name: 'C6', + iconUrl: '', + color: '#000000', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '23f2c42491e33b32', + bankProviderId: '8e8d3c0762eda53c', + name: 'C6 Platinum', + iconUrl: '', + color: '#000000', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '098e7d2a8912042f', + bankProviderId: '8e8d3c0762eda53c', + name: 'C6 Black', + iconUrl: '', + color: '#000000', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + { + id: 'c8b7770a17183281', + bankProviderId: '8e8d3c0762eda53c', + name: 'C6 Carbon', + iconUrl: '', + color: '#000000', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/caixa.ts b/prisma/seeds/data/card-providers/credit/caixa.ts new file mode 100644 index 0000000..58ebe65 --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/caixa.ts @@ -0,0 +1,218 @@ +import type { CardProvider } from '../../../card-providers'; +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; + +export const CAIXA: Array = [ + // Pra Elas + { + id: '3efc3694d3ed4e2d', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Pra Elas', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: 'fbddb7f852f3473a', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Pra Elas', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + // Pra Mulher + { + id: '2267bc60592f710e', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Mulher', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '6c65cc1ad246c3f8', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Mulher', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + // Sim + { + id: 'b9e5acfbcc82f113', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Sim', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '56b1f9aedf4ac7aa', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Sim', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + // Caixa + { + id: '3b840c8e02b70b0f', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Simples', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '9888dac8102023d4', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Simples', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '48f2ae87f398e544', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Simples', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: 'dbf44f40b6de3c23', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Mais', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '6f387318f2959414', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Gold', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '437a3b4d051d6fd6', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Gold', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: 'a04603eefcb7d16f', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Grafite', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '1bb4ef89c3ef45b0', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Platinum', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'eedb5725f2029fbb', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Platinum', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '3d03bb3d741a1176', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Nanquim', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + { + id: 'c25a339c10dfdebc', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Black', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + { + id: '8d8a4011b5da5fb1', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Infinite', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + { + id: 'ac56dedc2b781746', + bankProviderId: 'c931015b6f053501', + name: 'Caixa Dinners Club', + iconUrl: '', + color: '#005CA9', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.ELO, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/inter.ts b/prisma/seeds/data/card-providers/credit/inter.ts new file mode 100644 index 0000000..9864678 --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/inter.ts @@ -0,0 +1,49 @@ +import type { CardProvider } from '../../../card-providers'; +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; + +export const INTER: Array = [ + { + id: 'ed7b6c0e61e792b0', + bankProviderId: '6a1bd35be594a1e6', + name: 'Inter Gold', + iconUrl: '', + color: '#ff9d42', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: 'aa13ea95124dac1b', + bankProviderId: '6a1bd35be594a1e6', + name: 'Inter Platinum', + iconUrl: '', + color: '#ff9d42', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'd60434b2648a0d8f', + bankProviderId: '6a1bd35be594a1e6', + name: 'Inter Black', + iconUrl: '', + color: '#ff9d42', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + { + id: 'a3dc10b36ab2e3f1', + bankProviderId: '6a1bd35be594a1e6', + name: 'Inter Win', + iconUrl: '', + color: '#ff9d42', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/itau.ts b/prisma/seeds/data/card-providers/credit/itau.ts new file mode 100644 index 0000000..6c9ea85 --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/itau.ts @@ -0,0 +1,960 @@ +import type { CardProvider } from '../../../card-providers'; +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; + +export const ITAU: Array = [ + // Uniclass + { + id: '3731b22ff6cc4e42', + bankProviderId: '2a3d7a70b7423102', + name: 'Uniclass', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'd5ba6d11490dffd9', + bankProviderId: '2a3d7a70b7423102', + name: 'Uniclass', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.SUPER_PREMIUM, + statementDays: 7, + }, + { + id: 'c355840de016f73e', + bankProviderId: '2a3d7a70b7423102', + name: 'Uniclass', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + + // Azul + { + id: '5d267481b914d200', + bankProviderId: '2a3d7a70b7423102', + name: 'Azul Internacional', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: 'a0d3a9afe13e313d', + bankProviderId: '2a3d7a70b7423102', + name: 'Azul Internacional', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: 'ba51b51df358d5c6', + bankProviderId: '2a3d7a70b7423102', + name: 'Azul Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '2e08b88c54a5dfcf', + bankProviderId: '2a3d7a70b7423102', + name: 'Azul Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '49f1d3ae9186b4a2', + bankProviderId: '2a3d7a70b7423102', + name: 'Azul Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '25ddd0ece3ebd4c6', + bankProviderId: '2a3d7a70b7423102', + name: 'Azul Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '4656cc78d06b068a', + bankProviderId: '2a3d7a70b7423102', + name: 'Azul Infinite', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + + // LATAM + { + id: '97a226701ae0d3aa', + bankProviderId: '2a3d7a70b7423102', + name: 'LATAM Pass Internacional', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '746acad5009942ea', + bankProviderId: '2a3d7a70b7423102', + name: 'LATAM Pass Internacional', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '6cd42c20dc343e38', + bankProviderId: '2a3d7a70b7423102', + name: 'LATAM Pass Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '9ffbfb3d8a46fac5', + bankProviderId: '2a3d7a70b7423102', + name: 'LATAM Pass Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '8d4ed3b1a620b69d', + bankProviderId: '2a3d7a70b7423102', + name: 'LATAM Pass Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '20cf88d180bc22e4', + bankProviderId: '2a3d7a70b7423102', + name: 'LATAM Pass Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'dd23a2757b066b57', + bankProviderId: '2a3d7a70b7423102', + name: 'LATAM Pass Black', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + { + id: 'a2752bb73f293129', + bankProviderId: '2a3d7a70b7423102', + name: 'LATAM Pass Infinite', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + + // Passaí + { + id: '9bcc41d2ce501108', + bankProviderId: '2a3d7a70b7423102', + name: 'Passaí Internacional', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: 'e4779d5f4f83167d', + bankProviderId: '2a3d7a70b7423102', + name: 'Passaí Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + + // Extra + { + id: '9000c431dd39f14d', + bankProviderId: '2a3d7a70b7423102', + name: 'Extra Internacional', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '6cb4fd6b1655c504', + bankProviderId: '2a3d7a70b7423102', + name: 'Extra Internacional', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: 'fc7b82fba856c049', + bankProviderId: '2a3d7a70b7423102', + name: 'Extra Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: 'c187c53eecff4418', + bankProviderId: '2a3d7a70b7423102', + name: 'Extra Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + + // N Card + { + id: '0e5cf2ca5068436b', + bankProviderId: '2a3d7a70b7423102', + name: 'N Card', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: 'de1e3c5eaa9c24ca', + bankProviderId: '2a3d7a70b7423102', + name: 'N Card', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: 'cfb4ec620ce70b16', + bankProviderId: '2a3d7a70b7423102', + name: 'N Card Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '1c0ee4b453fb2d53', + bankProviderId: '2a3d7a70b7423102', + name: 'N Card Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + + // Vivo + { + id: 'f01ceafaaf3febb8', + bankProviderId: '2a3d7a70b7423102', + name: 'Vivo Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '43b1dd25eb0fba3d', + bankProviderId: '2a3d7a70b7423102', + name: 'Vivo Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: 'e3d98d4a701ef23d', + bankProviderId: '2a3d7a70b7423102', + name: 'Vivo Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + + // Personnalité + { + id: 'eefd53d0ad409b04', + bankProviderId: '2a3d7a70b7423102', + name: 'Itaú', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: 'ac76ec5d4e05f012', + bankProviderId: '2a3d7a70b7423102', + name: 'Itaú', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '9dd801533353e208', + bankProviderId: '2a3d7a70b7423102', + name: 'Itaú Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: 'ced02e9670b6cbd7', + bankProviderId: '2a3d7a70b7423102', + name: 'Itaú Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: 'd2d9246db432ae31', + bankProviderId: '2a3d7a70b7423102', + name: 'Itaú Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '35d7878fa7702097', + bankProviderId: '2a3d7a70b7423102', + name: 'Itaú Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'e1709ca97ea68542', + bankProviderId: '2a3d7a70b7423102', + name: 'Personnalité Black', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + + // Click + { + id: '9e0bd50cfe51357a', + bankProviderId: '2a3d7a70b7423102', + name: 'Click', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '86e6cd09b8632ad5', + bankProviderId: '2a3d7a70b7423102', + name: 'Click', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '52a2fadbccf988e2', + bankProviderId: '2a3d7a70b7423102', + name: 'Click+', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '423e21b10e958c2e', + bankProviderId: '2a3d7a70b7423102', + name: 'Click+', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + + // Decathlon + { + id: '4b4eda0148ac623a', + bankProviderId: '2a3d7a70b7423102', + name: 'Decathlon Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + + // Player's Bank + { + id: '6abafb393f14b5ff', + bankProviderId: '2a3d7a70b7423102', + name: "Player's Bank", + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + + // Magalu + { + id: '0019345da2559f1f', + bankProviderId: '2a3d7a70b7423102', + name: 'Magalu Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + + // Pão de Açúcar + { + id: '21f60e00058df97e', + bankProviderId: '2a3d7a70b7423102', + name: 'Pão de Açúcar', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: 'a2abe6ed32322b45', + bankProviderId: '2a3d7a70b7423102', + name: 'Pão de Açúcar Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'cb6e30866d774adc', + bankProviderId: '2a3d7a70b7423102', + name: 'Pão de Açúcar Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '6197763411628a45', + bankProviderId: '2a3d7a70b7423102', + name: 'Pão de Açúcar Black', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + + // Instituto Ayrton Senna + { + id: '222dbd04251752ee', + bankProviderId: '2a3d7a70b7423102', + name: 'Instituto Ayrton Senna Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '7faceb5103e09844', + bankProviderId: '2a3d7a70b7423102', + name: 'Instituto Ayrton Senna Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + + // TIM + { + id: 'f5993738a29c5409', + bankProviderId: '2a3d7a70b7423102', + name: 'TIM', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '5de7bdeca61c06be', + bankProviderId: '2a3d7a70b7423102', + name: 'TIM', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '8439848a95f939ba', + bankProviderId: '2a3d7a70b7423102', + name: 'TIM Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '8d3a7dfe081ea876', + bankProviderId: '2a3d7a70b7423102', + name: 'TIM Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: 'fcd92654513240be', + bankProviderId: '2a3d7a70b7423102', + name: 'TIM Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'e6256627a183420e', + bankProviderId: '2a3d7a70b7423102', + name: 'TIM Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + + // Volkswagen + { + id: '4c6a1c059261c62c', + bankProviderId: '2a3d7a70b7423102', + name: 'Volkswagen', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '716eacf0fe3ef8a3', + bankProviderId: '2a3d7a70b7423102', + name: 'Volkswagen', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: 'e83876f06ac21a96', + bankProviderId: '2a3d7a70b7423102', + name: 'Volkswagen Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: 'bb76b8d4d51c7f60', + bankProviderId: '2a3d7a70b7423102', + name: 'Volkswagen Gold', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: 'f201f53b5f16fef6', + bankProviderId: '2a3d7a70b7423102', + name: 'Volkswagen Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '5726ec47b3b4cd55', + bankProviderId: '2a3d7a70b7423102', + name: 'Volkswagen Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + + // MIT + { + id: 'b22f21c73b81cda3', + bankProviderId: '2a3d7a70b7423102', + name: 'MIT Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '08d9cdcd1cd6b30e', + bankProviderId: '2a3d7a70b7423102', + name: 'MIT Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + + // CVC + { + id: 'ae5e0d57f251515a', + bankProviderId: '2a3d7a70b7423102', + name: 'CVC Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + + // Sam's + { + id: '7901a9c3d94e0e84', + bankProviderId: '2a3d7a70b7423102', + name: "Sam's Gold", + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + + // Ipiranga + { + id: '929464393a0ff3ad', + bankProviderId: '2a3d7a70b7423102', + name: 'Ipiranga', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '6e2beb0a12c6ddc5', + bankProviderId: '2a3d7a70b7423102', + name: 'Ipiranga', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '512c4e5e16a3c916', + bankProviderId: '2a3d7a70b7423102', + name: 'Ipiranga Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '8610adeb955c176b', + bankProviderId: '2a3d7a70b7423102', + name: 'Ipiranga Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + + // Compre Bem + { + id: '794489d949f91cb3', + bankProviderId: '2a3d7a70b7423102', + name: 'Compre Bem', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + + // ITI + { + id: '92ecd08ee8257b09', + bankProviderId: '2a3d7a70b7423102', + name: 'ITI', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + + // Samsung + { + id: 'de0380d5d4e523d6', + bankProviderId: '2a3d7a70b7423102', + name: 'Samsung Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '5d447b0a4e99113d', + bankProviderId: '2a3d7a70b7423102', + name: 'Samsung Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + + // FIAT + { + id: 'a7dbbdb979cb9fc9', + bankProviderId: '2a3d7a70b7423102', + name: 'FIAT', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '6cd1e441066a299e', + bankProviderId: '2a3d7a70b7423102', + name: 'FIAT', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '938f919d5298c2b2', + bankProviderId: '2a3d7a70b7423102', + name: 'FIAT Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '624a57ddbf5186e8', + bankProviderId: '2a3d7a70b7423102', + name: 'FIAT Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + + // Pontofrio + { + id: '5d5ae68f49cf3427', + bankProviderId: '2a3d7a70b7423102', + name: 'Pontofrio 2.0', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + + // Mbank + { + id: '3b9e3f6ac1648e1c', + bankProviderId: '2a3d7a70b7423102', + name: 'Mbank', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + + // Polishop + { + id: '168b6d6b072f442f', + bankProviderId: '2a3d7a70b7423102', + name: 'Polishop Platinum', + iconUrl: '', + color: '#ff6200', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/nubank.ts b/prisma/seeds/data/card-providers/credit/nubank.ts new file mode 100644 index 0000000..d896784 --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/nubank.ts @@ -0,0 +1,27 @@ +import type { CardProvider } from '../../../card-providers'; +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; + +export const NUBANK: Array = [ + { + id: '94953505a44250e0', + bankProviderId: '0353c5b72dfd1851', + name: 'Nubank', + iconUrl: '', + color: '#820ad1', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '', + bankProviderId: '0353c5b72dfd1851', + name: 'Nubank Ultravioleta', + iconUrl: '', + color: '#290B4D', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/original.ts b/prisma/seeds/data/card-providers/credit/original.ts new file mode 100644 index 0000000..52ea843 --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/original.ts @@ -0,0 +1,49 @@ +import type { CardProvider } from '../../../card-providers'; +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; + +export const ORIGINAL: Array = [ + { + id: '483d4eb666d63edc', + bankProviderId: '454b42246725ba61', + name: 'Original', + iconUrl: '', + color: '#00a857', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '4f93fe0dd42982f9', + bankProviderId: '454b42246725ba61', + name: 'Original Gold', + iconUrl: '', + color: '#00a857', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '90ea15210e66bdbd', + bankProviderId: '454b42246725ba61', + name: 'Original Platinum', + iconUrl: '', + color: '#00a857', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'd074ac64205c9834', + bankProviderId: '454b42246725ba61', + name: 'Original Black', + iconUrl: '', + color: '#00a857', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/picpay.ts b/prisma/seeds/data/card-providers/credit/picpay.ts new file mode 100644 index 0000000..a93cc8c --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/picpay.ts @@ -0,0 +1,38 @@ +import type { CardProvider } from '../../../card-providers'; +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; + +export const PICPAY: Array = [ + { + id: '990883a69d165215', + bankProviderId: '96d09014832bbf46', + name: 'PicPay Gold', + iconUrl: '', + color: '#11C76F', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '30f14a0ef74df273', + bankProviderId: '96d09014832bbf46', + name: 'PicPay Platinum', + iconUrl: '', + color: '#11C76F', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'caf1566a2fd29608', + bankProviderId: '96d09014832bbf46', + name: 'PicPay Black', + iconUrl: '', + color: '#11C76F', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/safra.ts b/prisma/seeds/data/card-providers/credit/safra.ts new file mode 100644 index 0000000..b8efcf6 --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/safra.ts @@ -0,0 +1,27 @@ +import type { CardProvider } from '../../../card-providers'; +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; + +export const SAFRA: Array = [ + { + id: '5c3165991ae41c00', + bankProviderId: '964e9df18f268cc4', + name: 'Visa Platinum', + iconUrl: '', + color: '#00003c', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'acc31ef1660ead69', + bankProviderId: '964e9df18f268cc4', + name: 'Visa Infinite', + iconUrl: '', + color: '#00003c', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/santander.ts b/prisma/seeds/data/card-providers/credit/santander.ts new file mode 100644 index 0000000..2e3e27f --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/santander.ts @@ -0,0 +1,130 @@ +import type { CardProvider } from '../../../card-providers'; +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; + +export const SANTANDER: Array = [ + // GOL Smiles + { + id: 'ad68577cef9bfb44', + bankProviderId: '39a26422a0b1e9d5', + name: 'GOL Smiles Gold', + iconUrl: '', + color: '#c00c00', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '4f907e3b46c76abe', + bankProviderId: '39a26422a0b1e9d5', + name: 'GOL Smiles Platinum', + iconUrl: '', + color: '#c00c00', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'e2cfeadee6bade98', + bankProviderId: '39a26422a0b1e9d5', + name: 'GOL Smiles Infinite', + iconUrl: '', + color: '#c00c00', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + // Decolar + { + id: '6ba4643690688ce8', + bankProviderId: '39a26422a0b1e9d5', + name: 'Decolar Gold', + iconUrl: '', + color: '#c00c00', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '8c0f67c048fda333', + bankProviderId: '39a26422a0b1e9d5', + name: 'Decolar Platinum', + iconUrl: '', + color: '#c00c00', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '0c6bdd6677750141', + bankProviderId: '39a26422a0b1e9d5', + name: 'Decolar Infinite', + iconUrl: '', + color: '#c00c00', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + // AAdvantage® + { + id: '7203ddde1a3155f5', + bankProviderId: '39a26422a0b1e9d5', + name: 'AAdvantage Quartz', + iconUrl: '', + color: '#c00c00', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '30f0f5c27be2a720', + bankProviderId: '39a26422a0b1e9d5', + name: 'AAdvantage Platinum', + iconUrl: '', + color: '#c00c00', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '1323da5595efe45c', + bankProviderId: '39a26422a0b1e9d5', + name: 'AAdvantage Black', + iconUrl: '', + color: '#c00c00', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + // AMEX + { + id: 'f783b41b423eb94b', + bankProviderId: '39a26422a0b1e9d5', + name: 'Amex Gold', + iconUrl: '', + color: '#c00c00', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.AMEX, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '1d141e8ad613816c', + bankProviderId: '39a26422a0b1e9d5', + name: 'Amex Platinum', + iconUrl: '', + color: '#c00c00', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.AMEX, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/sicoob.ts b/prisma/seeds/data/card-providers/credit/sicoob.ts new file mode 100644 index 0000000..ad78701 --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/sicoob.ts @@ -0,0 +1,107 @@ +import type { CardProvider } from '../../../card-providers'; +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; + +export const SICOOB: Array = [ + // Vooz + { + id: '7c8631af63fe0cb9', + bankProviderId: 'b279e36d7fefd59a', + name: 'Vooz', + iconUrl: '', + color: '#003641', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + // Mastercard + { + id: 'd23eb11e77d5db90', + bankProviderId: 'b279e36d7fefd59a', + name: 'Mastercard Clássico', + iconUrl: '', + color: '#003641', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '5dea069eed4d39e0', + bankProviderId: 'b279e36d7fefd59a', + name: 'Mastercard Gold', + iconUrl: '', + color: '#003641', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: '2031fe5818858a85', + bankProviderId: 'b279e36d7fefd59a', + name: 'Mastercard Platinum', + iconUrl: '', + color: '#003641', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'a6b3fa18cc23e358', + bankProviderId: 'b279e36d7fefd59a', + name: 'Mastercard Black', + iconUrl: '', + color: '#003641', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + // Visa + { + id: 'fc5bb814b95b57c2', + bankProviderId: 'b279e36d7fefd59a', + name: 'Visa Clásico', + iconUrl: '', + color: '#003641', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: 'c883c9214aa3759b', + bankProviderId: 'b279e36d7fefd59a', + name: 'Visa Gold', + iconUrl: '', + color: '#003641', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: 'f57907208dabca17', + bankProviderId: 'b279e36d7fefd59a', + name: 'Visa Platinum', + iconUrl: '', + color: '#003641', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '81a956c34ede73cf', + bankProviderId: 'b279e36d7fefd59a', + name: 'Visa Infinite', + iconUrl: '', + color: '#003641', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/credit/votorantim.ts b/prisma/seeds/data/card-providers/credit/votorantim.ts new file mode 100644 index 0000000..a49c057 --- /dev/null +++ b/prisma/seeds/data/card-providers/credit/votorantim.ts @@ -0,0 +1,84 @@ +import type { CardProvider } from '../../../card-providers'; +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; + +export const VOTORANTIM: Array = [ + // BV + { + id: 'c4c2155af54df44e', + bankProviderId: 'ccae54b0e3265b6d', + name: 'BV Livre', + iconUrl: '', + color: '#223ad2', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.MID_MARKET, + statementDays: 7, + }, + { + id: 'da837fb3bddb514b', + bankProviderId: 'ccae54b0e3265b6d', + name: 'BV Mais', + iconUrl: '', + color: '#223ad2', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: '8808f02dbb18f284', + bankProviderId: 'ccae54b0e3265b6d', + name: 'BV Único', + iconUrl: '', + color: '#223ad2', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ULTRA_PREMIUM, + statementDays: 7, + }, + // Dotz + { + id: '9e4aee67e8397feb', + bankProviderId: 'ccae54b0e3265b6d', + name: 'Dotz BV', + iconUrl: '', + color: '#223ad2', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: 'd985e1c11a225b67', + bankProviderId: 'ccae54b0e3265b6d', + name: 'Dotz BV', + iconUrl: '', + color: '#223ad2', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.ENTRY_LEVEL, + statementDays: 7, + }, + { + id: '54ca47c5ba6ce346', + bankProviderId: 'ccae54b0e3265b6d', + name: 'Dotz BV Platinum', + iconUrl: '', + color: '#223ad2', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.MASTERCARD, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, + { + id: 'f9c9066e88b02a74', + bankProviderId: 'ccae54b0e3265b6d', + name: 'Dotz BV Platinum', + iconUrl: '', + color: '#223ad2', + type: CardTypeEnum.CREDIT, + network: CardNetworkEnum.VISA, + variant: CardVariantEnum.PREMIUM, + statementDays: 7, + }, +]; diff --git a/prisma/seeds/data/card-providers/va/alelo.ts b/prisma/seeds/data/card-providers/va/alelo.ts new file mode 100644 index 0000000..1a9fb1f --- /dev/null +++ b/prisma/seeds/data/card-providers/va/alelo.ts @@ -0,0 +1,27 @@ +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; +import type { CardProvider } from '../../../card-providers'; + +export const ALELO: Array = [ + { + id: 'b16ab8d762ccbcc6', + bankProviderId: null, + name: 'Refeição', + iconUrl: '', + color: '#BDD654', + type: CardTypeEnum.BENEFIT, + network: CardNetworkEnum.ALELO, + variant: CardVariantEnum.VR, + statementDays: 0, + }, + { + id: '8bd8941195a79040', + bankProviderId: null, + name: 'Alimentação', + iconUrl: '', + color: '#015A43', + type: CardTypeEnum.BENEFIT, + network: CardNetworkEnum.ALELO, + variant: CardVariantEnum.VA, + statementDays: 0, + }, +]; diff --git a/prisma/seeds/data/card-providers/va/pluxee.ts b/prisma/seeds/data/card-providers/va/pluxee.ts new file mode 100644 index 0000000..883dcc6 --- /dev/null +++ b/prisma/seeds/data/card-providers/va/pluxee.ts @@ -0,0 +1,27 @@ +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; +import type { CardProvider } from '../../../card-providers'; + +export const PLUXEE: Array = [ + { + id: 'b16ab8d762ccbcc6', + bankProviderId: null, + name: 'Refeição', + iconUrl: '', + color: '#00EB5E', + type: CardTypeEnum.BENEFIT, + network: CardNetworkEnum.PLUXEE, + variant: CardVariantEnum.VR, + statementDays: 0, + }, + { + id: '8bd8941195a79040', + bankProviderId: null, + name: 'Alimentação', + iconUrl: '', + color: '#00EB5E', + type: CardTypeEnum.BENEFIT, + network: CardNetworkEnum.PLUXEE, + variant: CardVariantEnum.VA, + statementDays: 0, + }, +]; diff --git a/prisma/seeds/data/card-providers/va/ticket.ts b/prisma/seeds/data/card-providers/va/ticket.ts new file mode 100644 index 0000000..8e4d81f --- /dev/null +++ b/prisma/seeds/data/card-providers/va/ticket.ts @@ -0,0 +1,27 @@ +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; +import type { CardProvider } from '../../../card-providers'; + +export const TICKET: Array = [ + { + id: '', + bankProviderId: null, + name: 'Restaurante', + iconUrl: '', + color: '#EF8A67', + type: CardTypeEnum.BENEFIT, + network: CardNetworkEnum.TICKET, + variant: CardVariantEnum.VR, + statementDays: 0, + }, + { + id: '', + bankProviderId: null, + name: 'Alimentação', + iconUrl: '', + color: '#6E4997', + type: CardTypeEnum.BENEFIT, + network: CardNetworkEnum.TICKET, + variant: CardVariantEnum.VA, + statementDays: 0, + }, +]; diff --git a/prisma/seeds/data/card-providers/va/vr.ts b/prisma/seeds/data/card-providers/va/vr.ts new file mode 100644 index 0000000..2fb518d --- /dev/null +++ b/prisma/seeds/data/card-providers/va/vr.ts @@ -0,0 +1,27 @@ +import { CardTypeEnum, CardNetworkEnum, CardVariantEnum } from '@prisma/client'; +import type { CardProvider } from '../../../card-providers'; + +export const VR: Array = [ + { + id: 'b16ab8d762ccbcc6', + bankProviderId: null, + name: 'Refeição', + iconUrl: '', + color: '#0A802F', + type: CardTypeEnum.BENEFIT, + network: CardNetworkEnum.VR, + variant: CardVariantEnum.VR, + statementDays: 0, + }, + { + id: '8bd8941195a79040', + bankProviderId: null, + name: 'Alimentação', + iconUrl: '', + color: '#0A802F', + type: CardTypeEnum.BENEFIT, + network: CardNetworkEnum.VR, + variant: CardVariantEnum.VA, + statementDays: 0, + }, +]; diff --git a/prisma/seeds/index.ts b/prisma/seeds/index.ts new file mode 100644 index 0000000..893fe89 --- /dev/null +++ b/prisma/seeds/index.ts @@ -0,0 +1,22 @@ +import { PrismaClient } from '@prisma/client'; +import { bankProviders } from './bank-providers'; +import { cardProviders } from './card-providers'; + +const bootstrap = async () => { + const prisma = new PrismaClient(); + + try { + await bankProviders(prisma); + + await cardProviders(prisma); + + await prisma.$disconnect(); + process.exit(0); + } catch (err) { + console.error(err); + await prisma.$disconnect(); + process.exit(1); + } +}; + +bootstrap(); diff --git a/scripts/build.sh b/scripts/ci-cd/build.sh similarity index 100% rename from scripts/build.sh rename to scripts/ci-cd/build.sh diff --git a/scripts/cd-prepare.sh b/scripts/ci-cd/prepare.sh similarity index 100% rename from scripts/cd-prepare.sh rename to scripts/ci-cd/prepare.sh diff --git a/scripts/cd-start.sh b/scripts/ci-cd/start.sh similarity index 100% rename from scripts/cd-start.sh rename to scripts/ci-cd/start.sh diff --git a/scripts/cd-stop.sh b/scripts/ci-cd/stop.sh similarity index 100% rename from scripts/cd-stop.sh rename to scripts/ci-cd/stop.sh diff --git a/scripts/cd-validate.sh b/scripts/ci-cd/validate.sh similarity index 100% rename from scripts/cd-validate.sh rename to scripts/ci-cd/validate.sh diff --git a/scripts/id.ts b/scripts/id.ts new file mode 100644 index 0000000..1fba928 --- /dev/null +++ b/scripts/id.ts @@ -0,0 +1,27 @@ +import { createInterface } from 'readline'; +import { uid } from 'uid/secure'; + +import { UIDAdapterService } from 'adapters/implementations/uid/uid.service'; + +const inquirer = createInterface({ + input: process.stdin, + output: process.stdout, +}); + +inquirer.question('How many IDs should be created?\n', (amount) => { + const amountNbr = parseInt(amount, 10); + + const idAdapter = new UIDAdapterService(uid); + + Array(amountNbr) + .fill(0) + .forEach(() => { + console.log(idAdapter.genId()); + }); + + inquirer.close(); +}); + +inquirer.on('close', function () { + process.exit(0); +}); diff --git a/src/models/card.ts b/src/models/card.ts index 87cd401..108a005 100644 --- a/src/models/card.ts +++ b/src/models/card.ts @@ -3,7 +3,7 @@ import type { CardBill, CardNetworkEnum, CardProvider, - CardTypeEnum, + CardVariantEnum, PayAtEnum, } from '@prisma/client'; import type { @@ -46,9 +46,9 @@ export interface GetBalanceByUserInput { } export interface GetBalanceByUserOutput { - [CardTypeEnum.VA]: number; - [CardTypeEnum.VR]: number; - [CardTypeEnum.VT]: number; + [CardVariantEnum.VA]: number; + [CardVariantEnum.VR]: number; + [CardVariantEnum.VT]: number; } export interface GetPostpaidInput extends PaginatedRepository { diff --git a/src/repositories/postgres/card/card-repository.service.ts b/src/repositories/postgres/card/card-repository.service.ts index 5fb8fe7..a7b10d3 100644 --- a/src/repositories/postgres/card/card-repository.service.ts +++ b/src/repositories/postgres/card/card-repository.service.ts @@ -30,7 +30,7 @@ import type { CardNetworkEnum, CardProvider, } from '@prisma/client'; -import { CardTypeEnum, PayAtEnum } from '@prisma/client'; +import { CardTypeEnum, CardVariantEnum, PayAtEnum } from '@prisma/client'; import { IdAdapter } from 'adapters/id'; import { UIDAdapterService } from 'adapters/implementations/uid/uid.service'; @@ -87,13 +87,13 @@ export class CardRepositoryService extends CardRepository { }: GetBalanceByUserInput): Promise { const r = await this.rawPostgres< Array<{ - card_provider_type: CardTypeEnum; - total_balance: number; + variant: CardVariantEnum; + balance: number; }> >` SELECT - cp.type AS card_provider_type, - SUM(c.balance) AS total_balance + cp.type AS variant, + SUM(c.balance) AS balance FROM cards c JOIN @@ -101,20 +101,20 @@ export class CardRepositoryService extends CardRepository { WHERE c.account_id = ${accountId} AND - cp.type IN ${[CardTypeEnum.VA, CardTypeEnum.VR, CardTypeEnum.VT]} + cp.variant IN ${[CardVariantEnum.VA, CardVariantEnum.VR, CardVariantEnum.VT]} GROUP BY - cp.type + cp.variant ORDER BY - cp.type ASC; + cp.variant ASC; `; return r.reduce( (acc, cur) => { - acc[cur.card_provider_type] = cur.total_balance; + acc[cur.variant] = cur.balance; return acc; }, - {} as Record, + {} as Record, ); } @@ -212,8 +212,8 @@ export class CardRepositoryService extends CardRepository { where: { accountId, cardProvider: { - type: { - in: [CardTypeEnum.VA, CardTypeEnum.VR, CardTypeEnum.VT], + variant: { + in: [CardVariantEnum.VA, CardVariantEnum.VR, CardVariantEnum.VT], }, }, }, @@ -367,8 +367,8 @@ export class CardRepositoryService extends CardRepository { where: { id: cardId, cardProvider: { - type: { - in: [CardTypeEnum.VA, CardTypeEnum.VR, CardTypeEnum.VT], + variant: { + in: [CardVariantEnum.VA, CardVariantEnum.VR, CardVariantEnum.VT], }, }, }, diff --git a/src/usecases/card/card.service.ts b/src/usecases/card/card.service.ts index 0316f5e..548ebd9 100644 --- a/src/usecases/card/card.service.ts +++ b/src/usecases/card/card.service.ts @@ -227,9 +227,7 @@ export class CardService extends CardUseCase { } private isPrepaid(type: CardTypeEnum) { - return [CardTypeEnum.VA, CardTypeEnum.VR, CardTypeEnum.VT].includes( - type as any, - ); + return [CardTypeEnum.BENEFIT].includes(type as any); } private getCurBillDueDate({ diff --git a/src/usecases/wallet/wallet.service.ts b/src/usecases/wallet/wallet.service.ts index 2750c25..8552a05 100644 --- a/src/usecases/wallet/wallet.service.ts +++ b/src/usecases/wallet/wallet.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { CardTypeEnum } from '@prisma/client'; +import { CardVariantEnum } from '@prisma/client'; import { BankRepository } from 'models/bank'; import { CardRepository } from 'models/card'; import type { @@ -32,9 +32,9 @@ export class WalletService extends WalletUseCase { return { bankAccountBalance: bankBalance, - vaBalance: cardsBalance[CardTypeEnum.VA], - vrBalance: cardsBalance[CardTypeEnum.VR], - vtBalance: cardsBalance[CardTypeEnum.VT], + vaBalance: cardsBalance[CardVariantEnum.VA], + vrBalance: cardsBalance[CardVariantEnum.VR], + vtBalance: cardsBalance[CardVariantEnum.VT], }; } } From 9c7b3d474e1647093fbb847c5d3f1aa0d0155a57 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Wed, 31 Jan 2024 17:03:18 -0300 Subject: [PATCH 17/19] Update README --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index b5e3782..4768fc6 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,19 @@ This project use lot's of tools to be as efficient as possible, here's the list | `db:migrate` | Run the migrations | | `db:gen-migration ` | Generates a new migration based on the schema-database difference (you must run `start:dev` and `db:migrate` before run this!) | +## Teams + +The teams names are based on different currencies around the world, but the countries that they are used don't have any influence on the things that the teams works on. + +| Team | Responsible for | Full list | +| ----- | --------------- | ------------------------------------------------------------------- | +| Real | Auth | Accounts, SignInProvider, MagicLink, RefreshToken, TermsAndPolicies | +| Franc | Profile | Configs, Salary | +| Yuan | Transactions | Transactions, Recurrent transactions | +| Peso | Cards | Cards, Card Providers, Cards Bills | +| Rand | Bank Accounts | Bank Accounts, Bank Providers, Subscriptions | +| Rupee | Budgets | Budgets, Categories | + ## Process to develop a new feature The following documentation is to help you to understand the development process of every feature. It's a general documentation that should **cover everything** in the feature creation process, **not all steps are required for every feature**, so make sure to only follow the ones that your context needs. From 2f2bbf5a6526e1124f573acec874e5e4eea414f7 Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Sun, 18 Feb 2024 22:41:37 -0300 Subject: [PATCH 18/19] Partially add salary (#6) --- README.md | 5 + .../migration.sql | 55 ++++++++ prisma/schema.prisma | 124 +++++++++++++----- src/models/account.ts | 8 -- src/models/salary.ts | 23 ++++ .../account/account-repository.service.ts | 2 - src/types/enums/rt-template.ts | 10 ++ src/usecases/account/account.service.ts | 8 -- src/usecases/salary/salary.module.ts | 10 ++ src/usecases/salary/salary.service.ts | 12 ++ 10 files changed, 204 insertions(+), 53 deletions(-) create mode 100644 prisma/migrations/20240219013610_refact_salary/migration.sql create mode 100644 src/models/salary.ts create mode 100644 src/types/enums/rt-template.ts create mode 100644 src/usecases/salary/salary.module.ts create mode 100644 src/usecases/salary/salary.service.ts diff --git a/README.md b/README.md index 4768fc6..1efddcb 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,11 @@ This project use lot's of tools to be as efficient as possible, here's the list | `db:migrate` | Run the migrations | | `db:gen-migration ` | Generates a new migration based on the schema-database difference (you must run `start:dev` and `db:migrate` before run this!) | +## How to create a migration + +- Run `yarn start:db` +- In another tab, run `yarn db:gen-migration ` + ## Teams The teams names are based on different currencies around the world, but the countries that they are used don't have any influence on the things that the teams works on. diff --git a/prisma/migrations/20240219013610_refact_salary/migration.sql b/prisma/migrations/20240219013610_refact_salary/migration.sql new file mode 100644 index 0000000..f510381 --- /dev/null +++ b/prisma/migrations/20240219013610_refact_salary/migration.sql @@ -0,0 +1,55 @@ +/* + Warnings: + + - You are about to drop the column `salary_id` on the `configs` table. All the data in the column will be lost. + +*/ +-- CreateEnum +CREATE TYPE "employment_contract_type_enum" AS ENUM ('CLT', 'PJ'); + +-- CreateEnum +CREATE TYPE "salary_type_enum" AS ENUM ('MONEY', 'BENEFIT_VA', 'BENEFIT_VT'); + +-- DropForeignKey +ALTER TABLE "configs" DROP CONSTRAINT "configs_salary_id_fkey"; + +-- DropIndex +DROP INDEX "configs_salary_id_key"; + +-- AlterTable +ALTER TABLE "configs" DROP COLUMN "salary_id"; + +-- CreateTable +CREATE TABLE "salaries" ( + "id" CHAR(16) NOT NULL, + "account_id" CHAR(16) NOT NULL, + "employment_contract_type" "employment_contract_type_enum" NOT NULL, + "start_at" TIMESTAMP(3), + + CONSTRAINT "salaries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "salary_recurrent_transactions" ( + "id" CHAR(16) NOT NULL, + "salary_id" CHAR(16) NOT NULL, + "recurrent_transaction_id" CHAR(16) NOT NULL, + "type" "salary_type_enum" NOT NULL, + + CONSTRAINT "salary_recurrent_transactions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "salaries_account_id_key" ON "salaries"("account_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "salary_recurrent_transactions_salary_id_type_key" ON "salary_recurrent_transactions"("salary_id", "type"); + +-- AddForeignKey +ALTER TABLE "salaries" ADD CONSTRAINT "salaries_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "salary_recurrent_transactions" ADD CONSTRAINT "salary_recurrent_transactions_salary_id_fkey" FOREIGN KEY ("salary_id") REFERENCES "salaries"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "salary_recurrent_transactions" ADD CONSTRAINT "salary_recurrent_transactions_recurrent_transaction_id_fkey" FOREIGN KEY ("recurrent_transaction_id") REFERENCES "recurrent_transactions"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ef085e7..8644a34 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,11 +19,11 @@ datasource db { url = env("DATABASE_URL") } -// +// ********************************** // // Accounts // -// +// ********************************** enum SignInProviderEnum { GOOGLE @@ -34,11 +34,12 @@ enum SignInProviderEnum { /// Contains user's sign in information model Account { id String @id @db.Char(16) - email String? @unique @db.VarChar(150) - phone String? @unique @db.VarChar(25) + email String? @db.VarChar(150) + phone String? @db.VarChar(25) createdAt DateTime @default(now()) @map("created_at") config Config? + salary Salary? onboarding Onboarding? magicLinkCode MagicLinkCode? signInProviders SignInProvider[] @@ -52,6 +53,8 @@ model Account { transactions Transaction[] recurrentTransactions RecurrentTransaction[] + @@unique([email]) + @@unique([phone]) @@map("accounts") } @@ -73,22 +76,22 @@ model SignInProvider { /// Contains user's account config model Config { id String @id @db.Char(16) /// Same as accountId - accountId String @unique @map("account_id") @db.Char(16) + accountId String @map("account_id") @db.Char(16) name String? @db.VarChar(20) - currentBudgetId String? @unique @map("current_budget_id") @db.Char(16) - salaryId String? @unique @map("salary_id") @db.Char(16) + currentBudgetId String? @map("current_budget_id") @db.Char(16) - account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) - currentBudget Budget? @relation(fields: [currentBudgetId], references: [id]) - salary RecurrentTransaction? @relation(fields: [salaryId], references: [id]) + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + currentBudget Budget? @relation(fields: [currentBudgetId], references: [id]) + @@unique([accountId]) + @@unique([currentBudgetId]) @@map("configs") } /// Contains data about the user's onboarding and the steps concluded model Onboarding { id String @id @db.Char(16) /// Same as accountId - accountId String @unique @map("account_id") @db.Char(16) + accountId String @map("account_id") @db.Char(16) name DateTime? @db.Timestamp categories DateTime? @db.Timestamp bankAccounts DateTime? @map("bank_accounts") @db.Timestamp @@ -98,6 +101,7 @@ model Onboarding { account Account? @relation(fields: [accountId], references: [id], onDelete: Cascade) + @@unique([accountId]) @@map("onboardings") } @@ -116,20 +120,68 @@ model MagicLinkCode { /// Contains codes to be used to refresh the access token model RefreshToken { accountId String @map("account_id") @db.Char(16) - refreshToken String @unique @map("refresh_token") @db.Char(64) + refreshToken String @map("refresh_token") @db.Char(64) createdAt DateTime @default(now()) @map("created_at") account Account? @relation(fields: [accountId], references: [id], onDelete: Cascade) @@id([accountId, refreshToken]) + @@unique([refreshToken]) @@map("refresh_tokens") } +// ********************************** // +// Salary // -// Subscriptions +// ********************************** + +enum EmploymentContractTypeEnum { + CLT + PJ + + @@map("employment_contract_type_enum") +} + +enum SalaryTypeEnum { + MONEY + BENEFIT_VA + BENEFIT_VT + + @@map("salary_type_enum") +} + +model Salary { + id String @id @db.Char(16) /// Same as accountId + accountId String @map("account_id") @db.Char(16) + employmentContractType EmploymentContractTypeEnum @map("employment_contract_type") + startAt DateTime? @map("start_at") + + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + salaryRecurrentTransactions SalaryRecurrentTransaction[] + + @@unique([accountId]) + @@map("salaries") +} + +model SalaryRecurrentTransaction { + id String @id @db.Char(16) + salaryId String @map("salary_id") @db.Char(16) + recurrentTransactionId String @map("recurrent_transaction_id") @db.Char(16) + type SalaryTypeEnum + + salary Salary @relation(fields: [salaryId], references: [id], onDelete: Cascade) + recurrentTransaction RecurrentTransaction @relation(fields: [recurrentTransactionId], references: [id], onDelete: Cascade) + + @@unique([salaryId, type]) + @@map("salary_recurrent_transactions") +} + +// ********************************** // +// Subscriptions // +// ********************************** /// Contains information about the subscriptions model Subscription { @@ -176,11 +228,11 @@ model UserSubscription { @@map("user_subscriptions") } -// +// ********************************** // // Terms of service // -// +// ********************************** /// Contains the terms of use and privacy policy model TermsAndPolicies { @@ -208,11 +260,11 @@ model TermsAndPoliciesAccepted { @@map("terms_and_policies_accepteds") } -// +// ********************************** // // Banks // -// +// ********************************** /// Contains the record of all the existent banks model BankProvider { @@ -254,11 +306,11 @@ model BankAccount { @@map("bank_accounts") } -// +// ********************************** // // Cards // -// +// ********************************** enum CardTypeEnum { CREDIT @@ -372,11 +424,11 @@ model CardBill { @@map("card_bills") } -// +// ********************************** // // Categories // -// +// ********************************** enum IconEnum { house @@ -455,11 +507,11 @@ model Category { @@map("categories") } -// +// ********************************** // // Budgets // -// +// ********************************** /// Contains the user's budget information model Budget { @@ -505,11 +557,11 @@ model BudgetItem { @@map("budget_items") } -// +// ********************************** // // Transactions // -// +// ********************************** enum TransactionTypeEnum { IN /// Add money to bank account @@ -573,11 +625,11 @@ model Installment { @@map("installments") } -// +// ********************************** // // Recurrent Transactions // -// +// ********************************** enum RecurrenceFrequencyEnum { DAILY /// Every day @@ -684,16 +736,18 @@ model RecurrentTransaction { bankAccountFromId String? @map("bank_account_from_id") @db.Char(16) /// Only type=TRANSFER transactions have this column bankAccountToId String? @map("bank_account_to_id") @db.Char(16) /// Only type=TRANSFER transactions have this column - account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) - budget Budget @relation(fields: [budgetId], references: [id], onDelete: Restrict) - config Config? + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + budget Budget @relation(fields: [budgetId], references: [id], onDelete: Restrict) + salaryRecurrentTransactions SalaryRecurrentTransaction[] // Transaction type=IN,OUT,CREDIT - category Category? @relation(fields: [categoryId], references: [id], onDelete: Restrict) - card Card? @relation(fields: [cardId], references: [id], onDelete: Restrict) - bankAccount BankAccount? @relation(name: "RecurrentTransactionBankAccount", fields: [bankAccountId], references: [id], onDelete: Restrict) + category Category? @relation(fields: [categoryId], references: [id], onDelete: Restrict) + // Transaction type=CREDIT + card Card? @relation(fields: [cardId], references: [id], onDelete: Restrict) + // Transaction type=IN,OUT + bankAccount BankAccount? @relation(name: "RecurrentTransactionBankAccount", fields: [bankAccountId], references: [id], onDelete: Restrict) // Transaction type=TRANSFER - bankAccountFrom BankAccount? @relation(name: "RecurrentTransactionBankAccountFrom", fields: [bankAccountFromId], references: [id], onDelete: Restrict) - bankAccountTo BankAccount? @relation(name: "RecurrentTransactionBankAccountTo", fields: [bankAccountToId], references: [id], onDelete: Restrict) + bankAccountFrom BankAccount? @relation(name: "RecurrentTransactionBankAccountFrom", fields: [bankAccountFromId], references: [id], onDelete: Restrict) + bankAccountTo BankAccount? @relation(name: "RecurrentTransactionBankAccountTo", fields: [bankAccountToId], references: [id], onDelete: Restrict) @@map("recurrent_transactions") } diff --git a/src/models/account.ts b/src/models/account.ts index 10b083b..ca6c898 100644 --- a/src/models/account.ts +++ b/src/models/account.ts @@ -24,7 +24,6 @@ export interface UpdateConfigInput { accountId: string; name?: string; currentBudgetId?: string; - salaryId?: string; } export interface GetOnboardingRecordInput { @@ -84,11 +83,6 @@ export interface SetBudgetInput { budgetId: string; } -export interface SetSalaryInput { - accountId: string; - salaryId: string; -} - export interface GetOnboardingInput { accountId: string; } @@ -119,8 +113,6 @@ export abstract class AccountUseCase { abstract setBudget(i: SetBudgetInput): Promise; - abstract setSalary(i: SetSalaryInput): Promise; - abstract getOnboarding(i: GetOnboardingInput): Promise; abstract updateOnboarding(i: UpdateOnboardingInput): Promise; diff --git a/src/models/salary.ts b/src/models/salary.ts new file mode 100644 index 0000000..de71540 --- /dev/null +++ b/src/models/salary.ts @@ -0,0 +1,23 @@ +import type { EmploymentContractTypeEnum } from '@prisma/client'; +import type { RtTemplateEnum } from 'types/enums/rt-template'; + +export interface CreateInput { + accountId: string; + employmentContractType: EmploymentContractTypeEnum; + startAt?: Date; + salaries: Array<{ + template: RtTemplateEnum; + baseAmounts: Array; + days?: Array; + name: string; + description: string; + // Transaction type=IN,OUT,CREDIT + categoryId?: string; + // Transaction type=IN,OUT + bankAccountId?: string; + }>; +} + +export abstract class SalaryUseCase { + abstract create(i: CreateInput): Promise; +} diff --git a/src/repositories/postgres/account/account-repository.service.ts b/src/repositories/postgres/account/account-repository.service.ts index b61faeb..9ce037c 100644 --- a/src/repositories/postgres/account/account-repository.service.ts +++ b/src/repositories/postgres/account/account-repository.service.ts @@ -52,7 +52,6 @@ export class AccountRepositoryService extends AccountRepository { accountId, name, currentBudgetId, - salaryId, }: UpdateConfigInput): Promise { await this.configRepository.update({ where: { @@ -61,7 +60,6 @@ export class AccountRepositoryService extends AccountRepository { data: { name, currentBudgetId, - salaryId, }, }); } diff --git a/src/types/enums/rt-template.ts b/src/types/enums/rt-template.ts new file mode 100644 index 0000000..4db3150 --- /dev/null +++ b/src/types/enums/rt-template.ts @@ -0,0 +1,10 @@ +/** + * Recurrent Transaction Template + * + * As the recurrent transactions only can be created + * using templates, we need this enum to allow the user + * to communicate with the backend + */ +export enum RtTemplateEnum { + 'SALARY_DEFAULT' = 'SALARY_DEFAULT', +} diff --git a/src/usecases/account/account.service.ts b/src/usecases/account/account.service.ts index e9c516b..d6111f1 100644 --- a/src/usecases/account/account.service.ts +++ b/src/usecases/account/account.service.ts @@ -11,7 +11,6 @@ import type { IamInput, IamOutput, SetBudgetInput, - SetSalaryInput, UpdateNameInput, UpdateOnboardingInput, UpdateOnboardingRecordInput, @@ -63,13 +62,6 @@ export class AccountService extends AccountUseCase { }); } - async setSalary({ accountId, salaryId }: SetSalaryInput): Promise { - await this.accountRepository.updateConfig({ - accountId, - salaryId, - }); - } - async getOnboarding({ accountId, }: GetOnboardingInput): Promise { diff --git a/src/usecases/salary/salary.module.ts b/src/usecases/salary/salary.module.ts new file mode 100644 index 0000000..e2942bf --- /dev/null +++ b/src/usecases/salary/salary.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SalaryService } from './salary.service'; + +@Module({ + controllers: [], + imports: [], + providers: [SalaryService], + exports: [SalaryService], +}) +export class SalaryModule {} diff --git a/src/usecases/salary/salary.service.ts b/src/usecases/salary/salary.service.ts new file mode 100644 index 0000000..f243775 --- /dev/null +++ b/src/usecases/salary/salary.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import type { CreateInput } from 'models/salary'; +import { SalaryUseCase } from 'models/salary'; + +@Injectable() +export class SalaryService extends SalaryUseCase { + constructor() { + super(); + } + + async create(_i: CreateInput): Promise {} +} From 0f514e47f9e6d5cf6563e298c59ee5c88a2b3f5f Mon Sep 17 00:00:00 2001 From: Henrique Leite Date: Sun, 18 Feb 2024 22:43:18 -0300 Subject: [PATCH 19/19] Add manual deploy button --- .github/workflows/api-deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/api-deploy.yml b/.github/workflows/api-deploy.yml index dc773b1..f253486 100644 --- a/.github/workflows/api-deploy.yml +++ b/.github/workflows/api-deploy.yml @@ -6,6 +6,7 @@ on: - release paths: - 'src/**' + workflow_dispatch: jobs: build-deploy: