Skip to content

@memberjunction/actions-bizapps-accounting

Stateless ERP verbs for MemberJunction. Callers and agents use a verb name (CreateJournalEntry, GetChartOfAccounts, …) — never a vendor class name. A dispatcher loads the company’s accounting CompanyIntegration and forwards to the plugin registered as `${verb}:${integration.Name}`.

This package is HTTP plus trivial validation. It does not upsert GLAccount, does not flip journal-entry batch status, and does not write CashBalance. Those jobs belong to Open Apps (accounting is a subledger, not the GL). This package has zero knowledge of bizapps-accounting.

ERP is always ERP (acronym, caps).

Plan (domain brain, extensions, daily sync): erp-provider-layer.md (accounting PR #126 if next does not have the file yet).

This package is part of the BizApps Actions family.

These actionsNot these actions
Pull chart / dimensions / balances / GL entriesUpsert GLAccount, Dimension, DimensionValue
Post one balanced journal, all-or-nothing at the ERPFlip a Journal Entry Batch to Posted / stamp external id
Validate lines (≥ 2, debit xor credit, non-negative, balanced)Write CashBalance / CashBalanceLine
Resolve credentials via CompanyIntegration + env-first OAuthOwn a second integration engine

Generic Integration Actions (IntegrationActionExecutor) stay for object CRUD such as “list BC customers.” They are not how we post a batch. Do not migrate these verbs onto per-object CRUD.

flowchart LR
  Caller["Caller / agent / AccountingERPEngine"] --> Verb["Verb dispatcher<br/>CreateJournalEntry"]
  Verb -->|"CompanyID → CompanyIntegration<br/>TryCreateInstance(BaseAction, verb:Integration.Name)"| Plugin["Plugin subclass<br/>CreateJournalEntry:QuickBooks Online"]
  Plugin --> HTTP["ERP HTTP<br/>QBO query / BC OData v2.0"]
sequenceDiagram
  participant C as Caller
  participant D as CreateJournalEntry dispatcher
  participant CF as ClassFactory
  participant P as Plugin (QBO or BC)
  participant ERP as ERP HTTP

  C->>D: RunAction(CreateJournalEntry, CompanyID, Lines)
  D->>D: resolveCompanyAccountingIntegration(CompanyID)
  D->>CF: TryCreateInstance(BaseAction, "CreateJournalEntry:" + Integration.Name)
  alt Resolved
    CF-->>D: plugin instance
    D->>P: Run(same params) → InternalRunAction
    P->>ERP: POST journal / journalLines + post
    ERP-->>P: id / 204
    P-->>C: JournalEntryID, DocNumber, TotalAmount
  else not registered
    D-->>C: Success false, PROVIDER_NOT_REGISTERED
  end

Callers use the verb column. Plugins are ClassFactory keys `${verb}:${Integration.Name}`. Integration.Name must match the MJ Integrations row exactly (QuickBooks Online, Microsoft Dynamics 365 Business Central).

Verb (dispatcher key)QuickBooks OnlineBusiness CentralNotes
GetChartOfAccountsexisting GL codes + Accountsexisting GL accounts + AccountsShared ChartOfAccount on output Accounts; old GLCodes / GLAccounts unchanged
GetAccountBalancesexistingnewShared AccountBalance; BC accountCode = account number
CreateJournalEntryexistingnewShared line validation + balance check
GetDimensionsnewnewBC: dimensions + dimensionValues. QBO: Class + Department (no first-class dimensions API)
GetGLEntriesexisting transactionsexisting GL entriesWrap only
GetCustomersexistingWrap; QBO → PROVIDER_NOT_REGISTERED
GetSalesInvoicesexistingWrap; QBO → PROVIDER_NOT_REGISTERED

New in this package: the seven dispatchers, BC CreateJournalEntry, BC GetAccountBalances, and GetDimensions for both ERPs.

Backward-compat ClassFactory keys still resolve (same classes, second @RegisterClass):

  • GetQuickBooksGLCodesAction
  • GetQuickBooksTransactionsAction
  • GetQuickBooksAccountBalancesAction
  • CreateQuickBooksJournalEntryAction
  • GetBusinessCentralGLAccountsAction
  • GetBusinessCentralGeneralLedgerEntriesAction
  • GetBusinessCentralCustomersAction
  • GetBusinessCentralSalesInvoicesAction
ResultCodeWhen
SUCCESSPlugin completed
PROVIDER_NOT_REGISTEREDNo plugin for `${verb}:${integration.Name}`
NO_ACCOUNTING_INTEGRATIONCompany has no QBO/BC CompanyIntegration
VALIDATION_ERRORMissing CompanyID, or journal lines do not balance
ERRORStructural line problems, missing context user, HTTP failure

Journal structure (missing Lines, < 2 lines, both debit and credit, negative amounts) still surfaces as ERROR with the historical messages. Unbalanced debits/credits is VALIDATION_ERROR.

Terminal window
npm install @memberjunction/actions-bizapps-accounting

Import the package once in the host so @RegisterClass runs for every dispatcher and plugin:

import '@memberjunction/actions-bizapps-accounting';

Name is the plugin-key suffix. Do not rename without updating RegisterClass keys.

QuickBooks Online:

INSERT INTO Integration (Name, Description, NavigationBaseURL, ClassName)
VALUES ('QuickBooks Online', 'QuickBooks Online Accounting Integration',
'https://quickbooks.api.intuit.com', 'QuickBooksIntegration');

Business Central:

INSERT INTO Integration (Name, Description, NavigationBaseURL, ClassName)
VALUES ('Microsoft Dynamics 365 Business Central',
'Business Central Accounting Integration',
'', 'BusinessCentralIntegration');

One row per company per ERP. Hosts pick their ERP; this package is not married to BC.

QuickBooks Online: ExternalSystemID = Realm ID, CustomAttribute1 = production or sandbox.

Business Central: ExternalSystemID = BC company GUID, CustomAttribute1 = environment name.

3. Environment variables (preferred over database tokens)

Section titled “3. Environment variables (preferred over database tokens)”
Terminal window
BIZAPPS_QUICKBOOKS_ONLINE_{COMPANY_ID}_ACCESS_TOKEN=...
BIZAPPS_QUICKBOOKS_ONLINE_{COMPANY_ID}_REFRESH_TOKEN=...
BIZAPPS_QUICKBOOKS_ONLINE_{COMPANY_ID}_REALM_ID=... # optional if stored on CompanyIntegration
BIZAPPS_BUSINESS_CENTRAL_{COMPANY_ID}_ACCESS_TOKEN=...
BIZAPPS_BUSINESS_CENTRAL_{COMPANY_ID}_REFRESH_TOKEN=...
BIZAPPS_BUSINESS_CENTRAL_{COMPANY_ID}_TENANT_ID=...

Lookup order: environment, then CompanyIntegration token fields.

import { ActionEngineServer } from '@memberjunction/actions';
import '@memberjunction/actions-bizapps-accounting';
const engine = ActionEngineServer.Instance;
const result = await engine.RunAction({
ActionName: 'CreateJournalEntry',
Params: [
{ Name: 'CompanyID', Type: 'Input', Value: companyId },
{ Name: 'EntryDate', Type: 'Input', Value: '2025-06-15' },
{ Name: 'DocNumber', Type: 'Input', Value: 'JE-100' },
{
Name: 'Lines',
Type: 'Input',
Value: [
{ accountNumber: '4010', debit: 500, description: 'Revenue' },
{ accountNumber: '1100', credit: 500, description: 'AR' },
],
},
],
ContextUser: contextUser,
});

AM-4: when posting, send account numbers (accountNumber). QBO still uses accountId internally (QBO Account.Id); pass accountId for QBO lines.

GetChartOfAccounts / GetAccountBalances / GetDimensions take the same CompanyID. Optional filters (AsOfDate, IncludeInactive, …) are forwarded unchanged to the plugin.

Direct construction of a vendor class still works for old callers:

import { CreateQuickBooksJournalEntryAction } from '@memberjunction/actions-bizapps-accounting';
const action = new CreateQuickBooksJournalEntryAction();

New code should use the verb name.

  1. Create src/providers/{erp}/ with a subclass of BaseAccountingAction that implements the HTTP helper (makeXRequest). Set integrationName to the exact Integration.Name you will insert.

  2. For each verb you support, subclass the provider base and register the plugin key, not the vendor nickname:

    import { RegisterClass } from '@memberjunction/global';
    import { BaseAction } from '@memberjunction/actions';
    import { ACCOUNTING_VERBS, erpPluginKey } from '@memberjunction/actions-bizapps-accounting';
    @RegisterClass(BaseAction, erpPluginKey(ACCOUNTING_VERBS.CreateJournalEntry, 'Your ERP Name'))
    export class CreateYourERPJournalEntryAction extends YourERPBaseAction {
    protected async InternalRunAction(params: RunActionParams) { /* HTTP */ }
    }
  3. Export the class from src/index.ts so importing the package registers it.

  4. Reuse parseAndValidateJournalEntryLines and validateJournalEntryBalance. Return VALIDATION_ERROR when the entry does not balance. Do not invent domain writes.

  5. Add vitest coverage that spies the request helper. Do not hit a real ERP.

  6. Hosts insert an Integration row whose Name equals 'Your ERP Name'.

The dispatcher does not change. It already loads any company integration whose Integration.Name is in the known ERP list — add your name to ACCOUNTING_ERP_INTEGRATION_NAMES in src/constants.ts so resolveCompanyAccountingIntegration will select it.

TypeRole
JournalEntryLine{ accountId?, accountNumber?, debit?, credit?, description?, dimensions? }
ChartOfAccount{ id, code, name, accountType, isActive } — extra output Accounts
AccountBalanceaccountCode is the ERP account number; asOfDate from the param
Dimension{ code, displayName, values: Array<{ code, displayName }> }

QBO GetDimensions: there is no dimensions API. The plugin maps Class and Department. Empty values is success, not an error, when the company does not use that list.

BC CreateJournalEntry: GET journals (param JournalCode, else GENERAL/DEFAULT or first journal without a balancingAccountNumber) → POST each line to journals({id})/journalLines (amount = +debit / −credit) → POST journals({id})/Microsoft.NAV.post. If a line POST or the batch post throws, lines created in this call are DELETEd so they do not sit in the journal for the next post.

Microsoft.NAV.post posts the whole BC journal, not just this call’s lines. Two concurrent CreateJournalEntry calls against the same journal code can therefore post each other’s in-flight lines, and the loser gets no error. That is BC’s journal model, not a bug in the plugin. Mitigations: give each post its own journal (JournalCode), or serialize posts per journal code. Do not run overlapping posts into one shared GENERAL journal.

sequenceDiagram
    participant Action as Accounting Action
    participant BAA as BaseAccountingAction
    participant Env as Environment Variables
    participant DB as CompanyIntegration

    Action->>BAA: getOAuthTokens(integration)
    BAA->>Env: BIZAPPS_{PROVIDER}_{COMPANY_ID}_ACCESS_TOKEN
    alt Token in env
        Env-->>BAA: access + refresh
    else
        BAA->>DB: AccessToken
        alt valid
            DB-->>BAA: access token
        else expired or missing
            BAA-->>Action: throw
        end
    end

Common inputs on every action: CompanyID (required), FiscalYear, AccountingPeriod.

CreateJournalEntry / CreateQuickBooksJournalEntryAction / CreateBusinessCentralJournalEntryAction

Section titled “CreateJournalEntry / CreateQuickBooksJournalEntryAction / CreateBusinessCentralJournalEntryAction”
ParameterTypeDescription
LinesJournalEntryLine[] or JSONRequired, ≥ 2, must balance
EntryDatestringDefault today
DocNumberstringOptional document number
PrivateNotestringMemo / description
JournalCodestringBC only — journal to post into
AdjustmentEntrybooleanQBO only

Output: JournalEntryID, DocNumber, TotalAmount (QBO also CreatedDate).

QBO also emits GLCodes; BC also emits GLAccounts. Both emit Accounts (ChartOfAccount[]) and TotalCount.

ParameterDefaultDescription
AsOfDatetodaySnapshot date (BC returns current balance; date is stamped on the row)
AccountTypesQBO account types / BC categories
IncludeInactive / IncludeBlockedfalse
IncludeZeroBalancestrue

Output: AccountBalances, TotalAccounts (QBO also trial-balance summary).

Output: Dimensions: Array<{ code, displayName, values: Array<{ code, displayName }> }>.

GetGLEntries / GetCustomers / GetSalesInvoices

Section titled “GetGLEntries / GetCustomers / GetSalesInvoices”

Same filters and outputs as the historical BC/QBO actions they wrap. See the vendor classes for parameter lists.

PackagePurpose
@memberjunction/actionsBaseAction
@memberjunction/actions-baseparam / result types
@memberjunction/coreRunView, UserInfo
@memberjunction/core-entitiesCompanyIntegration, Integration
@memberjunction/global@RegisterClass, ClassFactory.TryCreateInstance
Terminal window
cd packages/Actions/BizApps/Accounting
pnpm test
pnpm run build

Tests spy makeQBORequest / makeBCRequest / queryQBO. They must not hit a real ERP.