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(); // your own ID for the payment, unique per payment
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. NexHealth uses it to identify the payment it writes into the health record system, andGET /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 16 days ago
Did this page help you?