From 9ec3d392f986b616636e0d1fbe1ccc77d5027355 Mon Sep 17 00:00:00 2001 From: clawedassistant26 <307253840+clawedassistant26@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:22:25 +0000 Subject: [PATCH] fix(rates): treat a bare flat fee as a project fee, not per-hour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rate spec with a price and no stated period ("$5000", "250 USDC") is a flat project fee. parseRate's own comment says so and warns that defaulting it to per-hour "would silently multiply the invoice by every hour tracked" — but the guard set rate.per = "hour" instead of "project", the exact bug the comment describes. Because the default is already "hour", the guard was a no-op: parseRate("$5000") returned per: "hour", so chargeFor scaled it by tracked time. Over a 10-hour job a $5,000 project fee billed as $50,000. With the fix it returns per: "project" -> flat, billed once by the invoice. Regression test asserts a bare fee (fiat and crypto) parses as a project fee and that an explicit period is left untouched. Full suite green. --- src/rates.mjs | 2 +- test/rates.test.mjs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/rates.mjs b/src/rates.mjs index fcdde8d..fbf2dc3 100644 --- a/src/rates.mjs +++ b/src/rates.mjs @@ -139,7 +139,7 @@ export function parseRate(spec) { // A flat fee with no period stated is a project fee, not an hourly one: "$5000 // for the project" is how it is written, and defaulting it to per-hour would // silently multiply the invoice by every hour tracked. - if (!sawPeriod && rate.unit === "flat" && rate.cap === null) rate.per = "hour"; + if (!sawPeriod && rate.unit === "flat" && rate.cap === null) rate.per = "project"; if (rate.cap !== null && rate.unit === "flat") { throw new Error("upto: caps a unit, so say what it caps — $100/hour/agent/upto:4"); } diff --git a/test/rates.test.mjs b/test/rates.test.mjs index 64c1694..4c5d973 100644 --- a/test/rates.test.mjs +++ b/test/rates.test.mjs @@ -87,6 +87,21 @@ test("periods convert, and a floor rounds up before the multiply", () => { assert.equal(chargeFor({ seconds: 15 * 60 }, floored).hours, 0.25, "the tracked time is still the truth"); }); +test("a bare flat fee is a project fee, not an hourly one", () => { + // A lone price with no period stated ("$5000 for the project") must not fall + // through to the per-hour default: chargeFor would then multiply it by every + // tracked hour, turning a $5,000 project fee into a $50,000 invoice over a + // 10-hour job. It is charged once, like any other project fee. + const bare = parseRate("$5000"); + assert.equal(bare.per, "project", "a bare flat fee defaults to a project fee"); + const charge = chargeFor({ seconds: 10 * 3600 }, bare); + assert.equal(charge.flat, true, "billed once, not per hour"); + assert.equal(charge.amount, null, "the invoice adds the fee, chargeFor does not scale it"); + // A crypto flat fee reads the same way, and an explicit period still wins. + assert.equal(parseRate("250 USDC").per, "project"); + assert.equal(parseRate("$100/hour").per, "hour", "a stated period is untouched"); +}); + test("a flat project fee is not earned per entry", () => { const project = parseRate("$5000/project"); const charge = chargeFor({ seconds: 3600 }, project);