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.