Payment posting

A command that applies a payment to the oldest charges, posts it to the ledger and waits for it to appear. Node, one file.

What it does

  1. Finds the patient by phone number with GET /patients and location_strict=true.
  2. Lists their charges with GET /charges, drops deleted ones and sorts oldest first.
  3. Splits the amount across the charges in that order, in cents, so the totals add up.
  4. Looks up the payment type by name in GET /payment_types and posts with POST /payments.
  5. Polls GET /payments by transaction_id until the payment is read back from the ledger.

Run it

export NEX_API_KEY="your-sandbox-api-key"
export NEX_SUBDOMAIN="your-subdomain"
export NEX_LOCATION_ID="your-location-id"

node post-payment.mjs --phone 5165550100 --amount 140 --method "Credit Card" --transaction MyApp:0001

Node 18 or later. Financial data has to be turned on for the key. Sandbox locations have it on. For production, see Payments configuration guide.

The code

// Payment posting: take a payment you collected, apply it to the patient's oldest charges,
// post it to the ledger and wait until it shows up.
// Run: node post-payment.mjs --phone 5165550100 --amount 140 --method "Credit Card"
// Node 18 or later, no packages to install. Environment: NEX_API_KEY, NEX_SUBDOMAIN, NEX_LOCATION_ID.
import { parseArgs } from "node:util";

const BASE = process.env.NEX_BASE || "https://nexhealth.info";
const { NEX_API_KEY, NEX_SUBDOMAIN, NEX_LOCATION_ID } = process.env;
const scope = { subdomain: NEX_SUBDOMAIN, location_id: NEX_LOCATION_ID };

const { values: args } = parseArgs({
  options: {
    phone: { type: "string" },
    amount: { type: "string" },
    method: { type: "string", default: "Credit Card" },
    transaction: { type: "string" }, // your own ID for the payment, up to 50 characters, unique
    wait: { type: "string", default: "120" }, // seconds to wait for the payment to appear on the ledger
  },
});
if (!args.phone || !args.amount) {
  console.error('Usage: node post-payment.mjs --phone 5165550100 --amount 140 [--method "Credit Card"] [--transaction MyApp:0001]');
  process.exit(1);
}

// --- NexHealth API -------------------------------------------------------

let bearer;
async function call(method, path, { query = {}, body } = {}) {
  if (!bearer) {
    const res = await fetch(BASE + "/authenticates", { method: "POST", headers: { "Nex-Api-Version": "v3.0.0", Authorization: NEX_API_KEY } });
    const json = await res.json();
    if (!res.ok || json.code === false) throw new Error("Token exchange failed: " + JSON.stringify(json.error));
    bearer = json.data.token;
  }
  const url = new URL(BASE + path);
  for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value);
  const res = await fetch(url, {
    method,
    headers: { "Nex-Api-Version": "v3.0.0", Authorization: "Bearer " + bearer, ...(body ? { "Content-Type": "application/json" } : {}) },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  const json = text ? JSON.parse(text) : {};
  if (!res.ok || json.code === false) throw new Error(Array.isArray(json.error) ? json.error.join("; ") : method + " " + path + " failed with " + res.status);
  return json.data;
}

// --- Money ---------------------------------------------------------------
// Amounts come back as strings in dollars ("70.00") and go in as numbers (-70). Work in cents in between.

const toCents = (amount) => Math.round(Number(amount) * 100);
const toDollars = (cents) => cents / 100;

// Apply the payment to the oldest charges first. Returns { charge_id: -amount } and the amount applied.
function allocate(charges, cents) {
  const splits = {};
  let left = cents;
  for (const charge of charges) {
    if (left <= 0) break;
    const fee = toCents(charge.fee.amount);
    const applied = Math.min(fee, left);
    splits[charge.id] = -toDollars(applied);
    left -= applied;
  }
  return { splits, applied: cents - left };
}

// --- Main -----------------------------------------------------------------

async function main() {
  const [patient] = await call("GET", "/patients", { query: { ...scope, phone_number: args.phone, location_strict: true, inactive: false } });
  if (!patient) throw new Error("No patient with phone " + args.phone + " at this location.");
  console.log("Patient:", patient.name, "(" + patient.id + ")");

  // Oldest charges first. Charges need at least one filter; patient_id is ours.
  const charges = (await call("GET", "/charges", { query: { ...scope, patient_id: patient.id, sort: "updated_at", per_page: 100 } }))
    .filter((charge) => !charge.deleted_at)
    .sort((a, b) => (a.charged_at || "").localeCompare(b.charged_at || ""));
  console.log("Open charges:", charges.map((c) => c.id + " " + c.description + " " + c.fee.amount).join(", ") || "none");

  const paymentTypes = await call("GET", "/payment_types", { query: { ...scope, per_page: 100 } });
  const type = paymentTypes.find((t) => t.name === args.method && t.active !== false);
  if (!type) throw new Error('No active payment type named "' + args.method + '". Available: ' + paymentTypes.map((t) => t.name).join(", "));

  const cents = toCents(args.amount);
  const { splits, applied } = allocate(charges, cents);
  if (applied < cents) console.log("Note: " + toDollars(cents - applied) + " is more than the open charges. It is posted without a charge split.");

  const transactionId = args.transaction || "MyApp:" + Date.now(); // tracking only, never visible to the office
  const payment = {
    patient_id: patient.id,
    amount: -toDollars(cents),
    payment_type_id: type.id,
    transaction_id: transactionId,
    paid_at: new Date().toISOString(),
    charge_splits: splits,
    notes: "Posted by MyApp",
  };
  await call("POST", "/payments", { query: scope, body: { payment } });
  console.log("Accepted (202). Transaction", transactionId, "for", payment.amount, "as", type.name);

  // The 202 means NexHealth accepted the request. Poll until the payment is read back from the ledger.
  // In a server, subscribe to the payment_insertion webhook instead of polling.
  const deadline = Date.now() + Number(args.wait) * 1000;
  while (Date.now() < deadline) {
    const found = await call("GET", "/payments", { query: { ...scope, transaction_id: transactionId } });
    if (found.length) {
      console.log("On the ledger:", found.map((p) => p.id + " " + p.payment_amount.amount + " paid " + p.paid_at).join(", "));
      process.exit(0);
    }
    await new Promise((r) => setTimeout(r, 10000));
  }
  console.log("Not on the ledger yet. Check GET /sync_status and the payment_insertion webhook.");
}

main().catch((err) => {
  console.error("Error:", err.message);
  process.exit(1);
});

How it works

  • Cents, not floats. Charge amounts arrive as strings like "70.00". The app converts them to cents, allocates, and converts back, so a $140 payment across $10, $60 and $70 comes out as exactly -10, -60 and -70.
  • Signs. The payment and every split are negative, because a payment lowers the balance. A positive amount would post as a refund.
  • Transaction ID. --transaction is your own ID, up to 50 characters and unique. Without it the app makes one from the time. The office never sees it, but GET /payments can filter by it, which is how the app finds the payment again.
  • More than the open charges. If the amount is larger than the charges, the rest is posted without a split.
  • The wait. POST /payments answers 202 when NexHealth accepts the request. The payment appears in GET /payments once NexHealth reads it back from the health record system, on the next read. In a server, subscribe to payment_insertion instead of polling.

Change it for production

  • Take the amount and the patient from your checkout flow instead of the command line, and keep --transaction tied to your own payment record.
  • On Dentrix, charge_id is always null and payments cannot be linked to charges. Send provider_splits instead of charge_splits.
  • Subscribe to payment_insertion to learn about a failed write without polling. See Troubleshooting payments.

Did this page help you?