Skip to content

SAP Integration Design for Ordermatic

Status: Design Document (Ready for PR Review) Created: 2026-03-09 Target: Production implementation by end of Q1 2026


This document defines the architecture for integrating SAP systems (Business One, S/4HANA Cloud, S/4HANA On-Prem, ECC) into Ordermatic using our existing Dagster β†’ Iceberg DWH β†’ Typesense data pipeline and TypeScript IERPService layer for real-time operations.

Key Design Principles:

  • βœ… Three separate implementations (not a monolithic adapter)
  • βœ… Follows existing Prophet21 patterns exactly
  • βœ… Dagster orchestrates continuous syncs (hourly/daily)
  • βœ… TypeScript IERPService handles real-time actions
  • βœ… Iceberg DWH with partitioned layout (connection_id isolation)
  • βœ… Typesense for search/matching (products, customers, cross-refs, addresses)
  • βœ… Connection-based multi-tenancy (connection_id for isolation)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ WEBAPP (Astro/React) β”‚
β”‚ - Order extraction from PDFs/Emails/Excel/Images β”‚
β”‚ - Matching via Typesense (customer/product/cross-ref) β”‚
β”‚ - Real-time order creation via IERPService β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ β”‚
v v
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ TypeScript β”‚ β”‚ Dagster Pipeline β”‚
β”‚ IERPService β”‚ β”‚ (Scheduled + Sensor) β”‚
β”‚ (Real-time) β”‚ β”‚ (Continuous Sync) β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ - searchCustomer β”‚ β”‚ Bronze Layer: β”‚
β”‚ - searchItems β”‚ β”‚ β”œβ”€ fetch_sap_* β”‚
β”‚ - getAddresses β”‚ β”‚ β”‚ (DLT sources) β”‚
β”‚ - createOrder β”‚ β”‚ β”‚ β”‚
β”‚ - submitPayment β”‚ β”‚ Silver/Gold: β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”œβ”€ dbt transforms β”‚
β”‚ β”‚ β”‚ (normalize data) β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ Typesense Layer: β”‚
β”‚ β”‚ β”œβ”€ sync assets β”‚
β”‚ β”‚ β”‚ (products,etc) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ β”‚
v v
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ SAP Systems β”‚ β”‚ Iceberg DWH β”‚
β”‚ │◄──────────────────────│ (R2 Partitioned)
β”‚ β€’ Business β”‚ OData APIs β”‚ β”‚
β”‚ One β”‚ (with auth) β”‚ Tables: β”‚
β”‚ β€’ S/4HANA β”‚ β”‚ - products β”‚
β”‚ Cloud β”‚ β”‚ - customers β”‚
β”‚ β€’ S/4HANA β”‚ β”‚ - xref β”‚
β”‚ On-Prem β”‚ β”‚ - addresses β”‚
β”‚ β€’ ECC β”‚ β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
v
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Typesense β”‚
β”‚ (Full-text) β”‚
β”‚ β”‚
β”‚ Collections: β”‚
β”‚ - products β”‚
β”‚ - customers β”‚
β”‚ - xref β”‚
β”‚ - addresses β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Each SAP system has a dedicated Dagster Orchestrator and DLT Source.

File: apps/dagster/erp_pipeline/implementations/sap_b1_sync_orchestrator.py

class SAPBusinessOneSyncOrchestratorV1:
"""
SAP Business One sync orchestrator.
Features:
- Version-aware B1 REST API routing
- Credential management via Infisical
- DLT pipeline for data extraction
"""
def __init__(self, connection_id: str, connection_config: dict[str, Any] | None = None):
self.connection_id = connection_id
self.dataset_name = "erp_data_native_v2"
self._r2_client: Any | None = None
# Load from database if not provided
if connection_config is None:
connection_config = self._load_connection_config()
self.connection_config = connection_config
# B1 specific config
extra_config = connection_config.get("extra_config") or {}
self.sap_url = connection_config.get("api_url") # e.g., https://sap-b1.example.com:50000
self.use_https = extra_config.get("use_https", True)
self.sap_version = extra_config.get("sap_version") # e.g., "9.3"
def _load_connection_config(self) -> dict[str, Any]:
"""Load connection config from database."""
from pdf_shared.crud import get_db_connection
with get_db_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT config FROM erp_connections WHERE id = %s AND erp_type = 'sap_b1'",
(self.connection_id,)
)
row = cur.fetchone()
if not row:
raise ValueError(f"SAP Business One connection {self.connection_id} not found")
return row[0]
def sync_products(self, full_sync: bool = False) -> dict[str, Any]:
"""Sync products from SAP Business One."""
logger.info(f"Syncing SAP B1 products for {self.connection_id}, full_sync={full_sync}")
# Use DLT source to fetch and load
dlt_result = dlt.run(
sap_b1_source(
connection_id=self.connection_id,
connection_config=self.connection_config,
entity="products",
full_sync=full_sync,
),
table_name="products",
dataset_name=self.dataset_name,
destination="iceberg",
)
return {"records_loaded": dlt_result.load_info.records_written}
# Similar methods for customers, addresses, cross-references

