Compare commits
45 Commits
users-modu
...
api-keys
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adb1bea374 | ||
|
|
5d96357042 | ||
|
|
9457b3cda1 | ||
|
|
84cb7693c8 | ||
|
|
9f6e9e85a5 | ||
|
|
83e698acf3 | ||
|
|
fa5c3bd955 | ||
|
|
0ca98c7ae4 | ||
|
|
0c0e1dc22e | ||
|
|
e7178a6575 | ||
|
|
6a39e9d71f | ||
|
|
9aa1ed93ca | ||
|
|
b8c9919799 | ||
|
|
e5701140e1 | ||
|
|
91976842a7 | ||
|
|
4d52059dba | ||
|
|
26c1f118c1 | ||
|
|
437bcb8854 | ||
|
|
f624cf7ae6 | ||
|
|
e057b4e2f0 | ||
|
|
c4668d7d22 | ||
|
|
88ef60ef28 | ||
|
|
bbf9ef9bc2 | ||
|
|
bcae2dae03 | ||
|
|
ff93168d72 | ||
|
|
1130975efd | ||
|
|
fa180b3ac5 | ||
|
|
90d6bea9b9 | ||
|
|
4366bf478a | ||
|
|
0a57b6e20e | ||
|
|
9a685ffe5d | ||
|
|
51988dba3b | ||
|
|
f87bd341e9 | ||
|
|
5595478e19 | ||
|
|
7247b52fe5 | ||
|
|
deadd5ac80 | ||
|
|
66a2261e50 | ||
|
|
c51347d3ec | ||
|
|
b7a3c42074 | ||
|
|
83c9392b74 | ||
|
|
24bf3dd06d | ||
|
|
2b3f98d8fe | ||
|
|
4e64a9eadb | ||
|
|
0823bfc4e9 | ||
|
|
99fe5a6b0d |
@@ -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
0
packages/server/public/pdf/.gitignore
vendored
Normal file
7
packages/server/src/common/config/bankfeed.ts
Normal file
7
packages/server/src/common/config/bankfeed.ts
Normal 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',
|
||||
}));
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
32
packages/server/src/common/decorators/Validators.ts
Normal file
32
packages/server/src/common/decorators/Validators.ts
Normal 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 (won’t 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);
|
||||
}
|
||||
33
packages/server/src/common/dtos/PaginatedResults.dto.ts
Normal file
33
packages/server/src/common/dtos/PaginatedResults.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
7
packages/server/src/common/types/Objection.d.ts
vendored
Normal file
7
packages/server/src/common/types/Objection.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
}
|
||||
3
packages/server/src/i18n/en/branches.json
Normal file
3
packages/server/src/i18n/en/branches.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"head_branch": "Head Branch"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
4
packages/server/src/i18n/en/inventory_adjustment.json
Normal file
4
packages/server/src/i18n/en/inventory_adjustment.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"decrement": "Decrement",
|
||||
"increment": "Increment"
|
||||
}
|
||||
25
packages/server/src/i18n/en/transaction_type.json
Normal file
25
packages/server/src/i18n/en/transaction_type.json
Normal 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"
|
||||
}
|
||||
6
packages/server/src/i18n/en/vendor_credit.json
Normal file
6
packages/server/src/i18n/en/vendor_credit.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"view.draft": "Draft",
|
||||
"view.published": "Published",
|
||||
"view.open": "Open",
|
||||
"view.closed": "Closed"
|
||||
}
|
||||
4
packages/server/src/i18n/en/warehouses.json
Normal file
4
packages/server/src/i18n/en/warehouses.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"primary_warehouse": "Primary Warehouse"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import FormData from 'form-data';
|
||||
import * as FormData from 'form-data';
|
||||
import { GotenbergUtils } from './GotenbergUtils';
|
||||
import { PageProperties } from './_types';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
171
packages/server/src/modules/Accounts/dtos/AccountResponse.dto.ts
Normal file
171
packages/server/src/modules/Accounts/dtos/AccountResponse.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -33,6 +33,7 @@ const models = [
|
||||
|
||||
@Module({
|
||||
imports: [S3Module, ...models],
|
||||
exports: [...models],
|
||||
controllers: [AttachmentsController],
|
||||
providers: [
|
||||
DeleteAttachment,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import path from 'path';
|
||||
import * as path from 'path';
|
||||
// import config from '@/config';
|
||||
|
||||
export const getUploadedObjectUri = (objectKey: string) => {
|
||||
|
||||
@@ -26,3 +26,5 @@ export const SendResetPasswordMailJob = 'SendResetPasswordMailJob';
|
||||
export const SendSignupVerificationMailQueue =
|
||||
'SendSignupVerificationMailQueue';
|
||||
export const SendSignupVerificationMailJob = 'SendSignupVerificationMailJob';
|
||||
|
||||
export const AuthApiKeyPrefix = 'bc_';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 : '';
|
||||
};
|
||||
|
||||
26
packages/server/src/modules/Auth/AuthApiKeys.controllers.ts
Normal file
26
packages/server/src/modules/Auth/AuthApiKeys.controllers.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
13
packages/server/src/modules/Auth/api-key/AuthApiKey.guard.ts
Normal file
13
packages/server/src/modules/Auth/api-key/AuthApiKey.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
24
packages/server/src/modules/Auth/api-key/MixedAuth.guard.ts
Normal file
24
packages/server/src/modules/Auth/api-key/MixedAuth.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()],
|
||||
|
||||
72
packages/server/src/modules/Auth/models/ApiKey.model.ts
Normal file
72
packages/server/src/modules/Auth/models/ApiKey.model.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Transformer } from '@/modules/Transformer/Transformer';
|
||||
|
||||
export class GetApiKeysTransformer extends Transformer {
|
||||
public excludeAttributes = (): string[] => {
|
||||
return ['tenantId'];
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Knex } from 'knex';
|
||||
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
|
||||
import { GetMatchedTransactionCashflowTransformer } from './GetMatchedTransactionCashflowTransformer';
|
||||
import { GetMatchedTransactionsFilter } from '../types';
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
// };
|
||||
@@ -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, {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>}
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user