/* ──────────────────────────────────────────────────────────────────────────── K2B DMS — vehicle deal money: ONE calculation for saving, printing, posting and funding a deal (RC21, lane deals; owner-reviewer R3 / R5 of 2026-10-14, spec K2B-002 / K2B-003 / K2B-015, 4.3, 6.3, 6.5). The SAME file is used twice, byte for byte: server src/lib/deal-calc.js — POST /api/deals/post | fund | receipt (src/routes/deals.js) and the deal-save guard of the generic records route (src/lib/deals-guard.js) re-compute the deal with it; the server's answer wins browser DealCalc.jsx.txt — window.DealCalc, for the PREVIEW only (the close gates on the Cash Deal / Finance Deal, the Funding screen), so a station shows the same refusal the server would give before it submits. (tests check the two files are identical.) What it ports (nothing new is invented): • the structure the browser screens compute today: Finance Deal F5 (FinanceWorksheet fwCompute — amount financed = price + tax + title + doc + products + "other" + trade payoff − trade allowance − rebate − down; payment = the standard amortization, an explicit 0 % stays 0 %, RC17 K2B-005), the Cash Deal (CashWorksheet cwTotals / DMSCore cashDealFigures — total due = price + tax + doc + title + products + "other" + payoff − allowance − rebate; RECEIVED = the recorded payments only, RC18 K2B-003); • the journal of DMSCore.postDeal (the shipped GL account map, DMSCore ACCT — a station's own Settings › GL Flow remap lives in that station's browser and is NOT used by the server, like RC20's fixed-ops map), with the RC18 cash-deal rules now applied to every deal type: a rebate → factory receivable 261 (+ the factory-receivable row), a trade over-allowance reduces the sale on the vehicle's own sale account (an under-allowance adds to it), the trade goes into used inventory 240 at its ACV, the payoff to 303, a finance deal's balance to contracts in transit 205 with the lender; NOTHING is balanced with cash nobody received; • the F&I income entry (FI-) and the commission accrual (CM-) of postDeal, with the shipped pay plan (DMSCore PAY_PLAN_DEFAULT); • funding = DMSCore.fundDealAmount (cash 202, the difference coded to FUND_DIFF_ACCTS, CIT 205 cleared). A LEASE is booked the way postDeal books it today from the deal's own lease figures (the lease engine LeaseCalc.jsx.txt is NOT re-run on the server: the station's F5 computes the lease, the server books it) — the lessor assignment (205) is what balances; the rebate and the over-allowance get the same accounts as a retail deal; a "refund at signing" is refused (RC20 plugged it to cash). Money rules (spec 4.3): every amount is an INTEGER number of cents read through its decimal text (1.445 → 145, half-up), never float × 100; a rate is a number of percent; the payment is rounded half-up to the cent once. Stored deal figures written by the screens are read leniently (a third decimal rounds); amounts TYPED into a command (receipts, funding) must have at most 2 decimals. Pure functions only — no database, no window, no Date (the caller passes dates). Node 20 CommonJS / browser global. RC27 (dsk — desking profit screen): the deal's COST LINES (deal.costAdds: down payment assistance, lender fee, we owe, dealer-paid fee, F&I item, other — Front / Back, we-owe, passed to customer) are checked here (costAddsOf), the Profit panel and the posting share ONE profit / commission function (profitPlan: the commission on the front gross AFTER the front cost lines), and the posting books them (the down payment assistance in the sale entry in place of the customer's cash; DCA- for the lender fee / dealer-paid costs; WOA- per we-owe). A deal with no cost lines gives exactly the RC26 figures, journals and commission. ──────────────────────────────────────────────────────────────────────── */ (function (root, factory) { var api = factory(); if (typeof module === "object" && module && module.exports) module.exports = api; if (root) root.DealCalc = api; })(typeof window !== "undefined" ? window : null, function () { "use strict"; var CALC_VERSION = "deal-1"; /* ── the posting map: DMSCore.jsx.txt ACCT (the shipped defaults) — only the accounts a deal / its funding uses ── */ var ACCT = { cashBank: "202", cashOnHand: "201", cit: "205", arCustomer: "220", invNewCar: "231", invNewTruck: "237", invUsedCar: "240", invUsedTruck: "241", factoryRec: "261", payoffPayable: "303", salesTaxPay: "324A", titlePay: "905D", docFee: "905A", comPayable: "321", saleNewCar: "400A", costNewCar: "600A", saleNewTruck: "429A", costNewTruck: "629A", saleUsedCar: "446B", costUsedCar: "646B", saleUsedTruck: "450B", costUsedTruck: "650B", fiNew: "806", fiUsed: "808", vscPayable: "302", gapPayable: "302G", commExpNew: "0111", commExpUsed: "0112", customerDeposits: "331", // RC23 (corr): money held for the customer (chart "331 DEPOSITS", group Customer Deposits) — after an unwind / a cancelled product, until refunded or applied otherReserves: "332", // RC27 (dsk): money SET ASIDE for a deal's cost the store still has to pay — a we-owe's estimated cost, a dealer-paid fee, an "Other" / F&I-item cost (chart "332 OTHER RESERVES", Accrued) until the work is done / the bill is paid lenderOverfund: "331", // RC33 (mpx): a lender's OVER-funding held until it is paid back (Funding › Lender refund) — owner decision: 331 DEPOSITS (the accountant may prefer a lender payable) }; /* DMSCore.FUND_DIFF_ACCTS — where a funding difference is coded (Funding › Reconcile & Fund) */ var FUND_DIFF_ACCTS = { "Bank fee": { acct: "604", label: "Bank / finance fees (expense)" }, "Contract error": { acct: "606", label: "Contract error / adjustment" }, "Product cancellation": { acct: "302", label: "F&I product payable reversal" }, "Holdback": { acct: "232", label: "Lender holdback receivable" }, "Short fund": { acct: "606", label: "Short fund / write-off" }, }; var RECEIPT_METHODS = ["Cash", "Check", "Card", "Wire / ACH", "Other"]; // DMSCore.RECEIPT_METHODS (Record Payment) var PAY_PLAN_DEFAULT = { usedPct: 0.25, newPct: 0.20, miniFloor: 150, packCounts: true, vscBonus: 75, gapBonus: 50, appearanceBonus: 100 }; // DMSCore.PAY_PLAN_DEFAULT (RC23 corr: + the product bonuses, for a cancelled product's chargeback) /* RC28 (fx, reviewer finding 3): the pay plan is ONE server-stored, versioned configuration (routes/pay-plan.js: GET / POST /api/pay-plan, a version with an effective date, audited). It is stored in whole units — percent as BASIS POINTS (2000 = 20 %), money as CENTS — and read back into the shape the calculation has always used (usedPct / newPct fractions, dollars). The desk preview, the posting, the product-cancel chargeback, the commission / payroll screens all call payPlanCalc() on the stored copy; the browser never posts its own. A posted deal keeps the version it was posted with (deal.posted.payPlan). */ var PAY_PLAN_FIELDS = [ ["newPctBp", "pct", "New vehicles — percent of the commissionable front gross"], ["usedPctBp", "pct", "Used vehicles — percent of the commissionable front gross"], ["miniFloorCents", "money", "Minimum commission (mini)"], ["vscBonusCents", "money", "Service contract (VSC) bonus"], ["gapBonusCents", "money", "GAP bonus"], ["appearanceBonusCents", "money", "Appearance / paint bonus"], ["packCounts", "bool", "The unit's pack comes off the gross before the percent"], ["fundedOnly", "bool", "Pay only after the deal is funded"], ]; /** the stored (whole-unit) form of the shipped default — version 0, used until the office saves a plan */ var PAY_PLAN_STORED_DEFAULT = { newPctBp: 2000, usedPctBp: 2500, miniFloorCents: 15000, vscBonusCents: 7500, gapBonusCents: 5000, appearanceBonusCents: 10000, packCounts: true, fundedOnly: true }; /** stored form → the calculation's form (usedPct / newPct fractions, dollar amounts) */ function payPlanCalc(s) { s = s && typeof s === "object" ? s : PAY_PLAN_STORED_DEFAULT; var g = function (k) { return s[k] === undefined || s[k] === null ? PAY_PLAN_STORED_DEFAULT[k] : s[k]; }; return { newPct: Number(g("newPctBp")) / 10000, usedPct: Number(g("usedPctBp")) / 10000, miniFloor: Number(g("miniFloorCents")) / 100, vscBonus: Number(g("vscBonusCents")) / 100, gapBonus: Number(g("gapBonusCents")) / 100, appearanceBonus: Number(g("appearanceBonusCents")) / 100, packCounts: g("packCounts") !== false, fundedOnly: g("fundedOnly") !== false }; } /** a plan typed on the Pay Plan page → { plan (stored form), problems: [msg] }. Percent: 0 to 100 with at most 2 decimals ("25", "25.5 %"); money: $0.00 to $100,000.00, at most 2 decimals; the two switches on / off. Every field is required (the page always sends the whole plan). */ function payPlanProblems(input) { var x = input && typeof input === "object" && !Array.isArray(input) ? input : {}, out = {}, problems = []; PAY_PLAN_FIELDS.forEach(function (f) { var k = f[0], v = x[k]; if (f[1] === "bool") { if (typeof v !== "boolean") problems.push(f[2] + ": must be on or off."); else out[k] = v; return; } if (typeof v === "number" ? !isFinite(v) : typeof v !== "string") { problems.push(f[2] + ": enter a number."); return; } if (f[1] === "pct") { var s = typeof v === "number" ? String(v) : String(v).replace(/%/g, "").trim(); if (!/^\d{1,3}(\.\d{1,2})?$/.test(s)) { problems.push(f[2] + ": \"" + String(v).slice(0, 12) + "\" is not a percent (like 20 or 22.5)."); return; } var bp = Math.round(Number(s) * 100); if (bp > 10000) { problems.push(f[2] + ": more than 100 %."); return; } out[k] = bp; return; } var c = strictCents(v); if (c === "decimals") { problems.push(f[2] + ": more than two decimals."); return; } if (!isFinite(c) || c < 0) { problems.push(f[2] + ": enter dollars and cents, $0.00 or more."); return; } if (c > 10000000) { problems.push(f[2] + ": more than $100,000.00."); return; } out[k] = c; }); return { plan: problems.length ? null : out, problems: problems }; } var MAX_CENTS = 1000000000; // $10,000,000 — a deal / payment above this is refused var TOL = 1; // 1 cent: a figure the station computed in floating point may differ from the integer sum by one cent /* ── numbers (the same reading as lib/fixedops-calc.js, except typed text: see GROUPED) ── */ /* round 2 (attack D1b): a figure written as text may carry a "$" and commas ONLY as thousands separators (1,500.00 / 26,500); a comma anywhere else ("1,00") or a space inside the digits ("1 0 0") is not a dollar amount and is refused — it used to be read as 100. Spaces around the figure and after the "$" are fine. */ var GROUPED = /^(-)?\$?\s*(\d{1,3}(?:,\d{3})+|\d*)(\.\d*)?$/; function decText(v) { var s; if (typeof v === "number") { if (!isFinite(v)) return null; s = v.toFixed(6); } else if (typeof v === "string") { var g = GROUPED.exec(v.trim()); if (!g) return null; s = (g[1] || "") + g[2].replace(/,/g, "") + (g[3] || ""); if (!s) return null; } else return null; var m = /^(-)?(\d*)(?:\.(\d*))?$/.exec(s); if (!m || (m[2] === "" && (m[3] === undefined || m[3] === ""))) return null; return { neg: !!m[1], int: m[2] || "0", frac: m[3] || "" }; } /** Any dollar figure → integer cents, half-up on the third decimal. 0 for blank; NaN when not a number. */ function toCents(v) { if (v === undefined || v === null || v === "") return 0; var d = decText(v); if (!d) return NaN; var f = (d.frac + "000").slice(0, 3); var c = Number(d.int) * 100 + Number(f.slice(0, 2)) + (Number(f.charAt(2)) >= 5 ? 1 : 0); if (!isFinite(c)) return NaN; return d.neg && c !== 0 ? -c : c; } /** A money figure TYPED by a person / sent with a command: at most 2 decimals → cents | NaN | 'decimals'. */ function strictCents(v) { if (v === undefined || v === null || v === "") return NaN; var d = decText(v); if (!d) return NaN; if (typeof v === "string" && d.frac.length > 2) return "decimals"; if (typeof v === "number" && Math.abs(v * 100 - Math.round(v * 100)) > 1e-6) return "decimals"; return toCents(v); } function dollars(c) { return Math.round(c) / 100; } function fmt(c) { var neg = c < 0; c = Math.abs(Math.round(c)); var s = String(Math.floor(c / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, ","); return (neg ? "-$" : "$") + s + "." + String(c % 100).padStart(2, "0"); } var blank = function (v) { return v === undefined || v === null || String(v).trim() === ""; }; /* ── the deal's figures ── */ var MONEY = [ ["salePrice", "The sale price"], ["cost", "The vehicle cost"], ["tax", "The sales tax"], ["docFee", "The doc fee"], ["titleFee", "The title / license fee"], ["tradeAllow", "The trade allowance"], ["tradePayoff", "The trade payoff"], ["tradeACV", "The trade ACV"], ["tradeAcv", "The trade ACV"], ["rebate", "The rebate"], ["cashDown", "The down payment"], ["amtFinanced", "The amount financed"], ["payment", "The payment"], ["vscSale", "The service-contract price"], ["vscCost", "The service-contract cost"], ["gapSale", "The GAP price"], ["gapCost", "The GAP cost"], ["otherSale", "The \"Other\" charge"], ["fiGross", "The F&I gross"], ["dueAtSigning", "The due at signing"], ["leaseUpfrontTax", "The lease tax at signing"], ["received", "The amount received"], ]; var isLease = function (d) { return /lease/i.test(String((d && (d.dealType || d.mode)) || "")); }; /* ── RC27 (dsk): DEAL COST ADDS — costs the store takes on a deal (the desk's Profit panel; deal.costAdds) ── A line: { id, type, desc, amount, side: "front" | "back", weOwe: bool, passed: bool (lender fee only — "passed to customer"), kind (lender fee: acquisition | bank | flat | points), points, lender, auto }. The types and their default side (owner 2026-10-19: down payment assistance → Front, lender fee → Front, F&I-type items → Back, we owe → Front unless switched). A lender fee passed to the customer is NOT a cost: it is the customer's fee line (in the amount financed). Everything else is a cost of the deal: a Front line lowers the front gross (and the commission the pay plan figures on it), a Back line lowers the F&I gross. Down payment assistance is the store's money toward the customer's down payment: the customer pays that much less cash, the deal still balances. Integer cents, amounts > $0.00 with at most two decimals, at most 20 lines. Pure: no database. */ var COST_ADD_TYPES = { dpa: { label: "Down payment assistance", side: "front" }, lenderFee: { label: "Lender fee", side: "front" }, weOwe: { label: "We owe — parts & labor", side: "front", weOwe: true }, dealerFee: { label: "Dealer-paid fee", side: "front" }, fiItem: { label: "F&I item (dealer-paid)", side: "back" }, other: { label: "Other", side: "front" }, }; var COST_ADD_ORDER = ["dpa", "lenderFee", "weOwe", "dealerFee", "fiItem", "other"]; var LENDER_FEE_KINDS = { acquisition: "Acquisition fee", bank: "Bank fee", flat: "Flat fee", points: "Discount points" }; var MAX_COST_ADDS = 20; /** the cost lines of a deal, normalised + checked: { any, lines, problems, dpaCents, feeCustCents (passed to the customer), feeDealerCents (lender fees the store pays), frontCents, backCents (every cost line by side, DPA included), weOweCents, accrueCents } */ function costAddsOf(d) { var raw = d && d.costAdds; var out = { any: false, lines: [], problems: [], dpaCents: 0, feeCustCents: 0, feeDealerCents: 0, frontCents: 0, backCents: 0, weOweCents: 0, accrueCents: 0 }; if (raw === undefined || raw === null || (Array.isArray(raw) && raw.length === 0)) return out; var P = function (code, msg) { out.problems.push({ code: code, field: "costAdds", msg: msg }); }; out.any = true; if (!Array.isArray(raw)) { P("bad_cost_adds", "The deal's cost lines (Profit panel) are not a list — open the desk and enter them again."); return out; } if (raw.length > MAX_COST_ADDS) { P("too_many_cost_adds", "A deal can carry at most " + MAX_COST_ADDS + " cost lines (Profit panel) — this one has " + raw.length + ". Combine some lines."); return out; } var seen = {}; raw.forEach(function (x, i) { var n = "Cost line " + (i + 1); if (!x || typeof x !== "object" || Array.isArray(x)) { P("bad_cost_add", n + " is not a cost line."); return; } // fix round (attack V side): every switch / pick of a line is TEXT (a list or an object that reads as the same word is refused) var type = typeof x.type === "string" ? x.type : ""; var T = Object.prototype.hasOwnProperty.call(COST_ADD_TYPES, type) ? COST_ADD_TYPES[type] : null; if (!T) { P("bad_cost_type", n + ": pick what it is (" + COST_ADD_ORDER.map(function (k) { return COST_ADD_TYPES[k].label; }).join(", ") + ")."); return; } n = n + " (" + T.label + ")"; if (!blank(x.id) && typeof x.id !== "string" && typeof x.id !== "number") { P("bad_cost_add", n + ": its id is not usable — take the line off and add it again."); return; } var id = blank(x.id) ? "CA" + (i + 1) : String(x.id).trim(); if (id.length > 40 || /[\u0000-\u001f]/.test(id)) { P("bad_cost_add", n + ": its id is not usable — take the line off and add it again."); return; } if (seen[id.toUpperCase()]) { P("dup_cost_add", n + ": two cost lines have the same id (" + id + ") — take one off and add it again."); return; } seen[id.toUpperCase()] = 1; if (!blank(x.desc) && typeof x.desc !== "string") { P("bad_cost_desc", n + ": the description must be text."); return; } var desc = blank(x.desc) ? "" : String(x.desc).replace(/[\u0000-\u001f]/g, " ").trim(); if (desc.length > 80) P("bad_cost_desc", n + ": the description is longer than 80 characters — shorten it."); var amt = strictCents(x.amount); if (amt === "decimals") P("bad_amount", n + ": the amount has more than two decimals — enter dollars and cents."); else if (!isFinite(amt) || amt <= 0) P("bad_amount", n + ": enter an amount more than $0.00 (got \"" + String(x.amount === undefined ? "" : x.amount).slice(0, 20) + "\")."); else if (amt > MAX_CENTS) P("too_large", n + ": the amount is larger than the DMS allows ($10,000,000)."); var side = blank(x.side) ? T.side : (typeof x.side === "string" ? x.side.trim().toLowerCase() : "?"); if (side !== "front" && side !== "back") P("bad_cost_side", n + ": pick Front or Back."); if (x.weOwe !== undefined && x.weOwe !== null && typeof x.weOwe !== "boolean") P("bad_cost_add", n + ": the we-owe switch must be on or off."); if (x.passed !== undefined && x.passed !== null && typeof x.passed !== "boolean") P("bad_cost_add", n + ": the passed-to-customer switch must be on or off."); var weOwe = T.weOwe ? true : x.weOwe === true; var passed = x.passed === true; if (weOwe && (type === "dpa" || type === "lenderFee")) P("bad_cost_weowe", n + ": this is not a we-owe — the we-owe switch is for work or items the store still owes the customer (it prints a due bill)."); if (passed && type !== "lenderFee") P("bad_cost_passed", n + ": only a lender fee can be passed to the customer."); if (weOwe && !desc && type !== "dpa" && type !== "lenderFee") P("bad_cost_desc", n + ": say what is owed — it prints on the customer's due bill (for example: Windshield)."); if (type === "other" && !desc) P("bad_cost_desc", n + ": say what the cost is."); var kind = null, points = null; if (type === "lenderFee") { kind = blank(x.kind) ? "acquisition" : (typeof x.kind === "string" ? x.kind : "?"); if (!Object.prototype.hasOwnProperty.call(LENDER_FEE_KINDS, kind)) P("bad_cost_kind", n + ": pick the kind of lender fee (" + Object.keys(LENDER_FEE_KINDS).map(function (k) { return LENDER_FEE_KINDS[k]; }).join(", ") + ")."); if (!blank(x.points)) { points = typeof x.points === "number" ? x.points : (typeof x.points === "string" ? Number(x.points.replace(/[%\s]/g, "")) : NaN); if (!(isFinite(points) && points > 0 && points <= 10)) P("bad_cost_points", n + ": discount points must be a number from 0.001 to 10."); } } if (!blank(x.lender) && typeof x.lender !== "string") P("bad_cost_add", n + ": the lender must be text."); if (type !== "lenderFee" && !blank(x.kind) && typeof x.kind !== "string") P("bad_cost_kind", n + ": the kind of fee must be text."); var ok = isFinite(amt) && amt > 0 && amt <= MAX_CENTS; var line = { id: id, index: i, type: type, label: T.label, desc: desc, amountCents: ok ? amt : 0, side: side === "back" ? "back" : "front", weOwe: weOwe, passed: passed, kind: kind, points: points, lender: blank(x.lender) ? "" : String(x.lender).slice(0, 80), cost: !passed }; out.lines.push(line); if (!ok) return; if (passed) { out.feeCustCents += amt; return; } if (line.side === "back") out.backCents += amt; else out.frontCents += amt; if (type === "dpa") out.dpaCents += amt; else if (type === "lenderFee") out.feeDealerCents += amt; else if (weOwe) out.weOweCents += amt; else out.accrueCents += amt; }); return out; } /** the stored shape of the cost lines a posting booked (what figures.costAdds.lines keeps; compared by the archive import) */ function costAddStoreShape(l) { return { id: l.id, type: l.type, desc: l.desc, amountCents: l.amountCents, side: l.side, weOwe: l.weOwe, passed: l.passed, kind: l.kind }; } /** The profit of a deal (cents) — ONE function for the desk's Profit panel and the posting's commission (RC21 pay plan: newPct / usedPct of the commissionable front gross rounded to whole dollars, never below the mini floor (to the cent); the pack only when the plan's packCounts is on): front gross = price − vehicle cost − trade over-allowance; net front = front − pack − FRONT cost adds (the commissionable gross); net back = F&I gross − BACK cost adds; total = net front + net back. The pay plan pays on the front only: a Back line lowers the back and the total, never the commission. x: { priceCents, costCents, overCents, packCents, fiGrossCents, isNew, ca | costAdds, payPlan } */ function profitPlan(x) { x = x || {}; var PP = x.payPlan || PAY_PLAN_DEFAULT; var ca = x.ca || costAddsOf({ costAdds: x.costAdds }); var front = (x.priceCents || 0) - (x.costCents || 0) - (x.overCents || 0); // RC28 fix round (audit FU3): the plan's "pack counts" switch is applied HERE, for every caller (the desk's Profit panel passed the // unit's pack even when the plan said the pack does not count, so the desk showed a lower commission than the posting booked) var pack = PP.packCounts === false ? 0 : Math.max(0, x.packCents || 0); var netFront = front - pack - ca.frontCents; var fi = x.fiGrossCents || 0; var netBack = fi - ca.backCents; var pctBp = Math.round((x.isNew ? PP.newPct : PP.usedPct) * 10000); var floor = Math.round(Number(PP.miniFloor || 0) * 100); // RC28 fix round (audit FU6): the minimum to the cent ($150.50 was paid as $151.00) var base = Math.round(netFront * pctBp / 1000000) * 100; return { frontGrossCents: front, packCents: pack, frontAddsCents: ca.frontCents, netFrontCents: netFront, fiGrossCents: fi, backAddsCents: ca.backCents, netBackCents: netBack, totalGrossCents: netFront + netBack, commissionCents: Math.max(floor, base), baseCommissionCents: base, isMini: base < floor, pctBp: pctBp, dpaCents: ca.dpaCents, feeCustCents: ca.feeCustCents, feeDealerCents: ca.feeDealerCents }; } /** what a deal's kind does not allow of its cost lines (a lender fee on a cash deal, a lease fee passed to the customer, assistance larger than the down payment / the cash due) — shared by the save gate and the posting. A deal still on the desk (not pushed to the Cash / Finance Deal yet) carries no down payment of its own: its assistance is checked when it is pushed. */ function costAddKindProblems(F, d) { var ca = F.ca, c = F.c, out = []; if (!ca || !ca.any) return out; if (F.kind === "cash" && ca.lines.some(function (l) { return l.type === "lenderFee"; })) out.push({ code: "lender_fee_cash", msg: "A cash deal has no lender — take the lender fee line off the Profit panel (or push the deal to the Finance Deal)." }); if (F.kind === "lease" && ca.feeCustCents > 0) out.push({ code: "lender_fee_lease", msg: "On a lease, the acquisition fee the customer pays is the lease's own acquisition fee (desk › Taxes & fees) — turn \"passed to customer\" off on the lender fee line (then it is a cost the store pays) or take the line off." }); if (ca.dpaCents > 0) { if (F.kind === "cash") { if (ca.dpaCents > F.totalDue) out.push({ code: "dpa_over_due", msg: "The down payment assistance (" + fmt(ca.dpaCents) + ") is more than the cash deal's total due (" + fmt(Math.max(0, F.totalDue)) + ") — lower the assistance." }); } else if (F.kind === "lease") { var due = !blank(d && d.dueAtSigning) ? c.dueAtSigning : c.cashDown; if (ca.dpaCents > due) out.push({ code: "dpa_over_down", msg: "The down payment assistance (" + fmt(ca.dpaCents) + ") is more than the lease's due at signing (" + fmt(due) + ") — the store's help can't be more than what the customer pays at signing. Lower the assistance." }); } else if (F.kind === "finance" && ca.dpaCents > c.cashDown) out.push({ code: "dpa_over_down", msg: "The down payment assistance (" + fmt(ca.dpaCents) + ") is more than the down payment (" + fmt(c.cashDown) + ") — the store's help can't be more than the down payment itself. Raise the down payment or lower the assistance." }); } return out; } /* ── RC28 (fx, reviewer finding 5): ONE normalised reading of a state word (a deal's stage / status / funding status, an RO's or a body job's status, a policy adjustment's status). Before RC28 the deal-save guard tested the stage as typed while isPostedDeal trimmed it, so " Posted " was saved as an unposted deal that every command then treated as posted (no journal). Now the guard, the commands and the screens read the SAME word: compatibility forms folded (NFKC: full-width letters), invisible / format characters dropped (zero-width space, joiners, direction marks, soft hyphen, BOM …), the Cyrillic / Greek letters that look exactly like Latin ones read as Latin, every kind of space read as one space, trimmed. cleanWord keeps the capitals (what a save stores); foldWord is lower case (what a test compares). ── */ // RC28 fix round (attack A8): + the invisible TAG characters and the variation selectors supplement (U+E0000-U+E01EF, written as their // surrogate pairs \uDB40\uDC00-\uDB40\uDDEF so the expression needs no "u" flag) var INVISIBLE_RE = /(?:[\u0000-\u001f\u007f-\u009f\u00ad\u034f\u061c\u115f\u1160\u17b4\u17b5\u180b-\u180f\u200b-\u200f\u202a-\u202e\u2060-\u206f\u3164\ufe00-\ufe0f\ufeff\uffa0\ufff0-\ufff8]|\uDB40[\uDC00-\uDDEF])/g; var SPACE_RE = /[\s\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+/g; var LOOK_FROM = "\u0410\u0412\u0421\u0415\u041d\u0406\u0408\u041a\u041c\u041e\u0420\u0405\u0422\u0425\u0423\u0430\u0441\u0435\u043e\u0440\u0445\u0443\u0456\u0458\u0455\u0501\u04bb\u04cf\u0391\u0392\u0395\u0396\u0397\u0399\u039a\u039c\u039d\u039f\u03a1\u03a4\u03a5\u03a7\u03bf\u03b9\u03ba\u03bd\u03c1\u03c4\u03c5\u03c7"; var LOOK_TO = "ABCEHIJKMOPSTXYaceopxyijsdhlABEZHIKMNOPTYXoikvptux"; function cleanWord(v) { var s = v === undefined || v === null ? "" : String(v); try { s = s.normalize("NFKC"); } catch (e) { /* as typed */ } s = s.replace(INVISIBLE_RE, ""); var out = ""; for (var i = 0; i < s.length; i++) { var k = LOOK_FROM.indexOf(s.charAt(i)); out += k === -1 ? s.charAt(i) : LOOK_TO.charAt(k); } return out.replace(SPACE_RE, " ").trim(); } function foldWord(v) { return cleanWord(v).toLowerCase(); } /** RC28 fix round (attack A8 / M1): a STATE word a save stores (a deal's stage / status / funding status, an RO's, a body job's, a policy adjustment's, a unit's, a bill's status …) must read as PLAIN text once cleaned: keyboard letters, digits, spaces and punctuation. A letter no table can fold (a Cherokee / Lisu / mathematical look-alike, a combining mark that does not join its letter) let a record DISPLAY "Posted" / "Approved" on screens and printouts while every rule read another word. null = fine, else the plain reason. Only text is judged (a state that is not text is left to the record's own rules). */ var PLAIN_STATE_RE = /^[\x20-\x7e\u00b7\u2010-\u2027]*$/; function stateWordProblem(v) { if (typeof v !== "string") return null; var c = cleanWord(v); if (c.length > 80) return "is longer than 80 characters"; if (!PLAIN_STATE_RE.test(c)) return "has a character that is not a plain keyboard letter, digit or punctuation mark (a look-alike letter, or a mark that does not join its letter) — type the word again"; return null; } /** a server mark is "set" when it carries anything but blank / false / 0 (a station may send postedToGL "true", 1, "yes" …) */ function markSet(v) { return !(v === undefined || v === null || v === false || v === 0 || v === "" || (typeof v === "string" && /^(false|no|0|null|undefined)$/i.test(cleanWord(v)))); } var isFundedWord = function (s) { return /^funded/.test(foldWord(s)); }; /** A deal that is on the books: posted (postedToGL), or carried as Posted / Funded by an older station or a history import — the ONE predicate the save guard, the commands and the screens share (RC28: the folded words above). */ function isPostedDeal(d) { return !!d && (d.postedToGL === true || /^(posted|funded)$/.test(foldWord(d.stage)) || isFundedWord(d.status)); } /** RC28: does this deal (a record a station / an import wants to SAVE) say it is posted or funded in ANY spelling — postedToGL set to anything, a stage that reads Posted / Funded, a status / funding status that reads Funded? (The posting and funding commands set these.) */ function claimsPosted(d) { return !!d && (markSet(d.postedToGL) || /^(posted|funded)$/.test(foldWord(d.stage))); } function claimsFunded(d) { return !!d && (isFundedWord(d.status) || isFundedWord(d.fundingStatus) || /^funded$/.test(foldWord(d.stage))); } /** RC28 fix round (audit FU10): a deal that was posted and UNWOUND and is not posted again — read from the server-owned history (priorPosts, written by the unwind command), never from a word a station could type. Its commission was reversed: no screen pays it. */ function isUnwoundDeal(d) { return !!d && !isPostedDeal(d) && Array.isArray(d.priorPosts) && d.priorPosts.some(function (p) { return p && p.unwound; }); } /** cash | finance | lease | draft — the browser's own reading (DMSCore.isCashDeal / dealStage). */ function kindOf(d) { if (!d) return "draft"; if (isLease(d)) return "lease"; var af = toCents(d.amtFinanced); var finAmt = isFinite(af) && af > 0; if (!finAmt && (/cash/i.test(String(d.dealType || "")) || d.stage === "Cash")) return "cash"; if (finAmt || !blank(d.lender) || /retail|financ/i.test(String(d.dealType || "")) || d.stage === "F&I") return "finance"; return "draft"; } /** The ACV the trade was entered at: tradeACV, else tradeAcv; null = not entered (never the allowance). */ function acvOf(d) { if (!d) return null; var v = !blank(d.tradeACV) ? d.tradeACV : (!blank(d.tradeAcv) ? d.tradeAcv : null); return v === null ? null : toCents(v); } function aprOf(d) { if (!d || blank(d.apr)) return { given: false, value: null, ok: false }; var n = typeof d.apr === "number" ? d.apr : (decText(d.apr) ? Number(String(d.apr).replace(/[,$\s%]/g, "")) : NaN); return { given: true, value: n, ok: isFinite(n) && n >= 0 && n < 100 }; } function termOf(d) { if (!d || blank(d.term)) return { given: false, value: 0, ok: false }; var n = Number(d.term); return { given: true, value: n, ok: isFinite(n) && Math.floor(n) === n && n >= 0 && n <= 144 }; } /** Every figure of a deal in cents + the structure (amount financed, total due) and the shape problems. */ function figures(d) { d = d || {}; var c = {}, problems = []; MONEY.forEach(function (k) { var v = toCents(d[k[0]]); if (!isFinite(v)) { problems.push({ code: "bad_amount", field: k[0], msg: k[1] + " is not a dollar amount (got \"" + String(d[k[0]]).slice(0, 30) + "\")." }); v = 0; } else if (v < 0) problems.push({ code: "negative_amount", field: k[0], msg: k[1] + " can't be below $0.00 (" + fmt(v) + ")." }); else if (v > MAX_CENTS) problems.push({ code: "too_large", field: k[0], msg: k[1] + " is larger than the DMS allows ($10,000,000)." }); c[k[0]] = v; }); var acv = acvOf(d); if (acv !== null && !isFinite(acv)) acv = 0; // RC28 (fx, finding 1 class): every F&I product's price and cost must be a readable amount — productsOf reads them for the // cancellation's bounds; an unreadable one counted as $0.00 silently before (Array.isArray(d.products) ? d.products : []).forEach(function (p, i) { if (!p || typeof p !== "object") return; [["price", "price"], ["sale", "price"], ["cost", "cost"]].forEach(function (f) { var v = p[f[0]]; if (blank(v)) return; var cc = toCents(v); if (!isFinite(cc)) problems.push({ code: "bad_amount", field: "products", msg: "Product " + (i + 1) + " (" + String(p.name || p.code || "product").slice(0, 40) + "): the " + f[1] + " \"" + String(v).slice(0, 20) + "\" is not a dollar amount." }); else if (cc < 0) problems.push({ code: "negative_amount", field: "products", msg: "Product " + (i + 1) + " (" + String(p.name || p.code || "product").slice(0, 40) + "): the " + f[1] + " can't be below $0.00." }); }); }); var apr = aprOf(d), term = termOf(d); if (apr.given && !apr.ok) problems.push({ code: "bad_apr", field: "apr", msg: "The APR must be a percent from 0 to 99.99 (0 % is allowed) — got \"" + String(d.apr).slice(0, 20) + "\"." }); if (term.given && !term.ok) problems.push({ code: "bad_term", field: "term", msg: "The term must be a whole number of months from 0 to 144 — got \"" + String(d.term).slice(0, 20) + "\"." }); // RC27 (dsk): the deal's cost lines; a lender fee passed to the customer is the customer's fee line on a finance deal (in the amount financed) var ca = costAddsOf(d); ca.problems.forEach(function (p) { problems.push(p); }); var kind = kindOf(d); var feeCust = (kind === "finance" || kind === "draft") ? ca.feeCustCents : 0; var charges = c.salePrice + c.tax + c.docFee + c.titleFee + c.vscSale + c.gapSale + c.otherSale + c.tradePayoff + feeCust; var totalDue = charges - c.tradeAllow - c.rebate; return { kind: kind, c: c, acv: acv, apr: apr, term: term, problems: problems, ca: ca, feeCustCents: feeCust, dpaCents: ca.dpaCents, totalDue: totalDue, // cash: what the customer owes (before any payment) financed: totalDue - c.cashDown, // retail: amount financed (Finance Deal F5) negEquity: Math.max(0, c.tradePayoff - c.tradeAllow), overAllow: acv === null ? 0 : c.tradeAllow - acv, products: c.vscSale + c.gapSale, }; } /** Standard amortization: principal (cents), APR %, n months → payment in cents (r = 0 → P / n), rounded half-up once. */ function paymentCents(principalC, apr, n) { if (!(n > 0)) return 0; var P = principalC / 100, r = apr / 1200; var pmt = r === 0 ? P / n : (P * r) / (1 - Math.pow(1 + r, -n)); return Math.round(pmt * 100 + 1e-9); } /* ── the save gate (src/lib/deals-guard.js on every deal write; the screens' own gates use the same words) ── */ var negFinancedMsg = function (over, lease) { return "Down payment too high — the down payment, trade equity and rebates are " + fmt(over) + " more than the whole " + (lease ? "cap cost" : "balance") + " — lower the down payment."; }; var negBalanceMsg = function (over) { return "Trade-in and rebates too high — they are " + fmt(over) + " more than the price and charges — check the trade-in and rebates."; }; /** What makes a deal's figures impossible to SAVE (R3): bad / negative amounts, a negative amount financed or balance, broken terms. */ function saveProblems(d) { var F = figures(d), out = F.problems.slice(); if (F.kind === "finance" || F.kind === "draft") { if (F.financed < 0 && (F.kind === "finance" || F.c.cashDown > 0)) out.push({ code: "negative_financed", msg: negFinancedMsg(-F.financed) }); if (F.kind === "finance" && F.c.amtFinanced > 0) { if (!F.term.ok || F.term.value < 1) out.push({ code: "missing_terms", msg: "A finance deal needs its term (1 to 144 months) — open the Finance Deal and press F5 (Recalculate)." }); if (!F.apr.given) out.push({ code: "missing_terms", msg: "A finance deal needs its APR (0 % is allowed) — open the Finance Deal and press F5 (Recalculate)." }); } } if (F.kind === "cash" && F.totalDue < 0) out.push({ code: "negative_balance", msg: negBalanceMsg(-F.totalDue) }); costAddKindProblems(F, d).forEach(function (p) { out.push(p); }); // RC27 (dsk) return out; } /* ── the vehicle ── */ function dealTypeOfUnit(u) { if (!u) return ""; var t = String(u.type || u.condition || u.newUsed || "").trim(); if (!t) return ""; return /^n|new/i.test(t) ? "New" : "Used"; } /** The vehicle cost of sale (in cents; 0 = unknown). Round 2 (attack I1): the UNIT's cost in Inventory decides — the cost of sale and the inventory relief must be what inventory carries for the unit (its cost, else its invoice); the deal's own `cost` is the desk's copy and no longer overrides it (a stale or mistyped deal cost understated the cost of sale and inflated the gross and the commission). Without the unit (a preview of a deal whose unit is not loaded) the deal's cost is shown; posting needs the unit. */ function vehicleCost(d, u) { if (u) { var uc = toCents(u.cost), ui = toCents(u.invoice); // RC28 (fx, finding 1 class): a cost that is on the unit but can't be read ("garbage", a list, "1e9") is never taken as "no cost" // and never skipped for the invoice — the posting is refused until Inventory carries a readable cost if (!blank(u.cost) && !isFinite(uc)) return { cents: 0, source: "", bad: "cost", raw: u.cost }; if (isFinite(uc) && uc > 0) return { cents: uc, source: "unit" }; if (!blank(u.invoice) && !isFinite(ui)) return { cents: 0, source: "", bad: "invoice", raw: u.invoice }; if (isFinite(ui) && ui > 0) return { cents: ui, source: "unit invoice" }; return { cents: 0, source: "" }; } var dc = toCents(d && d.cost); if (isFinite(dc) && dc > 0) return { cents: dc, source: "deal" }; return { cents: 0, source: "" }; } /** Round 2 (attack E1b): a unit may be sold on a deal only while it is in stock. An ALLOW-list (not "anything but Sold"): blank, In Stock / Stock / Available, Hold, In Transit, Aged, Demo, Loaner, Recon, Ready, or a New / Used / Certified label an import put in the status. Anything else (Sold, Wholesaled, Delivered, Transferred, Traded, Auction, Returned …) is refused. */ var SELLABLE = /^(in ?stock|stock|available|avail|active|for ?sale|on ?lot|lot|front ?line|ready|ready ?line|aged|on ?hold|hold|held|in ?transit|transit|trans|demo|demonstrator|loaner|service ?loaner|courtesy ?(car|vehicle)?|recon|in ?recon|reconditioning|new|used|certified|cpo)$/; /* RC28 fix round (attack B1 / A3 / M1 — the reviewer's finding 1 class): ONE reading of a unit's status for every reader: the inventory reconciliation (lib/imp-calc.js unitOnLot), the deal posting (unitSellable / unit_sold), the unwind, the recon tracker and the screens. The word is folded first (cleanWord: NFKC, invisible characters dropped, look-alikes read as Latin, any case, - _ . / read as spaces). A unit has LEFT stock only when the word STARTS with one that says so — Sold, Delivered, Wholesaled, Dealer trade(d) / Traded, Transferred, Returned, Scrapped, Junked, Removed, Gone. Before RC28 the reconciliation tested those words ANYWHERE in the status, so a unit a station saved as "Pending Delivery" or "Unsold" dropped off the list while it was still owned and on the books, and the adjusting entry wrote it off. A station may only SAVE a status that is in stock (stationStatusProblem); the sold status is the deal posting's. */ var GONE_START = /^(sold|delivered|wholesaled|dealer trade|dealer traded|traded|transferred|returned|scrapped|junked|removed|gone)\b/; function unitStatusWord(u) { return foldWord(u && typeof u === "object" ? u.status : u).replace(/[\s_\-.\/]+/g, " ").trim(); } function unitGone(u) { return GONE_START.test(unitStatusWord(u)); } function unitOnLot(u) { return !!u && typeof u === "object" && !unitGone(u); } function unitSellable(u) { var s = unitStatusWord(u); if (unitGone(u)) return false; return s === "" || SELLABLE.test(s); } /** the status a STATION may save on a unit (the generic records route, the AI connector, an archive merge): blank or an in-stock word (In Stock, Hold, In Transit, Demo, Loaner, Recon …). → null | { code, why } — "gone": a sold / delivered / wholesaled … word (the deal posting's), "unknown": any other word (the reconciliation could not tell whether the unit is still owned). */ function stationStatusProblem(v) { if (v === undefined || v === null) return null; if (typeof v !== "string") return { code: "unknown", why: "is not a word" }; var s = unitStatusWord(v); if (s === "" || (!unitGone(v) && SELLABLE.test(s))) return null; return unitGone(v) ? { code: "gone", why: "says the unit has left stock" } : { code: "unknown", why: "is not an in-stock status (In Stock, Hold, In Transit, Demo, Loaner, Recon)" }; } /* ── POSTING (POST /api/deals/post) ── ctx: { unit: the inventory unit (data) or null, receipts: [{ id, amountCents, method, ref, date }] (the server's table — cash deals), payPlan: PAY_PLAN_DEFAULT unless given, acct: ACCT unless given, unitSoldTo: id of a deal that already sold the unit or null } → { ok, problems: [{ code, msg }], kind, isNew, sale: { lines }, fi: { lines } | null, cm: { lines, commissionCents, frontGrossCents } | null, figures (cents), warnings } — lines in CENTS { acct, dr, cr, desc }. No database, nothing written. */ function postPlan(d, ctx) { ctx = ctx || {}; var A = ctx.acct || ACCT, PP = ctx.payPlan || PAY_PLAN_DEFAULT, u = ctx.unit || null; var F = figures(d), c = F.c, kind = F.kind, problems = [], warnings = []; var P = function (code, msg) { problems.push({ code: code, msg: msg }); }; var buyer = String((d && d.buyer) || "").trim() || "the buyer"; var stock = String((d && d.stock) || "").trim(); // 1. the vehicle (re-checked by the server inside the transaction, on the locked unit) if (!stock) P("no_unit", "No vehicle on the deal — pick the unit in Desking first."); else if (!u) P("unit_missing", "Stock # " + stock + " is not in Inventory — stock the vehicle in (Vehicle Stock-In) before posting this deal."); else if (/^sold\b/.test(unitStatusWord(u))) P("unit_sold", "Stock # " + stock + " is already sold" + (u.soldDeal ? " (deal " + u.soldDeal + ")" : "") + " — it can't be sold twice."); // round 2 (attack I2): the unit record's status is not the only interlock — a stock number already sold on another POSTED deal is // refused even when its Inventory record was set back to In Stock by hand (reversing a posted deal is not part of this release) else if (ctx.unitSoldTo) P("unit_sold", "Stock # " + stock + " was already sold on posted deal " + ctx.unitSoldTo + " — it can't be sold twice. (Setting the unit back to In Stock in Inventory does not undo that sale — if that sale is being reversed, the office unwinds deal " + ctx.unitSoldTo + " first: Deal Backend › Unwind deal.)"); // round 2 (attack E1b): only a unit that is in stock can be sold — not one marked Wholesaled, Delivered, Transferred … else if (!unitSellable(u)) P("unit_not_in_stock", "Stock # " + stock + " is marked \"" + String(u.status).trim().slice(0, 40) + "\" in Inventory — only a unit that is in stock (In Stock, Hold, In Transit, Demo, Loaner) can be sold on a deal. If it really is on the lot, change its status in Inventory first."); if (!(c.salePrice > 0)) P("no_price", "No sale price — desk the deal first."); F.problems.forEach(function (x) { P(x.code, x.msg); }); var ca = F.ca; // RC27 (dsk): the deal's cost lines (Profit panel) costAddKindProblems(F, d).forEach(function (x) { P(x.code, x.msg); }); if (kind === "draft") P("not_pushed", "Push the deal to the Cash Deal or the Finance Deal from the desk first — a deal posts as a cash, finance or lease deal."); // 2. K2B-015 (lead decision): no posting without the vehicle's cost — no exception workflow in this release. The cost is the // UNIT's (round 2, attack I1): a deal that carries a different cost of its own is posted at the unit's cost, with a warning. var cost = vehicleCost(d, u); if (u && cost.bad) P("bad_cost", "Stock # " + stock + " carries a " + (cost.bad === "invoice" ? "invoice" : "cost") + " in Inventory that is not a dollar amount (\"" + String(cost.raw).slice(0, 30) + "\") — correct it in Inventory before posting this deal (the cost of sale, the inventory relief and the commission are figured from it)."); // RC28 (fx): the unit's pack and floorplan amount are read by the posting too (commission / payoff) — an unreadable one refuses it if (u && !blank(u.pack) && !isFinite(toCents(u.pack))) P("bad_pack", "Stock # " + stock + " carries a pack in Inventory that is not a dollar amount (\"" + String(u.pack).slice(0, 30) + "\") — correct it in Inventory before posting (the commission is figured after the pack)."); if (u && !blank(u.floorAmt) && !isFinite(toCents(u.floorAmt))) P("bad_floor", "Stock # " + stock + " carries a floorplan amount in Inventory that is not a dollar amount (\"" + String(u.floorAmt).slice(0, 30) + "\") — correct it in Inventory before posting (the floorplan payoff is figured from it)."); if (u && !cost.bad && !(cost.cents > 0)) P("no_cost", "Enter the vehicle's cost in Inventory before posting this deal (stock # " + stock + " has no cost, so the cost of sale and the inventory relief can't be booked)."); var dealCostC = toCents(d && d.cost); if (u && cost.cents > 0 && isFinite(dealCostC) && dealCostC > 0 && dealCostC !== cost.cents) warnings.push("The deal carried a vehicle cost of " + fmt(dealCostC) + "; the cost in Inventory for stock # " + stock + " (" + fmt(cost.cents) + ") was used for the cost of sale, the inventory relief and the commission."); // 3. the trade: an ACV for every trade (R5: allowance $5,000 / ACV $0 was posted locally and refused by the ledger) var acv = F.acv; if ((c.tradeAllow > 0 || c.tradePayoff > 0) && !(acv !== null && acv > 0)) P("trade_acv", "Enter the trade-in's ACV (actual cash value — what it goes into used inventory at) before posting (trade allowance " + fmt(c.tradeAllow) + ", ACV " + (acv === null ? "not entered" : fmt(acv)) + ")."); // 4. an "Other" charge has no account in the GL Posting Map (RC18 refused it on cash deals; now on every deal) if (c.otherSale > 0) P("other_unmapped", "This deal has an \"Other\" charge of " + fmt(c.otherSale) + ". The GL Posting Map has no account for \"Other\" charges, so it cannot be posted — ask your accountant which account it belongs to, or put the amount on a line that has one (price, doc fee or a product)."); // 5. the structure of its type var receipts = Array.isArray(ctx.receipts) ? ctx.receipts : []; var received = receipts.reduce(function (s, r) { return s + (r.amountCents || 0); }, 0); var paymentC = 0, cit = 0, assigned = 0, leaseRefund = 0; // RC33 (mpx): leaseRefund = paid at signing more than the lease's charges (held for the customer) if (kind === "finance") { if (blank(d.lender)) P("no_lender", "Pick the lender on the Finance Deal before posting."); if (F.financed < 0) P("negative_financed", negFinancedMsg(-F.financed)); else if (F.financed === 0) P("zero_financed", "This finance deal has $0.00 financed — push it to the Cash Deal (the customer pays in full) or enter the financing."); if (!F.apr.given) P("no_apr", "The APR is missing — open the Finance Deal, press F5 (Recalculate) and F10 (Save), then post (0 % is allowed)."); if (!F.term.ok || F.term.value < 1) P("no_term", "The term is missing — open the Finance Deal, press F5 (Recalculate) and F10 (Save), then post."); if (F.financed > 0 && Math.abs(c.amtFinanced - F.financed) > TOL) P("stale_financed", "The amount financed on the deal (" + fmt(c.amtFinanced) + ") does not match its figures (" + fmt(F.financed) + "). Open the Finance Deal, press F5 (Recalculate) and F10 (Save), then post."); if (F.financed > 0 && F.apr.ok && F.term.ok && F.term.value >= 1) { paymentC = paymentCents(F.financed, F.apr.value, F.term.value); if (Math.abs(c.payment - paymentC) > TOL) P("stale_payment", "The payment on the deal (" + fmt(c.payment) + ") does not match " + fmt(F.financed) + " at " + F.apr.value + "% for " + F.term.value + " months (" + fmt(paymentC) + "). Open the Finance Deal, press F5 (Recalculate) and F10 (Save), then post."); } cit = F.financed - ca.feeCustCents; // RC27 (dsk): a lender fee passed to the customer is in the amount financed, and the lender keeps it out of the funding if (ca.any && F.financed > 0 && ca.feeCustCents + ca.feeDealerCents >= F.financed) P("lender_fee_over", "The lender fees on the Profit panel (" + fmt(ca.feeCustCents + ca.feeDealerCents) + ") are as much as the amount financed (" + fmt(F.financed) + ") — check the lender fee lines."); } else if (kind === "cash") { var dueNet = F.totalDue - ca.dpaCents; // RC27 (dsk): the store's down payment assistance is money the customer does not pay if (F.totalDue < 0) P("negative_balance", negBalanceMsg(-F.totalDue)); else if (received < dueNet) P("balance_due", "Record the customer's payment before posting this cash deal (balance due " + fmt(dueNet - received) + (ca.dpaCents ? ", after the store's " + fmt(ca.dpaCents) + " down payment assistance" : "") + ")."); else if (received > dueNet) P("overpaid", "The payments recorded are " + fmt(received - dueNet) + " more than this cash deal's total due" + (ca.dpaCents ? " (less the store's " + fmt(ca.dpaCents) + " down payment assistance)" : "") + " — void a payment recorded by mistake, or (money really received, e.g. held since an unwind) give the extra back with Refund customer, then post."); } else if (kind === "lease") { if (blank(d.lender) && !(d.lienholder && !blank(d.lienholder.name))) P("no_lessor", "Pick the lessor on the Finance Deal before posting this lease."); } // 6. the journal (cents) — DMSCore.postDeal's lines, the RC18 rules on every type var isNew = (String((d && d.type) || dealTypeOfUnit(u) || "Used") === "New"); var saleAcct = u && u.saleAcct ? String(u.saleAcct) : (isNew ? A.saleNewCar : A.saleUsedCar); var costAcct = u && u.costAcct ? String(u.costAcct) : (isNew ? A.costNewCar : A.costUsedCar); var invAcct = u && u.invAcct ? String(u.invAcct) : (isNew ? A.invNewCar : A.invUsedCar); var lessor = (d && d.lienholder && d.lienholder.name) ? d.lienholder.name : ((d && d.lender) || "lessor"); var pre = kind === "lease" ? "LEASE — " : ""; var lines = []; var L = function (acct, dr, cr, desc) { if (dr || cr) lines.push({ acct: String(acct), dr: dr, cr: cr, desc: String(desc).slice(0, 120) }); }; var due = kind === "lease" ? (!blank(d.dueAtSigning) ? c.dueAtSigning : c.cashDown) : 0; // RC27 (dsk): where a cost line goes — FRONT: the vehicle's own cost of sale; BACK: the F&I income of the deal (a debit lowers it) var sideAcct = function (side) { return side === "back" ? (isNew ? A.fiNew : A.fiUsed) : costAcct; }; if (kind === "finance") { L(A.cit, cit, 0, "Contract in transit — " + buyer + " (" + String(d.lender || "").trim() + ")"); L(A.cashOnHand, c.cashDown - ca.dpaCents, 0, "Cash down (collected at signing) — " + buyer); } else if (kind === "cash") { receipts.forEach(function (r) { L(A.cashOnHand, r.amountCents, 0, "Cash received — " + (r.method || "payment") + (r.ref ? " #" + r.ref : "") + " " + (r.date || "") + " (" + r.id + ") — " + buyer); }); } else if (kind === "lease") { L(A.cashOnHand, due - ca.dpaCents, 0, "LEASE — cash due at signing — " + buyer); } // RC27 (dsk): down payment assistance — the store's money in place of the customer's cash (the deal still balances) ca.lines.forEach(function (l) { if (l.type === "dpa" && l.cost && l.amountCents > 0) L(sideAcct(l.side), l.amountCents, 0, pre + "Down payment assistance (the store's money toward the down payment" + (l.desc ? ": " + l.desc : "") + ") — " + buyer); }); var tradeIn = acv !== null && acv > 0; if (tradeIn) L(A.invUsedCar, acv, 0, pre + "Trade-in to inventory (ACV)"); L(saleAcct, 0, c.salePrice, pre + "Vehicle sale — " + String((d && d.vehicle) || stock)); var taxC = c.tax; if (kind === "lease") taxC = !blank(d.leaseUpfrontTax) ? c.leaseUpfrontTax : (d.leaseTaxMethod && d.leaseTaxMethod !== "monthly" ? c.tax : 0); L(A.salesTaxPay, 0, taxC, pre + (kind === "lease" ? "tax collected at signing" : "Sales tax payable")); L(A.docFee, 0, c.docFee, pre + "Doc fee income"); L(A.titlePay, 0, c.titleFee, pre + "Title/license payable"); L(A.payoffPayable, 0, c.tradePayoff, pre + "Trade payoff payable" + (d && d.tradeLender ? " — " + d.tradeLender : "")); L(A.vscPayable, 0, c.vscSale, pre + "Service contract payable"); L(A.gapPayable, 0, c.gapSale, pre + "GAP payable"); if (cost.cents > 0) { L(costAcct, cost.cents, 0, pre + "Cost of vehicle sold"); L(invAcct, 0, cost.cents, pre + "Relieve inventory — " + stock); } L(A.factoryRec, c.rebate, 0, pre + "Customer rebate — factory receivable (GM pays the store) — " + buyer); var over = tradeIn ? c.tradeAllow - acv : 0; if (over > 0) L(saleAcct, over, 0, pre + "Trade over-allowance (allowance " + fmt(c.tradeAllow) + " − ACV " + fmt(acv) + ") — reduces the sale"); else if (over < 0) L(saleAcct, 0, -over, pre + "Trade under-allowance (ACV " + fmt(acv) + " − allowance " + fmt(c.tradeAllow) + ") — adds to the sale"); var sumDr = lines.reduce(function (s, l) { return s + l.dr; }, 0), sumCr = lines.reduce(function (s, l) { return s + l.cr; }, 0); if (kind === "lease") { assigned = sumCr - sumDr; if (assigned > 0) { L(A.cit, assigned, 0, "LEASE — assignment to lessor " + lessor + " (net due from lessor)"); sumDr += assigned; } // RC33 (mpx): the due at signing, trade and rebates are MORE than the lease's charges — the extra is the customer's money, HELD on 331 // (customer deposits) by the posting and paid back with Refund customer (before RC33 the lease could not be posted at all) else if (assigned < 0) { L(A.customerDeposits, 0, -assigned, "LEASE — paid at signing more than the lease's charges: held for the customer (Refund customer pays it back)"); sumCr += -assigned; leaseRefund = -assigned; } else P("lease_refund", "This lease has nothing due from the lessor — check the lease figures on the Finance Deal."); cit = Math.max(0, assigned); } if (!problems.length && sumDr !== sumCr) P("unbalanced", "The deal entry does not balance (debits " + fmt(sumDr) + ", credits " + fmt(sumCr) + ") — nothing was posted. Check the deal's figures."); // 7. F&I income (DMSCore.postDeal: FI- entry, DR A/R 220 / CR finance income 806 | 808) and the commission accrual (CM- entry) var fi = c.fiGross > 0 ? { lines: [{ acct: A.arCustomer, dr: c.fiGross, cr: 0, desc: "F&I income — " + buyer }, { acct: isNew ? A.fiNew : A.fiUsed, dr: 0, cr: c.fiGross, desc: "F&I income — " + buyer }] } : null; var pack = PP.packCounts ? Math.max(0, toCents(u && u.pack) || 0) : 0; // RC27 (dsk): the commission is figured on the front gross AFTER the front cost lines (profitPlan — the same function as the desk's // Profit panel). With no cost lines it is exactly the RC21 figure: price − cost − pack − over-allowance. var pp = profitPlan({ priceCents: c.salePrice, costCents: cost.cents, overCents: over, packCents: pack, fiGrossCents: c.fiGross, isNew: isNew, ca: ca, payPlan: PP }); var frontGross = pp.netFrontCents; var commission = pp.commissionCents; var cm = cost.cents > 0 ? { commissionCents: commission, frontGrossCents: frontGross, packCents: pack, lines: [{ acct: isNew ? A.commExpNew : A.commExpUsed, dr: commission, cr: 0, desc: "Commission accrual — " + String((d && d.salesperson) || "") }, { acct: A.comPayable, dr: 0, cr: commission, desc: "Commission accrual — " + String((d && d.salesperson) || "") }] } : null; // 8. RC27 (dsk): the cost lines the store pays — the DCA- entry (a lender fee the store pays: the lender keeps it out of the funding → // credit 205; a dealer-paid fee / Other / F&I item: set aside to pay → credit 332) and one WOA- entry per we-owe (its estimated // cost set aside in 332 until Service does the work). Debit: the line's side (front → the vehicle's cost of sale, back → F&I income). var caLines = [], weOwes = []; ca.lines.forEach(function (l) { if (!l.cost || l.type === "dpa" || !(l.amountCents > 0)) return; var what = l.desc ? l.desc : l.label; if (l.weOwe) { weOwes.push({ lineId: l.id, k: weOwes.length + 1, desc: l.desc || l.label, type: l.type, side: l.side, debitAcct: sideAcct(l.side), reserveAcct: A.otherReserves, estimateCents: l.amountCents, lines: [{ acct: sideAcct(l.side), dr: l.amountCents, cr: 0, desc: pre + "We owe — " + what + " (estimated cost, " + l.side + ") — " + buyer }, { acct: A.otherReserves, dr: 0, cr: l.amountCents, desc: pre + "We owe — set aside until the work is done — " + what }] }); return; } if (l.type === "lenderFee") { caLines.push({ acct: sideAcct(l.side), dr: l.amountCents, cr: 0, desc: pre + "Lender fee the store pays (" + (LENDER_FEE_KINDS[l.kind] || "fee") + ", " + l.side + ")" + (l.desc ? " — " + l.desc : "") + " — " + String(d.lender || lessor || "").trim() }); caLines.push({ acct: A.cit, dr: 0, cr: l.amountCents, desc: pre + "Lender fee kept out of the funding — " + String(d.lender || lessor || "").trim() }); } else { caLines.push({ acct: sideAcct(l.side), dr: l.amountCents, cr: 0, desc: pre + l.label + " (" + l.side + ") — " + what + " — " + buyer }); caLines.push({ acct: A.otherReserves, dr: 0, cr: l.amountCents, desc: pre + l.label + " — set aside to pay — " + what }); } }); var figs = { priceCents: c.salePrice, taxCents: taxC, docCents: c.docFee, titleCents: c.titleFee, productsCents: F.products, payoffCents: c.tradePayoff, allowanceCents: c.tradeAllow, acvCents: acv, rebateCents: c.rebate, downCents: kind === "cash" ? received : (kind === "lease" ? due : c.cashDown), totalDueCents: F.totalDue, financedCents: kind === "finance" ? F.financed : (kind === "lease" ? c.amtFinanced : 0), paymentCents: kind === "finance" ? paymentC : c.payment, apr: F.apr.given ? F.apr.value : null, term: F.term.given ? F.term.value : null, receivedCents: received, balanceDueCents: kind === "cash" ? F.totalDue - ca.dpaCents - received : 0, citCents: cit - ca.feeDealerCents, costCents: cost.cents, costSource: cost.source, overAllowCents: over, fiGrossCents: c.fiGross, leaseAssignedCents: kind === "lease" ? Math.max(0, assigned) : 0, }; if (leaseRefund > 0) figs.leaseRefundCents = leaseRefund; // RC33 (mpx): only a lease that paid more at signing carries it (every other deal keeps exactly the RC32 figures) // a deal WITH cost lines records what they booked; a deal without any keeps exactly the RC26 figures if (ca.any) figs.costAdds = { lines: ca.lines.map(costAddStoreShape), dpaCents: ca.dpaCents, feeCustCents: ca.feeCustCents, feeDealerCents: ca.feeDealerCents, frontAddsCents: ca.frontCents, backAddsCents: ca.backCents, weOweCents: ca.weOweCents, accrueCents: ca.accrueCents, customerCashCents: kind === "cash" ? received : (kind === "lease" ? due : c.cashDown) - ca.dpaCents, frontGrossCents: pp.frontGrossCents, packCents: pp.packCents, netFrontCents: pp.netFrontCents, netBackCents: pp.netBackCents, totalGrossCents: pp.totalGrossCents, commissionCents: commission }; return { ok: problems.length === 0, problems: problems, warnings: warnings, kind: kind, isNew: isNew, calc: CALC_VERSION, accts: { sale: saleAcct, cost: costAcct, inv: invAcct }, sale: { lines: lines, dr: sumDr, cr: sumCr }, fi: fi, cm: cm, ca: caLines.length ? { lines: caLines } : null, weOwes: weOwes, profit: pp, figures: figs, }; } /* ── FUNDING (POST /api/deals/fund) — DMSCore.fundDealAmount: cash 202 for what arrived; a coded difference to FUND_DIFF_ACCTS; CIT 205 credited for what this funding clears. openCents = what is still in transit for the deal. input: { amountCents, diffReason?: a FUND_DIFF_ACCTS key → the rest of the CIT is coded to it, fees?: [{ reason, amountCents }] } ── */ function fundPlan(openCents, input) { var x = input || {}, problems = [], lines = []; var P = function (code, msg) { problems.push({ code: code, msg: msg }); }; var amt = x.amountCents; if (!(isFinite(amt) && amt > 0)) P("bad_amount", "Enter the amount the lender paid — dollars and cents, more than $0.00."); var fees = Array.isArray(x.fees) ? x.fees : []; var feeTotal = 0; fees.forEach(function (f, i) { if (!f || !FUND_DIFF_ACCTS[f.reason]) P("bad_reason", "Fee " + (i + 1) + ": pick what the difference is (" + Object.keys(FUND_DIFF_ACCTS).join(", ") + ")."); else if (!(isFinite(f.amountCents) && f.amountCents > 0)) P("bad_amount", "Fee " + (i + 1) + " (" + f.reason + "): enter an amount more than $0.00."); else feeTotal += f.amountCents; }); if (x.diffReason !== undefined && x.diffReason !== null && x.diffReason !== "" && !FUND_DIFF_ACCTS[x.diffReason]) P("bad_reason", "Pick what the difference is (" + Object.keys(FUND_DIFF_ACCTS).join(", ") + ")."); if (problems.length) return { ok: false, problems: problems }; var cleared = amt + feeTotal, overpaid = 0; if (cleared > openCents) { // RC33 (mpx): an over-funding is HELD (the lender's money, owed back — ACCT.lenderOverfund) when the office says so (overpay: "hold"), // then paid back with Funding › Lender refund; without it the funding is refused as before if (x.overpay !== "hold" || feeTotal || (x.diffReason !== undefined && x.diffReason !== null && x.diffReason !== "")) return { ok: false, problems: [{ code: "over_funded", msg: "The lender paid " + fmt(amt) + (feeTotal ? " + " + fmt(feeTotal) + " coded" : "") + ", which is " + fmt(cleared - openCents) + " more than the " + fmt(openCents) + " still in contracts in transit for this deal. Check the amount — or, if the lender really over-funded, hold the extra for a refund to the lender (no coded fees with it)." }] }; overpaid = cleared - openCents; cleared = openCents; } var coded = fees.map(function (f) { return { reason: f.reason, acct: FUND_DIFF_ACCTS[f.reason].acct, amountCents: f.amountCents }; }); var gap = openCents - cleared; if (gap > 0 && x.diffReason) { coded.push({ reason: x.diffReason, acct: FUND_DIFF_ACCTS[x.diffReason].acct, amountCents: gap }); cleared += gap; gap = 0; } lines.push({ acct: ACCT.cashBank, dr: amt, cr: 0, desc: "Lender funding received" }); coded.forEach(function (f) { lines.push({ acct: f.acct, dr: f.amountCents, cr: 0, desc: f.reason }); }); lines.push({ acct: ACCT.cit, dr: 0, cr: cleared, desc: "Contracts in transit cleared" }); if (overpaid) lines.push({ acct: ACCT.lenderOverfund, dr: 0, cr: overpaid, desc: "Lender over-funding — held until it is refunded to the lender" }); return { ok: true, problems: [], lines: lines, amountCents: amt, coded: coded, clearedCents: cleared, openAfterCents: openCents - cleared, cleared: openCents - cleared === 0, overpaidCents: overpaid }; } /* ── CORRECTIONS (RC23, lane corr — spec 4.2 "Deal correct/reverse", 6.5): a cancelled F&I product on a POSTED deal ── POST /api/deals/product-cancel. A deal's sold products: deal.products (FinanceWorksheet / DealBackend: { id, code, name, category, provider, price | sale, cost }), or — an older deal with only the lumped figures — the VSC / GAP lines FinanceWorksheet itself synthesises (ids P-VSC-LEGACY / GAP). The cancellation entry (cents): DR F&I income 806 new / 808 used the dealer's chargeback (the income share the post booked, given back) DR product payable 302 / 302G the provider's part of the refund (refund − chargeback: what the provider no longer gets / gives back) CR 331 customer deposits the refund owed TO THE CUSTOMER (cash deal / loan paid off) — paid out with Refund customer or CR 303 payoffs payable the refund owed TO THE LENDER as a payoff reduction (financed) — paid with a check to the lender The salesperson's product bonus of the pay plan (VSC / GAP / appearance) is charged back (a commission chargeback row, no journal: the post never accrued product bonuses — only the front-gross commission). Pure: no database. */ var REFUND_ACCT = { "Cash": "cashOnHand", "Card": "cashBank", "Wire / ACH": "cashBank", "Other": "cashBank" }; // a refund's credit by method; "Check" = the check command (the bank of Check Setup) var isGapProduct = function (p) { return /\bgap\b/i.test(String((p && (p.category || "")) + " " + (p && (p.code || "")) + " " + (p && (p.name || "")))); }; var isAppearanceProduct = function (p) { return /appearance|paint|fabric|interior ?protect/i.test(String((p && (p.category || "")) + " " + (p && (p.name || "")))); }; /** The deal's sold products (normalised, cents): [{ id, name, code, category, priceCents, costCents, shareCents, gap, index, cancelled, cancelledOn, legacy }] */ function productsOf(d) { var out = [], seen = {}; var list = Array.isArray(d && d.products) ? d.products.filter(function (p) { return p && typeof p === "object"; }) : []; if (list.length) { list.forEach(function (p, i) { var id = String(p.id || p.code || ("P" + (i + 1))).trim().slice(0, 60) || ("P" + (i + 1)); if (seen[id]) id = id + "-" + (i + 1); seen[id] = 1; var pr = toCents(!blank(p.price) ? p.price : p.sale), co = toCents(p.cost); pr = isFinite(pr) ? pr : 0; co = isFinite(co) ? co : 0; out.push({ id: id, name: String(p.name || p.code || "Product").slice(0, 80), code: String(p.code || ""), category: String(p.category || p.cat || ""), priceCents: pr, costCents: co, shareCents: Math.max(0, pr - co), gap: isGapProduct(p), appearance: isAppearanceProduct(p), index: i, cancelled: p.cancelled === true, cancelledOn: p.cancelledOn || null, legacy: false }); }); return out; } var vs = toCents(d && d.vscSale), vc = toCents(d && d.vscCost), gs = toCents(d && d.gapSale), gc = toCents(d && d.gapCost); if (isFinite(vs) && vs > 0) out.push({ id: "P-VSC-LEGACY", name: "SERVICE CONTRACT", code: "VSC", category: "Service Contract", priceCents: vs, costCents: isFinite(vc) ? vc : 0, shareCents: Math.max(0, vs - (isFinite(vc) ? vc : 0)), gap: false, appearance: false, index: -1, cancelled: false, cancelledOn: null, legacy: true }); if (isFinite(gs) && gs > 0) out.push({ id: "GAP", name: "GAP", code: "GAP", category: "GAP", priceCents: gs, costCents: isFinite(gc) ? gc : 0, shareCents: Math.max(0, gs - (isFinite(gc) ? gc : 0)), gap: true, appearance: false, index: -1, cancelled: false, cancelledOn: null, legacy: true }); return out; } /** x: { productId, refundCents, chargebackCents, refundTo: 'customer' | 'lender', isNew, heldAcct, payPlan, acct, booked?: { incomeCents, incomeAcct, vscCents, gapCents } — what the posting booked, less earlier cancellations (the server) } → { ok, problems, product, lines (cents), incomeAcct, payableAcct, creditAcct, providerCents, bonusCents } */ function productCancelPlan(d, x) { x = x || {}; var A = x.acct || ACCT, PP = x.payPlan || PAY_PLAN_DEFAULT, problems = []; var P = function (code, msg) { problems.push({ code: code, msg: msg }); }; var list = productsOf(d); var prod = null; for (var i = 0; i < list.length; i++) if (list[i].id === String(x.productId || "")) prod = list[i]; if (!prod) { P("no_product", "Product \"" + String(x.productId || "").slice(0, 60) + "\" is not on this deal" + (list.length ? " (its products: " + list.map(function (p) { return p.id + " " + p.name; }).join(", ") + ")." : " — it has no F&I products.")); return { ok: false, problems: problems }; } if (prod.cancelled) { P("already_cancelled", prod.name + " was already cancelled" + (prod.cancelledOn ? " on " + prod.cancelledOn : "") + " — a product is cancelled once."); return { ok: false, problems: problems, product: prod }; } var R = x.refundCents, cb = x.chargebackCents; if (R === "decimals" || cb === "decimals") P("bad_amount", "An amount has more than two decimals — enter dollars and cents."); else { if (!(isFinite(R) && R >= 0)) P("bad_amount", "Enter the customer refund the provider computed — dollars and cents ($0.00 if none)."); if (!(isFinite(cb) && cb >= 0)) P("bad_amount", "Enter the dealer chargeback — the dealership's share of the refund ($0.00 if none)."); } if (!problems.length) { if (R > MAX_CENTS) P("too_large", "The refund is larger than the DMS allows ($10,000,000)."); else if (R > prod.priceCents) P("refund_over_price", "The customer refund (" + fmt(R) + ") is more than " + prod.name + "'s price on the deal (" + fmt(prod.priceCents) + ")."); else if (cb > R) P("chargeback_over_refund", "The dealer chargeback (" + fmt(cb) + ") can't be more than the customer refund (" + fmt(R) + ") — the chargeback is the dealership's share of that refund."); else if (cb > prod.shareCents) P("chargeback_over_income", "The dealer chargeback (" + fmt(cb) + ") is more than the income this deal booked on " + prod.name + " (" + fmt(prod.shareCents) + " = price " + fmt(prod.priceCents) + " − cost " + fmt(prod.costCents) + ")."); else if (R - cb > prod.costCents) P("provider_over_cost", "The provider's part of the refund (" + fmt(R - cb) + " = refund − chargeback) is more than " + prod.name + "'s cost (" + fmt(prod.costCents) + ") — check the chargeback amount."); // corr round 2 (attack C7): never more than the POSTING booked (x.booked — the server's figures, less earlier cancellations): the // product list alone is not what is on the books else if (x.booked && cb > Math.max(0, x.booked.incomeCents)) P("chargeback_over_booked", "The dealer chargeback (" + fmt(cb) + ") is more than the F&I income this deal's posting booked and has not given back yet (" + fmt(Math.max(0, x.booked.incomeCents)) + "). The deal's product list does not match what was posted — check the amounts, or ask the office to look at the deal."); else if (x.booked && R - cb > Math.max(0, prod.gap ? x.booked.gapCents : x.booked.vscCents)) P("provider_over_booked", "The provider's part of the refund (" + fmt(R - cb) + ") is more than this deal's posting booked to " + (prod.gap ? "GAP payable " + A.gapPayable : "service-contract payable " + A.vscPayable) + " and has not given back yet (" + fmt(Math.max(0, prod.gap ? x.booked.gapCents : x.booked.vscCents)) + "). The deal's product list does not match what was posted — check the amounts, or ask the office to look at the deal."); } var to = String(x.refundTo || ""); if (to !== "customer" && to !== "lender") P("bad_refund_to", "Say who gets the refund: the customer (cash deal or loan paid off) or the lender (a payoff reduction on the contract)."); var incomeAcct = (x.booked && x.booked.incomeAcct) || (x.isNew ? A.fiNew : A.fiUsed), payableAcct = prod.gap ? A.gapPayable : A.vscPayable; var creditAcct = to === "lender" ? A.payoffPayable : String(x.heldAcct || A.customerDeposits); var bonusDollars = prod.gap ? PP.gapBonus : (prod.appearance ? PP.appearanceBonus : PP.vscBonus); var bonus = Math.max(0, Math.round((+bonusDollars || 0) * 100)); var lines = []; if (!problems.length) { if (cb > 0) lines.push({ acct: incomeAcct, dr: cb, cr: 0, desc: "Product cancelled — dealer chargeback (F&I income given back) — " + prod.name }); if (R - cb > 0) lines.push({ acct: payableAcct, dr: R - cb, cr: 0, desc: "Product cancelled — provider's part of the refund — " + prod.name }); if (R > 0) lines.push({ acct: creditAcct, dr: 0, cr: R, desc: (to === "lender" ? "Refund to the lender (payoff reduction) — " : "Refund owed to the customer (held) — ") + prod.name }); } return { ok: problems.length === 0, problems: problems, product: prod, lines: lines, incomeAcct: incomeAcct, payableAcct: payableAcct, creditAcct: creditAcct, refundCents: R, chargebackCents: cb, providerCents: (isFinite(R) && isFinite(cb)) ? R - cb : 0, bonusCents: bonus, refundTo: to }; } /** a reversing entry's lines: debits and credits swapped (cents) */ function mirrorLines(lines) { return (lines || []).map(function (l) { return { acct: l.acct, dr: l.cr || 0, cr: l.dr || 0, desc: "Reverse: " + String(l.desc || "") }; }); } /* ── receipts (POST /api/deals/receipt): one payment the customer actually made on a CASH deal ── */ /** "YYYY-MM-DD" or "MM/DD/YYYY" → { y, m, d } | null */ function parseDay(s) { var t = String(s === undefined || s === null ? "" : s).trim(), m, y, mo, d; if ((m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(t))) { y = +m[1]; mo = +m[2]; d = +m[3]; } else if ((m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(t))) { mo = +m[1]; d = +m[2]; y = +m[3]; } else return null; if (y < 2000 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > new Date(Date.UTC(y, mo, 0)).getUTCDate()) return null; return { y: y, m: mo, d: d }; } var isoDay = function (p) { return p.y + "-" + String(p.m).padStart(2, "0") + "-" + String(p.d).padStart(2, "0"); }; /** The cash-deal figures the screens show: total due, received (non-void receipts), balance — in cents. */ function cashFigures(d, receipts) { var F = figures(d); var list = Array.isArray(receipts) ? receipts : (d && Array.isArray(d.receipts) ? d.receipts.filter(function (r) { return r && !r.void; }).map(function (r) { return { amountCents: toCents(r.amount) }; }) : []); var rec = list.reduce(function (s, r) { return s + (isFinite(r.amountCents) ? r.amountCents : 0); }, 0); // RC27 (dsk): the store's down payment assistance is money the customer does not pay — the balance due is net of it var out = { totalDueCents: F.totalDue, receivedCents: rec, balanceDueCents: F.totalDue - F.dpaCents - rec }; if (F.dpaCents) { out.dpaCents = F.dpaCents; out.customerDueCents = F.totalDue - F.dpaCents; } return out; } function sumSides(lines) { var dr = 0, cr = 0; (lines || []).forEach(function (l) { dr += l.dr || 0; cr += l.cr || 0; }); return { dr: dr, cr: cr, balanced: dr === cr }; } /** Journal lines in cents → the stored shape (dollars, 2 decimals). */ function toJournalLines(lines) { return (lines || []).filter(function (l) { return l.dr || l.cr; }).map(function (l) { var o = { acct: l.acct, dr: dollars(l.dr), cr: dollars(l.cr) }; if (l.desc) o.desc = String(l.desc).slice(0, 120); return o; }); } return { CALC_VERSION: CALC_VERSION, ACCT: ACCT, FUND_DIFF_ACCTS: FUND_DIFF_ACCTS, RECEIPT_METHODS: RECEIPT_METHODS, PAY_PLAN_DEFAULT: PAY_PLAN_DEFAULT, MAX_CENTS: MAX_CENTS, TOL: TOL, MONEY: MONEY, toCents: toCents, strictCents: strictCents, dollars: dollars, fmt: fmt, kindOf: kindOf, isLease: isLease, isPostedDeal: isPostedDeal, acvOf: acvOf, figures: figures, paymentCents: paymentCents, saveProblems: saveProblems, dealTypeOfUnit: dealTypeOfUnit, vehicleCost: vehicleCost, unitSellable: unitSellable, postPlan: postPlan, fundPlan: fundPlan, parseDay: parseDay, isoDay: isoDay, cashFigures: cashFigures, sumSides: sumSides, toJournalLines: toJournalLines, REFUND_ACCT: REFUND_ACCT, productsOf: productsOf, productCancelPlan: productCancelPlan, mirrorLines: mirrorLines, isGapProduct: isGapProduct, COST_ADD_TYPES: COST_ADD_TYPES, COST_ADD_ORDER: COST_ADD_ORDER, LENDER_FEE_KINDS: LENDER_FEE_KINDS, MAX_COST_ADDS: MAX_COST_ADDS, // RC27 (dsk) costAddsOf: costAddsOf, costAddStoreShape: costAddStoreShape, profitPlan: profitPlan, costAddKindProblems: costAddKindProblems, // RC28 (fx): one normalised state predicate; the server-stored pay plan cleanWord: cleanWord, foldWord: foldWord, markSet: markSet, isFundedWord: isFundedWord, claimsPosted: claimsPosted, claimsFunded: claimsFunded, // RC28 fix round: plain state words, ONE on-lot predicate, the unwound test stateWordProblem: stateWordProblem, unitStatusWord: unitStatusWord, unitGone: unitGone, unitOnLot: unitOnLot, stationStatusProblem: stationStatusProblem, isUnwoundDeal: isUnwoundDeal, PAY_PLAN_FIELDS: PAY_PLAN_FIELDS, PAY_PLAN_STORED_DEFAULT: PAY_PLAN_STORED_DEFAULT, payPlanCalc: payPlanCalc, payPlanProblems: payPlanProblems, }; });