File: apps/dagster/erp_pipeline/implementations/sap_s4_cloud_sync_orchestrator.py

class SAPS4HANACloudSyncOrchestratorV1:
"""
SAP S/4HANA Cloud sync orchestrator.
Features:
- OAuth2 authentication
- Cloud-specific OData v4 endpoints
- Credential caching via extra_config
"""
def __init__(self, connection_id: str, connection_config: dict[str, Any] | None = None):
self.connection_id = connection_id
self.dataset_name = "erp_data_native_v2"
if connection_config is None:
connection_config = self._load_connection_config()
self.connection_config = connection_config
# S/4HANA Cloud specific
extra_config = connection_config.get("extra_config") or {}
self.auth_type = extra_config.get("auth_type", "oauth2") # oauth2 or basic
self.sap_client = extra_config.get("sap_client") # e.g., "100"
self.odata_version = extra_config.get("odata_version", "v4") # v4 for cloud
def sync_customers(self, full_sync: bool = False) -> dict[str, Any]:
"""Sync customers from SAP S/4HANA Cloud."""
logger.info(f"Syncing S/4HANA Cloud customers for {self.connection_id}")
dlt_result = dlt.run(
sap_s4_cloud_source(
connection_id=self.connection_id,
connection_config=self.connection_config,
entity="customers",
full_sync=full_sync,
),
table_name="customers",
dataset_name=self.dataset_name,
destination="iceberg",
)
return {"records_loaded": dlt_result.load_info.records_written}

File: apps/dagster/erp_pipeline/implementations/sap_s4_onprem_sync_orchestrator.py

class SAPS4HANAOnPremSyncOrchestratorV1:
"""
SAP S/4HANA On-Premises sync orchestrator.
Features:
- Basic auth or Kerberos
- On-prem-specific connectivity (direct IP, VPN)
- RFC gateway support (optional)
"""
def __init__(self, connection_id: str, connection_config: dict[str, Any] | None = None):
self.connection_id = connection_id
self.dataset_name = "erp_data_native_v2"
if connection_config is None:
connection_config = self._load_connection_config()
self.connection_config = connection_config
extra_config = connection_config.get("extra_config") or {}
self.auth_type = extra_config.get("auth_type", "basic")
self.sap_client = extra_config.get("sap_client")
self.use_rfc = extra_config.get("use_rfc", False)

File: apps/dagster/erp_pipeline/sources/sap_b1/dlt_source.py

@dlt.source
def sap_b1_source(
connection_id: str,
connection_config: dict[str, Any],
entity: str = "all", # products, customers, addresses, cross_references
full_sync: bool = False,
) -> List[dlt.Resource]:
"""
DLT source for SAP Business One OData API.
Supports:
- Products (Item Master)
- Customers (Business Partner)
- Addresses (Address Master)
- Cross-references (Customer-specific item aliases)
"""
client = SAPB1Client(connection_config)
resources = []
if entity in ("products", "all"):
resources.append(
dlt.resource(
fetch_sap_b1_products(client, connection_id, full_sync),
name="sap_b1_products",
write_disposition="merge",
primary_key="erp_id",
)
)
if entity in ("customers", "all"):
resources.append(
dlt.resource(
fetch_sap_b1_customers(client, connection_id, full_sync),
name="sap_b1_customers",
write_disposition="merge",
primary_key="erp_id",
)
)
# Similar for addresses, cross-references
return resources
async def fetch_sap_b1_products(
client: SAPB1Client,
connection_id: str,
full_sync: bool,
) -> Generator[dict, None, None]:
"""Fetch products from SAP Business One."""
# Build OData query
query = "$filter=IsActive eq 'Y'"
if not full_sync:
last_sync = get_last_sync_date("sap_b1_products", connection_id)
if last_sync:
query += f" and UpdateDate gt '{last_sync}'"
# Fetch from B1 REST API
response = await client.get(
"/b1s/v1/Items",
params={"$filter": query, "$top": 1000}
)
for item in response.get("value", []):
yield {
"connection_id": connection_id,
"erp_id": item["ItemCode"],
"name": item["ItemName"],
"description": item["ItemDescription"],
"upc": item.get("Barcode"),
"category": item.get("InventoryItem", {}).get("InventoryClassCode"),
# ... more fields
"_sap_raw": item,
}
  • apps/dagster/erp_pipeline/sources/sap_s4_cloud/dlt_source.py
  • apps/dagster/erp_pipeline/sources/sap_s4_onprem/dlt_source.py

