Dentolize · Balance Invoice Webhooks & API Config Cleanup Walkthrough
On this pageBusiness viewTechnical view

Balance webhook events

Business view

Dentolize can notify an outside system in real time whenever certain things happen in a clinic — a new patient is added, an invoice is created, a payment comes in. Clinics that use this (currently an internal/opt-in integration feature) plug their own backend URL into Settings → Integrations → API Config and pick which of these events they want to receive.

Until this PR, adding money to a patient's balance was invisible to that integration. Two situations create balance money in Dentolize:

  • A staff member explicitly tops up a patient's balance (the Add Balance

button on a patient profile) — useful for prepaying for future treatment.

  • A patient pays more than they currently owe on an invoice, and the clinic

chooses to keep the extra as credit rather than refund it — the overage is automatically converted into a balance credit.

Both of these create a special kind of invoice (a "Balance Invoice") and a matching payment behind the scenes, but neither used to trigger a webhook. An external accounting or insurance system watching for NEW_INVOICE / NEW_PAYMENT events would simply never hear about balance money moving.

This PR adds two new event types — NEW_BALANCE_INVOICE and NEW_BALANCE_PAYMENT — that fire in both of the situations above, using the exact same payload shape as regular invoices and payments. A clinic just ticks the new "Balance Invoices" and "Patient Balance" checkboxes next to their webhook URL to start receiving them.

Technical view

New event type constants

apiTypes.newBalanceInvoice (NEW_BALANCE_INVOICE) and apiTypes.newBalancePayment (NEW_BALANCE_PAYMENT) are added to the shared apiTypes map:

packages/server/src/apis/apiConfig/mutationsApiConfig.js:6-17

export const apiTypes = {
  newPatient: 'NEW_PATIENT',
  newInvoice: 'NEW_INVOICE',
  newPayment: 'NEW_PAYMENT',
  newTreasury: 'NEW_TREASURY',
  newProcedure: 'NEW_PROCEDURE',
  newOperation: 'NEW_OPERATION',
  newExpense: 'NEW_EXPENSE',
  newExpensePayment: 'NEW_EXPENSE_PAYMENT',
  newBalanceInvoice: 'NEW_BALANCE_INVOICE',
  newBalancePayment: 'NEW_BALANCE_PAYMENT'
}

Payload shape: reuses the invoice/payment mapping

getOtherDataFromPayload (packages/server/src/apis/apiConfig/otherAPIs.js) is the function that turns an internal record into the JSON body sent to the webhook URL. Rather than writing new mapping logic, the new types fall through into the existing case blocks:

packages/server/src/apis/apiConfig/otherAPIs.js:51-52

case apiTypes.newInvoice:
case apiTypes.newBalanceInvoice:
  return { /* same invoice_line_ids / patient_data / insurance_policy_data / ... shape */ }

packages/server/src/apis/apiConfig/otherAPIs.js:130-131

case apiTypes.newPayment:
case apiTypes.newBalancePayment:
  return { /* same amount / payment_type / invoice_id / treasury_id shape */ }

So an integration that already parses NEW_INVOICE / NEW_PAYMENT payloads needs no new parsing logic for the balance variants — only new routing on the type field in the request body.

Trigger 1 — direct balance top-up (addPatientBalance)

packages/server/src/resolvers/mutations/actions/appointments/addPatientBalance.js was restructured in this PR (previously the invoice, payment, and treasury transaction were created as one deeply nested prisma.invoice.create() call; now they're three separate tx.invoice.create / tx.payment.create / tx.treasury.update calls inside the same transaction). Functionally equivalent, but it lets the resolver hold onto the created invoice and payment records after the transaction commits, which is what makes firing the webhooks possible:

packages/server/src/resolvers/mutations/actions/appointments/addPatientBalance.js:268-293

await handleCallApi({
  redisClient, request, prisma,
  type: apiTypes.newBalanceInvoice,
  payload: { ...invoice, operations: [], patient }
})

await handleCallApi({
  redisClient, request, prisma,
  type: apiTypes.newBalancePayment,
  payload: {
    id: payment.id, amount: payment.amount, type: payment.type, other: payment.other,
    invoiceId: invoice.id, treasury, patientId: patient.id
  }
})

Both calls happen after the prisma.$transaction block commits, outside the transaction. handleCallApi swallows its own errors (see Payload accuracy fixes for why that matters), so a webhook failure never rolls back or blocks the balance top-up itself.

Trigger 2 — overpayment converted to balance (handleNewPayment)

packages/server/src/resolvers/mutations/mutationUtils/paymentUtils.js already had logic to create a balanceInvoice when a payment exceeds what's owed on an invoice (balance = amount - remaining, around line 941). This PR adds the same two webhook calls right after that invoice is created, gated on if (balanceInvoice):

packages/server/src/resolvers/mutations/mutationUtils/paymentUtils.js:1680-1708

if (balanceInvoice) {
  await handleCallApi({
    redisClient, request, prisma,
    type: apiTypes.newBalanceInvoice,
    payload: { ...balanceInvoice, operations: [], patient: invoice.patient }
  })

  const payment = balanceInvoice.payments[0]

  await handleCallApi({
    redisClient, request, prisma,
    type: apiTypes.newBalancePayment,
    payload: {
      id: payment.id, amount: payment.amount, type: payment.type, other: payment.other,
      invoiceId: balanceInvoice.id, treasury, patientId: invoice.patient.id
    }
  })
}

Because balanceInvoice is only created when there's an overage, ordinary invoice payments (paying exactly what's owed, or less) never fire these two new events — only the existing NEW_PAYMENT webhook for the invoice itself.

UI: subscribing to the new events

packages/clinic-web/src/components/dashboard/settings/Account/APIConfig.js:17-26 adds the two new checkbox options to the multi-select "APIs" field on each webhook row:

const apiIntegrations = [
  { value: 'NEW_PATIENT', label: 'patients.newPatient' },
  { value: 'NEW_INVOICE', label: 'app.newInvoice' },
  { value: 'NEW_PAYMENT', label: 'app.newPayment' },
  { value: 'NEW_TREASURY', label: 'app.newTreasury' },
  { value: 'NEW_PROCEDURE', label: 'settings.procedures.newProcedure' },
  { value: 'NEW_OPERATION', label: 'settings.medications.newOperation' },
  { value: 'NEW_EXPENSE', label: 'expenses.newExpense' },
  { value: 'NEW_EXPENSE_PAYMENT', label: 'app.expensePayments' },
  { value: 'NEW_BALANCE_INVOICE', label: 'settings.procedures.balanceInvoices' },
  { value: 'NEW_BALANCE_PAYMENT', label: 'settings.groups.patientBalance' }
]

These translate to "Balance Invoices" and "Patient Balance" in the English locale. A webhook row only receives an event type if its apis array includes that type's string value — see handleCallApi's api.apis.includes(type) check in packages/server/src/apis/apiConfig/mutationsApiConfig.js:40.