Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions docs/tally/TALLY_PROTOCOL_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1261,10 +1261,48 @@ changes the buckets. On a bill dated 1-May-26 with a 30-day credit period, due 3
Both are correct. Neither is "the" answer, so **any tool computing ageing must state which
basis it used.**

`BILLCREDITPERIOD` tags appear in the ordinary wildcard voucher fetch — 19 occurrences in a
single partition capture of a book that sets none. That establishes serialization of the empty
field only, not the usable value or format of a configured credit period. Capture and read back
a nonzero period before claiming that due-date ageing needs no request-shape change.
Licensed-gateway capture/read-back evidence on **2026-08-23** establishes these accepted units:

| entered value | returned `BILLCREDITPERIOD` |
| --- | --- |
| `2 Months` | `2 Months` |
| `3 Weeks` | `3 Weeks` |
| `1 Day` | `1 Days` |

The same licensed TallyPrime 7.1 import/read-back on **2026-08-23** measured the
day-unit persistence ceiling and confirms that it is not a shared ceiling for
other units:

| entered value | returned `BILLCREDITPERIOD` |
| --- | --- |
| `365 Days` | `365 Days` |
| `3650 Days` | `3650 Days` |
| `9999 Days` | `9999 Days` |
| `10000 Days` | empty |
| `100 Months` | `100 Months` |
| `1000 Months` | `1000 Months` |

Tally silently discards a day count above `9999` to an **empty** credit period.
That is not neutral: due-date ageing then uses the bill date as the due date,
which can move the bill to a different ageing bucket. The parser therefore
rejects day counts above the measured `9999` ceiling, but does not invent a
matching weeks or months ceiling; those units are constrained by checked
resulting-date arithmetic instead.

The voucher parser accepts explicit `N Day(s)`, `N Week(s)`, and `N Month(s)` forms only. It
rejects an unknown unit rather than guessing a day count. Weeks add exactly seven days each;
months use a calendar-month operation that preserves the day of month when possible and otherwise
clamps to the target month's last day. The same licensed-gateway evidence measured:

| bill date and period | due date |
| --- | --- |
| 15-Jan-26 + 4 Weeks | 12-Feb-26 |
| 30-Jan-26 + 1 Month | 28-Feb-26 |
| 31-Jan-26 + 1 Month | 28-Feb-26 |
| 31-Mar-26 + 2 Months | 31-May-26 |

2026 is not a leap year. The 29-Feb clamp is implemented from the Gregorian calendar rule but was
not directly observed in this capture.