3. Dagster Assets (Bronze β†’ Silver β†’ Gold β†’ Typesense)

Section titled β€œ3. Dagster Assets (Bronze β†’ Silver β†’ Gold β†’ Typesense)”

File: apps/dagster/erp_pipeline/assets/bronze.py (extend existing)

@asset(
partitions_def=erp_connection_partitions,
group_name="bronze",
tags={"kind": "erp_sync"},
config_schema={
"full_sync": Field(bool, default_value=False),
}
)
def sap_products(context: AssetExecutionContext) -> None:
"""Extract SAP products (Business One, S/4 Cloud, S/4 On-Prem)."""
connection_id = get_connection_id_from_context(context)
erp_type = get_erp_type_for_connection(connection_id)
if erp_type not in ("sap_b1", "sap_s4_cloud", "sap_s4_onprem"):
return # Not a SAP connection
# Get sync parameters
full_sync, last_sync_date = get_sync_params_safe(
context, "sap_products", connection_id
)
# Get appropriate orchestrator
orchestrator = ERPSourceFactory.create_orchestrator(connection_id)
# Execute sync
result = orchestrator.sync_products(full_sync=full_sync)
context.log.info(f"SAP products synced: {result}")
yield MaterializeResult(
metadata={
"records_loaded": result.get("records_loaded", 0),
"full_sync": full_sync,
"connection_id": connection_id,
"erp_type": erp_type,
}
)
# Similar assets: sap_customers, sap_addresses, sap_cross_references

File: apps/dagster/erp_pipeline/dbt/models/silver/stg_sap_products.sql

{{ config(
materialized = 'iceberg',
partitioned_by = ['connection_id'],
sort_by = 'connection_id',
tags = ['sap', 'products']
) }}
SELECT
connection_id,
erp_id,
name,
description,
upc,
category,
-- Normalize pricing fields (SAP has various price lists)
price1,
price2,
-- ... more prices
unit_of_measure,
weight,
-- Metadata
_sap_raw,
_dlt_load_id,
_dlt_timestamp
FROM {{ source('bronze', 'sap_b1_products') }}
WHERE connection_id = dbt_config.get('connection_id')
UNION ALL
SELECT
connection_id,
erp_id,
name,
description,
upc,
category,
price1,
-- ... (S/4 schema may differ)
FROM {{ source('bronze', 'sap_s4_cloud_products') }}
WHERE connection_id = dbt_config.get('connection_id')
UNION ALL
-- S/4 On-Prem products

File: apps/dagster/erp_pipeline/assets/typesense.py (extend existing)

@asset(
partitions_def=erp_connection_partitions,
group_name="typesense",
deps=[sap_gold_products],
)
def sap_products_typesense(context: AssetExecutionContext) -> None:
"""Sync SAP products to Typesense for search."""
connection_id = get_connection_id_from_context(context)
# Read from gold layer
records = get_gold_parquet_records("dim_sap_products", connection_id)
# Transform for Typesense
typesense_docs = [
{
"id": f"{connection_id}_{rec['erp_id']}",
"connection_id": connection_id,
"erp_product_id": rec["erp_id"],
"name": rec["name"],
"description": rec["description"],
"upc": rec["upc"],
"category_code": rec["category"],
"ocr_variants": [], # Populated during extraction
"format_variants": [],
"search_text": f"{rec['name']} {rec['description']} {rec['upc']}",
"price1": rec.get("price1"),
# ... price2-price10, list_price
"unit_of_measure": rec.get("unit_of_measure"),
"weight": rec.get("weight"),
"has_product_image": False,
"delete_flag": False,
}
for rec in records
]
# Upsert to Typesense
typesense_resource = TypesenseResource.get()
typesense_resource.upsert_collection(
collection_name="products",
documents=typesense_docs,
)
yield MaterializeResult(
metadata={
"documents_upserted": len(typesense_docs),
"connection_id": connection_id,
}
)

