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
- Finds the patient by phone number with
GET /patientsandlocation_strict=true. - Lists their charges with
GET /charges, drops deleted ones and sorts oldest first. - Splits the amount across the charges in that order, in cents, so the totals add up.
- Looks up the payment type by name in
GET /payment_typesand posts withPOST /payments. - Polls
GET /paymentsbytransaction_iduntil 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:0001Node 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,-60and-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.
--transactionis your own ID, up to 50 characters and unique. Without it the app makes one from the time. The office never sees it, butGET /paymentscan 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 /paymentsanswers202when NexHealth accepts the request. The payment appears inGET /paymentsonce NexHealth reads it back from the health record system, on the next read. In a server, subscribe topayment_insertioninstead of polling.
Change it for production
- Take the amount and the patient from your checkout flow instead of the command line, and keep
--transactiontied to your own payment record. - On Dentrix,
charge_idis alwaysnulland payments cannot be linked to charges. Sendprovider_splitsinstead ofcharge_splits. - Subscribe to
payment_insertionto learn about a failed write without polling. See Troubleshooting payments.
Updated about 1 hour ago
Did this page help you?