Compare commits

..

45 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
adb1bea374 feat: use the same Authorization header for jwt and api key 2025-07-02 08:30:53 +02:00
Ahmed Bouhuolia
5d96357042 feat: clean up items controller 2025-07-01 23:48:56 +02:00
Ahmed Bouhuolia
9457b3cda1 feat: api keys 2025-07-01 23:45:38 +02:00
Ahmed Bouhuolia
84cb7693c8 feat: api keys 2025-07-01 23:05:58 +02:00
Ahmed Bouhuolia
9f6e9e85a5 feat(server): endpoints swagger docs 2025-06-30 16:30:55 +02:00
Ahmed Bouhuolia
83e698acf3 fix:create customer/vendor 2025-06-29 16:55:02 +02:00
Ahmed Bouhuolia
fa5c3bd955 feat: deleteIfNoRelations 2025-06-28 22:35:29 +02:00
Ahmed Bouhuolia
0ca98c7ae4 fix: cycle dependecy 2025-06-27 02:18:01 +02:00
Ahmed Bouhuolia
0c0e1dc22e fix: invoice generate sharable link 2025-06-27 01:59:46 +02:00
Ahmed Bouhuolia
e7178a6575 fix: adjust contact balance 2025-06-26 17:04:46 +02:00
Ahmed Bouhuolia
6a39e9d71f feat: endpoints swagger document 2025-06-22 23:46:39 +02:00
Ahmed Bouhuolia
9aa1ed93ca feat: update endpoint swagger docs 2025-06-22 20:58:53 +02:00
Ahmed Bouhuolia
b8c9919799 fox: journal sheet 2025-06-21 21:10:05 +02:00
Ahmed Bouhuolia
e5701140e1 feat: swagger doc 2025-06-21 20:55:32 +02:00
Ahmed Bouhuolia
91976842a7 fix: AR/AP aging report 2025-06-21 20:15:42 +02:00
Ahmed Bouhuolia
4d52059dba feat: swagger document endpoints 2025-06-19 21:04:54 +02:00
Ahmed Bouhuolia
26c1f118c1 feat: more response docs 2025-06-19 00:49:43 +02:00
Ahmed Bouhuolia
437bcb8854 feat: models default views 2025-06-17 20:53:13 +02:00
Ahmed Bouhuolia
f624cf7ae6 feat: document more endpoints 2025-06-16 23:40:12 +02:00
Ahmed Bouhuolia
e057b4e2f0 feat: add swagger docs 2025-06-16 15:53:00 +02:00
Ahmed Bouhuolia
c4668d7d22 feat: add swagger docs for responses 2025-06-16 13:50:30 +02:00
Ahmed Bouhuolia
88ef60ef28 fix: delete inventory adjustment gl entries 2025-06-15 17:51:44 +02:00
Ahmed Bouhuolia
bbf9ef9bc2 fix: formatted transaction type 2025-06-15 15:22:19 +02:00
Ahmed Bouhuolia
bcae2dae03 feat: change the controllers tags 2025-06-13 01:57:53 +02:00
Ahmed Bouhuolia
ff93168d72 refactor(nestjs): landed cost 2025-06-11 14:04:37 +02:00
Ahmed Bouhuolia
1130975efd refactor(nestjs): landed cost 2025-06-10 17:08:32 +02:00
Ahmed Bouhuolia
fa180b3ac5 refactor: gl entries 2025-06-10 12:29:46 +02:00
Ahmed Bouhuolia
90d6bea9b9 fix: mail state 2025-06-09 15:37:20 +02:00
Ahmed Bouhuolia
4366bf478a refactor: mail templates 2025-06-08 16:49:03 +02:00
Ahmed Bouhuolia
0a57b6e20e fix: cashflow statement localization 2025-06-06 20:40:56 +02:00
Ahmed Bouhuolia
9a685ffe5d refactor: financial reports query dtos 2025-06-06 00:11:51 +02:00
Ahmed Bouhuolia
51988dba3b refactor(nestjs): bank transactions matching 2025-06-05 14:41:26 +02:00
Ahmed Bouhuolia
f87bd341e9 refactor(nestjs): banking modules 2025-06-03 21:42:09 +02:00
Ahmed Bouhuolia
5595478e19 refactor(nestjs): banking module 2025-06-02 21:32:53 +02:00
Ahmed Bouhuolia
7247b52fe5 refactor(nestjs): banking module 2025-06-02 15:41:41 +02:00
Ahmed Bouhuolia
deadd5ac80 refactor(nestjs): plaid banking syncing 2025-06-01 18:38:44 +02:00
Ahmed Bouhuolia
66a2261e50 refactor(nestjs): wip 2025-05-28 21:32:48 +02:00
Ahmed Bouhuolia
c51347d3ec refactor(nestjs): wip import module 2025-05-28 17:01:46 +02:00
Ahmed Bouhuolia
b7a3c42074 refactor(nestjs): wip 2025-05-27 15:42:27 +02:00
Ahmed Bouhuolia
83c9392b74 refactor(nestjs): wip dtos validation schema 2025-05-26 17:04:53 +02:00
Ahmed Bouhuolia
24bf3dd06d refactor(nestjs): validation schema dtos 2025-05-25 23:39:54 +02:00
Ahmed Bouhuolia
2b3f98d8fe refactor(nestjs): hook the new endpoints 2025-05-22 19:55:55 +02:00
Ahmed Bouhuolia
4e64a9eadb refactor(nestjs): pdf templates 2025-05-22 13:36:10 +02:00
Ahmed Bouhuolia
0823bfc4e9 refactor(nestjs): contacts module 2025-05-20 23:55:39 +02:00
Ahmed Bouhuolia
99fe5a6b0d refactor(nestjs): Implement users module 2025-05-20 17:55:58 +02:00
583 changed files with 20856 additions and 2755 deletions

View File

@@ -39,6 +39,7 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^4.1.2",
"@nestjs/serve-static": "^5.0.3",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.2.1",
"@supercharge/promise-pool": "^3.2.0",
@@ -86,6 +87,7 @@
"object-hash": "^2.0.3",
"objection": "^3.1.5",
"passport": "^0.7.0",
"passport-headerapikey": "^1.2.2",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"plaid": "^10.3.0",

0
packages/server/public/pdf/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export default registerAs('bankfeed', () => ({
enabled:
process.env.BANK_FEED_ENABLED === 'true' ||
process.env.BANK_FEED_ENABLED === 'yes',
}));

View File

