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 actions | Not these actions |
|---|---|
| Pull chart / dimensions / balances / GL entries | Upsert GLAccount, Dimension, DimensionValue |
| Post one balanced journal, all-or-nothing at the ERP | Flip 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 OAuth |
Own 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
CreateJournalEntry"]
Verb -->|"CompanyID → CompanyIntegration
TryCreateInstance(BaseAction, verb:Integration.Name)"| Plugin["Plugin subclass
CreateJournalEntry:QuickBooks Online"]
Plugin --> HTTP["ERP HTTP
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 Online | Business Central | Notes |
|---|---|---|---|
GetChartOfAccounts |
existing GL codes + Accounts |
existing GL accounts + Accounts |
Shared ChartOfAccount on output Accounts; old GLCodes / GLAccounts unchanged |
GetAccountBalances |
existing | new | Shared AccountBalance; BC accountCode = account number |
CreateJournalEntry |
existing | new | Shared line validation + balance check |
GetDimensions |
new | new | BC: dimensions + dimensionValues. QBO: Class + Department (no first-class dimensions API) |
GetGLEntries |
existing transactions | existing GL entries | Wrap only |
GetCustomers |
— | existing | Wrap; QBO → PROVIDER_NOT_REGISTERED |
GetSalesInvoices |
— | existing | Wrap; 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):
GetQuickBooksGLCodesActionGetQuickBooksTransactionsActionGetQuickBooksAccountBalancesActionCreateQuickBooksJournalEntryActionGetBusinessCentralGLAccountsActionGetBusinessCentralGeneralLedgerEntriesActionGetBusinessCentralCustomersActionGetBusinessCentralSalesInvoicesAction| ResultCode | When |
|---|---|
SUCCESS |
Plugin completed |
PROVIDER_NOT_REGISTERED |
No plugin for `${verb}:${integration.Name}` |
NO_ACCOUNTING_INTEGRATION |
Company has no QBO/BC CompanyIntegration |
VALIDATION_ERROR |
Missing CompanyID, or journal lines do not balance |
ERROR |
Structural 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.
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.
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.
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.
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 */ }
}
Export the class from src/index.ts so importing the package registers it.
Reuse parseAndValidateJournalEntryLines and validateJournalEntryBalance.
Return VALIDATION_ERROR when the entry does not balance. Do not invent
domain writes.
Add vitest coverage that spies the request helper. Do not hit a real ERP.
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.
| Type | Role |
|---|---|
JournalEntryLine |
{ accountId?, accountNumber?, debit?, credit?, description?, dimensions? } |
ChartOfAccount |
{ id, code, name, accountType, isActive } — extra output Accounts |
AccountBalance |
accountCode 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.
| Parameter | Type | Description |
|---|---|---|
Lines |
JournalEntryLine[] or JSON |
Required, ≥ 2, must balance |
EntryDate |
string | Default today |
DocNumber |
string | Optional document number |
PrivateNote |
string | Memo / description |
JournalCode |
string | BC only — journal to post into |
AdjustmentEntry |
boolean | QBO only |
Output: JournalEntryID, DocNumber, TotalAmount (QBO also CreatedDate).
QBO also emits GLCodes; BC also emits GLAccounts. Both emit Accounts
(ChartOfAccount[]) and TotalCount.
| Parameter | Default | Description |
|---|---|---|
AsOfDate |
today | Snapshot date (BC returns current balance; date is stamped on the row) |
AccountTypes |
— | QBO account types / BC categories |
IncludeInactive / IncludeBlocked |
false | |
IncludeZeroBalances |
true |
Output: AccountBalances, TotalAccounts (QBO also trial-balance summary).
Output: Dimensions: Array<{ code, displayName, values: Array<{ code, displayName }> }>.
Same filters and outputs as the historical BC/QBO actions they wrap. See the vendor classes for parameter lists.
| Package | Purpose |
|---|---|
@memberjunction/actions |
BaseAction |
@memberjunction/actions-base |
param / result types |
@memberjunction/core |
RunView, UserInfo |
@memberjunction/core-entities |
CompanyIntegration, Integration |
@memberjunction/global |
@RegisterClass, ClassFactory.TryCreateInstance |
cd packages/Actions/BizApps/Accounting
pnpm test
pnpm run build
Tests spy makeQBORequest / makeBCRequest / queryQBO. They must not hit a
real ERP.