File: apps/webapp/src/services/erp/implementations/sap-service-base.ts

import type {
IERPService,
ErpCustomerSearchParams,
ErpCustomer,
} from '@repo/integrations/services';
import { logger } from '@/utils/logger';
import { ERPAuthService } from '@/services/erp-auth';
import httpx from 'httpx';
/**
* Abstract base class for SAP ERP services.
* Implements common auth, OData query building, and error handling.
*/
export abstract class SAPServiceBase implements IERPService {
protected readonly connection: ERPConnection;
protected readonly apiUrl: string;
protected authService: ERPAuthService;
protected cachedAccessToken: string | null = null;
constructor(connection: ERPConnection) {
this.connection = connection;
this.apiUrl = connection.apiUrl;
this.authService = new ERPAuthService(connection);
}
/**
* Get access token with caching (valid for 1 hour typically).
*/
protected async getAccessToken(): Promise<string> {
if (this.cachedAccessToken) {
return this.cachedAccessToken;
}
const token = await this.authService.getAccessToken();
this.cachedAccessToken = token;
// Reset cache after 50 minutes (SAP tokens valid 1 hour typically)
setTimeout(
() => {
this.cachedAccessToken = null;
},
50 * 60 * 1000
);
return token;
}
/**
* Build OData filter for customer search.
* Override in subclasses for system-specific field names.
*/
protected abstract buildCustomerFilterExpression(params: ErpCustomerSearchParams): string;
/**
* Search customers in SAP.
*/
async searchCustomers(params: ErpCustomerSearchParams): Promise<ErpCustomer[]> {
logger.info('SAP: Searching customers', { params });
const token = await this.getAccessToken();
const filter = this.buildCustomerFilterExpression(params);
const response = await httpx.get(
`${this.apiUrl}/odata/v4/c_customertp?$filter=${encodeURIComponent(filter)}&$top=10`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
timeout: 30.0,
}
);
const results = response.json();
return (results.value || []).map((customer: any) => ({
id: customer.customer_id,
name: customer.customer_name,
email: customer.email,
customerId: customer.customer_id,
companyId: this.connection.companyId,
}));
}
/**
* Get customer addresses.
*/
async getAddresses(
customerId: string,
addressType?: 'shipping' | 'billing' | 'both'
): Promise<ErpAddress[]> {
const token = await this.getAccessToken();
const response = await httpx.get(
`${this.apiUrl}/odata/v4/c_customertpaddresstp?$filter=customer_id eq '${customerId}'&$top=100`,
{
headers: {
Authorization: `Bearer ${token}`,
},
timeout: 30.0,
}
);
return (response.json().value || []).map((addr: any) => ({
id: addr.address_id,
customerId,
name: addr.address_description,
addressLine1: addr.street,
addressLine2: addr.street2,
city: addr.city,
state: addr.state,
country: addr.country,
postalCode: addr.postal_code,
types: [addressType || 'shipping'],
}));
}
/**
* Create order in SAP.
*/
async createOrder(input: ErpOrderInput): Promise<ErpOrderResult> {
logger.info('SAP: Creating order', { customerId: input.customerId });
const token = await this.getAccessToken();
const orderPayload = {
customer_id: input.customerId,
order_date: new Date().toISOString(),
currency: input.currency || 'USD',
items: input.items.map(item => ({
item_id: item.itemId,
quantity: item.quantity,
unit_price: item.unitPrice,
})),
};
const response = await httpx.post(`${this.apiUrl}/odata/v4/c_salesordertp`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
json: orderPayload,
timeout: 60.0,
});
const result = response.json();
return {
success: true,
orderId: result.order_id,
orderNumber: result.order_number,
status: 'created',
};
}
// Abstract methods - implement in subclasses
abstract validateConnection(): Promise<boolean>;
abstract searchItems(params: ErpItemSearchParams): Promise<ErpItem[]>;
abstract getERPCapabilities(): Promise<ERPCapabilities>;
}

File: apps/webapp/src/services/erp/implementations/sap-b1-service.ts