@@ -13,6 +13,7 @@ import signupRestrictions from './signup-restrictions';
import jwt from './jwt';
import mail from './mail';
import loops from './loops';
import bankfeed from './bankfeed';
export const config = [
systemDatabase,
@@ -29,5 +30,6 @@ export const config = [
signupRestrictions,
jwt,
mail,
loops
loops,
bankfeed,
];

View File

@@ -0,0 +1,57 @@
/**
* Map to store all models that have been marked to prevent base currency mutation.
* Key is the model name, value is the model class.
*/
export const preventMutateBaseCurrencyModels = new Map<string, any>();
/**
* Decorator that marks an ORM model to prevent base currency mutation.
* When applied to a model class, it adds a static property `preventMutateBaseCurrency` set to true
* and registers the model in the preventMutateBaseCurrencyModels map.
*
* @returns {ClassDecorator} A decorator function that can be applied to a class.
*/
export function PreventMutateBaseCurrency(): ClassDecorator {
return (target: any) => {
// Set the static property on the model class
target.preventMutateBaseCurrency = true;
// Register the model in the map
const modelName = target.name;
preventMutateBaseCurrencyModels.set(modelName, target);
// Return the modified class
return target;
};
}
/**
* Get all registered models that prevent base currency mutation.
*
* @returns {Map<string, any>} Map of model names to model classes
*/
export function getPreventMutateBaseCurrencyModels(): Map<string, any> {
return preventMutateBaseCurrencyModels;
}
/**
* Check if a model is registered to prevent base currency mutation.
*
* @param {string} modelName - The name of the model to check
* @returns {boolean} True if the model is registered, false otherwise
*/
export function isModelPreventMutateBaseCurrency(modelName: string): boolean {
return preventMutateBaseCurrencyModels.has(modelName);
}
/**
* Get a specific model by name that prevents base currency mutation.
*
* @param {string} modelName - The name of the model to retrieve
* @returns {any | undefined} The model class if found, undefined otherwise
*/
export function getPreventMutateBaseCurrencyModel(
modelName: string,
): any | undefined {
return preventMutateBaseCurrencyModels.get(modelName);
}

View File

@@ -0,0 +1,32 @@
import { Transform } from 'class-transformer';
import { ValidateIf, ValidationOptions } from 'class-validator';
/**
* Decorator that converts the property value to a number.
* @returns PropertyDecorator
*/
export function ToNumber() {
return Transform(({ value, key }) => {
const defaultValue = null;
if (typeof value === 'number') {
return value;
}
// If value is an empty string or undefined/null, return it as-is (wont pass validation)
if (value === '' || value === null || value === undefined) {
return defaultValue;
}
const parsed = Number(value);
return !isNaN(parsed) ? parsed : value;
});
}
/**
* Validates if the property is not empty.
* @returns PropertyDecorator
*/
export function IsOptional(validationOptions?: ValidationOptions) {
return ValidateIf((_obj, value) => {
return value !== null && value !== undefined && value !== '';
}, validationOptions);
}

View File

@@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
class Pagination {
@ApiProperty({
description: 'Total number of items across all pages',
example: 100,
})
total: number;
@ApiProperty({
description: 'Current page number (1-based)',
example: 1,
minimum: 1,
})
page: number;
@ApiProperty({
description: 'Number of items per page',
example: 10,
minimum: 1,
})
pageSize: number;
}
export class PaginatedResponseDto<TData> {
@ApiProperty({
description: 'Pagination metadata',
type: Pagination,
})
pagination: Pagination;
data: TData[];
}

View File

@@ -0,0 +1,9 @@
export class ModelHasRelationsError extends Error {
type: string;
constructor(type: string = 'ModelHasRelations', message?: string) {
message = message || `Entity has relations`;
super(message);
this.type = type;
}
}

View File

@@ -0,0 +1,27 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { ModelHasRelationsError } from '../exceptions/ModelHasRelations.exception';
@Catch(ModelHasRelationsError)
export class ModelHasRelationsFilter implements ExceptionFilter {
catch(exception: ModelHasRelationsError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = HttpStatus.CONFLICT;
response.status(status).json({
errors: [
{
statusCode: status,
type: exception.type || 'MODEL_HAS_RELATIONS',
message: exception.message,
},
],
});
}
}

View File

@@ -70,7 +70,10 @@ export class SerializeInterceptor implements NestInterceptor<any, any> {
next: CallHandler<any>,
): Observable<any> {
const request = context.switchToHttp().getRequest();
// Transform both body and query parameters
request.body = this.strategy.in(request.body);
request.query = this.strategy.in(request.query);
// handle returns stream..
return next.handle().pipe(map(this.strategy.out));

View File

@@ -14,12 +14,15 @@ export class ValidationPipe implements PipeTransform<any> {
return value;
}
const object = plainToInstance(metatype, value);
const errors = await validate(object);
const errors = await validate(object, {
// Strip validated object of any properties that do not have any decorators.
whitelist: true,
});
if (errors.length > 0) {
throw new BadRequestException(errors);
}
return value;
return object;
}
private toValidate(metatype: Function): boolean {

View File

@@ -0,0 +1,7 @@
import { QueryBuilder, Model } from 'objection';
declare module 'objection' {
interface QueryBuilder<M extends Model, R = M[]> {
deleteIfNoRelations(this: QueryBuilder<M, R>, ...args: any[]): Promise<any>;
}
}

View File

@@ -0,0 +1,3 @@
{
"head_branch": "Head Branch"
}

View File

@@ -1,9 +1,9 @@
{
"previoud_period_date": "{{date}} (PP)",
"previoud_period_date": "{date} (PP)",
"fianncial_sheet.previous_period_change": "Change (PP)",
"previous_period_percentage": "% Change (PP)",
"previous_year_date": "{{date}} (PY)",
"previous_year_date": "{date} (PY)",
"previous_year_change": "Change (PY)",
"previous_year_percentage": "% Change (PY)",
"total_row": "Total {{value}}"
"total_row": "Total {value}"
}

View File

@@ -0,0 +1,4 @@
{
"decrement": "Decrement",
"increment": "Increment"
}

View File

@@ -0,0 +1,25 @@
{
"sale_invoice": "Sale invoice",
"sale_receipt": "Sale receipt",
"payment_received": "Payment received",
"bill": "Bill",
"bill_payment": "Payment made",
"vendor_opening_balance": "Vendor opening balance",
"customer_opening_balance": "Customer opening balance",
"inventory_adjustment": "Inventory adjustment",
"manual_journal": "Manual journal",
"expense": "Expense",
"owner_contribution": "Owner contribution",
"transfer_to_account": "Transfer to account",
"transfer_from_account": "Transfer from account",
"other_income": "Other income",
"other_expense": "Other expense",
"owner_drawing": "Owner drawing",
"invoice_write_off": "Invoice write-off",
"credit_note": "Credit Note",
"vendor_credit": "Vendor Credit",
"refund_credit_note": "Refund Credit Note",
"refund_vendor_credit": "Refund Vendor Credit",
"landed_cost": "Landed Cost",
"payment_made": "Payment made"
}

View File

@@ -0,0 +1,6 @@
{
"view.draft": "Draft",
"view.published": "Published",
"view.open": "Open",
"view.closed": "Closed"
}

View File

@@ -0,0 +1,4 @@
{
"primary_warehouse": "Primary Warehouse"
}

View File

@@ -1,4 +1,4 @@
import FormData from 'form-data';
import * as FormData from 'form-data';
import { GotenbergUtils } from './GotenbergUtils';
import { PageProperties } from './_types';

View File

@@ -1,5 +1,5 @@
import FormData from 'form-data';
import Axios from 'axios';
import * as FormData from 'form-data';
import { Axios } from 'axios';
export class GotenbergUtils {
public static assert(condition: boolean, message: string): asserts condition {
@@ -10,12 +10,12 @@ export class GotenbergUtils {
public static async fetch(endpoint: string, data: FormData): Promise<Buffer> {
try {
const response = await Axios.post(endpoint, data, {
const response = await new Axios({
headers: {
...data.getHeaders(),
},
responseType: 'arraybuffer', // This ensures you get a Buffer bac
});
}).post(endpoint, data);
return response.data;
} catch (error) {
console.error(error);

View File

@@ -1,5 +1,5 @@
import { constants, createReadStream, PathLike, promises } from 'fs';
import FormData from 'form-data';
import * as FormData from 'form-data';
import { GotenbergUtils } from './GotenbergUtils';
import { IConverter, PageProperties } from './_types';
import { PdfFormat, ChromiumRoute } from './_types';

View File

@@ -1,4 +1,4 @@
import FormData from 'form-data';
import * as FormData from 'form-data';
import { IConverter, PageProperties, PdfFormat, ChromiumRoute } from './_types';
import { ConverterUtils } from './ConvertUtils';
import { Converter } from './Converter';

View File

@@ -5,9 +5,11 @@ import * as path from 'path';
import './utils/moment-mysql';
import { AppModule } from './modules/App/App.module';
import { ServiceErrorFilter } from './common/filters/service-error.filter';
import { ModelHasRelationsFilter } from './common/filters/model-has-relations.filter';
import { ValidationPipe } from './common/pipes/ClassValidation.pipe';
import { ToJsonInterceptor } from './common/interceptors/to-json.interceptor';
global.__public_dirname = path.join(__dirname, '..', 'public');
global.__static_dirname = path.join(__dirname, '../static');
global.__views_dirname = path.join(global.__static_dirname, '/views');
global.__images_dirname = path.join(global.__static_dirname, '/images');
@@ -35,6 +37,7 @@ async function bootstrap() {
SwaggerModule.setup('swagger', app, documentFactory);
app.useGlobalFilters(new ServiceErrorFilter());
app.useGlobalFilters(new ModelHasRelationsFilter());
await app.listen(process.env.PORT ?? 3000);
}

View File

@@ -1,4 +1,5 @@
import { QueryBuilder, Model } from 'objection';
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
interface PaginationResult<M extends Model> {
results: M[];
@@ -14,13 +15,13 @@ export type PaginationQueryBuilderType<M extends Model> = QueryBuilder<
PaginationResult<M>
>;
class PaginationQueryBuilder<M extends Model, R = M[]> extends QueryBuilder<
M,
R
> {
export class PaginationQueryBuilder<
M extends Model,
R = M[],
> extends QueryBuilder<M, R> {
pagination(page: number, pageSize: number): PaginationQueryBuilderType<M> {
const query = super.page(page, pageSize);
return query.runAfter(({ results, total }) => {
return {
results,
@@ -32,12 +33,58 @@ class PaginationQueryBuilder<M extends Model, R = M[]> extends QueryBuilder<
};
}) as unknown as PaginationQueryBuilderType<M>;
}
async deleteIfNoRelations({
type,
message,
}: {
type?: string;
message?: string;
} = {}) {
const relationMappings = this.modelClass().relationMappings;
const relationNames = Object.keys(relationMappings || {});
if (relationNames.length === 0) {
// No relations defined
return this.delete();
}
const recordQuery = this.clone();
relationNames.forEach((relationName: string) => {
recordQuery.withGraphFetched(relationName);
});
const record = await recordQuery;
const hasRelations = relationNames.some((name) => {
const val = record[name];
return Array.isArray(val) ? val.length > 0 : val != null;
});
if (!hasRelations) {
return this.clone().delete();
} else {
throw new ModelHasRelationsError(type, message);
}
}
}
export class BaseQueryBuilder<
M extends Model,
R = M[],
> extends PaginationQueryBuilder<M, R> {
changeAmount(whereAttributes, attribute, amount) {
const changeMethod = amount > 0 ? 'increment' : 'decrement';
return this.where(whereAttributes)[changeMethod](
attribute,
Math.abs(amount),
);
}
}
export class BaseModel extends Model {
public readonly id: number;
public readonly tableName: string;
QueryBuilderType!: PaginationQueryBuilder<this>;
static QueryBuilder = PaginationQueryBuilder;
QueryBuilderType!: BaseQueryBuilder<this>;
static QueryBuilder = BaseQueryBuilder;
}

View File

@@ -85,9 +85,7 @@ export class AccountTransformer extends Transformer {
* @returns {boolean}
*/
protected isFeedsPaused = (account: Account): boolean => {
// return account.plaidItem?.isPaused || false;
return false;
return account.plaidItem?.isPaused || false;
};
/**

View File

@@ -11,11 +11,25 @@ import {
import { AccountsApplication } from './AccountsApplication.service';
import { CreateAccountDTO } from './CreateAccount.dto';
import { EditAccountDTO } from './EditAccount.dto';
import { IAccountsFilter, IAccountsTransactionsFilter } from './Accounts.types';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { IAccountsFilter } from './Accounts.types';
import {
ApiExtraModels,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import { AccountResponseDto } from './dtos/AccountResponse.dto';
import { AccountTypeResponseDto } from './dtos/AccountTypeResponse.dto';
import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto';
import { GetAccountTransactionsQueryDto } from './dtos/GetAccountTransactionsQuery.dto';
@Controller('accounts')
@ApiTags('accounts')
@ApiTags('Accounts')
@ApiExtraModels(AccountResponseDto)
@ApiExtraModels(AccountTypeResponseDto)
@ApiExtraModels(GetAccountTransactionResponseDto)
export class AccountsController {
constructor(private readonly accountsApplication: AccountsApplication) {}
@@ -105,6 +119,12 @@ export class AccountsController {
@ApiResponse({
status: 200,
description: 'The account types have been successfully retrieved.',
schema: {
type: 'array',
items: {
$ref: getSchemaPath(AccountTypeResponseDto),
},
},
})
async getAccountTypes() {
return this.accountsApplication.getAccountTypes();
@@ -115,8 +135,16 @@ export class AccountsController {
@ApiResponse({
status: 200,
description: 'The account transactions have been successfully retrieved.',
schema: {
type: 'array',
items: {
$ref: getSchemaPath(GetAccountTransactionResponseDto),
},
},
})
async getAccountTransactions(@Query() filter: IAccountsTransactionsFilter) {
async getAccountTransactions(
@Query() filter: GetAccountTransactionsQueryDto,
) {
return this.accountsApplication.getAccountsTransactions(filter);
}
@@ -125,6 +153,7 @@ export class AccountsController {
@ApiResponse({
status: 200,
description: 'The account details have been successfully retrieved.',
schema: { $ref: getSchemaPath(AccountResponseDto) },
})
@ApiResponse({ status: 404, description: 'The account not found.' })
@ApiParam({
@@ -142,6 +171,10 @@ export class AccountsController {
@ApiResponse({
status: 200,
description: 'The accounts have been successfully retrieved.',
schema: {
type: 'array',
items: { $ref: getSchemaPath(AccountResponseDto) },
},
})
async getAccounts(@Query() filter: Partial<IAccountsFilter>) {
return this.accountsApplication.getAccounts(filter);

View File

@@ -10,13 +10,10 @@ import { GetAccount } from './GetAccount.service';
import { ActivateAccount } from './ActivateAccount.service';
import { GetAccountTypesService } from './GetAccountTypes.service';
import { GetAccountTransactionsService } from './GetAccountTransactions.service';
import {
IAccountsFilter,
IAccountsTransactionsFilter,
IGetAccountTransactionPOJO,
} from './Accounts.types';
import { IAccountsFilter, IAccountsTransactionsFilter } from './Accounts.types';
import { GetAccountsService } from './GetAccounts.service';
import { IFilterMeta } from '@/interfaces/Model';
import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto';
@Injectable()
export class AccountsApplication {
@@ -127,7 +124,7 @@ export class AccountsApplication {
*/
public getAccountsTransactions = (
filter: IAccountsTransactionsFilter,
): Promise<IGetAccountTransactionPOJO[]> => {
): Promise<Array<GetAccountTransactionResponseDto>> => {
return this.getAccountTransactionsService.getAccountsTransactions(filter);
};
}

View File

@@ -1,6 +1,5 @@
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
// import { IAccountEventDeletedPayload } from '@/interfaces';
import { CommandAccountValidators } from './CommandAccountValidators.service';
import { Account } from './models/Account.model';
import { EventEmitter2 } from '@nestjs/event-emitter';
@@ -8,6 +7,7 @@ import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { IAccountEventDeletedPayload } from './Accounts.types';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { ERRORS } from './constants';
@Injectable()
export class DeleteAccount {
@@ -70,8 +70,12 @@ export class DeleteAccount {
await this.unassociateChildrenAccountsFromParent(accountId, trx);
// Deletes account by the given id.
await this.accountModel().query(trx).deleteById(accountId);
await this.accountModel()
.query(trx)
.findById(accountId)
.deleteIfNoRelations({
type: ERRORS.ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS,
});
// Triggers `onAccountDeleted` event.
await this.eventEmitter.emitAsync(events.accounts.onDeleted, {
accountId,

View File

@@ -6,6 +6,7 @@ import { TransformerInjectable } from '../Transformer/TransformerInjectable.serv
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { AccountResponseDto } from './dtos/AccountResponse.dto';
@Injectable()
export class GetAccount {
@@ -19,9 +20,10 @@ export class GetAccount {
/**
* Retrieve the given account details.
* @param {number} accountId
* @param {number} accountId - The account id.
* @returns {Promise<IAccount>} - The account details.
*/
public getAccount = async (accountId: number) => {
public async getAccount(accountId: number): Promise<AccountResponseDto> {
// Find the given account or throw not found error.
const account = await this.accountModel()
.query()
@@ -43,5 +45,5 @@ export class GetAccount {
await this.eventEmitter.emitAsync(events.accounts.onViewed, eventPayload);
return transformed;
};
}
}

View File

@@ -8,6 +8,7 @@ import { Account } from './models/Account.model';
import { Inject, Injectable } from '@nestjs/common';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto';
@Injectable()
export class GetAccountTransactionsService {
@@ -29,7 +30,7 @@ export class GetAccountTransactionsService {
*/
public getAccountsTransactions = async (
filter: IAccountsTransactionsFilter,
): Promise<IGetAccountTransactionPOJO[]> => {
): Promise<Array<GetAccountTransactionResponseDto>> => {
// Retrieve the given account or throw not found error.
if (filter.accountId) {
await this.account().query().findById(filter.accountId).throwIfNotFound();

View File

@@ -13,9 +13,7 @@ export class MutateBaseCurrencyAccounts {
* Mutates the all accounts or the organziation.
* @param {string} currencyCode
*/
mutateAllAccountsCurrency = async (
currencyCode: string,
) => {
await Account.query().update({ currencyCode });
};
async mutateAllAccountsCurrency(currencyCode: string) {
await this.accountModel().query().update({ currencyCode });
}
}

View File

@@ -34,7 +34,7 @@ export const DEFAULT_VIEW_COLUMNS = [
export const MAX_ACCOUNTS_CHART_DEPTH = 5;
// Accounts default views.
export const DEFAULT_VIEWS = [
export const AccountDefaultViews = [
{
name: 'Assets',
slug: 'assets',

View File

@@ -0,0 +1,171 @@
import { ApiProperty } from '@nestjs/swagger';
export class AccountResponseDto {
@ApiProperty({
description: 'The unique identifier of the account',
example: 1,
})
id: number;
@ApiProperty({
description: 'The name of the account',
example: 'Cash Account',
})
name: string;
@ApiProperty({
description: 'The slug of the account',
example: 'cash-account',
})
slug: string;
@ApiProperty({
description: 'The code of the account',
example: '1001',
})
code: string;
@ApiProperty({
description: 'The index of the account',
example: 1,
})
index: number;
@ApiProperty({
description: 'The type of the account',
example: 'bank',
})
accountType: string;
@ApiProperty({
description: 'The formatted account type label',
example: 'Bank Account',
})
accountTypeLabel: string;
@ApiProperty({
description: 'The parent account ID',
example: null,
})
parentAccountId: number | null;
@ApiProperty({
description: 'Whether the account is predefined',
example: false,
})
predefined: boolean;
@ApiProperty({
description: 'The currency code of the account',
example: 'USD',
})
currencyCode: string;
@ApiProperty({
description: 'Whether the account is active',
example: true,
})
active: boolean;
@ApiProperty({
description: 'The bank balance of the account',
example: 5000.0,
})
bankBalance: number;
@ApiProperty({
description: 'The formatted bank balance',
example: '$5,000.00',
})
bankBalanceFormatted: string;
@ApiProperty({
description: 'The last feeds update timestamp',
example: '2024-03-20T10:30:00Z',
})
lastFeedsUpdatedAt: string | Date | null;
@ApiProperty({
description: 'The formatted last feeds update timestamp',
example: 'Mar 20, 2024 10:30 AM',
})
lastFeedsUpdatedAtFormatted: string;
@ApiProperty({
description: 'The amount of the account',
example: 5000.0,
})
amount: number;
@ApiProperty({
description: 'The formatted amount',
example: '$5,000.00',
})
formattedAmount: string;
@ApiProperty({
description: 'The Plaid item ID',
example: 'plaid-item-123',
})
plaidItemId: string;
@ApiProperty({
description: 'The Plaid account ID',
example: 'plaid-account-456',
})
plaidAccountId: string | null;
@ApiProperty({
description: 'Whether the feeds are active',
example: true,
})
isFeedsActive: boolean;
@ApiProperty({
description: 'Whether the account is syncing owner',
example: true,
})
isSyncingOwner: boolean;
@ApiProperty({
description: 'Whether the feeds are paused',
example: false,
})
isFeedsPaused: boolean;
@ApiProperty({
description: 'The account normal',
example: 'debit',
})
accountNormal: string;
@ApiProperty({
description: 'The formatted account normal',
example: 'Debit',
})
accountNormalFormatted: string;
@ApiProperty({
description: 'The flatten name with all dependant accounts names',
example: 'Assets: Cash Account',
})
flattenName: string;
@ApiProperty({
description: 'The account level in the hierarchy',
example: 2,
})
accountLevel?: number;
@ApiProperty({
description: 'The creation timestamp',
example: '2024-03-20T10:00:00Z',
})
createdAt: Date;
@ApiProperty({
description: 'The update timestamp',
example: '2024-03-20T10:30:00Z',
})
updatedAt: Date;
}

View File

@@ -0,0 +1,51 @@
import { ApiProperty, ApiExtraModels } from '@nestjs/swagger';
export class AccountTypeResponseDto {
@ApiProperty({
description: 'The display label for the account type',
example: 'Cash',
})
label: string;
@ApiProperty({
description: 'The unique key for the account type',
example: 'cash',
})
key: string;
@ApiProperty({
description: 'The normal balance type for the account',
example: 'debit',
})
normal: string;
@ApiProperty({
description: 'The parent type of the account',
example: 'current-asset',
})
parentType: string;
@ApiProperty({
description: 'The root type of the account',
example: 'asset',
})
rootType: string;
@ApiProperty({
description: 'Whether the account type supports multiple currencies',
example: true,
})
multiCurrency: boolean;
@ApiProperty({
description: 'Whether the account type appears on the balance sheet',
example: true,
})
balanceSheet: boolean;
@ApiProperty({
description: 'Whether the account type appears on the income sheet',
example: false,
})
incomeSheet: boolean;
}

View File

@@ -0,0 +1,114 @@
import { ApiProperty } from '@nestjs/swagger';
export class GetAccountTransactionResponseDto {
/**
* The transaction date (ISO string or Date).
*/
@ApiProperty({
description: 'The transaction date (ISO string or Date)',
example: '2024-01-01',
})
date: string | Date;
/**
* The formatted transaction date (string).
*/
@ApiProperty({
description: 'The formatted transaction date',
example: '01 Jan 2024',
})
formattedDate: string;
/**
* The transaction type (referenceType from model).
*/
@ApiProperty({
description: 'The transaction type (referenceType from model)',
example: 'INVOICE',
})
transactionType: string;
/**
* The transaction id (referenceId from model).
*/
@ApiProperty({
description: 'The transaction id (referenceId from model)',
example: 123,
})
transactionId: number;
/**
* The formatted transaction type (translated string).
*/
@ApiProperty({
description: 'The formatted transaction type (translated string)',
example: 'Invoice',
})
transactionTypeFormatted: string;
/**
* The credit amount (number).
*/
@ApiProperty({ description: 'The credit amount', example: 100 })
credit: number;
/**
* The debit amount (number).
*/
@ApiProperty({ description: 'The debit amount', example: 50 })
debit: number;
/**
* The formatted credit amount (string, e.g. currency formatted).
*/
@ApiProperty({
description: 'The formatted credit amount (e.g. currency formatted)',
example: '100.00 USD',
})
formattedCredit: string;
/**
* The formatted debit amount (string, e.g. currency formatted).
*/
@ApiProperty({
description: 'The formatted debit amount (e.g. currency formatted)',
example: '50.00 USD',
})
formattedDebit: string;
/**
* The foreign currency credit (number, credit * exchangeRate).
*/
@ApiProperty({
description: 'The foreign currency credit (credit * exchangeRate)',
example: 120,
})
fcCredit: number;
/**
* The foreign currency debit (number, debit * exchangeRate).
*/
@ApiProperty({
description: 'The foreign currency debit (debit * exchangeRate)',
example: 60,
})
fcDebit: number;
/**
* The formatted foreign currency credit (string).
*/
@ApiProperty({
description: 'The formatted foreign currency credit',
example: '120.00 EUR',
})
formattedFcCredit: string;
/**
* The formatted foreign currency debit (string).
*/
@ApiProperty({
description: 'The formatted foreign currency debit',
example: '60.00 EUR',
})
formattedFcDebit: string;
}

View File

@@ -0,0 +1,23 @@
import { IsInt, IsOptional } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { ToNumber } from '@/common/decorators/Validators';
export class GetAccountTransactionsQueryDto {
@ApiPropertyOptional({
type: Number,
description: 'ID of the account to fetch transactions for',
})
@IsOptional()
@IsInt()
@ToNumber()
accountId?: number;
@ApiPropertyOptional({
type: Number,
description: 'Maximum number of transactions to return',
})
@IsOptional()
@IsInt()
@ToNumber()
limit?: number;
}

View File

@@ -14,10 +14,13 @@ import { ExportableModel } from '../../Export/decorators/ExportableModel.decorat
import { AccountMeta } from './Account.meta';
import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator';
import { ImportableModel } from '@/modules/Import/decorators/Import.decorator';
import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator';
import { AccountDefaultViews } from '../constants';
@ExportableModel()
@ImportableModel()
@InjectModelMeta(AccountMeta)
@InjectModelDefaultViews(AccountDefaultViews)
export class Account extends TenantBaseModel {
public name!: string;
public slug!: string;
@@ -227,7 +230,7 @@ export class Account extends TenantBaseModel {
to: 'accounts_transactions.accountId',
},
},
/**
* Account may has many items as cost account.
*/
@@ -422,20 +425,6 @@ export class Account extends TenantBaseModel {
});
}
/**
* Model settings.
*/
// static get meta() {
// return AccountSettings;
// }
/**
* Retrieve the default custom views, roles and columns.
*/
// static get defaultViews() {
// return DEFAULT_VIEWS;
// }
/**
* Model search roles.
*/

View File

@@ -4,6 +4,7 @@ import { unitOfTime } from 'moment';
import { isEmpty, castArray } from 'lodash';
import { BaseModel } from '@/models/Model';
import { Account } from './Account.model';
import { getTransactionTypeLabel } from '@/modules/BankingTransactions/utils';
// import { getTransactionTypeLabel } from '@/utils/transactions-types';
export class AccountTransaction extends BaseModel {
@@ -19,7 +20,6 @@ export class AccountTransaction extends BaseModel {
public readonly date: Date | string;
public readonly transactionType: string;
public readonly currencyCode: string;
public readonly referenceTypeFormatted: string;
public readonly transactionNumber!: string;
public readonly referenceNumber!: string;
public readonly note!: string;
@@ -72,13 +72,13 @@ export class AccountTransaction extends BaseModel {
return this.debit * this.exchangeRate;
}
// /**
// * Retrieve formatted reference type.
// * @return {string}
// */
// get referenceTypeFormatted() {
// return getTransactionTypeLabel(this.referenceType, this.transactionType);
// }
/**
* Retrieve formatted reference type.
* @return {string}
*/
get referenceTypeFormatted() {
return getTransactionTypeLabel(this.referenceType, this.transactionType);
}
/**
* Model modifiers.

View File

@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { join } from 'path';
import { ServeStaticModule } from '@nestjs/serve-static';
import { RedisModule } from '@liaoliaots/nestjs-redis';
import {
AcceptLanguageResolver,
@@ -88,9 +89,18 @@ import { ViewsModule } from '../Views/Views.module';
import { CurrenciesModule } from '../Currencies/Currencies.module';
import { MiscellaneousModule } from '../Miscellaneous/Miscellaneous.module';
import { UsersModule } from '../UsersModule/Users.module';
import { ContactsModule } from '../Contacts/Contacts.module';
import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module';
import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module';
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module';
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: join(__dirname, '../../..', 'public'),
serveRoot: '/public',
}),
ConfigModule.forRoot({
envFilePath: '.env',
load: config,
@@ -149,6 +159,7 @@ import { UsersModule } from '../UsersModule/Users.module';
ScheduleModule.forRoot(),
TenancyDatabaseModule,
TenancyModelsModule,
TenantModelsInitializeModule,
AuthModule,
TenancyModule,
ChromiumlyTenancyModule,
@@ -169,6 +180,7 @@ import { UsersModule } from '../UsersModule/Users.module';
SaleEstimatesModule,
SaleReceiptsModule,
BillsModule,
BillLandedCostsModule,
ManualJournalsModule,
CreditNotesModule,
VendorCreditsModule,
@@ -181,10 +193,12 @@ import { UsersModule } from '../UsersModule/Users.module';
LedgerModule,
BankAccountsModule,
BankRulesModule,
BankingTransactionsModule,
BankingTransactionsExcludeModule,
BankingTransactionsRegonizeModule,
BankingTransactionsModule,
BankingMatchingModule,
BankingPlaidModule,
BankingCategorizeModule,
TransactionsLockingModule,
SettingsModule,
FeaturesModule,
@@ -210,7 +224,8 @@ import { UsersModule } from '../UsersModule/Users.module';
ViewsModule,
CurrenciesModule,
MiscellaneousModule,
UsersModule
UsersModule,
ContactsModule,
],
controllers: [AppController],
providers: [

View File

@@ -33,6 +33,7 @@ const models = [
@Module({
imports: [S3Module, ...models],
exports: [...models],
controllers: [AttachmentsController],
providers: [
DeleteAttachment,

View File

@@ -1,4 +1,4 @@
import path from 'path';
import * as path from 'path';
// import config from '@/config';
export const getUploadedObjectUri = (objectKey: string) => {

View File

@@ -26,3 +26,5 @@ export const SendResetPasswordMailJob = 'SendResetPasswordMailJob';
export const SendSignupVerificationMailQueue =
'SendSignupVerificationMailQueue';
export const SendSignupVerificationMailJob = 'SendSignupVerificationMailJob';
export const AuthApiKeyPrefix = 'bc_';

View File

@@ -34,7 +34,10 @@ export class AuthController {
@UseGuards(LocalAuthGuard)
@ApiOperation({ summary: 'Sign in a user' })
@ApiBody({ type: AuthSigninDto })
async signin(@Request() req: Request & { user: SystemUser }, @Body() signinDto: AuthSigninDto) {
async signin(
@Request() req: Request & { user: SystemUser },
@Body() signinDto: AuthSigninDto,
) {
const { user } = req;
const tenant = await this.tenantModel.query().findById(user.tenantId);
@@ -68,7 +71,6 @@ export class AuthController {
return this.authApp.signUpConfirm(email, token);
}
@Post('/send_reset_password')
@ApiOperation({ summary: 'Send reset password email' })
@ApiBody({

View File

@@ -32,11 +32,22 @@ import { AuthedController } from './Authed.controller';
import { GetAuthenticatedAccount } from './queries/GetAuthedAccount.service';
import { TenancyModule } from '../Tenancy/Tenancy.module';
import { EnsureUserVerifiedGuard } from './guards/EnsureUserVerified.guard';
import { ApiKeyAuthGuard } from './api-key/AuthApiKey.guard';
import { MixedAuthGuard } from './api-key/MixedAuth.guard';
import { ApiKeyStrategy } from './api-key/AuthApiKey.strategy';
import { ApiKeyModel } from './models/ApiKey.model';
import { AuthApiKeysController } from './AuthApiKeys.controllers';
import { AuthApiKeyAuthorizeService } from './commands/AuthApiKeyAuthorization.service';
import { GenerateApiKey } from './commands/GenerateApiKey.service';
import { GetApiKeysService } from './queries/GetApiKeys.service';
const models = [InjectSystemModel(PasswordReset)];
const models = [
InjectSystemModel(PasswordReset),
InjectSystemModel(ApiKeyModel),
];
@Module({
controllers: [AuthController, AuthedController],
controllers: [AuthController, AuthedController, AuthApiKeysController],
imports: [
MailModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
@@ -70,9 +81,15 @@ const models = [InjectSystemModel(PasswordReset)];
SendSignupVerificationMailProcessor,
GetAuthMetaService,
GetAuthenticatedAccount,
ApiKeyAuthGuard,
ApiKeyStrategy,
AuthApiKeyAuthorizeService,
GenerateApiKey,
GetApiKeysService,
JwtAuthGuard,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
useClass: MixedAuthGuard,
},
{
provide: APP_GUARD,

View File

@@ -1,4 +1,5 @@
import * as bcrypt from 'bcrypt';
import { AuthApiKeyPrefix } from './Auth.constants';
export const hashPassword = (password: string): Promise<string> =>
new Promise((resolve) => {
@@ -8,3 +9,12 @@ export const hashPassword = (password: string): Promise<string> =>
});
});
});
/**
* Extracts and validates an API key from the Authorization header
* @param {string} authorization - Full authorization header content.
*/
export const getAuthApiKey = (authorization: string) => {
const apiKey = authorization.toLowerCase().replace('bearer ', '').trim();
return apiKey.startsWith(AuthApiKeyPrefix) ? apiKey : '';
};

View File

@@ -0,0 +1,26 @@
import { Controller, Post, Param, Get, Put } from '@nestjs/common';
import { GenerateApiKey } from './commands/GenerateApiKey.service';
import { GetApiKeysService } from './queries/GetApiKeys.service';
@Controller('api-keys')
export class AuthApiKeysController {
constructor(
private readonly getApiKeysService: GetApiKeysService,
private readonly generateApiKeyService: GenerateApiKey,
) {}
@Post('generate')
async generate() {
return this.generateApiKeyService.generate();
}
@Put(':id/revoke')
async revoke(@Param('id') id: number) {
return this.generateApiKeyService.revoke(id);
}
@Get()
async getApiKeys() {
return this.getApiKeysService.getApiKeys();
}
}

View File

@@ -0,0 +1,13 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class ApiKeyAuthGuard extends AuthGuard('apiKey') {
constructor() {
super();
}
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
}

View File

@@ -0,0 +1,26 @@
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { AuthApiKeyAuthorizeService } from '../commands/AuthApiKeyAuthorization.service';
@Injectable()
export class ApiKeyStrategy extends PassportStrategy(
HeaderAPIKeyStrategy,
'apiKey',
) {
constructor(
private readonly authApiKeyAuthorizeService: AuthApiKeyAuthorizeService,
) {
super(
{
header: 'Authorization',
prefix: 'Bearer ',
},
false,
);
}
validate(apiKey: string): unknown {
return this.authApiKeyAuthorizeService.authorize(apiKey);
}
}

View File

@@ -0,0 +1,24 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { JwtAuthGuard } from '../guards/jwt.guard';
import { ApiKeyAuthGuard } from './AuthApiKey.guard';
import { getAuthApiKey } from '../Auth.utils';
// mixed-auth.guard.ts
@Injectable()
export class MixedAuthGuard implements CanActivate {
constructor(
private jwtGuard: JwtAuthGuard,
private apiKeyGuard: ApiKeyAuthGuard,
) {}
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const apiKey = getAuthApiKey(request.headers['authorization'] || '');
if (apiKey) {
return this.apiKeyGuard.canActivate(context);
} else {
return this.jwtGuard.canActivate(context);
}
}
}

View File

@@ -0,0 +1,50 @@
import { Inject, Injectable } from '@nestjs/common';
import { ApiKeyModel } from '../models/ApiKey.model';
import { ClsService } from 'nestjs-cls';
import { TenantModel } from '@/modules/System/models/TenantModel';
@Injectable()
export class AuthApiKeyAuthorizeService {
constructor(
private readonly clsService: ClsService,
@Inject(ApiKeyModel.name)
private readonly apikeyModel: typeof ApiKeyModel,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
) {}
/**
* Authenticate using the given api key.
*/
async authorize(apiKey: string): Promise<boolean> {
const apiKeyRecord = await this.apikeyModel
.query()
.findOne({ key: apiKey });
if (!apiKeyRecord) {
return false;
}
if (apiKeyRecord.revoked) {
return false;
}
if (
apiKeyRecord.expiresAt &&
new Date(apiKeyRecord.expiresAt) < new Date()
) {
return false;
}
const tenant = await this.tenantModel
.query()
.findById(apiKeyRecord.tenantId);
if (!tenant) return false;
this.clsService.set('tenantId', tenant.id);
this.clsService.set('organizationId', tenant.organizationId);
this.clsService.set('userId', apiKeyRecord.userId);
return true;
}
}

View File

@@ -0,0 +1,51 @@
import { Inject, Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
import { ApiKeyModel } from '../models/ApiKey.model';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { AuthApiKeyPrefix } from '../Auth.constants';
@Injectable()
export class GenerateApiKey {
constructor(
private readonly tenancyContext: TenancyContext,
@Inject(ApiKeyModel.name)
private readonly apiKeyModel: typeof ApiKeyModel,
) {}
/**
* Generates a new secure API key for the current tenant and system user.
* The key is saved in the database and returned (only the key and id for security).
* @returns {Promise<{ key: string; id: number }>} The generated API key and its database id.
*/
async generate() {
const tenant = await this.tenancyContext.getTenant();
const user = await this.tenancyContext.getSystemUser();
// Generate a secure random API key
const key = `${AuthApiKeyPrefix}${crypto.randomBytes(48).toString('hex')}`;
// Save the API key to the database
const apiKeyRecord = await this.apiKeyModel.query().insert({
key,
tenantId: tenant.id,
userId: user.id,
createdAt: new Date(),
revokedAt: null,
});
// Return the created API key (not the full record for security)
return { key: apiKeyRecord.key, id: apiKeyRecord.id };
}
/**
* Revokes an API key by setting its revokedAt timestamp.
* @param {number} apiKeyId - The id of the API key to revoke.
* @returns {Promise<{ id: number; revoked: boolean }>} The id of the revoked API key and a revoked flag.
*/
async revoke(apiKeyId: number) {
// Set the revoked flag to true for the given API key
await ApiKeyModel.query()
.findById(apiKeyId)
.patch({ revokedAt: new Date() });
return { id: apiKeyId, revoked: true };
}
}

View File

@@ -1,10 +1,16 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AuthSigninDto {
@ApiProperty({ example: 'password123', description: 'User password' })
@IsNotEmpty()
@IsString()
password: string;
@ApiProperty({
example: 'user@example.com',
description: 'User email address',
})
@IsNotEmpty()
@IsString()
email: string;

View File

@@ -1,19 +1,27 @@
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AuthSignupDto {
@ApiProperty({ example: 'John', description: 'User first name' })
@IsNotEmpty()
@IsString()
firstName: string;
@ApiProperty({ example: 'Doe', description: 'User last name' })
@IsNotEmpty()
@IsString()
lastName: string;
@ApiProperty({
example: 'john.doe@example.com',
description: 'User email address',
})
@IsNotEmpty()
@IsString()
@IsEmail()
email: string;
@ApiProperty({ example: 'password123', description: 'User password' })
@IsNotEmpty()
@IsString()
password: string;

View File

@@ -16,6 +16,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
}
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_ROUTE,
[context.getHandler(), context.getClass()],

View File

@@ -0,0 +1,72 @@
import { SystemModel } from '@/modules/System/models/SystemModel';
import { Model } from 'objection';
export class ApiKeyModel extends SystemModel {
readonly key: string;
readonly name?: string;
readonly createdAt: Date;
readonly expiresAt?: Date;
readonly revokedAt?: Date;
readonly userId: number;
readonly tenantId: number;
get revoked() {
return !!this.revokedAt;
}
static get virtualAttributes() {
return ['revoked'];
}
/**
* Table name
*/
static get tableName() {
return 'api_keys';
}
/**
* Timestamps columns.
*/
get timestamps() {
return ['createdAt'];
}
/**
* Relation mappings for Objection.js
*/
static get relationMappings() {
const { SystemUser } = require('../../System/models/SystemUser');
const { TenantModel } = require('../../System/models/TenantModel');
return {
user: {
relation: Model.BelongsToOneRelation,
modelClass: SystemUser,
join: {
from: 'api_keys.userId',
to: 'users.id',
},
},
tenant: {
relation: Model.BelongsToOneRelation,
modelClass: TenantModel,
join: {
from: 'api_keys.tenantId',
to: 'tenants.id',
},
},
};
}
/**
* Model modifiers.
*/
static get modifiers() {
return {
notRevoked(query) {
query.whereNull('revokedAt');
},
};
}
}

View File

@@ -0,0 +1,29 @@
import { Inject, Injectable } from '@nestjs/common';
import { ApiKeyModel } from '../models/ApiKey.model';
import { GetApiKeysTransformer } from './GetApiKeys.transformer';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
@Injectable()
export class GetApiKeysService {
constructor(
private readonly injectableTransformer: TransformerInjectable,
private readonly tenancyContext: TenancyContext,
@Inject(ApiKeyModel.name)
private readonly apiKeyModel: typeof ApiKeyModel,
) {}
async getApiKeys() {
const tenant = await this.tenancyContext.getTenant();
const apiKeys = await this.apiKeyModel
.query()
.modify('notRevoked')
.where({ tenantId: tenant.id });
return this.injectableTransformer.transform(
apiKeys,
new GetApiKeysTransformer(),
);
}
}

View File

@@ -0,0 +1,7 @@
import { Transformer } from '@/modules/Transformer/Transformer';
export class GetApiKeysTransformer extends Transformer {
public excludeAttributes = (): string[] => {
return ['tenantId'];
};
}

View File

@@ -1,4 +1,10 @@
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiOperation,
ApiTags,
ApiResponse,
getSchemaPath,
ApiExtraModels,
} from '@nestjs/swagger';
import {
Body,
Controller,
@@ -9,20 +15,28 @@ import {
Put,
} from '@nestjs/common';
import { BankRulesApplication } from './BankRulesApplication';
import { BankRule } from './models/BankRule';
import { CreateBankRuleDto } from './dtos/BankRule.dto';
import { EditBankRuleDto } from './dtos/BankRule.dto';
import { BankRuleResponseDto } from './dtos/BankRuleResponse.dto';
@Controller('banking/rules')
@ApiTags('bank-rules')
@ApiTags('Bank Rules')
@ApiExtraModels(BankRuleResponseDto)
export class BankRulesController {
constructor(private readonly bankRulesApplication: BankRulesApplication) {}
@Post()
@ApiOperation({ summary: 'Create a new bank rule.' })
@ApiResponse({
status: 201,
description: 'The bank rule has been successfully created.',
schema: {
$ref: getSchemaPath(BankRuleResponseDto),
},
})
async createBankRule(
@Body() createRuleDTO: CreateBankRuleDto,
): Promise<BankRule> {
): Promise<BankRuleResponseDto> {
return this.bankRulesApplication.createBankRule(createRuleDTO);
}
@@ -37,19 +51,36 @@ export class BankRulesController {
@Delete(':id')
@ApiOperation({ summary: 'Delete the given bank rule.' })
@ApiResponse({
status: 200,
description: 'The bank rule has been successfully deleted.',
})
async deleteBankRule(@Param('id') ruleId: number): Promise<void> {
return this.bankRulesApplication.deleteBankRule(ruleId);
}
@Get(':id')
@ApiOperation({ summary: 'Retrieves the bank rule details.' })
async getBankRule(@Param('id') ruleId: number): Promise<any> {
@ApiResponse({
status: 200,
description: 'The bank rule details have been successfully retrieved.',
schema: { $ref: getSchemaPath(BankRuleResponseDto) },
})
async getBankRule(@Param('id') ruleId: number): Promise<BankRuleResponseDto> {
return this.bankRulesApplication.getBankRule(ruleId);
}
@Get()
@ApiOperation({ summary: 'Retrieves the bank rules.' })
async getBankRules(): Promise<any> {
@ApiResponse({
status: 200,
description: 'The bank rules have been successfully retrieved.',
schema: {
type: 'array',
items: { $ref: getSchemaPath(BankRuleResponseDto) },
},
})
async getBankRules(): Promise<BankRuleResponseDto[]> {
return this.bankRulesApplication.getBankRules();
}
}

View File

@@ -23,13 +23,13 @@ export class EditBankRuleService {
) {}
/**
*
* @param createDTO
* Transforms the given edit bank rule dto to model object.
* @param editDTO
* @returns
*/
private transformDTO(createDTO: EditBankRuleDto): ModelObject<BankRule> {
private transformDTO(editDTO: EditBankRuleDto): ModelObject<BankRule> {
return {
...createDTO,
...editDTO,
} as ModelObject<BankRule>;
}

View File

@@ -12,6 +12,7 @@ import {
} from 'class-validator';
import { BankRuleComparator } from '../types';
import { ApiProperty } from '@nestjs/swagger';
import { ToNumber } from '@/common/decorators/Validators';
class BankRuleConditionDto {
@IsNotEmpty()
@@ -44,6 +45,8 @@ export class CommandBankRuleDto {
})
name: string;
@IsNotEmpty()
@ToNumber()
@IsInt()
@Min(0)
@ApiProperty({
@@ -53,6 +56,7 @@ export class CommandBankRuleDto {
order: number;
@IsOptional()
@ToNumber()
@IsInt()
@Min(0)
@ApiProperty({
@@ -61,6 +65,7 @@ export class CommandBankRuleDto {
})
applyIfAccountId?: number;
@IsNotEmpty()
@IsIn(['deposit', 'withdrawal'])
@ApiProperty({
description: 'The transaction type to apply the rule if',
@@ -82,11 +87,14 @@ export class CommandBankRuleDto {
@Type(() => BankRuleConditionDto)
@ApiProperty({
description: 'The conditions to apply the rule if',
example: [{ field: 'description', comparator: 'contains', value: 'Salary' }],
example: [
{ field: 'description', comparator: 'contains', value: 'Salary' },
],
})
conditions: BankRuleConditionDto[];
@IsString()
@IsNotEmpty()
@ApiProperty({
description: 'The category to assign the rule if',
example: 'Income:Salary',
@@ -95,6 +103,8 @@ export class CommandBankRuleDto {
@IsInt()
@Min(0)
@ToNumber()
@IsNotEmpty()
@ApiProperty({
description: 'The account ID to assign the rule if',
example: 1,

View File

@@ -0,0 +1,140 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BankRuleAssignCategory, BankRuleConditionType } from '../types';
class BankRuleConditionResponseDto {
@ApiProperty({
description: 'The unique identifier of the bank rule condition',
example: 1,
})
id: number;
@ApiProperty({
description: 'The field to check in the condition',
example: 'description',
enum: ['description', 'amount'],
})
field: string;
@ApiProperty({
description: 'The comparison operator to use',
example: 'contains',
enum: [
'equals',
'equal',
'contains',
'not_contain',
'bigger',
'bigger_or_equal',
'smaller',
'smaller_or_equal',
],
})
comparator: string;
@ApiProperty({
description: 'The value to compare against',
example: 'Salary',
})
value: string;
}
export class BankRuleResponseDto {
@ApiProperty({
description: 'The unique identifier of the bank rule',
example: 1,
})
id: number;
@ApiProperty({
description: 'The name of the bank rule',
example: 'Monthly Salary',
})
name: string;
@ApiProperty({
description: 'The order in which the rule should be applied',
example: 1,
})
order: number;
@ApiProperty({
description: 'The account ID to apply the rule if',
example: 1,
})
applyIfAccountId: number;
@ApiProperty({
description: 'The transaction type to apply the rule if',
example: 'deposit',
enum: ['deposit', 'withdrawal'],
})
applyIfTransactionType: string;
@ApiProperty({
description: 'The conditions type to apply the rule if',
example: 'and',
enum: ['and', 'or'],
})
conditionsType: BankRuleConditionType;
@ApiProperty({
description: 'The conditions to apply the rule if',
type: [BankRuleConditionResponseDto],
example: [
{
id: 1,
field: 'description',
comparator: 'contains',
value: 'Salary',
},
],
})
@Type(() => BankRuleConditionResponseDto)
conditions: BankRuleConditionResponseDto[];
@ApiProperty({
description: 'The category to assign the rule if',
example: 'InterestIncome',
enum: [
'InterestIncome',
'OtherIncome',
'Deposit',
'Expense',
'OwnerDrawings',
],
})
assignCategory: BankRuleAssignCategory;
@ApiProperty({
description: 'The account ID to assign the rule if',
example: 1,
})
assignAccountId: number;
@ApiProperty({
description: 'The payee to assign the rule if',
example: 'Employer Inc.',
required: false,
})
assignPayee?: string;
@ApiProperty({
description: 'The memo to assign the rule if',
example: 'Monthly Salary',
required: false,
})
assignMemo?: string;
@ApiProperty({
description: 'The creation timestamp of the bank rule',
example: '2024-03-20T10:00:00Z',
})
createdAt: string;
@ApiProperty({
description: 'The last update timestamp of the bank rule',
example: '2024-03-20T10:00:00Z',
})
updatedAt: string;
}

View File

@@ -2,8 +2,9 @@ import { BaseModel } from '@/models/Model';
import { Model } from 'objection';
import { BankRuleCondition } from './BankRuleCondition';
import { BankRuleAssignCategory, BankRuleConditionType } from '../types';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
export class BankRule extends BaseModel {
export class BankRule extends TenantBaseModel {
public readonly id!: number;
public readonly name!: string;
public readonly order!: number;
@@ -17,6 +18,9 @@ export class BankRule extends BaseModel {
public readonly conditions!: BankRuleCondition[];
public readonly createdAt: string;
public readonly updatedAt: string;
/**
* Table name
*/
@@ -28,7 +32,7 @@ export class BankRule extends BaseModel {
* Timestamps columns.
*/
static get timestamps() {
return ['created_at', 'updated_at'];
return ['createdAt', 'updatedAt'];
}
/**

View File

@@ -4,7 +4,7 @@ import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ICashflowAccountsFilter } from './types/BankAccounts.types';
@Controller('banking/accounts')
@ApiTags('banking-accounts')
@ApiTags('Bank Accounts')
export class BankAccountsController {
constructor(private bankAccountsApplication: BankAccountsApplication) {}

View File

@@ -1,8 +1,13 @@
import { Inject, Injectable } from '@nestjs/common';
import { Account } from '@/modules/Accounts/models/Account.model';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
import { BaseModel } from '@/models/Model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants';
import { initialize } from 'objection';
import { MatchedBankTransaction } from '@/modules/BankingMatching/models/MatchedBankTransaction';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { RecognizedBankTransaction } from '@/modules/BankingTranasctionsRegonize/models/RecognizedBankTransaction';
@Injectable()
export class GetBankAccountSummary {
@@ -14,6 +19,19 @@ export class GetBankAccountSummary {
private readonly uncategorizedBankTransactionModel: TenantModelProxy<
typeof UncategorizedBankTransaction
>,
@Inject(MatchedBankTransaction.name)
private readonly matchedBankTransactionModel: TenantModelProxy<
typeof MatchedBankTransaction
>,
@Inject(RecognizedBankTransaction.name)
private readonly recognizedBankTransaction: TenantModelProxy<
typeof RecognizedBankTransaction
>,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantDb: () => Knex,
) {}
/**
@@ -27,6 +45,11 @@ export class GetBankAccountSummary {
.findById(bankAccountId)
.throwIfNotFound();
await initialize(this.tenantDb(), [
this.uncategorizedBankTransactionModel(),
this.matchedBankTransactionModel(),
this.recognizedBankTransaction(),
]);
const commonQuery = (q) => {
// Include just the given account.
q.where('accountId', bankAccountId);
@@ -37,11 +60,6 @@ export class GetBankAccountSummary {
// Only the not categorized.
q.modify('notCategorized');
};
interface UncategorizedTransactionsCount {
total: number;
}
// Retrieves the uncategorized transactions count of the given bank account.
const uncategorizedTranasctionsCount =
await this.uncategorizedBankTransactionModel()

View File

@@ -0,0 +1,92 @@
import { Knex } from 'knex';
import { CategorizeBankTransaction } from './commands/CategorizeBankTransaction';
import { UncategorizeBankTransactionService } from './commands/UncategorizeBankTransaction.service';
import { UncategorizeBankTransactionsBulk } from './commands/UncategorizeBankTransactionsBulk.service';
import { UncategorizedBankTransactionDto } from './dtos/CreateUncategorizedBankTransaction.dto';
import { CategorizeBankTransactionDto } from './dtos/CategorizeBankTransaction.dto';
import { CategorizeTransactionAsExpense } from './commands/CategorizeTransactionAsExpense';
import { CreateUncategorizedTransactionService } from './commands/CreateUncategorizedTransaction.service';
import { ICategorizeCashflowTransactioDTO } from './types/BankingCategorize.types';
import { Injectable } from '@nestjs/common';
@Injectable()
export class BankingCategorizeApplication {
constructor(
private readonly categorizeBankTransaction: CategorizeBankTransaction,
private readonly uncategorizeBankTransaction: UncategorizeBankTransactionService,
private readonly uncategorizeBankTransactionsBulk: UncategorizeBankTransactionsBulk,
private readonly categorizeTransactionAsExpense: CategorizeTransactionAsExpense,
private readonly createUncategorizedTransaction: CreateUncategorizedTransactionService,
) {}
/**
* Categorize a bank transaction with the given ID and categorization data.
* @param {number | Array<number>} uncategorizedTransactionId - The ID(s) of the uncategorized transaction(s) to categorize.
* @param {CategorizeBankTransactionDto} categorizeDTO - Data for categorization.
* @returns {Promise<any>} The result of the categorization operation.
*/
public categorizeTransaction(
uncategorizedTransactionId: number | Array<number>,
categorizeDTO: CategorizeBankTransactionDto,
) {
return this.categorizeBankTransaction.categorize(
uncategorizedTransactionId,
categorizeDTO,
);
}
/**
* Uncategorize a bank transaction with the given ID.
* @param {number} uncategorizedTransactionId - The ID of the transaction to uncategorize.
* @returns {Promise<Array<number>>} Array of affected transaction IDs.
*/
public uncategorizeTransaction(
uncategorizedTransactionId: number,
): Promise<Array<number>> {
return this.uncategorizeBankTransaction.uncategorize(
uncategorizedTransactionId,
);
}
/**
* Uncategorize multiple bank transactions in bulk.
* @param {number | Array<number>} uncategorizedTransactionIds - The ID(s) of the transaction(s) to uncategorize.
* @returns {Promise<void>}
*/
public uncategorizeTransactionsBulk(
uncategorizedTransactionIds: number | Array<number>,
) {
return this.uncategorizeBankTransactionsBulk.uncategorizeBulk(
uncategorizedTransactionIds,
);
}
/**
* Categorize a transaction as an expense.
* @param {number} cashflowTransactionId - The ID of the cashflow transaction to categorize.
* @param {ICategorizeCashflowTransactioDTO} transactionDTO - Data for categorization.
* @returns {Promise<any>} The result of the categorization operation.
*/
public categorizeTransactionAsExpenseType(
cashflowTransactionId: number,
transactionDTO: ICategorizeCashflowTransactioDTO,
) {
return this.categorizeTransactionAsExpense.categorize(
cashflowTransactionId,
transactionDTO,
);
}
/**
* Create a new uncategorized bank transaction.
* @param {UncategorizedBankTransactionDto} createDTO - Data for creating the uncategorized transaction.
* @param {Knex.Transaction} [trx] - Optional Knex transaction.
* @returns {Promise<any>} The created uncategorized transaction.
*/
public createUncategorizedBankTransaction(
createDTO: UncategorizedBankTransactionDto,
trx?: Knex.Transaction,
) {
return this.createUncategorizedTransaction.create(createDTO, trx);
}
}

View File

@@ -0,0 +1,56 @@
import { Body, Controller, Delete, Param, Post, Query } from '@nestjs/common';
import { castArray, omit } from 'lodash';
import { BankingCategorizeApplication } from './BankingCategorize.application';
import { CategorizeBankTransactionRouteDto } from './dtos/CategorizeBankTransaction.dto';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
@Controller('banking/categorize')
@ApiTags('Banking Categorization')
export class BankingCategorizeController {
constructor(
private readonly bankingCategorizeApplication: BankingCategorizeApplication,
) {}
@Post()
@ApiOperation({ summary: 'Categorize bank transactions.' })
@ApiResponse({
status: 200,
description: 'The bank transactions have been categorized successfully.',
})
public categorizeTransaction(
@Body() body: CategorizeBankTransactionRouteDto,
) {
return this.bankingCategorizeApplication.categorizeTransaction(
castArray(body.uncategorizedTransactionIds),
omit(body, 'uncategorizedTransactionIds'),
);
}
@Delete('/bulk')
@ApiOperation({ summary: 'Uncategorize bank transactions.' })
@ApiResponse({
status: 200,
description: 'The bank transactions have been uncategorized successfully.',
})
public uncategorizeTransactionsBulk(
@Query() uncategorizedTransactionIds: number[] | number,
) {
return this.bankingCategorizeApplication.uncategorizeTransactionsBulk(
castArray(uncategorizedTransactionIds),
);
}
@Delete('/:id')
@ApiOperation({ summary: 'Uncategorize a bank transaction.' })
@ApiResponse({
status: 200,
description: 'The bank transaction has been uncategorized successfully.',
})
public uncategorizeTransaction(
@Param('id') uncategorizedTransactionId: number,
) {
return this.bankingCategorizeApplication.uncategorizeTransaction(
Number(uncategorizedTransactionId),
);
}
}

View File

@@ -1,20 +1,38 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { CreateUncategorizedTransactionService } from './commands/CreateUncategorizedTransaction.service';
import { CategorizeTransactionAsExpense } from './commands/CategorizeTransactionAsExpense';
import { BankingTransactionsModule } from '../BankingTransactions/BankingTransactions.module';
import { ExpensesModule } from '../Expenses/Expenses.module';
import { UncategorizedTransactionsImportable } from './commands/UncategorizedTransactionsImportable';
import { BankingCategorizeController } from './BankingCategorize.controller';
import { BankingCategorizeApplication } from './BankingCategorize.application';
import { CategorizeBankTransaction } from './commands/CategorizeBankTransaction';
import { UncategorizeBankTransactionService } from './commands/UncategorizeBankTransaction.service';
import { UncategorizeBankTransactionsBulk } from './commands/UncategorizeBankTransactionsBulk.service';
@Module({
imports: [BankingTransactionsModule, ExpensesModule],
imports: [
BankingTransactionsModule,
ExpensesModule,
forwardRef(() => BankingTransactionsModule),
],
providers: [
CreateUncategorizedTransactionService,
CategorizeTransactionAsExpense,
UncategorizedTransactionsImportable
UncategorizedTransactionsImportable,
BankingCategorizeApplication,
CategorizeBankTransaction,
UncategorizeBankTransactionService,
UncategorizeBankTransactionsBulk,
],
exports: [
CreateUncategorizedTransactionService,
CategorizeTransactionAsExpense,
BankingCategorizeApplication,
CategorizeBankTransaction,
UncategorizeBankTransactionService,
UncategorizeBankTransactionsBulk,
],
controllers: [BankingCategorizeController],
})
export class BankingCategorizeModule {}

View File

@@ -5,7 +5,6 @@ import { Knex } from 'knex';
import {
ICashflowTransactionCategorizedPayload,
ICashflowTransactionUncategorizingPayload,
ICategorizeCashflowTransactioDTO,
} from '../types/BankingCategorize.types';
import {
transformCategorizeTransToCashflow,
@@ -17,9 +16,10 @@ import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { CategorizeBankTransactionDto } from '../dtos/CategorizeBankTransaction.dto';
@Injectable()
export class CategorizeCashflowTransaction {
export class CategorizeBankTransaction {
constructor(
private readonly eventPublisher: EventEmitter2,
private readonly uow: UnitOfWork,
@@ -38,7 +38,7 @@ export class CategorizeCashflowTransaction {
*/
public async categorize(
uncategorizedTransactionId: number | Array<number>,
categorizeDTO: ICategorizeCashflowTransactioDTO,
categorizeDTO: CategorizeBankTransactionDto,
) {
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
@@ -68,7 +68,6 @@ export class CategorizeCashflowTransaction {
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionCategorizing,
{
// tenantId,
oldUncategorizedTransactions,
trx,
} as ICashflowTransactionUncategorizingPayload,

View File

@@ -1,6 +1,5 @@
import { Knex } from 'knex';
import {
CreateUncategorizedTransactionDTO,
IUncategorizedTransactionCreatedEventPayload,
IUncategorizedTransactionCreatingEventPayload,
} from '../types/BankingCategorize.types';
@@ -10,6 +9,7 @@ import { UncategorizedBankTransaction } from '../../BankingTransactions/models/U
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { UncategorizedBankTransactionDto } from '../dtos/CreateUncategorizedBankTransaction.dto';
@Injectable()
export class CreateUncategorizedTransactionService {
@@ -30,7 +30,7 @@ export class CreateUncategorizedTransactionService {
* @returns {Promise<UncategorizedBankTransaction>}
*/
public create(
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO,
createUncategorizedTransactionDTO: UncategorizedBankTransactionDto,
trx?: Knex.Transaction,
) {
return this.uow.withTransaction(async (trx: Knex.Transaction) => {

View File

@@ -12,7 +12,7 @@ import { UncategorizedBankTransaction } from '../../BankingTransactions/models/U
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class UncategorizeCashflowTransactionService {
export class UncategorizeBankTransactionService {
constructor(
private readonly eventPublisher: EventEmitter2,
private readonly uow: UnitOfWork,

View File

@@ -1,18 +1,17 @@
import { castArray } from 'lodash';
import { PromisePool } from '@supercharge/promise-pool';
import { Injectable } from '@nestjs/common';
import { UncategorizeCashflowTransactionService } from './UncategorizeCashflowTransaction.service';
import { UncategorizeBankTransactionService } from './UncategorizeBankTransaction.service';
@Injectable()
export class UncategorizeCashflowTransactionsBulk {
export class UncategorizeBankTransactionsBulk {
constructor(
private readonly uncategorizeTransactionService: UncategorizeCashflowTransactionService
private readonly uncategorizeTransactionService: UncategorizeBankTransactionService
) {}
/**
* Uncategorize the given bank transactions in bulk.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @param {number | Array<number>} uncategorizedTransactionId
*/
public async uncategorizeBulk(
uncategorizedTransactionId: number | Array<number>

View File

@@ -0,0 +1,113 @@
import { ToNumber } from '@/common/decorators/Validators';
import {
IsArray,
IsDateString,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO for categorizing bank transactions
*/
export class CategorizeBankTransactionDto {
@ApiProperty({
description: 'The date of the bank transaction',
type: Date,
example: '2023-01-01T00:00:00.000Z',
})
@IsDateString()
@IsNotEmpty()
date: Date;
@ApiProperty({
description: 'ID of the credit account associated with this transaction',
type: Number,
example: 1001,
})
@IsInt()
@ToNumber()
@IsNotEmpty()
creditAccountId: number;
@ApiPropertyOptional({
description: 'Optional external reference number',
type: String,
example: 'REF-001',
})
@IsString()
@IsOptional()
referenceNo: string;
@ApiPropertyOptional({
description: 'Optional transaction number or reference',
type: String,
example: 'TRX-001',
})
@IsString()
@IsOptional()
transactionNumber: string;
@ApiProperty({
description: 'Type of bank transaction (e.g., deposit, withdrawal)',
type: String,
example: 'deposit',
})
@IsString()
@IsNotEmpty()
transactionType: string;
@ApiPropertyOptional({
description: 'Exchange rate for currency conversion',
type: Number,
default: 1,
example: 1.15,
})
@IsNumber()
@ToNumber()
@IsOptional()
exchangeRate: number = 1;
@ApiPropertyOptional({
description: 'Currency code for the transaction',
type: String,
example: 'USD',
})
@IsString()
@IsOptional()
currencyCode: string;
@ApiPropertyOptional({
description: 'Description of the bank transaction',
type: String,
example: 'Monthly rent payment',
})
@IsString()
@IsOptional()
description: string;
@ApiPropertyOptional({
description: 'ID of the branch where the transaction occurred',
type: Number,
example: 101,
})
@IsNumber()
@IsOptional()
branchId: number;
}
/**
* Extended DTO for categorizing bank transactions with IDs of uncategorized transactions
*/
export class CategorizeBankTransactionRouteDto extends CategorizeBankTransactionDto {
@ApiProperty({
description: 'Array of uncategorized transaction IDs to be categorized',
type: [Number],
example: [1001, 1002, 1003],
})
@IsArray()
uncategorizedTransactionIds: Array<number>;
}

View File

@@ -0,0 +1,36 @@
import { IsBoolean, IsDateString, IsNumber, IsString } from 'class-validator';
export class UncategorizedBankTransactionDto {
@IsDateString()
date: Date | string;
@IsNumber()
accountId: number;
@IsNumber()
amount: number;
@IsString()
currencyCode: string;
@IsString()
payee?: string;
@IsString()
description?: string;
@IsString()
referenceNo?: string | null;
@IsString()
plaidTransactionId?: string | null;
@IsBoolean()
pending?: boolean;
@IsString()
pendingPlaidTransactionId?: string | null;
@IsString()
batch?: string;
}

View File

@@ -1,47 +1,44 @@
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { BankingMatchingApplication } from './BankingMatchingApplication';
import { GetMatchedTransactionsFilter, IMatchTransactionDTO } from './types';
import { GetMatchedTransactionsFilter } from './types';
import { MatchBankTransactionDto } from './dtos/MatchBankTransaction.dto';
@Controller('banking/matching')
@ApiTags('banking-transactions-matching')
@ApiTags('Banking Transactions Matching')
export class BankingMatchingController {
constructor(
private readonly bankingMatchingApplication: BankingMatchingApplication
private readonly bankingMatchingApplication: BankingMatchingApplication,
) {}
@Get('matched/transactions')
@Get('matched')
@ApiOperation({ summary: 'Retrieves the matched transactions.' })
async getMatchedTransactions(
@Query('uncategorizedTransactionIds') uncategorizedTransactionIds: number[],
@Query() filter: GetMatchedTransactionsFilter
@Query() filter: GetMatchedTransactionsFilter,
) {
return this.bankingMatchingApplication.getMatchedTransactions(
uncategorizedTransactionIds,
filter
filter,
);
}
@Post('/match/:uncategorizedTransactionId')
@Post('/match')
@ApiOperation({ summary: 'Match the given uncategorized transaction.' })
async matchTransaction(
@Param('uncategorizedTransactionId') uncategorizedTransactionId: number | number[],
@Body() matchedTransactions: MatchBankTransactionDto
) {
async matchTransaction(@Body() matchedTransactions: MatchBankTransactionDto) {
return this.bankingMatchingApplication.matchTransaction(
uncategorizedTransactionId,
matchedTransactions
matchedTransactions.uncategorizedTransactions,
matchedTransactions.matchedTransactions,
);
}
@Post('/unmatch/:uncategorizedTransactionId')
@ApiOperation({ summary: 'Unmatch the given uncategorized transaction.' })
async unmatchMatchedTransaction(
@Param('uncategorizedTransactionId') uncategorizedTransactionId: number
@Param('uncategorizedTransactionId') uncategorizedTransactionId: number,
) {
return this.bankingMatchingApplication.unmatchMatchedTransaction(
uncategorizedTransactionId
uncategorizedTransactionId,
);
}
}

View File

@@ -2,8 +2,8 @@ import { Injectable } from '@nestjs/common';
import { GetMatchedTransactions } from './queries/GetMatchedTransactions.service';
import { MatchBankTransactions } from './commands/MatchTransactions';
import { UnmatchMatchedBankTransaction } from './commands/UnmatchMatchedTransaction.service';
import { GetMatchedTransactionsFilter, IMatchTransactionDTO } from './types';
import { MatchBankTransactionDto } from './dtos/MatchBankTransaction.dto';
import { GetMatchedTransactionsFilter } from './types';
import { MatchTransactionEntryDto } from './dtos/MatchBankTransaction.dto';
@Injectable()
export class BankingMatchingApplication {
@@ -31,17 +31,18 @@ export class BankingMatchingApplication {
/**
* Matches the given uncategorized transaction with the given system transaction.
* @param {number} uncategorizedTransactionId
* @param {IMatchTransactionDTO} matchTransactionsDTO
* @param {IMatchBankTransactionDto} matchedTransactionsDTO
* @returns {Promise<void>}
*/
public matchTransaction(
uncategorizedTransactionId: number | Array<number>,
matchedTransactions: MatchBankTransactionDto,
matchedTransactionsDto:
| MatchTransactionEntryDto
| Array<MatchTransactionEntryDto>,
): Promise<void> {
return this.matchTransactionService.matchTransaction(
uncategorizedTransactionId,
matchedTransactions,
matchedTransactionsDto,
);
}

View File

@@ -21,7 +21,7 @@ import { ServiceError } from '@/modules/Items/ServiceError';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { MatchBankTransactionDto } from '../dtos/MatchBankTransaction.dto';
import { MatchTransactionEntryDto } from '../dtos/MatchBankTransaction.dto';
@Injectable()
export class MatchBankTransactions {
@@ -107,16 +107,15 @@ export class MatchBankTransactions {
/**
* Matches the given uncategorized transaction to the given references.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @returns {Promise<void>}
*/
public async matchTransaction(
uncategorizedTransactionId: number | Array<number>,
matchedTransactionsDto: MatchBankTransactionDto,
matchedTransactionsDto: MatchTransactionEntryDto | Array<MatchTransactionEntryDto>,
): Promise<void> {
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
const matchedTransactions = matchedTransactionsDto.entries;
const matchedTransactions = castArray(matchedTransactionsDto);
// Validates the given matching transactions DTO.
await this.validate(uncategorizedTransactionIds, matchedTransactions);
@@ -131,7 +130,7 @@ export class MatchBankTransactions {
// Matches the given transactions under promise pool concurrency controlling.
await PromisePool.withConcurrency(10)
.for(matchedTransactions)
.process(async (matchedTransaction) => {
.process(async (matchedTransaction: MatchTransactionEntryDto) => {
const getMatchedTransactionsService =
this.matchedBankTransactions.registry.get(
matchedTransaction.referenceType,

View File

@@ -1,4 +1,5 @@
import {
ArrayMinSize,
IsArray,
IsNotEmpty,
IsNumber,
@@ -27,6 +28,10 @@ export class MatchTransactionEntryDto {
}
export class MatchBankTransactionDto {
@IsArray()
@ArrayMinSize(1)
uncategorizedTransactions: Array<number>
@IsArray()
@ValidateNested({ each: true })
@Type(() => MatchTransactionEntryDto)
@@ -37,5 +42,5 @@ export class MatchBankTransactionDto {
{ referenceType: 'SaleInvoice', referenceId: 2 },
],
})
entries: MatchTransactionEntryDto[];
matchedTransactions: MatchTransactionEntryDto[];
}

View File

@@ -1,14 +1,14 @@
import PromisePool from '@supercharge/promise-pool';
import { OnEvent } from '@nestjs/event-emitter';
import { Inject, Injectable } from '@nestjs/common';
import {
IBankTransactionMatchedEventPayload,
IBankTransactionUnmatchedEventPayload,
} from '../types';
import PromisePool from '@supercharge/promise-pool';
import { OnEvent } from '@nestjs/event-emitter';
import { Account } from '@/modules/Accounts/models/Account.model';
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class DecrementUncategorizedTransactionOnMatchingSubscriber {

View File

@@ -1,3 +1,4 @@
import { Knex } from 'knex';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { GetMatchedTransactionCashflowTransformer } from './GetMatchedTransactionCashflowTransformer';
import { GetMatchedTransactionsFilter } from '../types';

View File

@@ -1,10 +1,13 @@
import { Inject, Injectable } from '@nestjs/common';
import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from '../types';
import { GetMatchedTransactionsFilter, MatchedTransactionPOJO, MatchedTransactionsPOJO } from '../types';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { GetMatchedTransactionExpensesTransformer } from './GetMatchedTransactionExpensesTransformer';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { Expense } from '@/modules/Expenses/models/Expense.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants';
import { initialize } from 'objection';
@Injectable()
export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByType {
@@ -13,17 +16,26 @@ export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByTy
@Inject(Expense.name)
protected readonly expenseModel: TenantModelProxy<typeof Expense>,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantDb: () => Knex,
@Inject('TENANT_MODELS_INIT')
private readonly tenantModelsInit: () => Promise<boolean>,
) {
super();
}
/**
* Retrieves the matched transactions of expenses.
* @param {number} tenantId
* @param {GetMatchedTransactionsFilter} filter
* @returns
*/
async getMatchedTransactions(filter: GetMatchedTransactionsFilter) {
async getMatchedTransactions(
filter: GetMatchedTransactionsFilter,
): Promise<MatchedTransactionsPOJO> {
// await this.tenantModelsInit();
// Retrieve the expense matches.
const expenses = await this.expenseModel()
.query()
@@ -49,6 +61,7 @@ export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByTy
}
query.orderBy('paymentDate', 'DESC');
});
return this.transformer.transform(
expenses,
new GetMatchedTransactionExpensesTransformer(),

View File

@@ -1,3 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { first } from 'lodash';
import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer';
@@ -9,7 +10,6 @@ import {
} from '../types';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { CreatePaymentReceivedService } from '@/modules/PaymentReceived/commands/CreatePaymentReceived.serivce';
import { Inject, Injectable } from '@nestjs/common';
import { SaleInvoice } from '@/modules/SaleInvoices/models/SaleInvoice';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
@@ -86,7 +86,6 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy
/**
* Creates the common matched transaction.
* @param {number} tenantId
* @param {Array<number>} uncategorizedTransactionIds
* @param {IMatchTransactionDTO} matchTransactionDTO
* @param {Knex.Transaction} trx

View File

@@ -1,10 +1,13 @@
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
import { initialize } from 'objection';
import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { GetMatchedTransactionsFilter } from '../types';
import { ManualJournal } from '@/modules/ManualJournals/models/ManualJournal';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants';
@Injectable()
export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactionsByType {

View File

@@ -12,7 +12,7 @@ import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
export abstract class GetMatchedTransactionsByType {
@Inject(MatchedBankTransaction.name)
private readonly matchedBankTransactionModel: TenantModelProxy<
matchedBankTransactionModel: TenantModelProxy<
typeof MatchedBankTransaction
>;

View File

@@ -0,0 +1,22 @@
import { Body, Controller, Post } from '@nestjs/common';
import { PlaidApplication } from './PlaidApplication';
import { PlaidItemDto } from './dtos/PlaidItem.dto';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
@Controller('banking/plaid')
@ApiTags('Banking Plaid')
export class BankingPlaidController {
constructor(private readonly plaidApplication: PlaidApplication) {}
@Post('link-token')
@ApiOperation({ summary: 'Get Plaid link token' })
getLinkToken() {
return this.plaidApplication.getLinkToken();
}
@Post('exchange-token')
@ApiOperation({ summary: 'Exchange Plaid access token' })
exchangeToken(@Body() itemDTO: PlaidItemDto) {
return this.plaidApplication.exchangeToken(itemDTO);
}
}

View File

@@ -1,3 +1,4 @@
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { PlaidUpdateTransactionsOnItemCreatedSubscriber } from './subscribers/PlaidUpdateTransactionsOnItemCreatedSubscriber';
import { PlaidUpdateTransactions } from './command/PlaidUpdateTransactions';
@@ -15,6 +16,11 @@ import { PlaidItemService } from './command/PlaidItem';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
import { InjectSystemModel } from '../System/SystemModels/SystemModels.module';
import { SystemPlaidItem } from './models/SystemPlaidItem';
import { BankingPlaidController } from './BankingPlaid.controller';
import { BankingPlaidWebhooksController } from './BankingPlaidWebhooks.controller';
import { SetupPlaidItemTenantService } from './command/SetupPlaidItemTenant.service';
import { UpdateBankingPlaidTransitionsQueueJob } from './types/BankingPlaid.types';
import { PlaidFetchTransactionsProcessor } from './jobs/PlaidFetchTransactionsJob';
const models = [RegisterTenancyModel(PlaidItem)];
@@ -24,6 +30,7 @@ const models = [RegisterTenancyModel(PlaidItem)];
AccountsModule,
BankingCategorizeModule,
BankingTransactionsModule,
BullModule.registerQueue({ name: UpdateBankingPlaidTransitionsQueueJob }),
...models,
],
providers: [
@@ -34,9 +41,12 @@ const models = [RegisterTenancyModel(PlaidItem)];
PlaidWebooks,
PlaidLinkTokenService,
PlaidApplication,
PlaidUpdateTransactionsOnItemCreatedSubscriber,
SetupPlaidItemTenantService,
TenancyContext,
PlaidFetchTransactionsProcessor,
PlaidUpdateTransactionsOnItemCreatedSubscriber,
],
exports: [...models],
controllers: [BankingPlaidController, BankingPlaidWebhooksController],
})
export class BankingPlaidModule {}

View File

@@ -0,0 +1,31 @@
import { Body, Controller, Post } from '@nestjs/common';
import { PlaidWebhookDto } from './dtos/PlaidItem.dto';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { PlaidApplication } from './PlaidApplication';
import { PublicRoute } from '../Auth/guards/jwt.guard';
import { SetupPlaidItemTenantService } from './command/SetupPlaidItemTenant.service';
@Controller('banking/plaid')
@ApiTags('banking-plaid')
@PublicRoute()
export class BankingPlaidWebhooksController {
constructor(
private readonly plaidApplication: PlaidApplication,
private readonly setupPlaidItemTenantService: SetupPlaidItemTenantService,
) {}
@Post('webhooks')
@ApiOperation({ summary: 'Listen to Plaid webhooks' })
webhooks(@Body() { itemId, webhookType, webhookCode }: PlaidWebhookDto) {
return this.setupPlaidItemTenantService.setupPlaidTenant(
itemId,
() => {
return this.plaidApplication.webhooks(
itemId,
webhookType,
webhookCode,
);
},
);
}
}

View File

@@ -1,8 +1,12 @@
import { ClsService } from 'nestjs-cls';
import { Inject, Injectable } from '@nestjs/common';
import { PlaidLinkTokenService } from './queries/GetPlaidLinkToken.service';
import { PlaidItemService } from './command/PlaidItem';
import { PlaidWebooks } from './command/PlaidWebhooks';
import { Injectable } from '@nestjs/common';
import { PlaidItemDTO } from './types/BankingPlaid.types';
import { PlaidItemDto } from './dtos/PlaidItem.dto';
import { SystemPlaidItem } from './models/SystemPlaidItem';
import { TenantModel } from '../System/models/TenantModel';
import { SystemUser } from '../System/models/SystemUser';
@Injectable()
export class PlaidApplication {
@@ -10,6 +14,16 @@ export class PlaidApplication {
private readonly getLinkTokenService: PlaidLinkTokenService,
private readonly plaidItemService: PlaidItemService,
private readonly plaidWebhooks: PlaidWebooks,
private readonly clsService: ClsService,
@Inject(SystemPlaidItem.name)
private readonly systemPlaidItemModel: typeof SystemPlaidItem,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
) {}
/**
@@ -25,7 +39,7 @@ export class PlaidApplication {
* @param {PlaidItemDTO} itemDTO
* @returns
*/
public exchangeToken(itemDTO: PlaidItemDTO): Promise<void> {
public exchangeToken(itemDTO: PlaidItemDto): Promise<void> {
return this.plaidItemService.item(itemDTO);
}
@@ -41,10 +55,33 @@ export class PlaidApplication {
webhookType: string,
webhookCode: string,
): Promise<void> {
return this.plaidWebhooks.webhooks(
plaidItemId,
webhookType,
webhookCode,
);
return this.plaidWebhooks.webhooks(plaidItemId, webhookType, webhookCode);
}
public async setupPlaidTenant(plaidItemId: string, callback: () => void) {
const plaidItem = await this.systemPlaidItemModel
.query()
.findOne({ plaidItemId });
if (!plaidItem) {
throw new Error('Plaid item not found');
}
const tenant = await this.tenantModel
.query()
.findOne({ id: plaidItem.tenantId })
.throwIfNotFound();
const user = await this.systemUserModel
.query()
.findOne({
tenantId: tenant.id,
})
.modify('active')
.throwIfNotFound();
this.clsService.set('organizationId', tenant.organizationId);
this.clsService.set('userId', user.id);
return callback();
}
}

View File

@@ -1,32 +0,0 @@
// import { Request, Response, NextFunction } from 'express';
// import { SystemPlaidItem, Tenant } from '@/system/models';
// import tenantDependencyInjection from '@/api/middleware/TenantDependencyInjection';
// export const PlaidWebhookTenantBootMiddleware = async (
// req: Request,
// res: Response,
// next: NextFunction
// ) => {
// const { item_id: plaidItemId } = req.body;
// const plaidItem = await SystemPlaidItem.query().findOne({ plaidItemId });
// const notFoundOrganization = () => {
// return res.boom.unauthorized('Organization identication not found.', {
// errors: [{ type: 'ORGANIZATION.ID.NOT.FOUND', code: 100 }],
// });
// };
// // In case the given organization not found.
// if (!plaidItem) {
// return notFoundOrganization();
// }
// const tenant = await Tenant.query()
// .findById(plaidItem.tenantId)
// .withGraphFetched('metadata');
// // When the given organization id not found on the system storage.
// if (!tenant) {
// return notFoundOrganization();
// }
// tenantDependencyInjection(req, tenant);
// next();
// };

View File

@@ -6,11 +6,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { SystemPlaidItem } from '../models/SystemPlaidItem';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import {
IPlaidItemCreatedEventPayload,
PlaidItemDTO,
} from '../types/BankingPlaid.types';
import { IPlaidItemCreatedEventPayload } from '../types/BankingPlaid.types';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { PlaidItemDto } from '../dtos/PlaidItem.dto';
@Injectable()
export class PlaidItemService {
@@ -19,9 +17,7 @@ export class PlaidItemService {
private readonly tenancyContext: TenancyContext,
@Inject(SystemPlaidItem.name)
private readonly systemPlaidItemModel: TenantModelProxy<
typeof SystemPlaidItem
>,
private readonly systemPlaidItemModel: typeof SystemPlaidItem,
@Inject(PlaidItem.name)
private readonly plaidItemModel: TenantModelProxy<typeof PlaidItem>,
@@ -33,10 +29,10 @@ export class PlaidItemService {
/**
* Exchanges the public token to get access token and item id and then creates
* a new Plaid item.
* @param {PlaidItemDTO} itemDTO - Plaid item data transfer object.
* @param {PlaidItemDto} itemDTO - Plaid item data transfer object.
* @returns {Promise<void>}
*/
public async item(itemDTO: PlaidItemDTO): Promise<void> {
public async item(itemDTO: PlaidItemDto): Promise<void> {
const { publicToken, institutionId } = itemDTO;
const tenant = await this.tenancyContext.getTenant();
@@ -57,7 +53,7 @@ export class PlaidItemService {
plaidInstitutionId: institutionId,
});
// Stores the Plaid item id on system scope.
await this.systemPlaidItemModel().query().insert({ tenantId, plaidItemId });
await this.systemPlaidItemModel.query().insert({ tenantId, plaidItemId });
// Triggers `onPlaidItemCreated` event.
await this.eventEmitter.emitAsync(events.plaid.onItemCreated, {

View File

@@ -1,5 +1,6 @@
import * as R from 'ramda';
import bluebird from 'bluebird';
import * as bluebird from 'bluebird';
import * as uniqid from 'uniqid';
import { entries, groupBy } from 'lodash';
import {
AccountBase as PlaidAccountBase,
@@ -12,7 +13,6 @@ import {
transformPlaidTrxsToCashflowCreate,
} from '../utils';
import { Knex } from 'knex';
import uniqid from 'uniqid';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { RemovePendingUncategorizedTransaction } from '../../BankingTransactions/commands/RemovePendingUncategorizedTransaction.service';
import { CreateAccountService } from '../../Accounts/CreateAccount.service';

View File

@@ -15,6 +15,13 @@ import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class PlaidUpdateTransactions {
/**
* Constructor method.
* @param {PlaidSyncDb} plaidSync - Plaid sync service.
* @param {UnitOfWork} uow - Unit of work.
* @param {TenantModelProxy<typeof PlaidItem>} plaidItemModel - Plaid item model.
* @param {PlaidApi} plaidClient - Plaid client.
*/
constructor(
private readonly plaidSync: PlaidSyncDb,
private readonly uow: UnitOfWork,
@@ -28,8 +35,7 @@ export class PlaidUpdateTransactions {
/**
* Handles sync the Plaid item to Bigcaptial under UOW.
* @param {number} tenantId - Tenant id.
* @param {number} plaidItemId - Plaid item id.
* @param {string} plaidItemId - Plaid item id.
* @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>}
*/
public async updateTransactions(plaidItemId: string) {
@@ -44,9 +50,9 @@ export class PlaidUpdateTransactions {
* - New bank accounts.
* - Last accounts feeds updated at.
* - Turn on the accounts feed flag.
* @param {number} tenantId - Tenant ID.
* @param {string} plaidItemId - The Plaid ID for the item.
* @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>}
* @param {Knex.Transaction} trx - Knex transaction.
* @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>}
*/
public async updateTransactionsWork(
plaidItemId: string,
@@ -97,7 +103,6 @@ export class PlaidUpdateTransactions {
/**
* Fetches transactions from the `Plaid API` for a given item.
* @param {number} tenantId - Tenant ID.
* @param {string} plaidItemId - The Plaid ID for the item.
* @returns {Promise<PlaidFetchedTransactionsUpdates>}
*/

View File

@@ -1,3 +1,4 @@
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { PlaidItem } from '../models/PlaidItem';
import { PlaidUpdateTransactions } from './PlaidUpdateTransactions';
import { Inject, Injectable } from '@nestjs/common';
@@ -8,7 +9,7 @@ export class PlaidWebooks {
private readonly updateTransactionsService: PlaidUpdateTransactions,
@Inject(PlaidItem.name)
private readonly plaidItemModel: typeof PlaidItem,
private readonly plaidItemModel: TenantModelProxy<typeof PlaidItem>,
) {}
/**
@@ -76,11 +77,10 @@ export class PlaidWebooks {
* @returns {Promise<void>}
*/
public async handleTransactionsWebooks(
tenantId: number,
plaidItemId: string,
webhookCode: string,
): Promise<void> {
const plaidItem = await this.plaidItemModel
const plaidItem = await this.plaidItemModel()
.query()
.findOne({ plaidItemId })
.throwIfNotFound();
@@ -122,9 +122,8 @@ export class PlaidWebooks {
/**
* Handles all Item webhook events.
* @param {number} tenantId - Tenant ID
* @param {string} webhookCode - The webhook code
* @param {string} plaidItemId - The Plaid ID for the item
* @param {string} webhookCode - The webhook code
* @returns {Promise<void>}
*/
public async itemsHandler(

View File

@@ -0,0 +1,54 @@
import { ClsService } from 'nestjs-cls';
import { Inject, Injectable } from '@nestjs/common';
import { SystemPlaidItem } from '../models/SystemPlaidItem';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { SystemUser } from '@/modules/System/models/SystemUser';
@Injectable()
export class SetupPlaidItemTenantService {
constructor(
private readonly clsService: ClsService,
@Inject(SystemPlaidItem.name)
private readonly systemPlaidItemModel: typeof SystemPlaidItem,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
) {}
/**
* Sets up the Plaid tenant.
* @param {string} plaidItemId - The Plaid item id.
* @param {() => void} callback - The callback function to execute after setting up the Plaid tenant.
* @returns {Promise<void>}
*/
public async setupPlaidTenant(plaidItemId: string, callback: () => void) {
const plaidItem = await this.systemPlaidItemModel
.query()
.findOne({ plaidItemId });
if (!plaidItem) {
throw new Error('Plaid item not found');
}
const tenant = await this.tenantModel
.query()
.findOne({ id: plaidItem.tenantId })
.throwIfNotFound();
const user = await this.systemUserModel
.query()
.findOne({
tenantId: tenant.id,
})
.modify('active')
.throwIfNotFound();
this.clsService.set('organizationId', tenant.organizationId);
this.clsService.set('userId', user.id);
return callback();
}
}

View File

@@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class PlaidItemDto {
@IsString()
@IsNotEmpty()
@ApiProperty({ example: '123', description: 'The public token' })
publicToken: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ example: '123', description: 'The institution ID' })
institutionId: string;
}
export class PlaidWebhookDto {
@IsString()
@IsNotEmpty()
@ApiProperty({ example: '123', description: 'The Plaid item ID' })
itemId: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ example: '123', description: 'The Plaid webhook type' })
webhookType: string;
@IsString()
@IsNotEmpty()
@ApiProperty({ example: '123', description: 'The Plaid webhook code' })
webhookCode: string;
}

View File

@@ -1,43 +1,46 @@
// import Container, { Service } from 'typedi';
// import { PlaidUpdateTransactions } from './PlaidUpdateTransactions';
// import { IPlaidItemCreatedEventPayload } from '@/interfaces';
import { Process } from '@nestjs/bull';
import { UseCls } from 'nestjs-cls';
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Scope } from '@nestjs/common';
import { Job } from 'bullmq';
import {
PlaidFetchTransitonsEventPayload,
UpdateBankingPlaidTransitionsJob,
UpdateBankingPlaidTransitionsQueueJob,
} from '../types/BankingPlaid.types';
import { PlaidUpdateTransactions } from '../command/PlaidUpdateTransactions';
import { SetupPlaidItemTenantService } from '../command/SetupPlaidItemTenant.service';
// @Service()
// export class PlaidFetchTransactionsJob {
// /**
// * Constructor method.
// */
// constructor(agenda) {
// agenda.define(
// 'plaid-update-account-transactions',
// { priority: 'high', concurrency: 2 },
// this.handler
// );
// }
@Processor({
name: UpdateBankingPlaidTransitionsQueueJob,
scope: Scope.REQUEST,
})
export class PlaidFetchTransactionsProcessor extends WorkerHost {
constructor(
private readonly plaidFetchTransactionsService: PlaidUpdateTransactions,
private readonly setupPlaidItemService: SetupPlaidItemTenantService,
) {
super();
}
// /**
// * Triggers the function.
// */
// private handler = async (job, done: Function) => {
// const { tenantId, plaidItemId } = job.attrs
// .data as IPlaidItemCreatedEventPayload;
/**
* Triggers the function.
*/
@Process(UpdateBankingPlaidTransitionsJob)
@UseCls()
async process(job: Job<PlaidFetchTransitonsEventPayload>) {
const { plaidItemId } = job.data;
// const plaidFetchTransactionsService = Container.get(
// PlaidUpdateTransactions
// );
// const io = Container.get('socket');
// try {
// await plaidFetchTransactionsService.updateTransactions(
// tenantId,
// plaidItemId
// );
// // Notify the frontend to reflect the new transactions changes.
// io.emit('NEW_TRANSACTIONS_DATA', { plaidItemId });
// done();
// } catch (error) {
// console.log(error);
// done(error);
// }
// };
// }
try {
await this.setupPlaidItemService.setupPlaidTenant(plaidItemId, () => {
return this.plaidFetchTransactionsService.updateTransactions(
plaidItemId,
);
});
// Notify the frontend to reflect the new transactions changes.
// io.emit('NEW_TRANSACTIONS_DATA', { plaidItemId });
} catch (error) {
console.log(error);
}
}
}

View File

@@ -30,7 +30,7 @@ export class SystemPlaidItem extends BaseModel {
* Relationship mapping.
*/
static get relationMappings() {
const Tenant = require('system/models/Tenant');
const { TenantModel } = require('../../System/models/TenantModel');
return {
/**
@@ -38,7 +38,7 @@ export class SystemPlaidItem extends BaseModel {
*/
tenant: {
relation: Model.BelongsToOneRelation,
modelClass: Tenant.default,
modelClass: TenantModel,
join: {
from: 'users.tenantId',
to: 'tenants.id',

View File

@@ -1,22 +1,34 @@
import { events } from '@/common/events/events';
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { IPlaidItemCreatedEventPayload } from '../types/BankingPlaid.types';
import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq';
import {
IPlaidItemCreatedEventPayload,
UpdateBankingPlaidTransitionsJob,
UpdateBankingPlaidTransitionsQueueJob,
} from '../types/BankingPlaid.types';
@Injectable()
export class PlaidUpdateTransactionsOnItemCreatedSubscriber {
constructor(
@InjectQueue(UpdateBankingPlaidTransitionsQueueJob)
private readonly updateTransitionsQueue: Queue,
) {}
/**
* Updates the Plaid item transactions
* @param {IPlaidItemCreatedEventPayload} payload - Event payload.
*/
@OnEvent(events.plaid.onItemCreated)
public async handleUpdateTransactionsOnItemCreated({
tenantId,
plaidItemId,
plaidAccessToken,
plaidInstitutionId,
}: IPlaidItemCreatedEventPayload) {
const payload = { tenantId, plaidItemId };
// await this.agenda.now('plaid-update-account-transactions', payload);
};
const payload = { plaidItemId };
await this.updateTransitionsQueue.add(
UpdateBankingPlaidTransitionsJob,
payload,
);
}
}

View File

@@ -1,11 +1,11 @@
import { Knex } from "knex";
import { RemovedTransaction, Transaction } from "plaid";
import { Knex } from 'knex';
import { RemovedTransaction, Transaction } from 'plaid';
export interface IPlaidTransactionsSyncedEventPayload {
// tenantId: number;
plaidAccountId: number;
batch: string;
trx?: Knex.Transaction
trx?: Knex.Transaction;
}
export interface PlaidItemDTO {
@@ -13,7 +13,6 @@ export interface PlaidItemDTO {
institutionId: string;
}
export interface PlaidFetchedTransactionsUpdates {
added: Transaction[];
modified: Transaction[];
@@ -22,11 +21,20 @@ export interface PlaidFetchedTransactionsUpdates {
cursor: string;
}
export interface IPlaidItemCreatedEventPayload {
tenantId: number;
plaidAccessToken: string;
plaidItemId: string;
plaidInstitutionId: string;
}
export const UpdateBankingPlaidTransitionsJob =
'update-banking-plaid-transitions-job';
export const UpdateBankingPlaidTransitionsQueueJob =
'update-banking-plaid-transitions-query';
export interface PlaidFetchTransitonsEventPayload {
plaidItemId: string;
}

View File

@@ -0,0 +1,70 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiExtraModels,
getSchemaPath,
} from '@nestjs/swagger';
import { RecognizedTransactionsApplication } from './RecognizedTransactions.application';
import { GetRecognizedTransactionResponseDto } from './dtos/GetRecognizedTransactionResponse.dto';
@Controller('banking/recognized')
@ApiTags('Banking Recognized Transactions')
@ApiExtraModels(GetRecognizedTransactionResponseDto)
export class BankingRecognizedTransactionsController {
constructor(
private readonly recognizedTransactionsApplication: RecognizedTransactionsApplication,
) {}
@Get(':recognizedTransactionId')
@ApiOperation({ summary: 'Get recognized transaction' })
@ApiParam({
name: 'recognizedTransactionId',
description: 'The ID of the recognized transaction',
type: 'number',
})
@ApiResponse({
status: 200,
description: 'Returns the recognized transaction details',
schema: {
$ref: getSchemaPath(GetRecognizedTransactionResponseDto),
},
})
@ApiResponse({
status: 404,
description: 'Recognized transaction not found',
})
async getRecognizedTransaction(
@Param('recognizedTransactionId') recognizedTransactionId: number,
) {
return this.recognizedTransactionsApplication.getRecognizedTransaction(
Number(recognizedTransactionId),
);
}
@Get()
@ApiOperation({ summary: 'Get a list of recognized transactions' })
@ApiQuery({
name: 'query',
required: false,
description: 'Query parameters for filtering recognized transactions',
})
@ApiResponse({
status: 200,
description: 'Returns a list of recognized transactions',
schema: {
type: 'array',
items: {
$ref: getSchemaPath(GetRecognizedTransactionResponseDto),
},
},
})
async getRecognizedTransactions(@Query() query: any) {
return this.recognizedTransactionsApplication.getRecognizedTransactions(
query,
);
}
}

View File

@@ -1,32 +1,46 @@
import { forwardRef, Module } from '@nestjs/common';
import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module';
import { RecognizedBankTransaction } from './models/RecognizedBankTransaction';
import { GetAutofillCategorizeTransactionService } from './queries/GetAutofillCategorizeTransaction.service';
import { RevertRecognizedTransactionsService } from './commands/RevertRecognizedTransactions.service';
import { RecognizeTranasctionsService } from './commands/RecognizeTranasctions.service';
import { TriggerRecognizedTransactionsSubscriber } from './events/TriggerRecognizedTransactions';
import { BankingTransactionsModule } from '../BankingTransactions/BankingTransactions.module';
import { BankRulesModule } from '../BankRules/BankRules.module';
import { BankingRecognizedTransactionsController } from './BankingRecognizedTransactions.controller';
import { RecognizedTransactionsApplication } from './RecognizedTransactions.application';
import { GetRecognizedTransactionsService } from './GetRecongizedTransactions';
import { GetRecognizedTransactionService } from './queries/GetRecognizedTransaction.service';
import { BullModule } from '@nestjs/bullmq';
import { RecognizeUncategorizedTransactionsQueue } from './_types';
import { RegonizeTransactionsPrcessor } from './jobs/RecognizeTransactionsJob';
import { TenancyModule } from '../Tenancy/Tenancy.module';
const models = [RegisterTenancyModel(RecognizedBankTransaction)];
@Module({
imports: [
BankingTransactionsModule,
TenancyModule,
forwardRef(() => BankRulesModule),
BullModule.registerQueue({
name: RecognizeUncategorizedTransactionsQueue,
}),
...models,
],
providers: [
GetAutofillCategorizeTransactionService,
RecognizedTransactionsApplication,
GetRecognizedTransactionsService,
RevertRecognizedTransactionsService,
RecognizeTranasctionsService,
TriggerRecognizedTransactionsSubscriber,
GetRecognizedTransactionService,
RegonizeTransactionsPrcessor,
],
exports: [
...models,
GetAutofillCategorizeTransactionService,
RevertRecognizedTransactionsService,
RecognizeTranasctionsService,
],
controllers: [BankingRecognizedTransactionsController],
})
export class BankingTransactionsRegonizeModule {}

Some files were not shown because too many files have changed in this diff Show More