diff --git a/fern/definition/accounting/account.yml b/fern/definition/accounting/account.yml new file mode 100644 index 000000000..f253d05ad --- /dev/null +++ b/fern/definition/accounting/account.yml @@ -0,0 +1,117 @@ +imports: + errors: ../common/errors.yml + types: ../common/types.yml + unified: ../common/unified.yml + associations: ../common/associations.yml + +types: + GetAccountResponse: + properties: + status: types.ResponseStatus + result: unknown + GetAccountsResponse: + properties: + status: types.ResponseStatus + next: optional + previous: optional + results: unknown + CreateOrUpdateAccountRequest: unknown + CreateOrUpdateAccountResponse: + properties: + status: types.ResponseStatus + message: string + result: unknown + DeleteAccountResponse: + properties: + status: types.ResponseStatus + message: string + +service: + base-path: /accounting/accounts + auth: false + headers: + x-revert-api-token: + type: string + docs: Your official API key for accessing revert apis. + x-revert-t-id: + type: string + docs: The unique customer id used when the customer linked their account. + x-api-version: + type: optional + docs: Optional Revert API version you're using. If missing we default to the latest version of the API. + audiences: + - external + endpoints: + getAccount: + docs: Get details of an account + method: GET + path: /{id} + path-parameters: + id: string + request: + name: GetAccountRequest + query-parameters: + fields: optional + response: GetAccountResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + getAccounts: + docs: Get all the accounts + method: GET + path: '' + request: + name: GetAccountsRequest + query-parameters: + fields: optional + pageSize: optional + cursor: optional + response: GetAccountsResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + createAccount: + docs: Post Account + method: POST + path: '' + request: + name: CreateAccountRequest + body: CreateOrUpdateAccountRequest + query-parameters: + fields: optional + response: CreateOrUpdateAccountResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + updateAccount: + docs: Update Account + method: PATCH + path: /{id} + path-parameters: + id: string + request: + name: UpdateAccountRequest + body: CreateOrUpdateAccountRequest + query-parameters: + fields: optional + response: CreateOrUpdateAccountResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + deleteAccount: + docs: Delete account + method: DELETE + path: /{id} + path-parameters: + id: string + request: + name: DeleteAccountRequest + response: DeleteAccountResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError diff --git a/fern/definition/accounting/expense.yml b/fern/definition/accounting/expense.yml new file mode 100644 index 000000000..aa8ca74dd --- /dev/null +++ b/fern/definition/accounting/expense.yml @@ -0,0 +1,115 @@ +imports: + errors: ../common/errors.yml + types: ../common/types.yml + unified: ../common/unified.yml + associations: ../common/associations.yml + +types: + GetExpenseResponse: + properties: + status: types.ResponseStatus + result: unknown + GetExpensesResponse: + properties: + status: types.ResponseStatus + next: optional + previous: optional + results: unknown + CreateOrUpdateExpenseRequest: unknown + CreateOrUpdateExpenseResponse: + properties: + status: types.ResponseStatus + message: string + result: unknown + DeleteExpenseResponse: + properties: + status: types.ResponseStatus + message: string + +service: + base-path: /accounting/expenses + auth: false + headers: + x-revert-api-token: + type: string + docs: Your official API key for accessing revert apis. + x-revert-t-id: + type: string + docs: The unique customer id used when the customer linked their account. + x-api-version: + type: optional + docs: Optional Revert API version you're using. If missing we default to the latest version of the API. + audiences: + - external + endpoints: + getExpense: + docs: Get details of an Expense + method: GET + path: /{id} + path-parameters: + id: string + request: + name: GetExpenseRequest + query-parameters: + fields: optional + response: GetExpenseResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + getExpenses: + docs: Get all the expenses + method: GET + path: '' + request: + name: GetExpensesRequest + query-parameters: + fields: optional + pageSize: optional + cursor: optional + response: GetExpensesResponse + createExpense: + docs: Post Expense + method: POST + path: '' + request: + name: CreateExpenseRequest + body: CreateOrUpdateExpenseRequest + query-parameters: + fields: optional + response: CreateOrUpdateExpenseResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + updateExpense: + docs: Update Expense + method: PATCH + path: /{id} + path-parameters: + id: string + request: + name: UpdateExpenseRequest + body: CreateOrUpdateExpenseRequest + query-parameters: + fields: optional + response: CreateOrUpdateExpenseResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + deleteExpense: + docs: Delete Expense + method: DELETE + path: /{id} + path-parameters: + id: string + request: + name: DeleteExpenseRequest + query-parameters: + fields: optional + response: DeleteExpenseResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError diff --git a/fern/definition/accounting/proxy.yml b/fern/definition/accounting/proxy.yml new file mode 100644 index 000000000..1f787861d --- /dev/null +++ b/fern/definition/accounting/proxy.yml @@ -0,0 +1,43 @@ +imports: + errors: ../common/errors.yml + types: ../common/types.yml + +types: + ProxyResponse: + properties: + result: unknown + PostProxyRequestBody: + properties: + path: string + body: optional + method: string + queryParams: optional + +service: + base-path: /accounting/proxy + auth: false + headers: + x-revert-api-token: + type: string + docs: Your official API key for accessing revert apis. + x-revert-t-id: + type: string + docs: The unique customer id used when the customer linked their account. + x-api-version: + type: optional + docs: Optional Revert API version you're using. If missing we default to the latest version of the API. + audiences: + - external + endpoints: + tunnel: + docs: Call the native Accounting app's api for a specific connection + method: POST + path: '' + request: + name: PostProxyRequest + body: PostProxyRequestBody + response: ProxyResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError diff --git a/fern/definition/accounting/vendor.yml b/fern/definition/accounting/vendor.yml new file mode 100644 index 000000000..c9c2de6a9 --- /dev/null +++ b/fern/definition/accounting/vendor.yml @@ -0,0 +1,113 @@ +imports: + errors: ../common/errors.yml + types: ../common/types.yml + unified: ../common/unified.yml + associations: ../common/associations.yml + +types: + GetVendorResponse: + properties: + status: types.ResponseStatus + result: unknown + GetVendorsResponse: + properties: + status: types.ResponseStatus + next: optional + previous: optional + results: unknown + CreateOrUpdateVendorRequest: unknown + CreateOrUpdateVendorResponse: + properties: + status: types.ResponseStatus + message: string + result: unknown + DeleteVendorResponse: + properties: + status: types.ResponseStatus + message: string + +service: + base-path: /accounting/vendors + auth: false + headers: + x-revert-api-token: + type: string + docs: Your official API key for accessing revert apis. + x-revert-t-id: + type: string + docs: The unique customer id used when the customer linked their account. + x-api-version: + type: optional + docs: Optional Revert API version you're using. If missing we default to the latest version of the API. + audiences: + - external + endpoints: + getVendor: + docs: Get details of a Vendor + method: GET + path: /{id} + path-parameters: + id: string + request: + name: GetVendorRequest + query-parameters: + fields: optional + response: GetVendorResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + getVendors: + docs: Get all the Vendors + method: GET + path: '' + request: + name: GetVendorsRequest + query-parameters: + fields: optional + pageSize: optional + cursor: optional + response: GetVendorsResponse + createVendor: + docs: Post Vendor + method: POST + path: '' + request: + name: CreateVendorRequest + body: CreateOrUpdateVendorRequest + query-parameters: + fields: optional + response: CreateOrUpdateVendorResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + updateVendor: + docs: Update Vendor + method: PATCH + path: /{id} + path-parameters: + id: string + request: + name: UpdateVendorRequest + body: CreateOrUpdateVendorRequest + query-parameters: + fields: optional + response: CreateOrUpdateVendorResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + deleteVendor: + docs: Delete Vendor + method: DELETE + path: /{id} + path-parameters: + id: string + request: + name: DeleteVendorRequest + response: DeleteVendorResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError diff --git a/fern/docs.yml b/fern/docs.yml index 02f28f754..f0ff566c7 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -86,6 +86,10 @@ navigation: path: ./docs/contents/guides/trello.mdx - page: BitBucket path: ./docs/contents/guides/bitbucket.mdx + - page: QuickBooks + path: ./docs/contents/guides/quickbooks.mdx + - page: Xero + path: ./docs/contents/guides/xero.mdx - page: Greenhouse path: ./docs/contents/guides/greenhouse.mdx - page: Lever diff --git a/fern/docs/contents/accounting-supported.mdx b/fern/docs/contents/accounting-supported.mdx new file mode 100644 index 000000000..d4a6de21d --- /dev/null +++ b/fern/docs/contents/accounting-supported.mdx @@ -0,0 +1,10 @@ +### Accounting Systems Support + +We currently support the following accounting apps with our APIs: + +| Accounting | Support Status | +| ---------- | -------------- | +| QuickBooks | ✅ | +| Xero | ✅ | + +Need an integration thats not listed here? Contact us on our [discord](https://discord.gg/q5K5cRhymW) diff --git a/fern/docs/contents/guides/quickbooks.mdx b/fern/docs/contents/guides/quickbooks.mdx new file mode 100644 index 000000000..f39fd9de6 --- /dev/null +++ b/fern/docs/contents/guides/quickbooks.mdx @@ -0,0 +1,18 @@ + + +- Enter the `client_id` and `client_secret` you copied in the previous step into the App credentials here and click `Submit`. diff --git a/fern/docs/contents/guides/xero.mdx b/fern/docs/contents/guides/xero.mdx new file mode 100644 index 000000000..f22fc7c64 --- /dev/null +++ b/fern/docs/contents/guides/xero.mdx @@ -0,0 +1,18 @@ + + +#### Obtaining Xero Client ID and Secret + +- Open a [Xero developer account](https://www.xero.com/signup/). +- Create a Xero OAuth consumer, using the steps mentioned [here](https://developer.xero.com/app/manage) + - `NOTE:` You can skip this step and use the default revert Xero app +- Set `https://app.revert.dev/oauth-callback/xero` as the `redirect` url for your app +- **Get your client_id and client_secret**: + +#### Connect to Xero via Revert + +- Create an account on Revert if you don't already have one. (https://app.revert.dev/sign-up) +- Login to your revert dashboard (https://app.revert.dev/sign-in) and go to Integrations section and click on `Create App` - `Xero` + + + +- Enter the `client_id` and `client_secret` you copied in the previous step into the App credentials here and click `Submit`. diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 9b8969600..59af76f7a 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -45,9 +45,14 @@ OPEN_INT_API_KEY= TWENTY_ACCOUNT_ID= BITBUCKET_CLIENT_ID= BITBUCKET_CLIENT_SECRET= +DEFAULT_RATE_LIMIT_DEVELOPER_PLAN= +QUICKBOOKS_CLIENT_ID= +QUICKBOOKS_CLIENT_SECRET= +XERO_CLIENT_ID= +XERO_CLIENT_SECRET= LEVER_CLIENT_ID= LEVER_CLIENT_SECRET= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -DEFAULT_RATE_LIMIT_DEVELOPER_PLAN= + diff --git a/packages/backend/config.ts b/packages/backend/config.ts index c3def043b..1f1c939af 100644 --- a/packages/backend/config.ts +++ b/packages/backend/config.ts @@ -57,6 +57,12 @@ const config = { GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET!, DEFAULT_RATE_LIMIT_DEVELOPER_PLAN: process.env.DEFAULT_RATE_LIMIT_DEVELOPER_PLAN, + + QUICKBOOKS_CLIENT_ID: process.env.QUICKBOOKS_CLIENT_ID!, + QUICKBOOKS_CLIENT_SECRET: process.env.QUICKBOOKS_CLIENT_SECRET!, + XERO_CLIENT_ID: process.env.XERO_CLIENT_ID!, + XERO_CLIENT_SECRET: process.env.XERO_CLIENT_SECRET!, + LEVER_CLIENT_ID: process.env.LEVER_CLIENT_ID!, LEVER_CLIENT_SECRET: process.env.LEVER_CLIENT_SECRET!, }; diff --git a/packages/backend/constants/common.ts b/packages/backend/constants/common.ts index cdc7f15ff..de687a0ed 100644 --- a/packages/backend/constants/common.ts +++ b/packages/backend/constants/common.ts @@ -3,7 +3,7 @@ import { Request, Response } from 'express'; export type CRM_TP_ID = 'zohocrm' | 'sfdc' | 'pipedrive' | 'hubspot' | 'closecrm' | 'ms_dynamics_365_sales'; export type CHAT_TP_ID = 'slack' | 'discord'; - +export type ACCOUNTING_TP_ID = 'quickbooks' | 'xero'; export type ATS_TP_ID = 'greenhouse' | 'lever'; export type TICKET_TP_ID = 'linear' | 'clickup' | 'asana' | 'jira' | 'trello' | 'bitbucket' | 'github'; @@ -51,7 +51,8 @@ export const DEFAULT_SCOPE = { [TP_ID.jira]: ['read:jira-work', 'read:jira-user', 'write:jira-work', 'offline_access'], [TP_ID.ms_dynamics_365_sales]: ['offline_access', 'User.Read'], [TP_ID.bitbucket]: ['issue', 'issue:write', 'repository', 'account'], - + [TP_ID.quickbooks]: ['com.intuit.quickbooks.accounting'], + [TP_ID.xero]: ['offline_access', 'accounting.contacts', 'accounting.transactions', 'accounting.settings'], [TP_ID.greenhouse]: [], [TP_ID.lever]: [ 'applications:read:admin', @@ -102,9 +103,10 @@ export const mapIntegrationIdToIntegrationName = { [TP_ID.jira]: 'Jira', [TP_ID.ms_dynamics_365_sales]: 'Microsoft Dynamics 365 Sales', [TP_ID.bitbucket]: 'Bitbucket', + [TP_ID.quickbooks]: 'QuickBooks', + [TP_ID.xero]: 'Xero', [TP_ID.greenhouse]: 'Greenhouse', [TP_ID.lever]: 'Lever', - [TP_ID.github]: 'GitHub', }; @@ -133,6 +135,12 @@ export enum TicketStandardObjects { ticketComment = 'ticketComment', } +export enum AccountingStandardObjects { + account = 'account', + expense = 'expense', + vendor = 'vendor', +} + export enum AtsStandardObjects { job = 'job', offer = 'offer', diff --git a/packages/backend/helpers/crm/transform/disunify.ts b/packages/backend/helpers/crm/transform/disunify.ts index acf829c04..411f5900f 100644 --- a/packages/backend/helpers/crm/transform/disunify.ts +++ b/packages/backend/helpers/crm/transform/disunify.ts @@ -1,5 +1,7 @@ import { TP_ID, accountFieldMappingConfig } from '@prisma/client'; import { + ACCOUNTING_TP_ID, + AccountingStandardObjects, ATS_TP_ID, AtsStandardObjects, CHAT_TP_ID, @@ -17,7 +19,14 @@ import { handleSfdcDisunify, handleZohoDisunify, } from '..'; -import { postprocessDisUnifyAtsObject, postprocessDisUnifyObject, postprocessDisUnifyTicketObject } from './preprocess'; + +import { + postprocessDisUnifyAccoutingObject, + postprocessDisUnifyAtsObject, + postprocessDisUnifyObject, + postprocessDisUnifyTicketObject, +} from './preprocess'; + import { flattenObj } from '../../../helpers/flattenObj'; import handleCloseCRMDisunify from '../closecrm'; @@ -460,3 +469,55 @@ export async function disunifyAtsObject>({ } } } +export async function disunifyAccountingObject>({ + obj, + tpId, + objType, + tenantSchemaMappingId, + accountFieldMappingConfig, +}: { + obj: T; + tpId: ACCOUNTING_TP_ID; + objType: AccountingStandardObjects; + tenantSchemaMappingId?: string; + accountFieldMappingConfig?: accountFieldMappingConfig; +}) { + const flattenedObj = flattenObj(obj, ['additional']); + const transformedObj = await transformModelToFieldMapping({ + unifiedObj: flattenedObj, + tpId, + objType, + tenantSchemaMappingId, + accountFieldMappingConfig, + }); + if (obj.additional) { + Object.keys(obj.additional).forEach((key: any) => (transformedObj[key] = obj.additional[key])); + } + + const processedObj = postprocessDisUnifyAccoutingObject({ obj: transformedObj, tpId, objType }); + + switch (tpId) { + case TP_ID.quickbooks: { + if (objType === 'expense') { + transformedObj['Line'] = obj.line; + + return { + ...transformedObj, + }; + } + + return processedObj; + } + + case TP_ID.xero: { + if (objType === 'account') { + const active = obj.active && obj.active === true ? 'ACTIVE' : 'ARCHIVED'; + return { + ...transformedObj, + Status: active, + }; + } + return processedObj; + } + } +} diff --git a/packages/backend/helpers/crm/transform/preprocess.ts b/packages/backend/helpers/crm/transform/preprocess.ts index 892359b27..58b15cd9a 100644 --- a/packages/backend/helpers/crm/transform/preprocess.ts +++ b/packages/backend/helpers/crm/transform/preprocess.ts @@ -5,6 +5,8 @@ import { StandardObjects, TICKET_TP_ID, TicketStandardObjects, + AccountingStandardObjects, + ACCOUNTING_TP_ID, AtsStandardObjects, ATS_TP_ID, } from '../../../constants/common'; @@ -19,7 +21,11 @@ export const preprocessUnifyObject = >({ }: { obj: T; tpId: CRM_TP_ID | TICKET_TP_ID; - objType: StandardObjects | ChatStandardObjects | TicketStandardObjects | AtsStandardObjects; + objType:StandardObjects + | ChatStandardObjects + | TicketStandardObjects + | AtsStandardObjects + | AccountingStandardObjects; }) => { const preprocessMap: any = { [TP_ID.hubspot]: { @@ -288,6 +294,45 @@ export const preprocessUnifyObject = >({ }; }, }, + + [TP_ID.xero]: { + [AccountingStandardObjects.account]: (obj: T) => { + const dateString = obj.UpdatedDateUTC.match(/\/Date\((\d+)\+0000\)\//)[1]; + const date = dateString ? dayjs(Number(dateString)).format('YYYY-MM-DD') : null; + + const active = obj.Status && obj.Status === 'ACTIVE' ? true : false; + return { ...obj, UpdatedDateUTC: date, Status: active }; + }, + [AccountingStandardObjects.vendor]: (obj: T) => { + const dateString = obj.UpdatedDateUTC.match(/\/Date\((\d+)\+0000\)\//)[1]; + const date = dateString ? dayjs(Number(dateString)).format('YYYY-MM-DD') : null; + + return { ...obj, UpdatedDateUTC: date }; + }, + [AccountingStandardObjects.expense]: (obj: T) => { + const updateDateString = obj.UpdatedDateUTC.match(/\/Date\((\d+)\+0000\)\//)[1]; + + const updated_at = updateDateString ? dayjs(Number(updateDateString)).format('YYYY-MM-DD') : null; + const date = obj.DateString ? dayjs(obj.DateString).format('YYYY-MM-DD') : null; + + const line: any[] = []; + + obj.LineItems && + obj.LineItems.map((item: any) => { + const lineItem = { + id: item.LineItemID, + description: item.Description, + amount: item.LineAmount, + detailType: undefined, + accountBasedExpenseLineDetail: undefined, + }; + + line.push(lineItem); + }); + + return { ...obj, UpdatedDateUTC: updated_at, DateString: date, LineItems: line }; + }, + }, [TP_ID.lever]: { [AtsStandardObjects.candidate]: (obj: T) => { let is_private = false; @@ -651,3 +696,19 @@ export const postprocessDisUnifyAtsObject = >({ const transformFn = (preprocessMap[tpId] || {})[objType]; return transformFn ? transformFn(obj) : obj; }; +export const postprocessDisUnifyAccoutingObject = >({ + obj, + tpId, + objType, +}: { + obj: T; + tpId: ACCOUNTING_TP_ID; + objType: AccountingStandardObjects; +}) => { + const preprocessMap: Record> = { + [TP_ID.quickbooks]: {}, + [TP_ID.xero]: {}, + }; + const transformFn = (preprocessMap[tpId] || {})[objType]; + return transformFn ? transformFn(obj) : obj; +}; diff --git a/packages/backend/helpers/crm/transform/transformSchemaMapping.ts b/packages/backend/helpers/crm/transform/transformSchemaMapping.ts index 6c19de17d..1b2be2dbb 100644 --- a/packages/backend/helpers/crm/transform/transformSchemaMapping.ts +++ b/packages/backend/helpers/crm/transform/transformSchemaMapping.ts @@ -4,6 +4,7 @@ import { ChatStandardObjects, StandardObjects, TicketStandardObjects, + AccountingStandardObjects, AtsStandardObjects, rootSchemaMappingId, } from '../../../constants/common'; @@ -20,7 +21,12 @@ export const transformFieldMappingToModel = async ({ }: { obj: any; tpId: TP_ID; - objType: StandardObjects | ChatStandardObjects | TicketStandardObjects | AtsStandardObjects; + objType: StandardObjects + | ChatStandardObjects + | TicketStandardObjects + | AtsStandardObjects + | AccountingStandardObjects; + tenantSchemaMappingId?: string; accountFieldMappingConfig?: accountFieldMappingConfig; }) => { @@ -87,7 +93,11 @@ export const transformModelToFieldMapping = async ({ }: { unifiedObj: any; tpId: TP_ID; - objType: StandardObjects | ChatStandardObjects | TicketStandardObjects | AtsStandardObjects; + objType:StandardObjects + | ChatStandardObjects + | TicketStandardObjects + | AtsStandardObjects + | AccountingStandardObjects; tenantSchemaMappingId?: string; accountFieldMappingConfig?: accountFieldMappingConfig; }) => { diff --git a/packages/backend/helpers/crm/transform/unify.ts b/packages/backend/helpers/crm/transform/unify.ts index f32748ca8..fa77f7fba 100644 --- a/packages/backend/helpers/crm/transform/unify.ts +++ b/packages/backend/helpers/crm/transform/unify.ts @@ -3,6 +3,7 @@ import { ChatStandardObjects, StandardObjects, TicketStandardObjects, + AccountingStandardObjects, AtsStandardObjects, } from '../../../constants/common'; @@ -20,7 +21,11 @@ export async function unifyObject, K>({ }: { obj: T; tpId: CRM_TP_ID; - objType: StandardObjects | ChatStandardObjects | TicketStandardObjects | AtsStandardObjects; + objType:StandardObjects + | ChatStandardObjects + | TicketStandardObjects + | AtsStandardObjects + | AccountingStandardObjects; tenantSchemaMappingId?: string; accountFieldMappingConfig?: accountFieldMappingConfig; }): Promise { diff --git a/packages/backend/helpers/endPointLoggerMiddleWare.ts b/packages/backend/helpers/endPointLoggerMiddleWare.ts index 7daba90b6..45416ebdc 100644 --- a/packages/backend/helpers/endPointLoggerMiddleWare.ts +++ b/packages/backend/helpers/endPointLoggerMiddleWare.ts @@ -7,7 +7,11 @@ const endpointLogger = () => async (req: Request, res: Response, next: NextFunct const path = req.path; const { 'x-revert-api-token': token } = req.headers; const toAllow = - path.includes('/crm') || path.includes('/chat') || path.includes('/ticket') || path.includes('/ats'); + path.includes('/crm') || + path.includes('/chat') || + path.includes('/ticket') || + path.includes('/ats') || + path.includes('/accounting'); if (!toAllow) return next(); diff --git a/packages/backend/index.ts b/packages/backend/index.ts index 4bbcd257d..fe1641aaa 100644 --- a/packages/backend/index.ts +++ b/packages/backend/index.ts @@ -152,6 +152,9 @@ app.listen(config.PORT, () => { await AuthService.refreshOAuthTokensForThirdParty(); await AuthService.refreshOAuthTokensForThirdPartyChatServices(); await AuthService.refreshOAuthTokensForThirdPartyTicketServices(); + + await AuthService.refreshOAuthTokensForThirdPartyAccountingServices(); + await AuthService.refreshOAuthTokensForThirdPartyAtsServices(); }); if (!config.DISABLE_REVERT_TELEMETRY) { diff --git a/packages/backend/models/unified/account.ts b/packages/backend/models/unified/account.ts new file mode 100644 index 000000000..36802b380 --- /dev/null +++ b/packages/backend/models/unified/account.ts @@ -0,0 +1,25 @@ +export interface UnifiedAccount { + id: string; + domain: string; + status?: 'deleted'; + metadata: { + createTime: string; + lastUpdatedTime: string; + }; + accountSubType: string; + accountType: string; + active: boolean; + classification: string; + currencyRef: { + name: string; + value: string; + }; + currentBalance: number; + currentBalanceWithSubAccounts: number; + fullyQualifiedName: string; + name: string; + subAccount: boolean; + syncToken: string; + sparse: boolean; + additional: any; +} diff --git a/packages/backend/models/unified/expense.ts b/packages/backend/models/unified/expense.ts new file mode 100644 index 000000000..bd6f7e09c --- /dev/null +++ b/packages/backend/models/unified/expense.ts @@ -0,0 +1,68 @@ +export interface UnifiedExpense { + id: string; + domain: string; + status?: 'deleted'; + metadata: { + createTime: string; + lastUpdatedTime: string; + }; + accountRef: { + value: string; + name: string; + }; + paymentMethodRef?: { + value: string; + }; + paymentType: string; + entityRef?: { + value: string; + name: string; + type?: string; + }; + credit?: boolean; + totalAmt: number; + purchaseEx: { + any: { + name: string; + declaredType: string; + scope: string; + value: { + name: string; + value: string; + }; + nil: boolean; + globalScope: boolean; + typeSubstituted: boolean; + }[]; + }; + sparse: boolean; + syncToken: string; + txnDate: string; + currencyRef: { + name: string; + value: string; + }; + privateNote: string; + line: { + id: string; + description: string; + amount: number; + detailType: string; + accountBasedExpenseLineDetail?: { + accountRef: { + value: string; + name: string; + }; + billableStatus: string; + taxCodeRef: { + value: string; + }; + customerRef?: { + value: string; + name: string; + }; + }; + }[]; + docNumber?: string; + additional: any; +} diff --git a/packages/backend/models/unified/vendor.ts b/packages/backend/models/unified/vendor.ts new file mode 100644 index 000000000..d2e6636ec --- /dev/null +++ b/packages/backend/models/unified/vendor.ts @@ -0,0 +1,12 @@ +export interface UnifiedVendor { + id: string; + domain: string; + status?: 'deleted'; + metadata: { + createTime: string; + lastUpdatedTime: string; + }; + displayName: string; + printOnCheckName?: string; + additional: any; +} diff --git a/packages/backend/oas/openapi.yml b/packages/backend/oas/openapi.yml index cada1b269..a84babbf3 100644 --- a/packages/backend/oas/openapi.yml +++ b/packages/backend/oas/openapi.yml @@ -3,6 +3,674 @@ info: title: revert-api version: '' paths: + /accounting/accounts/{id}: + get: + description: Get details of an account + operationId: accounting_account_getAccount + tags: + - AccountingAccount + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingGetAccountResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + patch: + description: Update Account + operationId: accounting_account_updateAccount + tags: + - AccountingAccount + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingCreateOrUpdateAccountResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/accountingCreateOrUpdateAccountRequest' + delete: + description: Delete account + operationId: accounting_account_deleteAccount + tags: + - AccountingAccount + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingDeleteAccountResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + /accounting/accounts: + get: + description: Get all the accounts + operationId: accounting_account_getAccounts + tags: + - AccountingAccount + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + - name: pageSize + in: query + required: false + schema: + type: string + nullable: true + - name: cursor + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingGetAccountsResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + post: + description: Post Account + operationId: accounting_account_createAccount + tags: + - AccountingAccount + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingCreateOrUpdateAccountResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/accountingCreateOrUpdateAccountRequest' + /accounting/expenses/{id}: + get: + description: Get details of an Expense + operationId: accounting_expense_getExpense + tags: + - AccountingExpense + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingGetExpenseResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + patch: + description: Update Expense + operationId: accounting_expense_updateExpense + tags: + - AccountingExpense + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingCreateOrUpdateExpenseResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/accountingCreateOrUpdateExpenseRequest' + delete: + description: Delete Expense + operationId: accounting_expense_deleteExpense + tags: + - AccountingExpense + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingDeleteExpenseResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + /accounting/expenses: + get: + description: Get all the expenses + operationId: accounting_expense_getExpenses + tags: + - AccountingExpense + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + - name: pageSize + in: query + required: false + schema: + type: string + nullable: true + - name: cursor + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingGetExpensesResponse' + post: + description: Post Expense + operationId: accounting_expense_createExpense + tags: + - AccountingExpense + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingCreateOrUpdateExpenseResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/accountingCreateOrUpdateExpenseRequest' + /accounting/proxy: + post: + description: Call the native Accounting app's api for a specific connection + operationId: accounting_proxy_tunnel + tags: + - AccountingProxy + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingProxyResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/accountingPostProxyRequestBody' + /accounting/vendors/{id}: + get: + description: Get details of a Vendor + operationId: accounting_vendor_getVendor + tags: + - AccountingVendor + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingGetVendorResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + patch: + description: Update Vendor + operationId: accounting_vendor_updateVendor + tags: + - AccountingVendor + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingCreateOrUpdateVendorResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/accountingCreateOrUpdateVendorRequest' + delete: + description: Delete Vendor + operationId: accounting_vendor_deleteVendor + tags: + - AccountingVendor + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingDeleteVendorResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + /accounting/vendors: + get: + description: Get all the Vendors + operationId: accounting_vendor_getVendors + tags: + - AccountingVendor + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + - name: pageSize + in: query + required: false + schema: + type: string + nullable: true + - name: cursor + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingGetVendorsResponse' + post: + description: Post Vendor + operationId: accounting_vendor_createVendor + tags: + - AccountingVendor + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/accountingCreateOrUpdateVendorResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/accountingCreateOrUpdateVendorRequest' /ats/candidates/{id}: get: description: Get details of a candidate. @@ -4551,6 +5219,184 @@ paths: $ref: '#/components/schemas/commonBaseError' components: schemas: + accountingGetAccountResponse: + title: accountingGetAccountResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + result: {} + required: + - status + - result + accountingGetAccountsResponse: + title: accountingGetAccountsResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + next: + type: string + nullable: true + previous: + type: string + nullable: true + results: {} + required: + - status + - results + accountingCreateOrUpdateAccountRequest: + title: accountingCreateOrUpdateAccountRequest + accountingCreateOrUpdateAccountResponse: + title: accountingCreateOrUpdateAccountResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + result: {} + required: + - status + - message + - result + accountingDeleteAccountResponse: + title: accountingDeleteAccountResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + required: + - status + - message + accountingGetExpenseResponse: + title: accountingGetExpenseResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + result: {} + required: + - status + - result + accountingGetExpensesResponse: + title: accountingGetExpensesResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + next: + type: string + nullable: true + previous: + type: string + nullable: true + results: {} + required: + - status + - results + accountingCreateOrUpdateExpenseRequest: + title: accountingCreateOrUpdateExpenseRequest + accountingCreateOrUpdateExpenseResponse: + title: accountingCreateOrUpdateExpenseResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + result: {} + required: + - status + - message + - result + accountingDeleteExpenseResponse: + title: accountingDeleteExpenseResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + required: + - status + - message + accountingProxyResponse: + title: accountingProxyResponse + type: object + properties: + result: {} + required: + - result + accountingPostProxyRequestBody: + title: accountingPostProxyRequestBody + type: object + properties: + path: + type: string + body: + nullable: true + method: + type: string + queryParams: + nullable: true + required: + - path + - method + accountingGetVendorResponse: + title: accountingGetVendorResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + result: {} + required: + - status + - result + accountingGetVendorsResponse: + title: accountingGetVendorsResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + next: + type: string + nullable: true + previous: + type: string + nullable: true + results: {} + required: + - status + - results + accountingCreateOrUpdateVendorRequest: + title: accountingCreateOrUpdateVendorRequest + accountingCreateOrUpdateVendorResponse: + title: accountingCreateOrUpdateVendorResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + result: {} + required: + - status + - message + - result + accountingDeleteVendorResponse: + title: accountingDeleteVendorResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + required: + - status + - message atsGetCandidateResponse: title: atsGetCandidateResponse type: object diff --git a/packages/backend/package.json b/packages/backend/package.json index 2b7cd1ad6..2d31215cf 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -72,6 +72,7 @@ "dotenv": "^16.3.1", "express": "^4.18.1", "ip": "^1.1.8", + "jwt-decode": "^4.0.0", "lodash": "^4.17.21", "moesif-nodejs": "^3.5.3", "morgan": "^1.10.0", diff --git a/packages/backend/prisma/fields.ts b/packages/backend/prisma/fields.ts index fc05b8b6e..1464d842c 100644 --- a/packages/backend/prisma/fields.ts +++ b/packages/backend/prisma/fields.ts @@ -1,5 +1,11 @@ import { TP_ID } from '@prisma/client'; -import { AtsStandardObjects, ChatStandardObjects, StandardObjects, TicketStandardObjects } from '../constants/common'; +import { + AtsStandardObjects, + ChatStandardObjects, + StandardObjects, + TicketStandardObjects, + AccountingStandardObjects, +} from '../constants/common'; // root schema mapping export const allFields = { @@ -1815,3 +1821,347 @@ export const atsFields = { }, ], }; +export const accountingFields = { + [AccountingStandardObjects.account]: [ + { + source_field_name: { + [TP_ID.quickbooks]: 'Id', + [TP_ID.xero]: 'AccountID', + }, + target_field_name: 'id', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'domain', + [TP_ID.xero]: undefined, + }, + target_field_name: 'domain', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'status', + [TP_ID.xero]: undefined, + }, + target_field_name: 'status', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'MetaData.CreateTime', + [TP_ID.xero]: undefined, + }, + target_field_name: 'metadata.createTime', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'MetaData.LastUpdatedTime', + [TP_ID.xero]: 'UpdatedDateUTC', + }, + target_field_name: 'metadata.lastUpdatedTime', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'AccountSubType', + [TP_ID.xero]: 'SystemAccount', + }, + target_field_name: 'accountSubType', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'AccountType', + [TP_ID.xero]: 'SystemAccount', + }, + target_field_name: 'accountType', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'Active', + [TP_ID.xero]: 'Status', + }, + target_field_name: 'active', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'Classification', + [TP_ID.xero]: 'Class', + }, + target_field_name: 'classification', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'CurrencyRef.name', + [TP_ID.xero]: undefined, + }, + target_field_name: 'currencyRef.name', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'CurrencyRef.value', + [TP_ID.xero]: 'CurrencyCode', + }, + target_field_name: 'currencyRef.value', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'CurrentBalance', + [TP_ID.xero]: undefined, + }, + target_field_name: 'currentBalance', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'CurrentBalanceWithSubAccounts', + [TP_ID.xero]: undefined, + }, + target_field_name: 'currentBalanceWithSubAccounts', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'FullyQualifiedName', + [TP_ID.xero]: 'Name', + }, + target_field_name: 'fullyQualifiedName', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'Name', + [TP_ID.xero]: 'Name', + }, + target_field_name: 'name', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'SubAccount', + [TP_ID.xero]: undefined, + }, + target_field_name: 'subAccount', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'SyncToken', + [TP_ID.xero]: undefined, + }, + target_field_name: 'syncToken', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'sparse', + [TP_ID.xero]: undefined, + }, + target_field_name: 'sparse', + }, + ], + [AccountingStandardObjects.vendor]: [ + { + source_field_name: { + [TP_ID.quickbooks]: 'Id', + [TP_ID.xero]: 'ContactID', + }, + target_field_name: 'id', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'domain', + [TP_ID.xero]: undefined, + }, + target_field_name: 'domain', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'status', + [TP_ID.xero]: undefined, + }, + target_field_name: 'status', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'MetaData.CreateTime', + [TP_ID.xero]: undefined, + }, + target_field_name: 'metadata.createTime', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'MetaData.LastUpdatedTime', + [TP_ID.xero]: 'UpdatedDateUTC', + }, + target_field_name: 'metadata.lastUpdatedTime', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'DisplayName', + [TP_ID.xero]: 'Name', + }, + target_field_name: 'displayName', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'PrintOnCheckName', + [TP_ID.xero]: 'Name', + }, + target_field_name: 'printOnCheckName', + }, + ], + [AccountingStandardObjects.expense]: [ + { + source_field_name: { + [TP_ID.quickbooks]: 'Id', + [TP_ID.xero]: 'InvoiceID', + }, + target_field_name: 'id', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'domain', + [TP_ID.xero]: undefined, + }, + target_field_name: 'domain', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'status', + [TP_ID.xero]: undefined, + }, + target_field_name: 'status', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'MetaData.CreateTime', + [TP_ID.xero]: 'DateString', + }, + target_field_name: 'metadata.createTime', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'MetaData.LastUpdatedTime', + [TP_ID.xero]: 'UpdatedDateUTC', + }, + target_field_name: 'metadata.lastUpdatedTime', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'AccountRef.value', + [TP_ID.xero]: undefined, + }, + target_field_name: 'accountRef.value', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'AccountRef.name', + [TP_ID.xero]: undefined, + }, + target_field_name: 'accountRef.name', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'PaymentMethodRef.value', + [TP_ID.xero]: undefined, + }, + target_field_name: 'paymentMethodRef.value', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'PaymentType', + [TP_ID.xero]: undefined, + }, + target_field_name: 'paymentType', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'EntityRef.value', + [TP_ID.xero]: undefined, + }, + target_field_name: 'entityRef.value', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'EntityRef.name', + [TP_ID.xero]: 'Contact.Name', + }, + target_field_name: 'entityRef.name', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'EntityRef.type', + [TP_ID.xero]: undefined, + }, + target_field_name: 'entityRef.type', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'Credit', + [TP_ID.xero]: undefined, + }, + target_field_name: 'credit', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'TotalAmt', + [TP_ID.xero]: 'Total', + }, + target_field_name: 'totalAmt', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'PurchaseEx.any', + [TP_ID.xero]: undefined, + }, + target_field_name: 'purchaseEx.any', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'sparse', + [TP_ID.xero]: undefined, + }, + target_field_name: 'sparse', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'SyncToken', + [TP_ID.xero]: undefined, + }, + target_field_name: 'syncToken', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'TxnDate', + [TP_ID.xero]: 'DateString', + }, + target_field_name: 'txnDate', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'CurrencyRef.name', + [TP_ID.xero]: undefined, + }, + target_field_name: 'currencyRef.name', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'CurrencyRef.value', + [TP_ID.xero]: 'CurrencyCode', + }, + target_field_name: 'currencyRef.value', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'PrivateNote', + [TP_ID.xero]: undefined, + }, + target_field_name: 'privateNote', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'Line', + [TP_ID.xero]: 'LineItems', + }, + target_field_name: 'line', + }, + { + source_field_name: { + [TP_ID.quickbooks]: 'DocNumber', + [TP_ID.xero]: 'InvoiceNumber', + }, + target_field_name: 'docNumber', + }, + ], +}; diff --git a/packages/backend/prisma/migrations/20240619100855_quickbook_enum/migration.sql b/packages/backend/prisma/migrations/20240619100855_quickbook_enum/migration.sql new file mode 100644 index 000000000..e54c71c6d --- /dev/null +++ b/packages/backend/prisma/migrations/20240619100855_quickbook_enum/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "TP_ID" ADD VALUE 'quickbooks'; diff --git a/packages/backend/prisma/migrations/20240620060450_xero_enum/migration.sql b/packages/backend/prisma/migrations/20240620060450_xero_enum/migration.sql new file mode 100644 index 000000000..78bd17fa4 --- /dev/null +++ b/packages/backend/prisma/migrations/20240620060450_xero_enum/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "TP_ID" ADD VALUE 'xero'; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index a2d4334a1..c575747bd 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -22,6 +22,8 @@ enum TP_ID { trello jira bitbucket + quickbooks + xero greenhouse lever github diff --git a/packages/backend/prisma/seed.ts b/packages/backend/prisma/seed.ts index 017779c22..089314de9 100644 --- a/packages/backend/prisma/seed.ts +++ b/packages/backend/prisma/seed.ts @@ -6,8 +6,9 @@ import { StandardObjects, TicketStandardObjects, rootSchemaMappingId, + AccountingStandardObjects, } from '../constants/common'; -import { allFields, atsFields, chatFields, ticketingFields } from './fields'; +import { allFields, atsFields, chatFields, ticketingFields, accountingFields } from './fields'; const prisma = new PrismaClient(); async function main() { @@ -84,8 +85,15 @@ async function main() { object: obj as AtsStandardObjects, }; }); + const accountingSchemas = Object.keys(accountingFields).map((obj) => { + return { + id: randomUUID(), + fields: accountingFields[obj as keyof typeof accountingFields].map((n) => n.target_field_name), + object: obj as AccountingStandardObjects, + }; + }); - const mergedSchema = [...allSchemas, ...chatSchemas, ...ticketSchemas, ...atsSchemas]; + const mergedSchema = [...allSchemas, ...chatSchemas, ...ticketSchemas, ...atsSchemas, ...accountingSchemas]; await prisma.schema_mapping.deleteMany({ where: { @@ -219,6 +227,28 @@ async function main() { } }); }); + Object.values(AccountingStandardObjects).forEach((obj) => { + Object.values(TP_ID).forEach(async (tpId) => { + if (!(tpId === 'quickbooks' || tpId === 'xero')) return; + const objSchema = accountingSchemas.find((s: any) => s.object === obj); + const fieldMappings = objSchema?.fields.map((field: any) => { + const sourceFields: any = (accountingFields[obj] as { target_field_name: string }[]).find( + (a) => a.target_field_name === field + ); + return { + id: randomUUID(), + source_tp_id: tpId, + schema_id: objSchema.id, + source_field_name: sourceFields?.source_field_name[tpId]!, + target_field_name: field, + is_standard_field: true, + }; + }); + if (fieldMappings) { + fieldMappingForAll.push(...fieldMappings); + } + }); + }); await prisma.fieldMappings.createMany({ data: fieldMappingForAll, diff --git a/packages/backend/routes/index.ts b/packages/backend/routes/index.ts index e92a0a513..2c1a65692 100644 --- a/packages/backend/routes/index.ts +++ b/packages/backend/routes/index.ts @@ -45,6 +45,13 @@ import { collectionServiceTicket } from '../services/ticket/collection'; import { commentServiceTicket } from '../services/ticket/comment'; import { proxyServiceTicket } from '../services/ticket/proxy'; import { syncService } from '../services/sync'; + +import accountingRouter from './v1/accounting'; +import { vendorServiceAccounting } from '../services/accounting/vendor'; +import { expenseServiceAccounting } from '../services/accounting/expense'; +import { accountServiceAccounting } from '../services/accounting/account'; +import { proxyServiceAccounting } from '../services/accounting/proxy'; + import atsRouter from './v1/ats'; import { departmentServiceAts } from '../services/ats/department'; import { candidateServiceAts } from '../services/ats/candidate'; @@ -153,6 +160,8 @@ router.use('/chat', cors(), [revertAuthMiddleware(), rateLimitMiddleware()], cha router.use('/ticket', cors(), [revertAuthMiddleware(), rateLimitMiddleware()], ticketRouter); +router.use('/accounting', cors(), [revertAuthMiddleware(), rateLimitMiddleware()], accountingRouter); + router.use('/ats', cors(), [revertAuthMiddleware(), rateLimitMiddleware()], atsRouter); register(router, { @@ -188,6 +197,14 @@ register(router, { collection: collectionServiceTicket, proxy: proxyServiceTicket, }, + + accounting: { + account: accountServiceAccounting, + expense: expenseServiceAccounting, + vendor: vendorServiceAccounting, + proxy: proxyServiceAccounting, + }, + ats: { department: departmentServiceAts, candidate: candidateServiceAts, diff --git a/packages/backend/routes/v1/accounting/auth.ts b/packages/backend/routes/v1/accounting/auth.ts new file mode 100644 index 000000000..f925dc3f7 --- /dev/null +++ b/packages/backend/routes/v1/accounting/auth.ts @@ -0,0 +1,103 @@ +import express from 'express'; +import { randomUUID } from 'crypto'; +import { logInfo } from '../../../helpers/logger'; +import { mapIntegrationIdToIntegrationName } from '../../../constants/common'; +import redis from '../../../redis/client'; +import { TP_ID } from '@prisma/client'; +import prisma from '../../../prisma/client'; +import processOAuthResult from '../../../helpers/auth/processOAuthResult'; +import quickbooks from './authHandlers/quickbooks'; +import xero from './authHandlers/xero'; + +const authRouter = express.Router(); + +authRouter.get('/oauth-callback', async (req, res) => { + logInfo('OAuth callback', req.query); + const integrationId = req.query.integrationId as TP_ID; + const revertPublicKey = req.query.x_revert_public_token as string; + const redirect_url = req.query?.redirect_url; + const redirectUrl = redirect_url ? (redirect_url as string) : undefined; + // generate a token for connection auth and save in redis for 5 mins + const tenantSecretToken = randomUUID(); + await redis.setEx(`tenantSecretToken_${req.query.t_id}`, 5 * 60, tenantSecretToken); + + try { + const account = await prisma.environments.findFirst({ + where: { + public_token: String(revertPublicKey), + }, + include: { + apps: { + select: { id: true, app_client_id: true, app_client_secret: true, is_revert_app: true }, + where: { tp_id: integrationId }, + }, + accounts: true, + }, + }); + + const clientId = account?.apps[0]?.is_revert_app ? undefined : account?.apps[0]?.app_client_id; + const clientSecret = account?.apps[0]?.is_revert_app ? undefined : account?.apps[0]?.app_client_secret; + const svixAppId = account!.accounts!.id; + const environmentId = account?.id; + + const authProps = { + account, + clientId, + clientSecret, + code: req.query.code as string, + integrationId, + revertPublicKey, + svixAppId, + environmentId, + tenantId: String(req.query.t_id), + tenantSecretToken, + response: res, + request: req, + redirectUrl, + }; + + if (req.query.code && req.query.t_id && revertPublicKey) { + switch (integrationId) { + case TP_ID.quickbooks: + return quickbooks.handleOAuth(authProps); + case TP_ID.xero: + return xero.handleOAuth(authProps); + + default: + return processOAuthResult({ + status: false, + revertPublicKey, + tenantSecretToken, + response: res, + tenantId: req.query.t_id as string, + statusText: 'Not implemented yet', + redirectUrl, + }); + } + } + + return processOAuthResult({ + status: false, + revertPublicKey, + tenantSecretToken, + response: res, + tenantId: req.query.t_id as string, + statusText: 'noop', + redirectUrl, + }); + } catch (error: any) { + return processOAuthResult({ + status: false, + error, + revertPublicKey, + integrationName: mapIntegrationIdToIntegrationName[integrationId], + tenantSecretToken, + response: res, + tenantId: req.query.t_id as string, + statusText: 'Error while getting oauth creds', + redirectUrl, + }); + } +}); + +export default authRouter; diff --git a/packages/backend/routes/v1/accounting/authHandlers/quickbooks.ts b/packages/backend/routes/v1/accounting/authHandlers/quickbooks.ts new file mode 100644 index 000000000..731dfd182 --- /dev/null +++ b/packages/backend/routes/v1/accounting/authHandlers/quickbooks.ts @@ -0,0 +1,114 @@ +import axios from 'axios'; +import qs from 'qs'; +import config from '../../../../config'; +import { logInfo } from '../../../../helpers/logger'; +import { xprisma } from '../../../../prisma/client'; +import { TP_ID } from '@prisma/client'; +import { IntegrationAuthProps, mapIntegrationIdToIntegrationName } from '../../../../constants/common'; +import processOAuthResult from '../../../../helpers/auth/processOAuthResult'; +import sendConnectionAddedEvent from '../../../../helpers/webhooks/connection'; +import BaseOAuthHandler from '../../../../helpers/auth/baseOAuthHandler'; + +class QuickBooksAuthHandler extends BaseOAuthHandler { + async handleOAuth({ + account, + clientId, + clientSecret, + code, + integrationId, + revertPublicKey, + svixAppId, + environmentId, + tenantId, + tenantSecretToken, + response, + redirectUrl, + }: IntegrationAuthProps) { + const formData = { + grant_type: 'authorization_code', + code: code, + redirect_uri: `${config.OAUTH_REDIRECT_BASE}/quickbooks`, + }; + const headerData = { + client_id: clientId || config.QUICKBOOKS_CLIENT_ID, + client_secret: clientSecret || config.QUICKBOOKS_CLIENT_SECRET, + }; + const encodedClientIdSecret = Buffer.from(headerData.client_id + ':' + headerData.client_secret).toString( + 'base64' + ); + const result: any = await axios({ + method: 'post', + url: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', + headers: { + Authorization: 'Basic ' + encodedClientIdSecret, + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data: qs.stringify(formData), + }); + + logInfo('OAuth creds for QUICKBOOKS', result.data); + + try { + await xprisma.connections.upsert({ + where: { + id: tenantId, + }, + create: { + id: tenantId, + t_id: tenantId, + tp_id: integrationId, + tp_access_token: result.data.access_token, + tp_refresh_token: result.data.refresh_token, + tp_customer_id: 'quickbooks customer', + app_client_id: clientId || config.QUICKBOOKS_CLIENT_ID, + app_client_secret: clientSecret || config.QUICKBOOKS_CLIENT_SECRET, + owner_account_public_token: revertPublicKey, + appId: account?.apps[0].id, + environmentId: environmentId, + }, + update: { + tp_access_token: result.data.access_token, + tp_refresh_token: result.data.refresh_token, + app_client_id: clientId || config.QUICKBOOKS_CLIENT_ID, + app_client_secret: clientSecret || config.QUICKBOOKS_CLIENT_SECRET, + tp_id: integrationId, + appId: account?.apps[0].id, + tp_customer_id: 'quickbooks customer', + }, + }); + + await sendConnectionAddedEvent( + svixAppId, + tenantId, + TP_ID.quickbooks, + result.data.access_token, + 'quickbooks customer' + ); + + return processOAuthResult({ + status: true, + revertPublicKey, + tenantSecretToken, + response, + tenantId: tenantId, + integrationName: mapIntegrationIdToIntegrationName[integrationId], + tpCustomerId: 'quickbooks customer', + redirectUrl, + }); + } catch (error: any) { + return processOAuthResult({ + status: false, + error, + revertPublicKey, + tenantSecretToken, + response, + tenantId: tenantId, + integrationName: mapIntegrationIdToIntegrationName[integrationId], + redirectUrl, + }); + } + } +} + +export default new QuickBooksAuthHandler(); diff --git a/packages/backend/routes/v1/accounting/authHandlers/xero.ts b/packages/backend/routes/v1/accounting/authHandlers/xero.ts new file mode 100644 index 000000000..19b2724d7 --- /dev/null +++ b/packages/backend/routes/v1/accounting/authHandlers/xero.ts @@ -0,0 +1,128 @@ +import axios from 'axios'; +import qs from 'qs'; +import config from '../../../../config'; +import { logInfo } from '../../../../helpers/logger'; +import { xprisma } from '../../../../prisma/client'; +import { TP_ID } from '@prisma/client'; +import { IntegrationAuthProps, mapIntegrationIdToIntegrationName } from '../../../../constants/common'; +import processOAuthResult from '../../../../helpers/auth/processOAuthResult'; +import sendConnectionAddedEvent from '../../../../helpers/webhooks/connection'; +import BaseOAuthHandler from '../../../../helpers/auth/baseOAuthHandler'; +import { jwtDecode } from 'jwt-decode'; + +class XeroAuthHandler extends BaseOAuthHandler { + async handleOAuth({ + account, + clientId, + clientSecret, + code, + integrationId, + revertPublicKey, + svixAppId, + environmentId, + tenantId, + tenantSecretToken, + response, + redirectUrl, + }: IntegrationAuthProps) { + const formData = { + grant_type: 'authorization_code', + code: code, + redirect_uri: `${config.OAUTH_REDIRECT_BASE}/xero`, + }; + const headerData = { + client_id: clientId || config.XERO_CLIENT_ID, + client_secret: clientSecret || config.XERO_CLIENT_SECRET, + }; + const encodedClientIdSecret = Buffer.from(headerData.client_id + ':' + headerData.client_secret).toString( + 'base64' + ); + + const result: any = await axios({ + method: 'post', + url: 'https://identity.xero.com/connect/token', + headers: { + Authorization: 'Basic ' + encodedClientIdSecret, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data: qs.stringify(formData), + }); + + logInfo('OAuth creds for Xero', result.data); + + const auth = 'Bearer ' + result.data?.access_token; + const decodedData: any = jwtDecode(result.data?.access_token); + + const info = await axios({ + method: 'GET', + url: `https://api.xero.com/connections?authEventId=${decodedData.authentication_event_id}`, + headers: { + Authorization: auth, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + + try { + await xprisma.connections.upsert({ + where: { + id: tenantId, + }, + create: { + id: tenantId, + t_id: tenantId, + tp_id: integrationId, + tp_access_token: result.data.access_token, + tp_refresh_token: result.data.refresh_token, + tp_customer_id: info.data[0]?.tenantId, //this is the tenantid for xero ,will be using in api calls + app_client_id: clientId || config.XERO_CLIENT_ID, + app_client_secret: clientSecret || config.XERO_CLIENT_SECRET, + owner_account_public_token: revertPublicKey, + appId: account?.apps[0].id, + environmentId: environmentId, + }, + update: { + tp_access_token: result.data.access_token, + tp_refresh_token: result.data.refresh_token, + app_client_id: clientId || config.XERO_CLIENT_ID, + app_client_secret: clientSecret || config.XERO_CLIENT_SECRET, + tp_id: integrationId, + appId: account?.apps[0].id, + tp_customer_id: info.data[0]?.tenantId, + }, + }); + + await sendConnectionAddedEvent( + svixAppId, + tenantId, + TP_ID.xero, + result.data.access_token, + info.data[0]?.tenantId + ); + + return processOAuthResult({ + status: true, + revertPublicKey, + tenantSecretToken, + response, + tenantId: tenantId, + integrationName: mapIntegrationIdToIntegrationName[integrationId], + tpCustomerId: info.data[0]?.tenantId, + redirectUrl, + }); + } catch (error: any) { + return processOAuthResult({ + status: false, + error, + revertPublicKey, + tenantSecretToken, + response, + tenantId: tenantId, + integrationName: mapIntegrationIdToIntegrationName[integrationId], + redirectUrl, + }); + } + } +} + +export default new XeroAuthHandler(); diff --git a/packages/backend/routes/v1/accounting/index.ts b/packages/backend/routes/v1/accounting/index.ts new file mode 100644 index 000000000..0e752172c --- /dev/null +++ b/packages/backend/routes/v1/accounting/index.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import authRouter from './auth'; + +const accountingRouter = express.Router(); + +accountingRouter.get('/ping', async (_, res) => { + res.send({ + status: 'ok', + message: 'PONG', + }); +}); + +accountingRouter.use('/', authRouter); + +export default accountingRouter; diff --git a/packages/backend/services/accounting/account.ts b/packages/backend/services/accounting/account.ts new file mode 100644 index 000000000..6fce344e9 --- /dev/null +++ b/packages/backend/services/accounting/account.ts @@ -0,0 +1,459 @@ +import revertAuthMiddleware from '../../helpers/authMiddleware'; +import revertTenantMiddleware from '../../helpers/tenantIdMiddleware'; +import { logInfo, logError } from '../../helpers/logger'; +import { isStandardError } from '../../helpers/error'; +import { InternalServerError, NotFoundError } from '../../generated/typescript/api/resources/common'; +import { TP_ID } from '@prisma/client'; +import axios from 'axios'; +import { disunifyAccountingObject, unifyObject } from '../../helpers/crm/transform'; +import { AccountingStandardObjects, AppConfig } from '../../constants/common'; +import { AccountService } from '../../generated/typescript/api/resources/accounting/resources/account/service/AccountService'; +import { UnifiedAccount } from '../../models/unified/account'; + +const objType = AccountingStandardObjects.account; + +const accountServiceAccounting = new AccountService( + { + async getAccount(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const accountId = req.params.id; //this is id that will be used to get the particular acccount for the below integrations. + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse(req.query.fields as string); + logInfo( + 'Revert::GET ACCOUNT', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + accountId + ); + + switch (thirdPartyId) { + case TP_ID.quickbooks: { + if (!fields || (fields && !fields.realmID)) { + throw new NotFoundError({ + error: 'The query parameter "realmID" is required and should be included in the "fields" parameter.', + }); + } + const env = + connection?.app?.tp_id === 'quickbooks' && (connection?.app?.app_config as AppConfig)?.env; + + const result = await axios({ + method: 'GET', + url: `${ + (env === 'Sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com') + + `/v3/company/${fields.realmID}/account/${accountId}` + }`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + }, + }); + + const unifiedAccount: any = await unifyObject({ + obj: result.data.Account, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + res.send({ + status: 'ok', + result: unifiedAccount, + }); + break; + } + case TP_ID.xero: { + const result = await axios({ + method: 'GET', + url: `https://api.xero.com/api.xro/2.0/Accounts/${accountId}`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Xero-Tenant-Id': connection.tp_customer_id, + }, + }); + + const unifiedAccount: any = await unifyObject({ + obj: result.data.Accounts[0], + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + res.send({ + status: 'ok', + result: unifiedAccount, + }); + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch account', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + + async getAccounts(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const fields: any = req.query.fields ? JSON.parse(req.query.fields as string) : undefined; + const pageSize = parseInt(String(req.query.pageSize)); + const cursor = req.query.cursor; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + + logInfo( + 'Revert::GET ALL ACCOUNTS', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken + ); + switch (thirdPartyId) { + case TP_ID.quickbooks: { + if (!fields || (fields && !fields.realmID)) { + throw new NotFoundError({ + error: 'The query parameter "realmID" is required and should be included in the "fields" parameter.', + }); + } + + let pagingString = `${cursor ? ` STARTPOSITION +${cursor}+` : ''}${ + pageSize ? ` MAXRESULTS +${pageSize}` : '' + }`; + + const env = + connection?.app?.tp_id === 'quickbooks' && (connection?.app?.app_config as AppConfig)?.env; + + const result = await axios({ + method: 'GET', + url: `${ + (env === 'Sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com') + + `/v3/company/${fields.realmID}/query?query=select * from Account ${pagingString}` + }`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + }, + }); + + const unifiedAccounts: any = result.data.QueryResponse.Account + ? await Promise.all( + result.data.QueryResponse.Account.map( + async (accountItem: any) => + await unifyObject({ + obj: accountItem, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }) + ) + ) + : {}; + const nextCursor = + pageSize && result.data.QueryResponse?.maxResults + ? String(pageSize + (parseInt(String(cursor)) || 0)) + : undefined; + res.send({ + status: 'ok', + next: nextCursor, + results: unifiedAccounts, + }); + break; + } + case TP_ID.xero: { + const pagingString = `${cursor ? `page=${cursor}` : ''}`; + + const result = await axios({ + method: 'GET', + url: `https://api.xero.com/api.xro/2.0/Accounts?${pagingString}`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Xero-Tenant-Id': connection.tp_customer_id, + }, + }); + + const unifiedAccounts: any = await Promise.all( + result.data.Accounts.map( + async (accountItem: any) => + await unifyObject({ + obj: accountItem, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }) + ) + ); + const hasMoreResults = result.data.Accounts.length === 100; + const nextCursor = hasMoreResults ? (cursor ? cursor + 1 : 2) : undefined; + res.send({ + status: 'ok', + next: nextCursor ? String(nextCursor) : undefined, + results: unifiedAccounts, + }); + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch accounts', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + + async createAccount(req, res) { + try { + const accountData: any = req.body as unknown as UnifiedAccount; + const connection = res.locals.connection; + const account = res.locals.account; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + const disunifiedAccountData: any = await disunifyAccountingObject({ + obj: accountData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + logInfo('Revert::CREATE ACCOUNT', connection.app?.env?.accountId, tenantId, disunifiedAccountData); + + switch (thirdPartyId) { + case TP_ID.quickbooks: { + if (!fields || (fields && !fields.realmID)) { + throw new NotFoundError({ + error: 'The query parameter "realmID" is required and should be included in the "fields" parameter.', + }); + } + const env = + connection?.app?.tp_id === 'quickbooks' && (connection?.app?.app_config as AppConfig)?.env; + + const result: any = await axios({ + method: 'post', + url: `${ + (env === 'Sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com') + `/v3/company/${fields.realmID}/account` + }`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + data: JSON.stringify(disunifiedAccountData), + }); + res.send({ status: 'ok', message: 'QuickBooks account created', result: result.data.Account }); + + break; + } + case TP_ID.xero: { + const result: any = await axios({ + method: 'put', + url: `https://api.xero.com/api.xro/2.0/Accounts`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Xero-Tenant-Id': connection.tp_customer_id, + }, + data: JSON.stringify(disunifiedAccountData), + }); + res.send({ status: 'ok', message: 'Xero account created', result: result.data.Accounts[0] }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not create account', error.response); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async updateAccount(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const accountData = req.body as unknown as UnifiedAccount; + const accountId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + if (thirdPartyId === TP_ID.quickbooks && accountData && !accountData.id) { + throw new Error('The parameter "id" is required in request body.'); + } + + const disunifiedAccountData: any = await disunifyAccountingObject({ + obj: accountData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + logInfo('Revert::UPDATE ACCOUNT', connection.app?.env?.accountId, tenantId, accountData); + + switch (thirdPartyId) { + case TP_ID.quickbooks: { + if (!fields || (fields && !fields.realmID)) { + throw new NotFoundError({ + error: 'The query parameter "realmID" is required and should be included in the "fields" parameter.', + }); + } + disunifiedAccountData.Id = accountId; + + const env = + connection?.app?.tp_id === 'quickbooks' && (connection?.app?.app_config as AppConfig)?.env; + + const result: any = await axios({ + method: 'post', + + url: `${ + (env === 'Sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com') + `/v3/company/${fields.realmID}/account` + }`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + data: JSON.stringify(disunifiedAccountData), + }); + + res.send({ + status: 'ok', + message: 'QuickBooks Account updated', + result: result.data.Account, + }); + + break; + } + case TP_ID.xero: { + const result: any = await axios({ + method: 'post', + url: `https://api.xero.com/api.xro/2.0/Accounts/${accountId}`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Xero-Tenant-Id': connection.tp_customer_id, + }, + data: JSON.stringify(disunifiedAccountData), + }); + + res.send({ + status: 'ok', + message: 'Xero Account updated', + result: result.data.Accounts[0], + }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not update account', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async deleteAccount(req, res) { + try { + const connection = res.locals.connection; + const accountId = req.params.id; //this is id that will be used to get the particular acccount for the below integrations. + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + + logInfo( + 'Revert::DELETE ACCOUNT', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + accountId + ); + + switch (thirdPartyId) { + case TP_ID.quickbooks: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + break; + } + case TP_ID.xero: { + await axios({ + method: 'delete', + url: `https://api.xero.com/api.xro/2.0/Accounts/${accountId}`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + }, + }); + res.send({ status: 'ok', message: 'deleted' }); + break; + } + + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not delete account', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + }, + [revertAuthMiddleware(), revertTenantMiddleware()] +); + +export { accountServiceAccounting }; diff --git a/packages/backend/services/accounting/expense.ts b/packages/backend/services/accounting/expense.ts new file mode 100644 index 000000000..d4afd05f9 --- /dev/null +++ b/packages/backend/services/accounting/expense.ts @@ -0,0 +1,485 @@ +import revertAuthMiddleware from '../../helpers/authMiddleware'; +import revertTenantMiddleware from '../../helpers/tenantIdMiddleware'; +import { logInfo, logError } from '../../helpers/logger'; +import { isStandardError } from '../../helpers/error'; +import { InternalServerError, NotFoundError } from '../../generated/typescript/api/resources/common'; +import { TP_ID } from '@prisma/client'; +import axios from 'axios'; +import { disunifyAccountingObject, unifyObject } from '../../helpers/crm/transform'; +import { AccountingStandardObjects, AppConfig } from '../../constants/common'; +import { ExpenseService } from '../../generated/typescript/api/resources/accounting/resources/expense/service/ExpenseService'; +import { UnifiedExpense } from '../../models/unified/expense'; + +const objType = AccountingStandardObjects.expense; + +const expenseServiceAccounting = new ExpenseService( + { + async getExpense(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const expenseId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse(req.query.fields as string); + logInfo( + 'Revert::GET EXPENSE', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + expenseId + ); + + switch (thirdPartyId) { + case TP_ID.quickbooks: { + if (!fields || (fields && !fields.realmID)) { + throw new NotFoundError({ + error: 'The query parameter "realmID" is required and should be included in the "fields" parameter.', + }); + } + const env = + connection?.app?.tp_id === 'quickbooks' && (connection?.app?.app_config as AppConfig)?.env; + const result = await axios({ + method: 'GET', + url: `${ + (env === 'Sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com') + + `/v3/company/${fields.realmID}/purchase/${expenseId}` + }`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + }, + }); + + const unifiedExpense: any = await unifyObject({ + obj: result.data.Purchase, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + res.send({ + status: 'ok', + result: unifiedExpense, + }); + break; + } + case TP_ID.xero: { + const result = await axios({ + method: 'GET', + url: `https://api.xero.com/api.xro/2.0/Invoices/${expenseId}`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Xero-Tenant-Id': connection.tp_customer_id, + }, + }); + + const unifiedExpense: any = await unifyObject({ + obj: result.data.Invoices[0], + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + res.send({ + status: 'ok', + result: unifiedExpense, + }); + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch expense', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async getExpenses(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const fields: any = req.query.fields ? JSON.parse(req.query.fields as string) : undefined; + const pageSize = parseInt(String(req.query.pageSize)); + const cursor = req.query.cursor; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + + logInfo( + 'Revert::GET ALL EXPENSES', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken + ); + switch (thirdPartyId) { + case TP_ID.quickbooks: { + if (!fields || (fields && !fields.realmID)) { + throw new NotFoundError({ + error: 'The query parameter "realmID" is required and should be included in the "fields" parameter.', + }); + } + + let pagingString = `${cursor ? ` STARTPOSITION +${cursor}+` : ''}${ + pageSize ? ` MAXRESULTS +${pageSize}` : '' + }`; + const env = + connection?.app?.tp_id === 'quickbooks' && (connection?.app?.app_config as AppConfig)?.env; + + const result = await axios({ + method: 'GET', + + url: `${ + (env === 'Sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com') + + `/v3/company/${fields.realmID}/query?query=select * from Purchase ${pagingString}` + }`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + }, + }); + + const unifiedExpenses: any = result.data.QueryResponse.Purchase + ? await Promise.all( + result.data.QueryResponse.Purchase.map( + async (purchase: any) => + await unifyObject({ + obj: purchase, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }) + ) + ) + : {}; + const nextCursor = + pageSize && result.data.QueryResponse?.maxResults + ? String(pageSize + (parseInt(String(cursor)) || 0)) + : undefined; + res.send({ + status: 'ok', + next: nextCursor, + results: unifiedExpenses, + }); + break; + } + case TP_ID.xero: { + const result = await axios({ + method: 'GET', + url: `https://api.xero.com/api.xro/2.0/Invoices`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Xero-Tenant-Id': connection.tp_customer_id, + }, + }); + + const unifiedExpenses: any = await Promise.all( + result.data.Invoices.map( + async (invoice: any) => + await unifyObject({ + obj: invoice, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }) + ) + ); + const hasMoreResults = result.data.Invoices.length === 100; + const nextCursor = hasMoreResults ? (cursor ? cursor + 1 : 2) : undefined; + res.send({ + status: 'ok', + next: nextCursor ? String(nextCursor) : undefined, + results: unifiedExpenses, + }); + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch expenses', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + + async createExpense(req, res) { + try { + const expenseData: any = req.body as unknown as UnifiedExpense; + const connection = res.locals.connection; + const account = res.locals.account; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + const disunifiedExpenseData: any = await disunifyAccountingObject({ + obj: expenseData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + logInfo('Revert::CREATE EXPENSE', connection.app?.env?.accountId, tenantId, disunifiedExpenseData); + + switch (thirdPartyId) { + case TP_ID.quickbooks: { + if (!fields || (fields && !fields.realmID)) { + throw new NotFoundError({ + error: 'The query parameter "realmID" is required and should be included in the "fields" parameter.', + }); + } + const env = + connection?.app?.tp_id === 'quickbooks' && (connection?.app?.app_config as AppConfig)?.env; + const result: any = await axios({ + method: 'post', + + url: `${ + (env === 'Sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com') + `/v3/company/${fields.realmID}/purchase` + }`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + data: JSON.stringify(disunifiedExpenseData), + }); + res.send({ status: 'ok', message: 'QuickBooks Expense created', result: result.data.Purchase }); + + break; + } + case TP_ID.xero: { + const result: any = await axios({ + method: 'post', + url: `https://api.xero.com/api.xro/2.0/Invoices`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Xero-Tenant-Id': connection.tp_customer_id, + }, + data: JSON.stringify(disunifiedExpenseData), + }); + res.send({ status: 'ok', message: 'Xero Expense created', result: result.data.Invoices[0] }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not create Expense', error.response); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async updateExpense(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const expenseData = req.body as unknown as UnifiedExpense; + const expenseId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + if (thirdPartyId === TP_ID.quickbooks && expenseData && !expenseData.id) { + throw new Error('The parameter "id" is required in request body.'); + } + + const disunifiedExpenseData: any = await disunifyAccountingObject({ + obj: expenseData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + logInfo('Revert::UPDATE EXPENSE', connection.app?.env?.accountId, tenantId, expenseData); + + switch (thirdPartyId) { + case TP_ID.quickbooks: { + if (!fields || (fields && !fields.realmID)) { + throw new NotFoundError({ + error: 'The query parameter "realmID" is required and should be included in the "fields" parameter.', + }); + } + disunifiedExpenseData.Id = expenseId; + + const env = + connection?.app?.tp_id === 'quickbooks' && (connection?.app?.app_config as AppConfig)?.env; + + const result: any = await axios({ + method: 'post', + + url: `${ + (env === 'Sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com') + `/v3/company/${fields.realmID}/purchase` + }`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + data: JSON.stringify(disunifiedExpenseData), + }); + + res.send({ + status: 'ok', + message: 'QuickBooks Expense updated', + result: result.data.Purchase, + }); + + break; + } + case TP_ID.xero: { + disunifiedExpenseData.Id = expenseId; + + const result: any = await axios({ + method: 'post', + url: `https://api.xero.com/api.xro/2.0/Invoices`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Xero-Tenant-Id': connection.tp_customer_id, + }, + data: JSON.stringify(disunifiedExpenseData), + }); + + res.send({ + status: 'ok', + message: 'Xero Expense updated', + result: result.data.Invoices[0], + }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not update Expense', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async deleteExpense(req, res) { + try { + const connection = res.locals.connection; + const expenseId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + const expenseData: any = req.body as unknown as UnifiedExpense; + const account = res.locals.account; + + const disunifiedExpenseData: any = await disunifyAccountingObject({ + obj: expenseData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + logInfo( + 'Revert::DELETE EXPENSE', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + expenseId + ); + + switch (thirdPartyId) { + case TP_ID.quickbooks: { + if (!fields || (fields && !fields.realmID)) { + throw new NotFoundError({ + error: 'The query parameter "realmID" is required and should be included in the "fields" parameter.', + }); + } + const env = + connection?.app?.tp_id === 'quickbooks' && (connection?.app?.app_config as AppConfig)?.env; + + disunifiedExpenseData.Id = expenseId; + await axios({ + method: 'post', + + url: `${ + (env === 'Sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com') + + `/v3/company/${fields.realmID}/purchase?operation=delete` + }`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + data: JSON.stringify(disunifiedExpenseData), + }); + res.send({ status: 'ok', message: ' Expense deleted' }); + + break; + } + case TP_ID.xero: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + break; + } + + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not delete expense', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + }, + [revertAuthMiddleware(), revertTenantMiddleware()] +); + +export { expenseServiceAccounting }; diff --git a/packages/backend/services/accounting/proxy.ts b/packages/backend/services/accounting/proxy.ts new file mode 100644 index 000000000..ebd3e6b6a --- /dev/null +++ b/packages/backend/services/accounting/proxy.ts @@ -0,0 +1,86 @@ +import revertAuthMiddleware from '../../helpers/authMiddleware'; +import revertTenantMiddleware from '../../helpers/tenantIdMiddleware'; +import { logInfo, logError } from '../../helpers/logger'; +import { isStandardError } from '../../helpers/error'; +import { InternalServerError, NotFoundError } from '../../generated/typescript/api/resources/common'; +import { ProxyService } from '../../generated/typescript/api/resources/accounting/resources/proxy/service/ProxyService'; +import { TP_ID } from '@prisma/client'; +import axios from 'axios'; + +const proxyServiceAccounting = new ProxyService( + { + async tunnel(req, res) { + try { + const connection = res.locals.connection; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const request = req.body; + const path = request.path; + const body: any = request.body; + const method = request.method; + const queryParams = request.queryParams; + + logInfo( + 'Revert::POST PROXY FOR ACCOUNTING APP', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken + ); + + switch (thirdPartyId) { + case TP_ID.quickbooks: { + const result = await axios({ + method: method, + url: `https://quickbooks.api.intuit.com/v3/company/${path}`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + data: JSON.stringify(body), + params: queryParams, + }); + + res.send({ + result: result.data, + }); + break; + } + case TP_ID.xero: { + const result = await axios({ + method: method, + url: `https://api.xero.com/api.xro/2.0/${path}`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + data: JSON.stringify(body), + params: queryParams, + }); + + res.send({ + result: result.data, + }); + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app!' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not do proxy request', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + }, + [revertAuthMiddleware(), revertTenantMiddleware()] +); + +export { proxyServiceAccounting }; diff --git a/packages/backend/services/accounting/vendor.ts b/packages/backend/services/accounting/vendor.ts new file mode 100644 index 000000000..248ede034 --- /dev/null +++ b/packages/backend/services/accounting/vendor.ts @@ -0,0 +1,451 @@ +import revertAuthMiddleware from '../../helpers/authMiddleware'; +import revertTenantMiddleware from '../../helpers/tenantIdMiddleware'; +import { logInfo, logError } from '../../helpers/logger'; +import { isStandardError } from '../../helpers/error'; +import { InternalServerError, NotFoundError } from '../../generated/typescript/api/resources/common'; +import { TP_ID } from '@prisma/client'; +import axios from 'axios'; +import { disunifyAccountingObject, unifyObject } from '../../helpers/crm/transform'; +import { AccountingStandardObjects, AppConfig } from '../../constants/common'; +import { UnifiedVendor } from '../../models/unified/vendor'; +import { VendorService } from '../../generated/typescript/api/resources/accounting/resources/vendor/service/VendorService'; + +const objType = AccountingStandardObjects.vendor; + +const vendorServiceAccounting = new VendorService( + { + async getVendor(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const vendorId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse(req.query.fields as string); + logInfo( + 'Revert::GET VENDOR', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + vendorId + ); + + switch (thirdPartyId) { + case TP_ID.quickbooks: { + if (!fields || (fields && !fields.realmID)) { + throw new NotFoundError({ + error: 'The query parameter "realmID" is required and should be included in the "fields" parameter.', + }); + } + const env = + connection?.app?.tp_id === 'quickbooks' && (connection?.app?.app_config as AppConfig)?.env; + + const result = await axios({ + method: 'GET', + + url: `${ + (env === 'Sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com') + + `/v3/company/${fields.realmID}/vendor/${vendorId}` + }`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + }, + }); + + const unifiedVendor: any = await unifyObject({ + obj: result.data.Vendor, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + res.send({ + status: 'ok', + result: unifiedVendor, + }); + break; + } + case TP_ID.xero: { + const result = await axios({ + method: 'GET', + url: `https://api.xero.com/api.xro/2.0/contacts/${vendorId}`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Xero-Tenant-Id': connection.tp_customer_id, + }, + }); + + const unifiedVendor: any = await unifyObject({ + obj: result.data.Contacts[0], + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + res.send({ + status: 'ok', + result: unifiedVendor, + }); + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch vendor', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async getVendors(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const fields: any = req.query.fields ? JSON.parse(req.query.fields as string) : undefined; + const pageSize = parseInt(String(req.query.pageSize)); + const cursor = req.query.cursor; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + + logInfo( + 'Revert::GET ALL VENDORS', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken + ); + switch (thirdPartyId) { + case TP_ID.quickbooks: { + if (!fields || (fields && !fields.realmID)) { + throw new NotFoundError({ + error: 'The query parameter "realmID" is required and should be included in the "fields" parameter.', + }); + } + const env = + connection?.app?.tp_id === 'quickbooks' && (connection?.app?.app_config as AppConfig)?.env; + + let pagingString = `${cursor ? ` STARTPOSITION +${cursor}+` : ''}${ + pageSize ? ` MAXRESULTS +${pageSize}` : '' + }`; + + const result = await axios({ + method: 'GET', + + url: `${ + (env === 'Sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com') + + `/v3/company/${fields.realmID}/query?query=select * from Vendor ${pagingString}` + }`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + }, + }); + + const unifiedVendors: any = result.data.QueryResponse.Vendor + ? await Promise.all( + result.data.QueryResponse.Vendor.map( + async (vendor: any) => + await unifyObject({ + obj: vendor, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }) + ) + ) + : {}; + const nextCursor = + pageSize && result.data.QueryResponse?.maxResults + ? String(pageSize + (parseInt(String(cursor)) || 0)) + : undefined; + res.send({ + status: 'ok', + next: nextCursor, + results: unifiedVendors, + }); + break; + } + case TP_ID.xero: { + const pagingString = `${cursor ? `page=${cursor}` : ''}`; + + const result = await axios({ + method: 'GET', + url: `https://api.xero.com/api.xro/2.0/contacts?${pagingString}`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Xero-Tenant-Id': connection.tp_customer_id, + }, + }); + + const unifiedVendors: any = await Promise.all( + result.data.Contacts.map( + async (contact: any) => + await unifyObject({ + obj: contact, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }) + ) + ); + const hasMoreResults = result.data.Contacts.length === 100; + const nextCursor = hasMoreResults ? (cursor ? cursor + 1 : 2) : undefined; + + res.send({ + status: 'ok', + next: nextCursor ? String(nextCursor) : undefined, + results: unifiedVendors, + }); + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch vendors', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + + async createVendor(req, res) { + try { + const vendorData: any = req.body as unknown as UnifiedVendor; + const connection = res.locals.connection; + const account = res.locals.account; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + const disunifiedVendorData: any = await disunifyAccountingObject({ + obj: vendorData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + logInfo('Revert::CREATE VENDOR', connection.app?.env?.accountId, tenantId, disunifiedVendorData); + + switch (thirdPartyId) { + case TP_ID.quickbooks: { + if (!fields || (fields && !fields.realmID)) { + throw new NotFoundError({ + error: 'The query parameter "realmID" is required and should be included in the "fields" parameter.', + }); + } + + const env = + connection?.app?.tp_id === 'quickbooks' && (connection?.app?.app_config as AppConfig)?.env; + + const result: any = await axios({ + method: 'post', + url: `${ + (env === 'Sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com') + `/v3/company/${fields.realmID}/vendor` + }`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + data: JSON.stringify(disunifiedVendorData), + }); + res.send({ status: 'ok', message: 'QuickBooks Vendor created', result: result.data.Vendor }); + + break; + } + case TP_ID.xero: { + const result: any = await axios({ + method: 'post', + url: `https://api.xero.com/api.xro/2.0/contacts`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Xero-Tenant-Id': connection.tp_customer_id, + }, + data: JSON.stringify(disunifiedVendorData), + }); + res.send({ status: 'ok', message: 'Xero Vendor created', result: result.data.Contacts[0] }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not create Vendor', error.response); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async updateVendor(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const vendorData = req.body as unknown as UnifiedVendor; + const vendorId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + if (thirdPartyId === TP_ID.quickbooks && vendorData && !vendorData.id) { + throw new Error('The parameter "id" is required in request body.'); + } + + const disunifiedVendorData: any = await disunifyAccountingObject({ + obj: vendorData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + logInfo('Revert::UPDATE VENDOR', connection.app?.env?.accountId, tenantId, vendorData); + + switch (thirdPartyId) { + case TP_ID.quickbooks: { + if (!fields || (fields && !fields.realmID)) { + throw new NotFoundError({ + error: 'The query parameter "realmID" is required and should be included in the "fields" parameter.', + }); + } + disunifiedVendorData.Id = vendorId; + + const env = + connection?.app?.tp_id === 'quickbooks' && (connection?.app?.app_config as AppConfig)?.env; + + const result: any = await axios({ + method: 'post', + + url: `${ + (env === 'Sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com') + `/v3/company/${fields.realmID}/vendor` + }`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + data: JSON.stringify(disunifiedVendorData), + }); + + res.send({ + status: 'ok', + message: 'QuickBooks Vendor updated', + result: result.data.Vendor, + }); + + break; + } + case TP_ID.xero: { + const result: any = await axios({ + method: 'post', + url: `https://api.xero.com/api.xro/2.0/contacts/${vendorId}`, + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Xero-Tenant-Id': connection.tp_customer_id, + }, + data: JSON.stringify(disunifiedVendorData), + }); + res.send({ status: 'ok', message: 'Xero Vendor updated', result: result.data.Contacts[0] }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not update Vendor', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async deleteVendor(req, res) { + try { + const connection = res.locals.connection; + const vendorId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + + logInfo( + 'Revert::DELETE VENDOR', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + vendorId + ); + + switch (thirdPartyId) { + case TP_ID.quickbooks: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + break; + } + case TP_ID.xero: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + break; + } + + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not delete vendor', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + }, + [revertAuthMiddleware(), revertTenantMiddleware()] +); + +export { vendorServiceAccounting }; diff --git a/packages/backend/services/auth.ts b/packages/backend/services/auth.ts index e092d7bcf..48ec0d0ba 100644 --- a/packages/backend/services/auth.ts +++ b/packages/backend/services/auth.ts @@ -437,6 +437,106 @@ class AuthService { } return { status: 'ok', message: 'ATS services tokens refreshed' }; } + async refreshOAuthTokensForThirdPartyAccountingServices() { + try { + const connections = await prisma.connections.findMany({ + include: { app: true }, + }); + + for (let i = 0; i < connections.length; i++) { + const connection = connections[i]; + if (connection.tp_refresh_token) { + try { + if (connection.tp_id === TP_ID.xero) { + const url = `https://identity.xero.com/connect/token`; + const formData = { + grant_type: 'refresh_token', + refresh_token: connection.tp_refresh_token, + }; + const headerData = { + client_id: connection.app_client_id || config.XERO_CLIENT_ID, + client_secret: connection.app_client_secret || config.XERO_CLIENT_SECRET, + }; + + const encodedClientIdSecret = Buffer.from( + headerData.client_id + ':' + headerData.client_secret + ).toString('base64'); + + const result = await axios({ + method: 'post', + url: url, + data: qs.stringify(formData), + headers: { + Authorization: 'Basic ' + encodedClientIdSecret, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + if (result.data && result.data.access_token) { + await prisma.connections.update({ + where: { + id: connection.id, + }, + data: { + tp_access_token: result.data.access_token, + tp_refresh_token: result.data.refresh_token, + }, + }); + } else { + logInfo('Xero connection could not be refreshed', result); + } + } else if (connection.tp_id === TP_ID.quickbooks) { + const url = `https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer`; + const formData = { + grant_type: 'refresh_token', + refresh_token: connection.tp_refresh_token, + }; + const headerData = { + client_id: connection.app_client_id || config.QUICKBOOKS_CLIENT_ID, + client_secret: connection.app_client_secret || config.QUICKBOOKS_CLIENT_SECRET, + }; + + const encodedClientIdSecret = Buffer.from( + headerData.client_id + ':' + headerData.client_secret + ).toString('base64'); + + const result = await axios({ + method: 'post', + url: url, + data: qs.stringify(formData), + headers: { + Authorization: 'Basic ' + encodedClientIdSecret, + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + if (result.data && result.data.access_token) { + await prisma.connections.update({ + where: { + id: connection.id, + }, + data: { + tp_access_token: result.data.access_token, + tp_refresh_token: result.data.refresh_token, + }, + }); + } else { + logInfo('quickbooks connection could not be refreshed', result); + } + } + } catch (error: any) { + logError(error.response?.data); + console.error('Could not refresh token', connection.t_id, error.response?.data); + } + } + } + } catch (error: any) { + logError(error); + console.error('Could not update db', error.response?.data); + } + return { status: 'ok', message: 'Ticket services tokens refreshed' }; + } async createAccountOnClerkUserCreation(webhookData: any, webhookEventType: string) { let response; logInfo('webhookData', webhookData, webhookEventType); diff --git a/packages/backend/services/metadata.ts b/packages/backend/services/metadata.ts index 9aa4a4306..36fb4f83a 100644 --- a/packages/backend/services/metadata.ts +++ b/packages/backend/services/metadata.ts @@ -156,6 +156,26 @@ const metadataService = new MetadataService({ scopes: getScope(apps, TP_ID.bitbucket), clientId: getClientId(apps, TP_ID.bitbucket) || config.BITBUCKET_CLIENT_ID, }, + + { + integrationId: TP_ID.quickbooks, + name: 'QuickBooks', + imageSrc: + 'https://res.cloudinary.com/dwoiwg0t5/image/upload/f_auto,q_auto/v1/RevertAppLogos/pvkrmr3ld3ii5mu7ledy', + status: 'active', + scopes: getScope(apps, TP_ID.quickbooks), + clientId: getClientId(apps, TP_ID.quickbooks) || config.QUICKBOOKS_CLIENT_ID, + }, + { + integrationId: TP_ID.xero, + name: 'Xero', + imageSrc: + 'https://res.cloudinary.com/dwoiwg0t5/image/upload/f_auto,q_auto/v1/RevertAppLogos/ajwcpkre9llu037su7m5', + status: 'active', + scopes: getScope(apps, TP_ID.xero), + clientId: getClientId(apps, TP_ID.xero) || config.XERO_CLIENT_ID, + }, + { integrationId: TP_ID.greenhouse, name: 'Greenhouse', diff --git a/packages/client/src/common/oauth/index.tsx b/packages/client/src/common/oauth/index.tsx index f143e0c51..14043de91 100644 --- a/packages/client/src/common/oauth/index.tsx +++ b/packages/client/src/common/oauth/index.tsx @@ -560,6 +560,82 @@ export const OAuthCallback = (props) => { console.error(err); setStatus('Errored out'); }); + } else if (integrationId === 'quickbooks') { + console.log('Post accounting app installation', integrationId, params); + const { tenantId, revertPublicToken, redirectUrl } = JSON.parse(decodeURIComponent(params.state)); + fetch( + `${REVERT_BASE_API_URL}/v1/accounting/oauth-callback?integrationId=${integrationId}&code=${ + params.code + }&t_id=${tenantId}&x_revert_public_token=${revertPublicToken}${ + redirectUrl ? `&redirect_url=${redirectUrl}` : `` + }`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ) + .then((d) => { + return d.json(); + }) + .then((data) => { + console.log('OAuth flow succeeded', data); + if (data.error) { + const errorMessage = + data.error?.code === 'P2002' + ? ': Already connected another app. Please disconnect first.' + : ''; + setStatus('Errored out' + errorMessage); + } else { + setStatus('Succeeded. Please feel free to close this window.'); + } + setIsLoading(false); + }) + .catch((err) => { + Sentry.captureException(err); + setIsLoading(false); + console.error(err); + setStatus('Errored out'); + }); + } else if (integrationId === 'xero') { + console.log('Post accounting app installation', integrationId, params); + const { tenantId, revertPublicToken, redirectUrl } = JSON.parse(decodeURIComponent(params.state)); + fetch( + `${REVERT_BASE_API_URL}/v1/accounting/oauth-callback?integrationId=${integrationId}&code=${ + params.code + }&t_id=${tenantId}&x_revert_public_token=${revertPublicToken}${ + redirectUrl ? `&redirect_url=${redirectUrl}` : `` + }`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ) + .then((d) => { + return d.json(); + }) + .then((data) => { + console.log('OAuth flow succeeded', data); + if (data.error) { + const errorMessage = + data.error?.code === 'P2002' + ? ': Already connected another app. Please disconnect first.' + : ''; + setStatus('Errored out' + errorMessage); + } else { + setStatus('Succeeded. Please feel free to close this window.'); + } + setIsLoading(false); + }) + .catch((err) => { + Sentry.captureException(err); + setIsLoading(false); + console.error(err); + setStatus('Errored out'); + }); } } }, [integrationId]); diff --git a/packages/client/src/features/integration/enums/metadata.ts b/packages/client/src/features/integration/enums/metadata.ts index 2a06a8f29..dd600250c 100644 --- a/packages/client/src/features/integration/enums/metadata.ts +++ b/packages/client/src/features/integration/enums/metadata.ts @@ -68,6 +68,16 @@ export const appsInfo = { description: 'Configure your Bitbucket Ticketing App from here.', }, + quickbooks: { + name: 'QuickBooks', + logo: ' https://res.cloudinary.com/dwoiwg0t5/image/upload/f_auto,q_auto/v1/RevertAppLogos/wlnadel7twqr0rgaoq9o', + description: 'Configure your QuickBooks Accounting App from here.', + }, + xero: { + name: 'Xero', + logo: 'https://res.cloudinary.com/dwoiwg0t5/image/upload/f_auto,q_auto/v1/RevertAppLogos/jhqummxaixtrzfhyc6yr', + description: 'Configure your Xero Accounting App from here.', + }, greenhouse: { name: 'Greenhouse', logo: 'https://res.cloudinary.com/dwoiwg0t5/image/upload/f_auto,q_auto/v1/RevertAppLogos/rhqhzzrt6zmefncg3uid', diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index dfd6348d6..4c8ee4d7a 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -1519,6 +1519,28 @@ const createIntegrationBlock = function (self, integration) { state )}&response_type=code` ); + } else if (selectedIntegration.integrationId === 'quickbooks') { + const encodedScopes = encodeURIComponent(scopes.join(' ')); + const encodedRedirectUri = encodeURI(`${this.#REDIRECT_URL_BASE}/quickbooks`); + + window.open( + `https://appcenter.intuit.com/connect/oauth2?client_id=${ + selectedIntegration.clientId + }&redirect_uri=${encodedRedirectUri}&response_type=code&state=${encodeURIComponent( + state + )}&scope=${encodedScopes}` + ); + } else if (selectedIntegration.integrationId === 'xero') { + const encodedScopes = encodeURIComponent(scopes.join(' ')); + const encodedRedirectUri = encodeURI(`${this.#REDIRECT_URL_BASE}/xero`); + + window.open( + `https://login.xero.com/identity/connect/authorize?client_id=${ + selectedIntegration.clientId + }&redirect_uri=${encodedRedirectUri}&response_type=code&state=${encodeURIComponent( + state + )}&scope=${encodedScopes}` + ); } this.clearInitialOrProcessingOrSuccessStage(); if (!this.closeAfterOAuthFlow) { diff --git a/yarn.lock b/yarn.lock index d4448c487..bc16db2b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5762,6 +5762,7 @@ __metadata: express: ^4.18.1 ip: ^1.1.8 jest: ^29.6.4 + jwt-decode: ^4.0.0 lodash: ^4.17.21 moesif-nodejs: ^3.5.3 morgan: ^1.10.0 @@ -21334,6 +21335,13 @@ __metadata: languageName: node linkType: hard +"jwt-decode@npm:^4.0.0": + version: 4.0.0 + resolution: "jwt-decode@npm:4.0.0" + checksum: 390e2edcb31a92e86c8cbdd1edeea4c0d62acd371f8a8f0a8878e499390c0ecf4c658b365c4e941e4ef37d0170e4ca650aaa49f99a45c0b9695a235b210154b0 + languageName: node + linkType: hard + "kind-of@npm:^3.0.2, kind-of@npm:^3.0.3, kind-of@npm:^3.2.0": version: 3.2.2 resolution: "kind-of@npm:3.2.2"