export class SAPBusinessOneService extends SAPServiceBase {
/**
* Business One specific OData field mapping.
*/
protected buildCustomerFilterExpression(params: ErpCustomerSearchParams): string {
const filters = [];
if (params.name) {
filters.push(`substringof('${this.sanitizeInput(params.name)}', CardName)`);
}
if (params.customerId) {
filters.push(`CardCode eq '${this.sanitizeInput(params.customerId)}'`);
}
return filters.join(' or ') || `CardType eq 'C'`;
}
async validateConnection(): Promise<boolean> {
try {
const token = await this.getAccessToken();
const response = await httpx.get(`${this.apiUrl}/b1s/v1/$metadata`, {
headers: { Authorization: `Bearer ${token}` },
timeout: 10.0,
});
return response.status === 200;
} catch (error) {
logger.error('SAP B1 validation failed', error);
return false;
}
}
async searchItems(params: ErpItemSearchParams): Promise<ErpItem[]> {
const token = await this.getAccessToken();
const nameFilter = `substringof('${this.sanitizeInput(params.itemName)}', ItemName)`;
const response = await httpx.get(
`${this.apiUrl}/b1s/v1/Items?$filter=${encodeURIComponent(nameFilter)}&$top=10`,
{
headers: { Authorization: `Bearer ${token}` },
timeout: 30.0,
}
);
return (response.json().value || []).map((item: any) => ({
id: item.ItemCode,
itemId: item.ItemCode,
name: item.ItemName,
description: item.ItemDescription,
unitOfMeasure: item.BaseUnit,
price: item.LastPurchasePrice,
}));
}
async getERPCapabilities(): Promise<ERPCapabilities> {
return {
canSearchCustomers: true,
canSearchItems: true,
canGetAddresses: true,
canCreateOrders: true,
canSubmitOrders: false, // Requires manual approval in B1
canGetInventory: true,
canGetPricing: true,
};
}
private sanitizeInput(value: string): string {
return value.replace(/'/g, "''"); // Escape single quotes for OData
}
}

File: apps/webapp/src/services/erp/implementations/sap-s4-cloud-service.ts

export class SAPS4HANACloudService extends SAPServiceBase {
protected buildCustomerFilterExpression(params: ErpCustomerSearchParams): string {
const filters = [];
if (params.name) {
filters.push(`contains(CustomerName,'${this.sanitizeInput(params.name)}')`);
}
if (params.customerId) {
filters.push(`Customer eq '${this.sanitizeInput(params.customerId)}'`);
}
return filters.join(' or ') || `IsBlocked eq false`;
}
// S/4 Cloud uses OAuth2 + modern OData v4 endpoints
async validateConnection(): Promise<boolean> {
try {
const token = await this.getAccessToken();
const response = await httpx.get(`${this.apiUrl}/sap/opu/odata/IWFND/CATALOGSERVICE_SRVD`, {
headers: { Authorization: `Bearer ${token}` },
timeout: 10.0,
});
return response.status === 200;
} catch (error) {
logger.error('SAP S/4 Cloud validation failed', error);
return false;
}
}
async searchItems(params: ErpItemSearchParams): Promise<ErpItem[]> {
const token = await this.getAccessToken();
const filter = `contains(Product,'${this.sanitizeInput(params.itemName)}')`;
const response = await httpx.get(
`${this.apiUrl}/sap/opu/odata/sap/C_PRODUCT_SRV/C_ProductTP?$filter=${encodeURIComponent(filter)}&$top=10`,
{
headers: { Authorization: `Bearer ${token}` },
timeout: 30.0,
}
);
return (response.json().value || []).map((item: any) => ({
id: item.Product,
itemId: item.Product,
name: item.ProductName,
description: item.ProductDescription,
unitOfMeasure: item.BaseUnit,
price: item.StandardPrice,
}));
}
async getERPCapabilities(): Promise<ERPCapabilities> {
return {
canSearchCustomers: true,
canSearchItems: true,
canGetAddresses: true,
canCreateOrders: true,
canSubmitOrders: true, // Cloud supports direct submission
canGetInventory: true,
canGetPricing: true,
};
}
private sanitizeInput(value: string): string {
return value.replace(/'/g, "''");
}
}

File: apps/webapp/src/services/erp/erp-service-factory.ts (extend)

export class ERPServiceFactory {
static createService(connection: ERPConnection): IERPService {
switch (connection.erpType) {
case 'prophet21':
return new Prophet21Service(connection);
case 'netsuite':
return new NetSuiteService(connection);
case 'sap_b1':
return new SAPBusinessOneService(connection);
case 'sap_s4_cloud':
return new SAPS4HANACloudService(connection);
case 'sap_s4_onprem':
return new SAPS4HANAOnPremService(connection);
case 'demo':
return new DemoService(connection);
default:
throw new Error(`Unsupported ERP type: ${connection.erpType}`);
}
}
}

The existing erp_connections table in PostgreSQL should support SAP configurations:

-- erp_connections already has these fields:
-- - id (UUID)
-- - organization_id (UUID)
-- - erp_type (VARCHAR) ← 'sap_b1', 'sap_s4_cloud', 'sap_s4_onprem'
-- - api_url (VARCHAR)
-- - config (JSONB) ← store auth credentials, settings
-- - extra_config (JSONB) ← version, auth_type, etc.
-- Example config for Business One:
{
"username": "sap_user@company.com",
"api_url": "https://sap-b1.example.com:50000",
"auth_type": "basic",
"extra_config": {
"sap_version": "9.3",
"use_https": true,
"access_method": "api"
}
}
-- Example config for S/4 Cloud:
{
"client_id": "from-sap-iam",
"auth_type": "oauth2",
"api_url": "https://api.s4hana.ondemand.com/sap/opu/odata",
"extra_config": {
"sap_client": "100",
"odata_version": "v4"
}
}

SAP secrets stored in Infisical vault:

op://Ordermatic/SAP-{connection_id}/credential
β”œβ”€ password (for basic auth)
β”œβ”€ client_secret (for OAuth2)
└─ mTLS cert (if using certificate auth)

File: apps/dagster/erp_pipeline/schedules.py (extend)

@schedule(
job=full_sync_pipeline,
cron_schedule="0 2 * * *", # 2 AM daily
name="sap_full_sync_schedule",
description="Nightly full sync for SAP products/customers",
)
def sap_full_sync_schedule(context: ScheduleEvaluationContext):
# Trigger for all SAP connections
return RunRequest(
tags={
"kind": "full_sync",
"erp_type": "sap",
}
)

Existing bronze_completion_sensor in sensors.py automatically triggers dbt transform when bronze data lands.


GET https://sap-b1.example.com:50000/b1s/v1/Items
Authorization: Basic base64(username:password)

Credentials stored in Infisical, retrieved at sync time.

POST https://auth-server.com/oauth/token
client_id: from-iam
client_secret: from-infisical
grant_type: client_credentials
β†’ Returns: access_token (valid 1 hour)
GET https://sap-onprem.company.local:50000/sap/opu/odata/...
Authorization: Basic base64(username:password)
OR
Authorization: Negotiate [kerberos-token]

