Fleet costs in Business Central
This recipe posts Fleet’s cost ledger to Dynamics 365 Business Central through its API (v2.0): vendor costs as purchase invoices, everything else as general journal lines, all with BC dimensions. Read Costs and your ERP first for the model. The Odoo recipe shows the request-first flow; it works the same way against BC purchase orders.
Scopes: costs:write, accounting:write, webhooks:write, vendors:read, vehicles:read.
1. Map Fleet to Business Central once
Section titled “1. Map Fleet to Business Central once”-
Dimensions. Create a Fleet dimension per BC dimension you use on fleet costs, keyed in lower case (
department,project), and push the values from BC’sdimensionValues:const values = await bc(`dimensions(${deptDimensionId})/dimensionValues`)await fleet('PUT', '/accounting_dimensions/department/values', {values: values.value.map((v) => ({ code: v.code, name: v.displayName })),deactivate_missing: true,}) -
External references. Map vendors to BC vendor numbers, and cost categories to G/L accounts (and, if you use them, VAT product posting groups) in
metadata:await fleet('PUT', '/external_references', { data: [{ object_type: 'vendor', object_key: rubisId, system: 'business_central', external_id: 'V00042' },{ object_type: 'cost_category', object_key: 'fuel', system: 'business_central', external_id: '6110',metadata: { account: '6110', tax_group: 'VAT16' } },{ object_type: 'cost_category', object_key: 'maintenance.labor', system: 'business_central', external_id: '6125',metadata: { account: '6125', tax_group: 'VAT16' } },] }) -
Defaults. Put each branch’s default codes on it with
PUT /v1/branches/{id}/dimensions, for example{"dimensions": {"location": "NBO"}}, so every cost at that branch carries them.
2. Post the export queue
Section titled “2. Post the export queue”const FLEET = 'https://api.get-fleet.com/v1'const BC = `https://api.businesscentral.dynamics.com/v2.0/${TENANT}/${ENV}/api/v2.0/companies(${COMPANY})`
export async function syncCosts() { let cursor: string | undefined do { const page = await fleet('GET', `/cost_exports?system=business_central&limit=100${cursor ? `&cursor=${cursor}` : ''}`) const acks = [] for (const { cost_entry: e, export_status, last_export } of page.data) { if (export_status !== 'pending_new' && last_export) await reverse(last_export.external_id) const externalId = export_status === 'pending_void' ? last_export.external_id : await post(e) acks.push({ id: e.id, revision: e.revision, external_id: externalId }) } if (acks.length) { await fleet('POST', '/cost_exports', { system: 'business_central', entries: acks }) // "stale": the entry changed while we posted it. Fleet recorded what we posted, and the next run sees it as // pending_update with last_export = this document, so it is corrected, not posted twice. } cursor = page.has_more ? page.next_cursor : undefined } while (cursor)}
async function post(e: CostEntry): Promise<string> { const map = await categoryMapping(e.category, e.subcategory) // from the cost_category references const dims = Object.entries(e.dimensions).map(([key, code]) => ({ code: key.toUpperCase(), valueCode: code }))
if (e.settlement === 'vendor_bill' || e.settlement === 'fuel_card') { const invoice = await bc('purchaseInvoices', 'POST', { vendorNumber: await vendorNumber(e.payee?.id ?? e.vendor_id), invoiceDate: e.posting_date, vendorInvoiceNumber: e.references.tax_invoice_number ?? e.references.receipt_number ?? `FLEET-${e.id.slice(0, 8)}`, currencyCode: e.currency === LCY ? '' : e.currency, }) const line = await bc(`purchaseInvoices(${invoice.id})/purchaseInvoiceLines`, 'POST', { lineType: 'Account', lineObjectNumber: map.account, description: `${e.category} ${e.subcategory ?? ''}`.trim().slice(0, 100), quantity: 1, directUnitCost: Number(e.amount), // set Prices Including VAT on the vendor, or post e.amount - e.tax.amount }) for (const d of dims) await bc(`purchaseInvoices(${invoice.id})/purchaseInvoiceLines(${line.id})/dimensionSetLines`, 'POST', d) await bc(`purchaseInvoices(${invoice.id})/Microsoft.NAV.post`, 'POST') return invoice.number }
// prepaid_card, employee_reimbursement, cash, stock_issue…: a balanced journal pair const line = await bc(`journals(${FLEET_JOURNAL})/journalLines`, 'POST', { accountType: 'G/L Account', accountNumber: map.account, postingDate: e.posting_date, documentNumber: `FLEET-${e.id.slice(0, 8)}`, externalDocumentNumber: e.id, amount: Number(e.amount), description: `${e.category} ${e.subcategory ?? ''}`.trim().slice(0, 100), balanceAccountType: 'G/L Account', balancingAccountNumber: settlementAccount(e.settlement), // prepaid card, employee payable, cash… }) for (const d of dims) await bc(`journals(${FLEET_JOURNAL})/journalLines(${line.id})/dimensionSetLines`, 'POST', d) return line.documentNumber}reverse() posts a purchase credit memo for an invoice you created, or a journal line with the opposite amount.
Post with posting_date, never incurred_on: it is always in an open period.
3. Keep it current
Section titled “3. Keep it current”- Subscribe a webhook to
cost_entry.*and runsyncCosts()when one arrives (debounce to once a minute), plus a scheduled run every 15 minutes as a backstop. - When you close a period in BC (Allow Posting From), mirror it with
PATCH /v1/accounting_settings{"closed_through": "…"}. - Use
GET /v1/cost_summaries?from=…&to=…&group_by=category,monthto reconcile BC’s G/L totals with Fleet each month. Group bydimension:departmentto check dimension coverage.