**`BILLOVERDUE` in the native report is not usable as an ageing oracle.** Its as-of is
Tally's own period date, not the caller's, and it does **not** follow the `F6` setting:
Expand Down
2 changes: 1 addition & 1 deletion docs/tally/compatibility/compatibility-matrix.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e",
"compatibility_surface_sha256": "c26058d0396d7fe63d18e43e0d15f49ef5e10019f1088513c7cd3ee4d3977933",
"compatibility_surface_sha256": "a4b79de59c448ee291b02dc5a660e069c65f2e9b9218e6ea0a9c20eaf378c367",
"claims": [
{
"claim_id": "erp9-6-6-3-windows-education-xml-one-company",
Expand Down
24 changes: 12 additions & 12 deletions docs/tally/compatibility/compatibility-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
},
{
"path": "src-tauri/crates/bridge-tally-primitives/src/lib.rs",
"sha256": "48469d58b3a169d4cf34b80cfc28c491bb8d930cccbba847736520b72b78c130"
"sha256": "cc28ce7ba675e7db3efd418f7215244b5f9a563b80a90846dbb67515b9a76512"
},
{
"path": "src-tauri/crates/bridge-tally-protocol/Cargo.toml",
Expand Down Expand Up @@ -171,19 +171,19 @@
},
{
"path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/model.rs",
"sha256": "a62437fb389b3c2908bdbbc70bbd1bc50dadca232257484bda0fcf7dc9bed6d7"
"sha256": "308f1694ce35587c31f85c0f55bec78f7d59d818d62da6302213b1e80c9ecc2f"
},
{
"path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/parser.rs",
"sha256": "be3c2e63a531b63a39c8fc2a072b6c0cd3f3327bcda6268b37a224a7ea163b3c"
"sha256": "ad6f2c7b033947bdc476f94c917abe9b018bb821ce9f6b12f883bf8784488732"
},
{
"path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/request.rs",
"sha256": "07818e208476f2b81d452643cda7383750c0b573b7350e46ae5a842f7f530b27"
},
{
"path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/wire.rs",
"sha256": "c181efb3ac013543a40fe78a6e1fb900021ca25c443d034cda49f2639f8f9006"
"sha256": "bfefafd5e7c6e106ca1fccd42240f68a5d6c18762fc3d74eb0e725baf525683c"
},
{
"path": "src-tauri/crates/bridge-tally-protocol/src/outstandings_shared.rs",
Expand Down Expand Up @@ -247,7 +247,7 @@
},
{
"path": "src-tauri/src/commands.rs",
"sha256": "90c7ba9036384bfb8bffb82e7f87b59c2cbf7f44a92f9216a720a0ab2fd8f44d"
"sha256": "b0f1607b947b13066bb9030632bfe9f0c05ea9d710b007c4e4ff37a2a9ecd25c"
},
{
"path": "src-tauri/src/db/encrypted.rs",
Expand Down Expand Up @@ -363,15 +363,15 @@
},
{
"path": "src-tauri/src/tally/mod.rs",
"sha256": "e89a2f07121984cc4d5975bbaafc40865ea95fb0ee0a98c3f72ed3773e26f4ca"
"sha256": "8c98424344229cc81dd75e4f77977c8cf754a9adac69308566b28bcce8d30dc2"
},
{
"path": "src-tauri/src/tally/outstandings_runtime.rs",
"sha256": "afe1f27c9696e5c13bd95afe81959946d6bc9002545b4a9fabed218f238b6a8b"
},
{
"path": "src-tauri/src/tally/runtime.rs",
"sha256": "1cfbbbe99f301736f74f02f5c154c879f35437b45f1b870993562a5154909880"
"sha256": "760394d90156acb720a2a377959a8c356d5fc4061612b9e4d2d772ce374631a2"
},
{
"path": "src-tauri/src/tally/serial_queue.rs",
Expand All @@ -395,11 +395,11 @@
},
{
"path": "src/AllClientsScreen.tsx",
"sha256": "96d5fb74131379ea808d6fc66827a3fd3191b6ba7e0a67bcb91c08c187bbbf72"
"sha256": "f4cd60e3e777c71daa5eff330574a1988606f012da92133fd4b4f5ed8a27d8b7"
},
{
"path": "src/OutstandingsScreen.tsx",
"sha256": "a0f470b148be06af61dfac233989d482e4751589512a6c1f7047d01ccc91e305"
"sha256": "d743d03cc9eac7223a1d8dae807463aaf6505c2244c2e09d8680b8db6ef300f9"
},
{
"path": "src/TallyReadinessFlow.tsx",
Expand All @@ -411,7 +411,7 @@
},
{
"path": "src/outstandings-as-of.ts",
"sha256": "56d6452ad228b0e1177c50df4a133263b61bb545850769fb84fde41e5da09eb0"
"sha256": "d584d1d41414b5a7a2a31efb5d5cd7fd7db35e81415b1aaef82d7f111a1e8cf0"
},
{
"path": "src/outstandings-copy.ts",
Expand All @@ -423,7 +423,7 @@
},
{
"path": "src/styles.css",
"sha256": "b102be4dcdcbb197394e7cb85bbb3843f87861674c524dbece34a29e97d7843e"
"sha256": "eb68d453a222ba7901eccd1a317f6e2a16b1d4e172ecfa937d50ff51980d3020"
},
{
"path": "src/tally-company-selection.ts",
Expand Down Expand Up @@ -478,5 +478,5 @@
"sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a"
}
],
"manifest_sha256": "c26058d0396d7fe63d18e43e0d15f49ef5e10019f1088513c7cd3ee4d3977933"
"manifest_sha256": "a4b79de59c448ee291b02dc5a660e069c65f2e9b9218e6ea0a9c20eaf378c367"
}
9 changes: 8 additions & 1 deletion scripts/outstandings-as-of.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ test("the single-company request emits the selected canonical as-of date", () =>
{ host: "127.0.0.1", port: 9000 },
{ name: "Bridge Validation Lab", guid: "guid-1" },
"2026-08-17",
"bill_date",
),
{
request: {
Expand All @@ -127,10 +128,11 @@ test("the single-company request emits the selected canonical as-of date", () =>
expected_company_guid: "guid-1",
currency_assertion: "INR",
as_of_yyyymmdd: "20260817",
ageing_anchor: "bill_date",
},
},
);
assert.equal(singleCompanyOutstandingsInvokeArgument({ host: "127.0.0.1", port: 9000 }, { name: "Lab", guid: "guid-1" }, "2026-8-17"), null);
assert.equal(singleCompanyOutstandingsInvokeArgument({ host: "127.0.0.1", port: 9000 }, { name: "Lab", guid: "guid-1" }, "2026-8-17", "due_date"), null);
});