  • βœ… Create SAP Business One orchestrator + DLT source
  • βœ… Create SAP S/4HANA Cloud orchestrator + DLT source
  • βœ… Bronze/Silver/Gold/Typesense assets
  • βœ… Basic TypeScript IERPService for Business One
  • βœ… S/4HANA On-Prem implementation
  • βœ… S/4HANA Cloud service completion
  • βœ… Dagster schedules + sensors
  • βœ… Integration testing with sandbox systems
  • βœ… Error handling + retry logic
  • βœ… Performance optimization
  • βœ… Monitoring + alerting
  • βœ… Documentation + runbooks

test_sap_b1_orchestrator.py
def test_sync_products_full():
"""Test full product sync from Business One."""
...
def test_sync_products_incremental():
"""Test incremental product sync."""
...
sap-b1-service.test.ts
test('searchCustomers returns matching customers', async () => {
const service = new SAPBusinessOneService(mockConnection);
const results = await service.searchCustomers({ name: 'Acme' });
expect(results).toHaveLength(1);
expect(results[0].name).toBe('Acme Corp');
});
  • Use SAP sandbox systems for each version
  • Mock OData responses for unit tests
  • Full end-to-end flow in staging environment

# All SAP HTTP calls have explicit timeouts
httpx.get(url, timeout=30.0) # OData queries
httpx.post(url, timeout=60.0) # Order creation
@asset
def sap_products(...):
"""Auto-retry on transient failures."""
retry_policy = RetryPolicy(
max_retries=3,
delay=30,
backoff=exponential_backoff
)
  • DLT’s write_disposition="merge" prevents duplicate products
  • Order submission uses Temporal for durability (future)

yield MaterializeResult(
metadata={
"records_loaded": 1523,
"sync_duration_seconds": 45,
"connection_id": "...",
"erp_type": "sap_b1",
}
)
from opentelemetry import trace
with trace.get_tracer(__name__).start_as_current_span("sap.sync_products") as span:
span.set_attribute("sap.connection_id", connection_id)
span.set_attribute("sap.erp_type", "b1")
# ... sync logic

apps/dagster/erp_pipeline/
β”œβ”€β”€ implementations/
β”‚ β”œβ”€β”€ sap_b1_sync_orchestrator.py [NEW]
β”‚ β”œβ”€β”€ sap_s4_cloud_sync_orchestrator.py [NEW]
β”‚ └── sap_s4_onprem_sync_orchestrator.py [NEW]
β”œβ”€β”€ sources/
β”‚ β”œβ”€β”€ sap_b1/ [NEW]
β”‚ β”‚ β”œβ”€β”€ __init__.py
β”‚ β”‚ β”œβ”€β”€ auth.py
β”‚ β”‚ β”œβ”€β”€ client.py
β”‚ β”‚ └── dlt_source.py
β”‚ β”œβ”€β”€ sap_s4_cloud/ [NEW]
β”‚ β”‚ β”œβ”€β”€ __init__.py
β”‚ β”‚ β”œβ”€β”€ auth.py
β”‚ β”‚ β”œβ”€β”€ client.py
β”‚ β”‚ └── dlt_source.py
β”‚ └── sap_s4_onprem/ [NEW]
β”‚ β”œβ”€β”€ __init__.py
β”‚ β”œβ”€β”€ auth.py
β”‚ β”œβ”€β”€ client.py
β”‚ └── dlt_source.py
β”œβ”€β”€ assets/
β”‚ β”œβ”€β”€ bronze.py [EXTEND with sap_* assets]
β”‚ β”œβ”€β”€ typesense.py [EXTEND with sap_* assets]
β”‚ └── dbt/
β”‚ └── models/
β”‚ β”œβ”€β”€ silver/stg_sap_products.sql [NEW]
β”‚ β”œβ”€β”€ silver/stg_sap_customers.sql [NEW]
β”‚ └── gold/dim_sap_products.sql [NEW]
└── tests/
β”œβ”€β”€ test_sap_b1_orchestrator.py [NEW]
β”œβ”€β”€ test_sap_s4_orchestrators.py [NEW]
└── integration/
└── test_sap_sync_e2e.py [NEW]
apps/webapp/src/services/erp/
β”œβ”€β”€ implementations/
β”‚ β”œβ”€β”€ sap-service-base.ts [NEW]
β”‚ β”œβ”€β”€ sap-b1-service.ts [NEW]
β”‚ β”œβ”€β”€ sap-s4-cloud-service.ts [NEW]
β”‚ └── sap-s4-onprem-service.ts [NEW]
└── erp-service-factory.ts [EXTEND]
apps/webapp/src/services/
└── erp-auth/
β”œβ”€β”€ sap-auth.ts [NEW]
└── providers/
β”œβ”€β”€ sap-b1-auth.ts [NEW]
β”œβ”€β”€ sap-oauth2-auth.ts [NEW]
└── sap-kerberos-auth.ts [NEW]

βœ… Functional Requirements:

  • SAP Business One products/customers syncing to Iceberg
  • SAP S/4HANA Cloud OAuth2 auth working
  • SAP S/4HANA On-Prem basic auth working
  • All data appearing in Typesense within 5 minutes of sync
  • PDF extraction can match against SAP products via Typesense
  • Orders can be created in SAP via IERPService

βœ… Non-Functional Requirements:

  • Sync completes in <5 minutes for 10k products
  • OData timeouts handled gracefully (retry + alert)
  • Connection validation works for all 3 SAP systems
  • Monitoring shows asset metrics in Dagster UI
  • OTEL traces visible in Better Stack for each sync

  1. Temporal Workflow for order creation (durability)
  2. Bidirectional sync (Ordermatic β†’ SAP inventory updates)
  3. Custom field mappings per customer
  4. Real-time webhooks (SAP changes push to Ordermatic)
  5. Multi-language support for product names
  6. Batch order submission API

DateVersionAuthorNotes
2026-03-091.0JimmyInitial design document for SAP integration