@memberjunction/actions-bizapps-accounting
Version:
Accounting system integration actions for MemberJunction
204 lines (177 loc) • 7.9 kB
text/typescript
import { BaseAction } from '@memberjunction/actions';
import { ActionParam, ActionResultSimple, RunActionParams } from '@memberjunction/actions-base';
import { RegisterClass, UUIDsEqual } from '@memberjunction/global';
import { UserInfo } from '@memberjunction/core';
import { MJCompanyIntegrationEntity, MJIntegrationEntity } from '@memberjunction/core-entities';
import { IMetadataProvider, Metadata, RunView } from '@memberjunction/core';
/**
* Base class for all accounting-related actions.
* Provides common functionality and patterns for interacting with accounting systems.
*/
(BaseAction, 'BaseAccountingAction')
export abstract class BaseAccountingAction extends BaseAction {
/**
* The accounting provider this action is designed for (e.g., 'QuickBooks', 'NetSuite', etc.)
* Can be 'Generic' for provider-agnostic actions
*/
protected abstract accountingProvider: string;
/**
* The integration name to look up in the Integration entity
*/
protected abstract integrationName: string;
/**
* Cached company integration for the current execution
*/
private _companyIntegration: MJCompanyIntegrationEntity | null = null;
/**
* Override of the required abstract method from BaseAction
*/
protected abstract InternalRunAction(params: RunActionParams): Promise<ActionResultSimple>;
/**
* Helper to get a parameter value from the params array
*/
protected getParamValue(params: ActionParam[], name: string): any {
const param = params.find(p => p.Name === name);
return param?.Value;
}
/**
* Common accounting parameters that many actions will need
*/
protected getCommonAccountingParams(): ActionParam[] {
return [
{
Name: 'CompanyID',
Type: 'Input',
Value: null
},
{
Name: 'FiscalYear',
Type: 'Input',
Value: null
},
{
Name: 'AccountingPeriod',
Type: 'Input',
Value: null
}
];
}
/**
* Gets the company integration record for the specified company and accounting system
*/
protected async getCompanyIntegration(companyId: string, contextUser: UserInfo): Promise<MJCompanyIntegrationEntity> {
// Check cache first
if (this._companyIntegration && UUIDsEqual(this._companyIntegration.CompanyID, companyId)) {
return this._companyIntegration;
}
const rv = new RunView();
const result = await rv.RunView<MJCompanyIntegrationEntity>({
EntityName: 'MJ: Company Integrations',
ExtraFilter: `CompanyID = '${companyId}' AND Integration.Name = '${this.integrationName}'`,
ResultType: 'entity_object'
}, contextUser);
if (!result.Success) {
throw new Error(`Failed to retrieve company integration: ${result.ErrorMessage}`);
}
if (!result.Results || result.Results.length === 0) {
throw new Error(`No ${this.integrationName} integration found for company ${companyId}. Please configure the integration first.`);
}
this._companyIntegration = result.Results[0];
return this._companyIntegration;
}
/**
* Gets credentials from environment variables
* Format: BIZAPPS_{PROVIDER}_{COMPANY_ID}_{CREDENTIAL_TYPE}
* Example: BIZAPPS_QUICKBOOKS_12345_ACCESS_TOKEN
*/
protected getCredentialFromEnv(companyId: string, credentialType: string): string | undefined {
const envKey = `BIZAPPS_${this.accountingProvider.toUpperCase().replace(/\s+/g, '_')}_${companyId}_${credentialType.toUpperCase()}`;
return process.env[envKey];
}
/**
* Gets OAuth tokens - first tries environment variables, then falls back to database
*/
protected async getOAuthTokens(integration: MJCompanyIntegrationEntity): Promise<{ accessToken: string; refreshToken?: string }> {
const companyId = integration.CompanyID;
// Try environment variables first
const envAccessToken = this.getCredentialFromEnv(companyId, 'ACCESS_TOKEN');
const envRefreshToken = this.getCredentialFromEnv(companyId, 'REFRESH_TOKEN');
if (envAccessToken) {
return {
accessToken: envAccessToken,
refreshToken: envRefreshToken
};
}
// Fall back to database (for backwards compatibility)
if (!integration.AccessToken) {
throw new Error(`No access token found for ${this.integrationName} integration. Please set environment variable BIZAPPS_${this.accountingProvider.toUpperCase().replace(/\s+/g, '_')}_${companyId}_ACCESS_TOKEN or configure in database.`);
}
// Check if token is expired
if (integration.TokenExpirationDate && new Date(integration.TokenExpirationDate) < new Date()) {
throw new Error(`Access token for ${this.integrationName} has expired. Please re-authenticate.`);
}
return {
accessToken: integration.AccessToken!,
refreshToken: integration.RefreshToken || undefined
};
}
/**
* Gets the base URL for API calls from the integration
*/
protected async getAPIBaseURL(contextUser: UserInfo, provider?: IMetadataProvider): Promise<string> {
const md = provider ?? new Metadata();
const integration = await md.GetEntityObject<MJIntegrationEntity>('MJ: Integrations', contextUser);
const rv = new RunView();
const result = await rv.RunView<MJIntegrationEntity>({
EntityName: 'MJ: Integrations',
ExtraFilter: `Name = '${this.integrationName}'`,
ResultType: 'entity_object'
}, contextUser);
if (!result.Success || !result.Results || result.Results.length === 0) {
throw new Error(`Integration configuration not found for ${this.integrationName}`);
}
return result.Results[0].NavigationBaseURL || '';
}
/**
* Validates common accounting data formats
*/
protected validateAccountNumber(accountNumber: string): boolean {
// Basic validation - can be overridden by specific providers
return /^[0-9\-\.]+$/.test(accountNumber);
}
/**
* Validates journal entry balance (debits must equal credits)
*/
protected validateJournalEntryBalance(lines: Array<{debit?: number, credit?: number}>): boolean {
const totalDebits = lines.reduce((sum, line) => sum + (line.debit || 0), 0);
const totalCredits = lines.reduce((sum, line) => sum + (line.credit || 0), 0);
return Math.abs(totalDebits - totalCredits) < 0.01; // Allow for minor rounding differences
}
/**
* Formats currency values consistently
*/
protected formatCurrency(amount: number, currencyCode: string = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount);
}
/**
* Standard date format for accounting systems (ISO 8601)
*/
protected formatAccountingDate(date: Date): string {
return date.toISOString().split('T')[0];
}
/**
* Helper to build consistent error messages for accounting operations
*/
protected buildAccountingErrorMessage(operation: string, details: string, systemError?: any): string {
let message = `Accounting operation failed: ${operation}. ${details}`;
if (systemError) {
message += ` System error: ${systemError.message || systemError}`;
}
return message;
}
}