Skip to content

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. 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’s dimensionValues:

    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,
    })
  2. 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' } },
    ] })
  3. 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.

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.

  • Subscribe a webhook to cost_entry.* and run syncCosts() 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,month to reconcile BC’s G/L totals with Fleet each month. Group by dimension:department to check dimension coverage.