Dentolize · Treasury & Step Payments Fixes Walkthrough
On this pageBusiness viewTechnical view

Payments Across Operation Steps ("Pulses")

Business view

Some treatments are not paid all at once. They are billed as a course of sessions — Dentolize calls the remaining sessions "Pulses / Sessions" (you can see the column on the patient's Operations tab). Each session is an operation step. A patient might pay a bit against the whole operation, and staff add steps over time as the work is done.

Two things went wrong when money and steps mixed:

  1. A new step over-counted what was already paid. When staff added a new

step to an operation that had already received some payment, Dentolize spread a proportional slice of the total-paid amount onto the brand-new step — even if that money had already been fully allocated to earlier steps. The operation then looked more paid than it really was.

  1. **The next payment split into two records, and the doctor's commission was

under-recorded. Because the "is this operation fully paid?" check was comparing the wrong numbers, a single payment could be recorded as two step-payment records, and the doctor's salary/commission adjustment was only attached to the first** of them. The doctor's earnings on that treatment came out too low.

The fix makes two corrections:

  • A new step is filled with only the amount that is genuinely still unpaid,

capped at what that step actually costs — no double counting.

  • The "fully paid" check now uses the operation's own discount and tax, so

an operation is correctly marked paid in full and the payment is recorded as the single, correct record with the doctor's commission fully attributed.

For staff, the workflow is unchanged: add steps, take payments. The numbers on the invoice, the operation, and the doctor's commission now line up.

Technical view

Two server files change.

A. Filling a new step's paidaddOperationStep.js

packages/server/src/resolvers/mutations/actions/addOperationStep.js

When a step is added to an invoiced operation, the resolver computes how much of the step is already paid. The operation and its existing steps are loaded up front — steps are selected with their package and paid at addOperationStep.js:125-131. The step's share of the operation is computed at addOperationStep.js:246-248:

const totalPackage = operation.remaining + operation.steps.reduce((t, s) => t + (s.package || 0), 0)
const percentOfOperation = (args.package / totalPackage) * 100
const total = (percentOfOperation * operation.total) / 100

Before (single line), inside the if (operation.invoiceId) block:

paid = operation.paid ? (operation.paid * percentOfOperation) / 100 : 0

This attributed a proportional slice of the operation's entire paid amount to the new step — ignoring that earlier steps had already been credited with that same money. Summed across steps, recorded paid could exceed what was actually collected.

AfteraddOperationStep.js:285-293:

stepTotalToPay = Math.max(total - stepDiscount + stepTax, 0)

const operationPaid = Number(operation.paid) || 0

const leftToPayForStep = operationPaid
  ? Math.max(operationPaid - operation.steps.reduce((total, step) => total + (Number(step.paid) || 0), 0), 0)
  : 0

paid = operationPaid ? Math.min(leftToPayForStep, stepTotalToPay) : 0

Now the new step is credited with only operationPaid − Σ(existing steps.paid) — the portion of collected money not yet allocated to any step — and never more than the step's own cost (stepTotalToPay). Number(...) || 0 guards against null/undefined paid values. When the operation has no payments yet, paid is 0, unchanged from before.

B. The "paid in full" decision — paymentUtils.js

packages/server/src/resolvers/mutations/mutationUtils/paymentUtils.js

updateOperationStepsPayments distributes an incoming payment across the operation's unpaid steps (stepsToPay), creating a stepPayment per step and a salaryAdjustment for the doctor on each (paymentUtils.js:144-176). After the step updates run, it decides which whole operations are now fully paid.

The step-update query now also returns the operation's discount and tax (paymentUtils.js:206):

operation: { select: { id: true, total: true, paid: true, discount: true, tax: true } }

Before, the "is the operation fully paid?" reduce compared the operation total against the individual step's discount and tax:

const toPay = Math.max(step.operation.total - step.discount + step.tax, 0)

For a multi-step operation, a single step's discount/tax is only a fraction of the operation's, so toPay was overstated and the operation was rarely flagged paidInFull. It could stay "open," letting the next collection spill into an extra step-payment record.

AfterpaymentUtils.js:214-224:

const paidOperations = res
  .filter(s => s.operation)
  .reduce((arr, step) => {
    const toPay = Math.max(step.operation.total - step.operation.discount + step.operation.tax, 0)

    if (Number(Number(step.operation.paid).toFixed()) >= Number(toPay.toFixed()) && !arr.includes(step.operation.id)) {
      arr.push(step.operation.id)
    }
    return arr
  }, [])

Now toPay is the operation's net payable (operation.total − operation.discount + operation.tax), compared against operation.paid. When that threshold is met the operation id is collected once and, downstream, the operation and all its steps are marked paid in full (paymentUtils.js:226-236).

How the two fixes work together

  • Fix A keeps each step's recorded paid honest, so the sum of step paid

never overshoots operation.paid.

  • Fix B compares operation.paid against the operation's real net total,

so "paid in full" is detected at the right moment.

Together they stop a payment from being fragmented into an extra record. Because the doctor's salaryAdjustment is created per stepPayment (paymentUtils.js:160-176), removing the spurious split also removes the scenario where the commission was attached to only one of two records — the symptom called out in the PR title.

Honesty / verification notes

  • The PR description/title frames the symptom as "payment splits into two

records" and "salary adjustment only recorded for the first record." The code fixes the underlying allocation and paid-in-full math; the salary behavior is a downstream consequence of that math, not a separately edited line. This walkthrough documents the code as written.

  • These are the two server files in the diff; there is no schema/migration

change. Amounts continue to be rounded with .toFixed() at the comparison boundary, so sub-unit rounding at the "paid in full" threshold behaves as before.