Custom ERP Integration
If your ERP system is not SAP S/4HANA or Oracle Fusion Cloud, you can implement a custom connector by satisfying the IERPConnector interface and registering it with ConnectorRegistry.
IERPConnector interface
export interface IERPConnector { /** * Fetch document data from the ERP by external reference. * Returns a plain object that will be merged with SDF data.json. */ fetch(documentRef: string): Promise<Record<string, unknown>>;
/** * Push SDF data to the ERP. * @param documentId - SDF document UUID * @param data - Transformed data.json content */ push(documentId: string, data: Record<string, unknown>): Promise<void>;
/** * Test connectivity and authentication. * Resolves on success, rejects with a descriptive error on failure. */ healthCheck(): Promise<void>;}ERPHttpClient base class
ERPHttpClient handles OAuth2 token caching, timeout, and retry logic for you. Extend it instead of writing raw fetch() calls:
export abstract class ERPHttpClient { constructor(protected config: ConnectorConfig) {}
/** Authenticated GET — token refresh handled automatically */ protected async get<T>(path: string): Promise<T>;
/** Authenticated POST */ protected async post<T>(path: string, body: unknown): Promise<T>;
/** Authenticated PUT */ protected async put<T>(path: string, body: unknown): Promise<T>;}Default behavior:
- Timeout: 30 seconds
- Retry: exponential backoff on
429and5xx, up to 3 attempts - OAuth2 tokens are cached in memory and refreshed 60 seconds before expiry
- No external dependencies — uses native
fetch()
FieldMapper
FieldMapper translates between SDF data.json field paths and ERP API field names. Transformations can be plain renames or functions:
export type FieldMapping = { [sdfPath: string]: string | { path: string; transform: (v: unknown) => unknown };};
export class FieldMapper { constructor(private mapping: FieldMapping) {}
/** SDF data.json → ERP payload */ transform(data: Record<string, unknown>): Record<string, unknown>;
/** ERP payload → SDF data.json */ reverse(erpData: Record<string, unknown>): Record<string, unknown>;}Full working example
import { IERPConnector, ERPHttpClient, FieldMapper, ConnectorConfig,} from '@etapsky/sdf-server-core';
const MY_ERP_MAPPING = { 'data.invoice_number': 'docNumber', 'data.totals.gross.amount': 'totalAmount', 'data.totals.gross.currency': 'currency', 'data.issue_date': { path: 'documentDate', transform: (v: unknown) => String(v).replace(/-/g, ''), // ISO → YYYYMMDD },};
export class MyERPConnector extends ERPHttpClient implements IERPConnector { private mapper = new FieldMapper(MY_ERP_MAPPING);
constructor(config: ConnectorConfig) { super(config); }
async fetch(documentRef: string): Promise<Record<string, unknown>> { const erpDoc = await this.get<Record<string, unknown>>( `/api/documents/${documentRef}` ); return this.mapper.reverse(erpDoc); }
async push(documentId: string, data: Record<string, unknown>): Promise<void> { const erpPayload = this.mapper.transform(data); await this.post('/api/documents', { ...erpPayload, sdfRef: documentId, }); }
async healthCheck(): Promise<void> { await this.get('/api/ping'); }}Registering the connector
Register your connector factory at server startup, before routes are mounted:
import { ConnectorRegistry } from '@etapsky/sdf-server-core';import { MyERPConnector } from './connectors/my-erp/connector.js';
ConnectorRegistry.registerFactory( 'my-erp', (config) => new MyERPConnector(config));Once registered, tenants can configure it via the Connector API using "erp_type": "my-erp":
POST /connectors/configure
{ "erp_type": "my-erp", "name": "My Custom ERP (Production)", "base_url": "https://erp.internal.example.com", "auth_type": "oauth2", "credentials": { ... }}Security requirements
- Never store plaintext credentials. Pass them through the standard
credentialsfield in the configure endpoint — they are encrypted with AES-256-GCM by SDF Server before storage. - Do not add
@aws-sdk/*or other external HTTP clients as dependencies. UseERPHttpClient(which uses nativefetch()) for all outbound calls. - Connector instances are scoped to a
tenant_id. Never allow data from one tenant to flow into another tenant’s connector.