FinBaze for Platforms

Guides

A transport management system knows when a shipment was delivered, what it cost and who pays. FinBaze books that. You push shipments as custom records; a flow template creates the sales invoice when a shipment is delivered and reverses it when it is cancelled. Carrier invoices go in as purchase invoices. The TMS demo contains the code of this page.

1. The shipment record#

mutation {
  definePlatformRecordType(input: {
    key: "shipment"
    name: "Shipment"
    schema: {
      type: "object"
      required: ["reference", "customer", "currency", "lanes"]
      properties: {
        reference: { type: "string" }
        customer: { type: "object" }
        currency: { type: "string" }
        deliveredAt: { type: "string" }
        lanes: { type: "array" }
      }
    }
    states: ["booked", "delivered", "cancelled"]
    initialState: "booked"
    transitions: { booked: ["delivered", "cancelled"], delivered: ["cancelled"] }
    displayTemplate: "Shipment {{data.reference}}"
  }) { version }
}

2. The template#

apiVersion: finbaze.com/flows/v1
kind: FlowTemplate
metadata: { key: shipment-invoicing, version: 1, recordType: shipment }
chart: rgs
on:
  - transition: { to: delivered }
    onDataChange: rebook
    steps:
      - id: customer
        upsert_relation:
          externalId: "= record.data.customer.id"
          legalName: "= record.data.customer.name"
          vatNumber: "= record.data.customer.vatNumber"
          registrationCountry: "= record.data.customer.country"
      - id: invoice
        create_sales_invoice:
          relation: "= steps.customer.id"
          date: "= $date(record.data.deliveredAt, 'Europe/Amsterdam')"
          currency: "= record.data.currency"
          reference: "= record.data.reference"
          finalise: true
          lines:
            - forEach: "= record.data.lanes"
              as: lane
              name: "Transport {{ lane.from }} - {{ lane.to }}"
              priceMinor: "= lane.revenueMinor"
              taxCode: "= lane.taxCode"
  - transition: { to: cancelled }
    steps:
      - id: credit
        reverse: { step: invoice }

Each lane carries its own taxCode (see Reverse charge and the EU). A reverse step credits what an earlier run of the record created, using what that run recorded, so editing the template later never changes what is reversed. Test with testFlowTemplate, then saveFlowTemplate and activateFlowTemplate with the version it returned. The syntax is in Records and flow templates.

Push the shipment and move it through its states with upsertCustomRecord:

mutation Ship($company: JSON!, $data: JSON!) {
  upsertCustomRecord(input: { company: $company, type: "shipment", externalId: "SHP-2026-0042", status: "delivered", data: $data }) {
    status
    record { id revision effects { stepId objectType } runs { id status } }
  }
}

If a run fails (flow_run.failed), nothing of that run is kept and later runs of the record wait. Fix the data or template and call retryFlowRun.

3. Carrier invoices#

A carrier invoice is a purchase invoice. Push it when it arrives and let the company approve it in purchase-inbox, or create it with upsertPurchaseInvoice:

mutation Carrier($company: CompanyRef!) {
  upsertPurchaseInvoice(input: {
    company: $company
    externalId: "carrier-4411"
    purchaseInvoice: {
      relation: { externalId: "carrier-dhl" }
      currency: EUR
      date: "2026-03-04"
      number: "INV-4411"
      lines: [{ name: "Linehaul NL-DE", priceMinor: 84000, taxCode: "NL_2024_TRANSFER" }]
    }
  }) { status id }
}

Hidden modules still accept data, and a module that is switched off answers FEATURE_DISABLED. See Sending data.

Reverse charge and the EU#

Tax is derived from the tax code on the line. These are the codes this guide uses:

SituationTax code
Dutch customer, high rateNL_2012_HIGH
Dutch customer, low rateNL_2019_LOW
Dutch reverse charge (verlegd)NL_2024_TRANSFER
Intra-community supply to a business in another EU country (reverse charge)EU_Directive_2006/112/EC_Article_40
Consumer in another EU country (OSS)the country's own code, for example DE_2025_HIGH

Which codes a company may use depends on its tax obligations: nl_vat allows the Dutch and EU transfer codes, nl_oss the EU country codes. Add the obligation when you create the company (taxObligations) or the onboarding component asks for it.

  • ICP. Supplies with the EU transfer code appear in the ICP return. Mount icp-return for the quarter.
  • OSS. Sales to consumers with a country code appear in the OSS return. Mount oss-return.
  • Both returns are filed with the company's consent for nl_icp and nl_oss (Filing consent). The components show the period and status; see their pages for what is available per environment.

Multiple currencies#

Set currency per invoice. Amounts are minor units of that currency (priceMinor: 12000 is 120.00 EUR or 120.00 USD; for a currency without decimals it is the whole number). The books stay in the company's currency, so FinBaze converts with its own exchange rates. Use expectedTotalMinor on upserts to fail with TOTAL_MISMATCH if your prices and tax codes do not add up to what you expect.

Try it in the sandbox#

seedTestCompany(input: { template: nl_bv_logistics_oss }) gives a Dutch B.V. with EU customers and VAT, ICP and OSS obligations. Attach a test clock to run the quarter, then open icp-return and oss-return. The nightly sandbox benchmark runs the same company through 365 days.

Pitfalls#

  • A lane without a tax code: the invoice step is rejected and the whole run is rolled back (flow_run.failed).
  • Cancelling a shipment that has no invoice: the reverse step fails with NOTHING_TO_REVERSE. Guard it with when.
  • Changing a finalised invoice: correct it with a credit note (creditOf), not an edit.