test("compare clients emits the same selected canonical as-of date", () => {
Expand All @@ -142,6 +144,7 @@ test("compare clients emits the same selected canonical as-of date", () => {
{ name: "Bridge Ageing Lab", guid: "guid-2" },
],
"2026-08-17",
"bill_date",
),
{
request: {
Expand All @@ -152,6 +155,7 @@ test("compare clients emits the same selected canonical as-of date", () => {
],
currency_assertion: "INR",
as_of_yyyymmdd: "20260817",
ageing_anchor: "bill_date",
},
},
);
Expand All @@ -160,6 +164,7 @@ test("compare clients emits the same selected canonical as-of date", () => {
test("Excel and PDF statement builders emit the report's actual as-of date", () => {
const result = {
report: { company_name: "Bridge Validation Lab", as_of_yyyymmdd: "20260801" },
ageing_anchor: "bill_date",
statement_open_bills: [{ party: "Alpha", amount: "1" }],
statement_unallocated_by_party: [{ party: "Alpha", amount: "2" }],
};
Expand All @@ -172,6 +177,7 @@ test("Excel and PDF statement builders emit the report's actual as-of date", ()
as_of_yyyymmdd: "20260801",
party: "Alpha",
format: "xlsx",
ageing_anchor: "bill_date",
open_bills: [{ party: "Alpha", amount: "1" }],
unallocated_by_party: [{ party: "Alpha", amount: "2" }],
},
Expand All @@ -184,6 +190,7 @@ test("Excel and PDF statement builders emit the report's actual as-of date", ()
as_of_yyyymmdd: "20260801",
destination: "/tmp/statements",
format: "pdf",
ageing_anchor: "bill_date",
open_bills: [{ party: "Alpha", amount: "1" }],
unallocated_by_party: [{ party: "Alpha", amount: "2" }],
},
Expand Down
113 changes: 113 additions & 0 deletions src-tauri/crates/bridge-tally-primitives/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,54 @@ impl TallyDate {
"{previous_year:04}{previous_month:02}{previous_day:02}"
))
}

/// Adds calendar days in constant time. This deliberately does not step
/// one day at a time: callers can be parsing an untrusted wire magnitude.
pub fn add_days(&self, days: u32) -> Result<Self, TallyError> {
let (year, month, day) = self.parts()?;
let target = civil_day(year, month, day)
.checked_add(i64::from(days))
.ok_or_else(|| invalid_data("tally_date_overflow"))?;
let (year, month, day) = civil_date(target);
if !(1..=9999).contains(&year) {
return Err(invalid_data("tally_date_overflow"));
}
Self::parse(format!("{year:04}{month:02}{day:02}"))
}

/// Adds calendar months, clamping a month-end source date to the last
/// valid day of the target month. Thus 31-Jan + 1 month is 28-Feb (or
/// 29-Feb in a leap year), rather than an implicit fixed-day conversion.
pub fn add_months_clamped(&self, months: u32) -> Result<Self, TallyError> {
let (year, month, day) = self.parts()?;
let month_index = month
.checked_sub(1)
.and_then(|value| value.checked_add(months))
.ok_or_else(|| invalid_data("tally_date_overflow"))?;
let target_year = year
.checked_add(month_index / 12)
.filter(|value| *value <= 9999)
.ok_or_else(|| invalid_data("tally_date_overflow"))?;
let target_month = month_index % 12 + 1;
let target_day = day.min(
gregorian_month_days(target_year, target_month)
.ok_or_else(|| invalid_data("invalid_tally_date"))?,
);
Self::parse(format!("{target_year:04}{target_month:02}{target_day:02}"))
}

fn parts(&self) -> Result<(u32, u32, u32), TallyError> {
let year = self.0[0..4]
.parse::<u32>()
.map_err(|_| invalid_data("invalid_tally_date"))?;
let month = self.0[4..6]
.parse::<u32>()
.map_err(|_| invalid_data("invalid_tally_date"))?;
let day = self.0[6..8]
.parse::<u32>()
.map_err(|_| invalid_data("invalid_tally_date"))?;
Ok((year, month, day))
}
}

impl<'de> Deserialize<'de> for TallyDate {
Expand Down Expand Up @@ -229,3 +277,68 @@ fn invalid_data(code: &'static str) -> TallyError {
code: code.to_string(),
}
}

fn civil_day(year: u32, month: u32, day: u32) -> i64 {
let year = i64::from(year) - i64::from(month <= 2);
let era = year.div_euclid(400);
let year_of_era = year - era * 400;
let shifted_month = i64::from(month) + if month > 2 { -3 } else { 9 };
let day_of_year = (153 * shifted_month + 2) / 5 + i64::from(day) - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era
}

fn civil_date(days: i64) -> (i64, i64, i64) {
let era = days.div_euclid(146_097);
let day_of_era = days - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += i64::from(month <= 2);
(year, month, day)
}

#[cfg(test)]
mod tests {
use super::TallyDate;

#[test]
fn adding_calendar_months_clamps_to_the_target_month_end() {
assert_eq!(
TallyDate::parse("20260131")
.unwrap()
.add_months_clamped(1)
.unwrap()
.as_str(),
"20260228"
);
assert_eq!(
TallyDate::parse("20240131")
.unwrap()
.add_months_clamped(1)
.unwrap()
.as_str(),
"20240229"
);
}

#[test]
fn adding_days_is_constant_time_calendar_arithmetic() {
assert_eq!(
TallyDate::parse("20260228")
.unwrap()
.add_days(1)
.unwrap()
.as_str(),
"20260301"
);
assert!(TallyDate::parse("20260101")
.unwrap()
.add_days(u32::MAX)
.is_err());
}
}
Loading