feat(settings): invoicing, bills and contact settings (P4b S4.2)
Eleven settings the invoice and bill workflow reads: the number prefix and first number, default terms, payment methods with their bank details and QR links, a footer, the unsent and reminder day counts, the bill reminder and discount-warning day counts, and the company's email and website that the seller snapshot prints. Three new kinds keep them exact: a count is a positive whole number, terms are canonicalized through Terms so "net 30" is stored once as "Net 30" (B-39), and a payment method list admits at most eight methods whose lines are at most 120 characters. A routing number is nine digits, so an ach or wire method's bank-detail lines are the one text exempt from the full-TIN check (A-3); every other text in a method is scanned. The keys need the documents_v2 feature (D-52): an older version doesn't know them, and the settings replay refuses an unknown key as newer. The constant, the known-features entry and the gate test land with this slice (its 8.0.6 work); S4.3 adds the document write call sites and must not redeclare them. A settings file now exports the [invoicing] and [bills] tables, a payment method as an inline table (never a [[payment_methods]] section that would land outside its table), and imports them back to the same values. The unknown-key test uses fax instead of website, which is now a setting.
This commit is contained in:
1 parent
6995ebfee6
commit
1fa77cff02
5 files changed
+591
-24
No files matched your search
@@ -349,23 +349,53 @@ pub fn receipt_policy(s: &Settings) -> ReceiptPolicy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A scalar as TOML writes it (strings escaped and quoted the TOML way).
|
||||||
|
fn toml_scalar(v: toml::Value) -> String {
|
||||||
|
let mut t = toml::Table::new();
|
||||||
|
t.insert("x".to_string(), v);
|
||||||
|
let out = toml::to_string(&t).unwrap_or_default();
|
||||||
|
let out = out.trim_end();
|
||||||
|
out.strip_prefix("x = ").unwrap_or(out).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `v` on one line, as TOML's inline form: a table is `{ k = v }`, so a
|
||||||
|
/// list of payment methods stays inside its table instead of becoming
|
||||||
|
/// `[[payment_methods]]` sections under a later table.
|
||||||
|
fn inline_toml(v: &Value) -> String {
|
||||||
|
match v {
|
||||||
|
Value::String(s) => toml_scalar(toml::Value::String(s.clone())),
|
||||||
|
Value::Bool(b) => b.to_string(),
|
||||||
|
Value::Number(n) => n.to_string(),
|
||||||
|
Value::Array(a) => format!(
|
||||||
|
"[{}]",
|
||||||
|
a.iter().map(inline_toml).collect::<Vec<_>>().join(", ")
|
||||||
|
),
|
||||||
|
Value::Object(o) => format!(
|
||||||
|
"{{ {} }}",
|
||||||
|
o.iter()
|
||||||
|
.map(|(k, v)| format!("{} = {}", toml_key(k), inline_toml(v)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
),
|
||||||
|
Value::Null => "\"\"".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A TOML key: bare when it can be, quoted when it can't.
|
||||||
|
fn toml_key(k: &str) -> String {
|
||||||
|
if !k.is_empty()
|
||||||
|
&& k.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
{
|
||||||
|
k.to_string()
|
||||||
|
} else {
|
||||||
|
toml_scalar(toml::Value::String(k.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One TOML line `name = value`, the value spelled as TOML.
|
/// One TOML line `name = value`, the value spelled as TOML.
|
||||||
fn toml_line(name: &str, v: &Value) -> String {
|
fn toml_line(name: &str, v: &Value) -> String {
|
||||||
let tv = match v {
|
format!("{name} = {}\n", inline_toml(v))
|
||||||
Value::String(s) => toml::Value::String(s.clone()),
|
|
||||||
Value::Array(a) => toml::Value::Array(
|
|
||||||
a.iter()
|
|
||||||
.map(|x| toml::Value::String(x.as_str().unwrap_or_default().to_string()))
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
other => toml::Value::String(other.to_string()),
|
|
||||||
};
|
|
||||||
let mut t = toml::Table::new();
|
|
||||||
t.insert(name.to_string(), tv);
|
|
||||||
toml::to_string(&t)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim_end()
|
|
||||||
.to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn provenance(out: &mut String, r: &settings::Recorded) {
|
fn provenance(out: &mut String, r: &settings::Recorded) {
|
||||||
@@ -397,7 +427,14 @@ pub fn export(s: &Settings, base_currency: &str) -> String {
|
|||||||
out.push_str(
|
out.push_str(
|
||||||
"# The books keep their own settings: this file is a copy, and editing it\n# changes nothing (books.set_setting changes them).\n",
|
"# The books keep their own settings: this file is a copy, and editing it\n# changes nothing (books.set_setting changes them).\n",
|
||||||
);
|
);
|
||||||
for table in ["company", "books"] {
|
for table in ["company", "books", "invoicing", "bills"] {
|
||||||
|
// The invoicing and bills tables are written only once a key is
|
||||||
|
// set: an empty table says nothing.
|
||||||
|
if !["company", "books"].contains(&table)
|
||||||
|
&& !s.iter().any(|(k, _)| k.toml.0 == table)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let _ = write!(out, "\n[{table}]\n");
|
let _ = write!(out, "\n[{table}]\n");
|
||||||
if table == "company" {
|
if table == "company" {
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
@@ -537,9 +574,65 @@ open_at_start = "ask"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// INVARIANT 4 for policy.require_tax_category (rules.md B-32): it is
|
/// INVARIANT 4 for the invoicing settings (P4b S4.2): the two new
|
||||||
/// `[books] require_tax_category` in a file, and round-trips; a file
|
/// tables export, a payment method with its bank details, its link and
|
||||||
/// without it clears it back to warn.
|
/// its QR flag included, and the file imports as no change at all.
|
||||||
|
#[test]
|
||||||
|
fn the_invoicing_settings_round_trip() {
|
||||||
|
let file = format!(
|
||||||
|
"{FERNHILL}\n[invoicing]\n\
|
||||||
|
number_prefix = \"ACME-\"\n\
|
||||||
|
first_number = 1000\n\
|
||||||
|
default_terms = \"2/10 net 30\"\n\
|
||||||
|
footer = \"Thank you.\"\n\
|
||||||
|
unsent_days = 2\n\
|
||||||
|
reminder_days = 14\n\
|
||||||
|
payment_methods = [{{kind = \"ach\", label = \"Bank transfer\", lines = [\"Routing 123456789\"]}}, {{kind = \"link\", label = \"Card\", lines = [], link = \"https://pay.example/acme\", qr = true}}]\n\
|
||||||
|
\n[bills]\n\
|
||||||
|
remind_days = 7\n\
|
||||||
|
discount_warn_days = 3\n"
|
||||||
|
);
|
||||||
|
let s = imported(&file);
|
||||||
|
assert_eq!(
|
||||||
|
s.get("invoicing.first_number").unwrap().value,
|
||||||
|
json!(1000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.get("invoicing.default_terms").unwrap().value,
|
||||||
|
json!("2/10 net 30")
|
||||||
|
);
|
||||||
|
let text = export(&s, "USD");
|
||||||
|
assert!(text.contains("[invoicing]"), "{text}");
|
||||||
|
assert!(text.contains("[bills]"), "{text}");
|
||||||
|
assert!(text.contains("payment_methods = ["), "{text}");
|
||||||
|
assert!(text.contains("qr = true"), "{text}");
|
||||||
|
let back = import(&text, None, &s, "USD").unwrap();
|
||||||
|
assert!(back.changes.is_empty(), "{:?}\n{text}", back.changes);
|
||||||
|
assert!(back.unknown.is_empty(), "{:?}", back.unknown);
|
||||||
|
// Into empty books: the same values, in one spelling.
|
||||||
|
let fresh = import(&text, None, &Settings::default(), "USD").unwrap();
|
||||||
|
let mut other = Settings::default();
|
||||||
|
other.apply(&fresh.changes, &src(), 1).unwrap();
|
||||||
|
for name in [
|
||||||
|
"invoicing.number_prefix",
|
||||||
|
"invoicing.first_number",
|
||||||
|
"invoicing.default_terms",
|
||||||
|
"invoicing.footer",
|
||||||
|
"invoicing.unsent_days",
|
||||||
|
"invoicing.reminder_days",
|
||||||
|
"invoicing.payment_methods",
|
||||||
|
"bills.remind_days",
|
||||||
|
"bills.discount_warn_days",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
other.get(name).map(|r| &r.value),
|
||||||
|
s.get(name).map(|r| &r.value),
|
||||||
|
"{name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_tax_category_policy_round_trips() {
|
fn the_tax_category_policy_round_trips() {
|
||||||
let file = FERNHILL.replace(
|
let file = FERNHILL.replace(
|
||||||
@@ -582,13 +675,13 @@ open_at_start = "ask"
|
|||||||
#[test]
|
#[test]
|
||||||
fn unknown_and_currency() {
|
fn unknown_and_currency() {
|
||||||
let i = import(
|
let i = import(
|
||||||
"[company]\nwebsite = \"x\"\n[extra]\na = 1\n",
|
"[company]\nfax = \"x\"\n[extra]\na = 1\n",
|
||||||
None,
|
None,
|
||||||
&Settings::default(),
|
&Settings::default(),
|
||||||
"USD",
|
"USD",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(i.unknown, ["[company] website", "[extra] a"]);
|
assert_eq!(i.unknown, ["[company] fax", "[extra] a"]);
|
||||||
assert!(
|
assert!(
|
||||||
import(
|
import(
|
||||||
"[company]\nbase_currency = \"EUR\"\n",
|
"[company]\nbase_currency = \"EUR\"\n",
|
||||||
|
|||||||
@@ -70,6 +70,25 @@ fn candidates() -> Vec<(&'static str, Value)> {
|
|||||||
("policy.evidence.exempt_accounts", json!(["6999", "6998"])),
|
("policy.evidence.exempt_accounts", json!(["6999", "6998"])),
|
||||||
("policy.parties", json!("require")),
|
("policy.parties", json!("require")),
|
||||||
("policy.approval_rules", json!("enforce")),
|
("policy.approval_rules", json!("enforce")),
|
||||||
|
// P4b S4.2: invoicing, bills and contact details.
|
||||||
|
("invoicing.number_prefix", json!("ACME-")),
|
||||||
|
("invoicing.first_number", json!(1000)),
|
||||||
|
("invoicing.default_terms", json!("2/10 net 30")),
|
||||||
|
("invoicing.default_terms", json!("Net 15 EOM")),
|
||||||
|
(
|
||||||
|
"invoicing.payment_methods",
|
||||||
|
json!([{"kind": "ach", "label": "Bank transfer",
|
||||||
|
"lines": ["Routing 123456789", "Account 000123456789"]},
|
||||||
|
{"kind": "link", "label": "Card", "lines": [],
|
||||||
|
"link": "https://pay.example/acme", "qr": true}]),
|
||||||
|
),
|
||||||
|
("invoicing.footer", json!("Thank you for your business.")),
|
||||||
|
("invoicing.unsent_days", json!(2)),
|
||||||
|
("invoicing.reminder_days", json!(14)),
|
||||||
|
("bills.remind_days", json!(7)),
|
||||||
|
("bills.discount_warn_days", json!(3)),
|
||||||
|
("company.email", json!("billing@acme.example")),
|
||||||
|
("company.website", json!("https://acme.example")),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,15 @@
|
|||||||
//! 5. Unset keys mean the defaults the settings file had: "Unnamed
|
//! 5. Unset keys mean the defaults the settings file had: "Unnamed
|
||||||
//! company", a calendar year, accrual basis, receipts above 25.00,
|
//! company", a calendar year, accrual basis, receipts above 25.00,
|
||||||
//! parties suggested, a missing tax category warned of (not
|
//! parties suggested, a missing tax category warned of (not
|
||||||
//! refused), approval rules off, the computer's time zone.
|
//! refused), approval rules off, the computer's time zone; and, for
|
||||||
|
//! invoicing, the prefix "INV-", the first number 1, "Net 30", and
|
||||||
|
//! day counts of 2, 14, 7 and 3 (P4b S4.2).
|
||||||
|
//! 6. Payment terms keep one spelling ([`crate::Terms::describe`]):
|
||||||
|
//! "net 30" is stored as "Net 30", never as it was typed.
|
||||||
|
//! 7. A payment method's bank-detail lines (an `ach` or `wire` method's
|
||||||
|
//! `lines`) are the one text the full-TIN check doesn't scan, because
|
||||||
|
//! a routing number is nine digits (A-3); every other text in the
|
||||||
|
//! list is scanned.
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use crate::TaxForm;
|
use crate::TaxForm;
|
||||||
@@ -129,6 +137,13 @@ pub enum Kind {
|
|||||||
/// A folder named from the root of its disk: one line, an absolute
|
/// A folder named from the root of its disk: one line, an absolute
|
||||||
/// path.
|
/// path.
|
||||||
Folder,
|
Folder,
|
||||||
|
/// A positive whole number (a day count, the first invoice number).
|
||||||
|
Count,
|
||||||
|
/// Payment terms, in their one canonical spelling ([`crate::Terms`],
|
||||||
|
/// rules.md B-39).
|
||||||
|
Terms,
|
||||||
|
/// Payment methods (A-3): a list of `{kind, label, lines, link?, qr?}`.
|
||||||
|
PaymentMethods,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One setting the books know.
|
/// One setting the books know.
|
||||||
@@ -398,6 +413,84 @@ pub static KEYS: &[Key] = &[
|
|||||||
sensitivity: Sensitivity::Public,
|
sensitivity: Sensitivity::Public,
|
||||||
doc: "A folder the books back themselves up to as well (D-30): an absolute path, an external disk or a synced folder; the backup is a repository in it named for the book. Unset: none.",
|
doc: "A folder the books back themselves up to as well (D-30): an absolute path, an external disk or a synced folder; the backup is a repository in it named for the book. Unset: none.",
|
||||||
},
|
},
|
||||||
|
// P4b S4.2: invoicing, bills and the company's contact details.
|
||||||
|
Key {
|
||||||
|
name: "invoicing.number_prefix",
|
||||||
|
toml: ("invoicing", "number_prefix"),
|
||||||
|
kind: Kind::Text,
|
||||||
|
sensitivity: Sensitivity::Public,
|
||||||
|
doc: "The prefix of an invoice number (\"INV-\" gives INV-0001): 1 to 12 visible ASCII characters. Unset: \"INV-\".",
|
||||||
|
},
|
||||||
|
Key {
|
||||||
|
name: "invoicing.first_number",
|
||||||
|
toml: ("invoicing", "first_number"),
|
||||||
|
kind: Kind::Count,
|
||||||
|
sensitivity: Sensitivity::Public,
|
||||||
|
doc: "The number the first numbered invoice takes (the series starts there): a positive whole number, settable only while no numbered invoice exists. Unset: 1.",
|
||||||
|
},
|
||||||
|
Key {
|
||||||
|
name: "invoicing.default_terms",
|
||||||
|
toml: ("invoicing", "default_terms"),
|
||||||
|
kind: Kind::Terms,
|
||||||
|
sensitivity: Sensitivity::Public,
|
||||||
|
doc: "The terms an invoice takes when neither the invoice nor the customer gives any (\"Net 30\", \"Net 15 EOM\", \"2/10 net 30\", \"Due on receipt\"). Unset: \"Net 30\".",
|
||||||
|
},
|
||||||
|
Key {
|
||||||
|
name: "invoicing.payment_methods",
|
||||||
|
toml: ("invoicing", "payment_methods"),
|
||||||
|
kind: Kind::PaymentMethods,
|
||||||
|
sensitivity: Sensitivity::Public,
|
||||||
|
doc: "The ways the company takes payment, printed on an invoice (A-3): [{kind: ach|wire|link|check|p2p|crypto|other, label, lines, link?, qr?}], at most 8; a bank-detail line of an ach or wire method may hold a routing number. Unset: none.",
|
||||||
|
},
|
||||||
|
Key {
|
||||||
|
name: "invoicing.footer",
|
||||||
|
toml: ("invoicing", "footer"),
|
||||||
|
kind: Kind::LongText,
|
||||||
|
sensitivity: Sensitivity::Public,
|
||||||
|
doc: "Text printed at the foot of each invoice (thank-you, payment note). Unset: none.",
|
||||||
|
},
|
||||||
|
Key {
|
||||||
|
name: "invoicing.unsent_days",
|
||||||
|
toml: ("invoicing", "unsent_days"),
|
||||||
|
kind: Kind::Count,
|
||||||
|
sensitivity: Sensitivity::Public,
|
||||||
|
doc: "How many days after issue an invoice nobody marked sent is listed as unsent: a positive whole number. Unset: 2.",
|
||||||
|
},
|
||||||
|
Key {
|
||||||
|
name: "invoicing.reminder_days",
|
||||||
|
toml: ("invoicing", "reminder_days"),
|
||||||
|
kind: Kind::Count,
|
||||||
|
sensitivity: Sensitivity::Public,
|
||||||
|
doc: "How many days after a reminder an overdue invoice is reminded again: a positive whole number. Unset: 14.",
|
||||||
|
},
|
||||||
|
Key {
|
||||||
|
name: "bills.remind_days",
|
||||||
|
toml: ("bills", "remind_days"),
|
||||||
|
kind: Kind::Count,
|
||||||
|
sensitivity: Sensitivity::Public,
|
||||||
|
doc: "A bill due within this many days is listed as due soon: a positive whole number. Unset: 7.",
|
||||||
|
},
|
||||||
|
Key {
|
||||||
|
name: "bills.discount_warn_days",
|
||||||
|
toml: ("bills", "discount_warn_days"),
|
||||||
|
kind: Kind::Count,
|
||||||
|
sensitivity: Sensitivity::Public,
|
||||||
|
doc: "A bill whose early-payment discount ends within this many days is listed: a positive whole number. Unset: 3.",
|
||||||
|
},
|
||||||
|
Key {
|
||||||
|
name: "company.email",
|
||||||
|
toml: ("company", "email"),
|
||||||
|
kind: Kind::Text,
|
||||||
|
sensitivity: Sensitivity::Public,
|
||||||
|
doc: "The company's email address, printed on an invoice as the seller's.",
|
||||||
|
},
|
||||||
|
Key {
|
||||||
|
name: "company.website",
|
||||||
|
toml: ("company", "website"),
|
||||||
|
kind: Kind::Text,
|
||||||
|
sensitivity: Sensitivity::Public,
|
||||||
|
doc: "The company's website, printed on an invoice as the seller's.",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The key named `name`.
|
/// The key named `name`.
|
||||||
@@ -688,6 +781,15 @@ fn no_tin(what: &str, s: &str) -> Result<(), String> {
|
|||||||
/// One line of text: trimmed non-empty, at most `max` characters, no line
|
/// One line of text: trimmed non-empty, at most `max` characters, no line
|
||||||
/// breaks or other control characters, no full TIN.
|
/// breaks or other control characters, no full TIN.
|
||||||
pub fn line_text(what: &str, s: &str, max: usize) -> Result<String, String> {
|
pub fn line_text(what: &str, s: &str, max: usize) -> Result<String, String> {
|
||||||
|
let t = bare_line_text(what, s, max)?;
|
||||||
|
no_tin(what, &t)?;
|
||||||
|
Ok(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`line_text`] without the full-TIN check: for the one place a nine-digit
|
||||||
|
/// number is legitimate (an `ach` or `wire` payment method's bank-detail
|
||||||
|
/// line, where it is a routing number: A-3).
|
||||||
|
fn bare_line_text(what: &str, s: &str, max: usize) -> Result<String, String> {
|
||||||
let t = s.trim();
|
let t = s.trim();
|
||||||
if t.is_empty() {
|
if t.is_empty() {
|
||||||
return Err(format!("{what} is blank"));
|
return Err(format!("{what} is blank"));
|
||||||
@@ -700,7 +802,6 @@ pub fn line_text(what: &str, s: &str, max: usize) -> Result<String, String> {
|
|||||||
"{what} holds a line break or other control character"
|
"{what} holds a line break or other control character"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
no_tin(what, t)?;
|
|
||||||
Ok(t.to_string())
|
Ok(t.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,13 +922,127 @@ fn want_list<'a>(name: &str, v: &'a Value) -> Result<&'a Vec<Value>, String> {
|
|||||||
.ok_or_else(|| format!("{name} takes a list, not {v}"))
|
.ok_or_else(|| format!("{name} takes a list, not {v}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The payment-method kinds A-3 allows.
|
||||||
|
const PAYMENT_KINDS: &[&str] = &["ach", "wire", "link", "check", "p2p", "crypto", "other"];
|
||||||
|
|
||||||
|
/// A positive whole number: a JSON number, or the digits a settings file
|
||||||
|
/// writes it as.
|
||||||
|
fn count_of(name: &str, v: &Value) -> Result<u32, String> {
|
||||||
|
let n = match v {
|
||||||
|
Value::Number(n) => n.as_u64().and_then(|n| u32::try_from(n).ok()),
|
||||||
|
Value::String(s) => {
|
||||||
|
let s = s.trim();
|
||||||
|
(s.bytes().all(|b| b.is_ascii_digit()) && !s.is_empty())
|
||||||
|
.then(|| s.parse::<u32>().ok())
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
n.filter(|n| *n >= 1)
|
||||||
|
.ok_or_else(|| format!("{name} {v}: expected a positive whole number (1 or more)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `qr` flag: a boolean, or the word a settings file writes it as.
|
||||||
|
fn flag_of(name: &str, v: &Value) -> Result<bool, String> {
|
||||||
|
match v {
|
||||||
|
Value::Bool(b) => Ok(*b),
|
||||||
|
Value::String(s) if s == "true" => Ok(true),
|
||||||
|
Value::String(s) if s == "false" => Ok(false),
|
||||||
|
_ => Err(format!("{name} {v}: expected true or false")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `invoicing.payment_methods` in its canonical form (A-3): at most 8
|
||||||
|
/// methods, each `{kind, label, lines, link?, qr?}`. An `ach` or `wire`
|
||||||
|
/// method's `lines` are its bank details, where a nine-digit routing
|
||||||
|
/// number is legitimate; every other text in the list is scanned for a
|
||||||
|
/// full TIN like every other setting.
|
||||||
|
fn payment_methods(name: &str, list: &[Value]) -> Result<Value, String> {
|
||||||
|
if list.len() > 8 {
|
||||||
|
return Err(format!("{name}: at most 8 payment methods"));
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (i, m) in list.iter().enumerate() {
|
||||||
|
let n = format!("{name} method {}", i + 1);
|
||||||
|
let o = m
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| format!("{n}: expected {{kind, label, lines}}"))?;
|
||||||
|
for k in o.keys() {
|
||||||
|
if !["kind", "label", "lines", "link", "qr"].contains(&k.as_str()) {
|
||||||
|
return Err(format!("{n}: unknown field {k:?}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let kind = o
|
||||||
|
.get("kind")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|k| k.trim().to_lowercase())
|
||||||
|
.filter(|k| PAYMENT_KINDS.contains(&k.as_str()))
|
||||||
|
.ok_or_else(|| format!("{n}: kind is one of {}", PAYMENT_KINDS.join(", ")))?;
|
||||||
|
let label = line_text(
|
||||||
|
&format!("{n} label"),
|
||||||
|
o.get("label").and_then(Value::as_str).unwrap_or_default(),
|
||||||
|
MAX_TEXT,
|
||||||
|
)?;
|
||||||
|
let lines = o
|
||||||
|
.get("lines")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or_else(|| format!("{n}: lines is a list of strings"))?;
|
||||||
|
let bank_details = matches!(kind.as_str(), "ach" | "wire");
|
||||||
|
let mut checked_lines = Vec::new();
|
||||||
|
for l in lines {
|
||||||
|
let text = l
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| format!("{n}: each line is a string"))?;
|
||||||
|
// A routing number is nine digits: bank-detail lines are the
|
||||||
|
// one text the TIN check doesn't scan (A-3). Length and
|
||||||
|
// control characters are still checked.
|
||||||
|
let text = if bank_details {
|
||||||
|
bare_line_text(&format!("{n} line"), text, 120)?
|
||||||
|
} else {
|
||||||
|
line_text(&format!("{n} line"), text, 120)?
|
||||||
|
};
|
||||||
|
checked_lines.push(json!(text));
|
||||||
|
}
|
||||||
|
let mut method = serde_json::Map::new();
|
||||||
|
method.insert("kind".into(), json!(kind));
|
||||||
|
method.insert("label".into(), json!(label));
|
||||||
|
method.insert("lines".into(), Value::Array(checked_lines));
|
||||||
|
if let Some(link) = o.get("link").filter(|l| !l.is_null()) {
|
||||||
|
let link = line_text(
|
||||||
|
&format!("{n} link"),
|
||||||
|
want_str(&format!("{n} link"), link)?,
|
||||||
|
MAX_TEXT,
|
||||||
|
)?;
|
||||||
|
method.insert("link".into(), json!(link));
|
||||||
|
}
|
||||||
|
if let Some(qr) = o.get("qr").filter(|q| !q.is_null()) {
|
||||||
|
method.insert("qr".into(), json!(flag_of(&format!("{n} qr"), qr)?));
|
||||||
|
}
|
||||||
|
out.push(Value::Object(method));
|
||||||
|
}
|
||||||
|
Ok(Value::Array(out))
|
||||||
|
}
|
||||||
|
|
||||||
/// Check a value for `key` before it is journaled, and return it in its
|
/// Check a value for `key` before it is journaled, and return it in its
|
||||||
/// canonical spelling (INVARIANT 1). `null` is never a value: clearing a
|
/// canonical spelling (INVARIANT 1). `null` is never a value: clearing a
|
||||||
/// key is its own change.
|
/// key is its own change.
|
||||||
pub fn check(key: &Key, v: &Value) -> Result<Value, String> {
|
pub fn check(key: &Key, v: &Value) -> Result<Value, String> {
|
||||||
let name = key.name;
|
let name = key.name;
|
||||||
match key.kind {
|
match key.kind {
|
||||||
Kind::Text => Ok(json!(line_text(name, want_str(name, v)?, MAX_TEXT)?)),
|
Kind::Text => {
|
||||||
|
let text = line_text(name, want_str(name, v)?, MAX_TEXT)?;
|
||||||
|
// One key's own bound: an invoice number's prefix is short and
|
||||||
|
// visible (a space or a control character would break the
|
||||||
|
// number's reading, and the file's).
|
||||||
|
if name == "invoicing.number_prefix"
|
||||||
|
&& (text.chars().count() > 12 || !text.chars().all(|c| c.is_ascii_graphic()))
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"{name} {text:?}: 1 to 12 visible ASCII characters (no spaces)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(json!(text))
|
||||||
|
}
|
||||||
Kind::LongText => Ok(json!(long_text(name, want_str(name, v)?, MAX_LONG_TEXT)?)),
|
Kind::LongText => Ok(json!(long_text(name, want_str(name, v)?, MAX_LONG_TEXT)?)),
|
||||||
Kind::Choice(choices) => {
|
Kind::Choice(choices) => {
|
||||||
let s = want_str(name, v)?.trim().to_lowercase();
|
let s = want_str(name, v)?.trim().to_lowercase();
|
||||||
@@ -879,6 +1094,13 @@ pub fn check(key: &Key, v: &Value) -> Result<Value, String> {
|
|||||||
Ok(json!(out))
|
Ok(json!(out))
|
||||||
}
|
}
|
||||||
Kind::Members => Ok(members_json(&check_members(want_list(name, v)?)?)),
|
Kind::Members => Ok(members_json(&check_members(want_list(name, v)?)?)),
|
||||||
|
Kind::Count => Ok(json!(count_of(name, v)?)),
|
||||||
|
Kind::Terms => {
|
||||||
|
let s = want_str(name, v)?.trim();
|
||||||
|
let terms = crate::Terms::parse(s).map_err(|e| format!("{name}: {e}"))?;
|
||||||
|
Ok(json!(terms.describe()))
|
||||||
|
}
|
||||||
|
Kind::PaymentMethods => payment_methods(name, want_list(name, v)?),
|
||||||
Kind::TimeZone => {
|
Kind::TimeZone => {
|
||||||
let s = want_str(name, v)?.trim();
|
let s = want_str(name, v)?.trim();
|
||||||
let zone = jiff::tz::TimeZone::get(s).map_err(|_| {
|
let zone = jiff::tz::TimeZone::get(s).map_err(|_| {
|
||||||
@@ -1005,6 +1227,29 @@ pub fn shape(key: &Key, v: &Value) -> Result<(), String> {
|
|||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.ok_or_else(|| format!("{name} {s:?} is not a decimal"))
|
.ok_or_else(|| format!("{name} {s:?} is not a decimal"))
|
||||||
}
|
}
|
||||||
|
Kind::Count => count_of(name, v).map(|_| ()),
|
||||||
|
Kind::PaymentMethods => {
|
||||||
|
for (i, m) in want_list(name, v)?.iter().enumerate() {
|
||||||
|
let n = format!("{name} method {}", i + 1);
|
||||||
|
let o = m
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| format!("{n}: expected an object"))?;
|
||||||
|
let field = |f: &str| o.get(f).ok_or_else(|| format!("{n}: no {f}"));
|
||||||
|
want_str(&format!("{n} kind"), field("kind")?)?;
|
||||||
|
want_str(&format!("{n} label"), field("label")?)?;
|
||||||
|
let lines = want_list(&format!("{n} lines"), field("lines")?)?;
|
||||||
|
for l in lines {
|
||||||
|
want_str(&format!("{n} line"), l)?;
|
||||||
|
}
|
||||||
|
if let Some(link) = o.get("link").filter(|l| !l.is_null()) {
|
||||||
|
want_str(&format!("{n} link"), link)?;
|
||||||
|
}
|
||||||
|
if o.get("qr").is_some_and(|q| !q.is_boolean() && !q.is_null()) {
|
||||||
|
return Err(format!("{n} qr: expected true or false"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Kind::MonthStart => parse_month_start_lenient(want_str(name, v)?).map(|_| ()),
|
Kind::MonthStart => parse_month_start_lenient(want_str(name, v)?).map(|_| ()),
|
||||||
_ => want_str(name, v).map(|_| ()),
|
_ => want_str(name, v).map(|_| ()),
|
||||||
}
|
}
|
||||||
@@ -1530,6 +1775,11 @@ pub fn display(key: &Key, v: &Value, full: bool) -> String {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", "),
|
.join(", "),
|
||||||
|
Value::Array(a) if key.kind == Kind::PaymentMethods => a
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| m.get("label").and_then(Value::as_str))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
Value::Array(a) => a
|
Value::Array(a) => a
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(Value::as_str)
|
.filter_map(Value::as_str)
|
||||||
|
|||||||
@@ -417,3 +417,184 @@ fn dates_take_one_spelling() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------ P4b S4.2: the new kinds
|
||||||
|
|
||||||
|
/// `Kind::Count`: a positive whole number, in one canonical JSON number
|
||||||
|
/// (a settings file writes it as digits; the journal keeps the number).
|
||||||
|
#[test]
|
||||||
|
fn counts_are_positive_whole_numbers() {
|
||||||
|
let days = |v: Value| checked("invoicing.unsent_days", v);
|
||||||
|
assert_eq!(days(json!(2)), Ok(json!(2)));
|
||||||
|
assert_eq!(days(json!("14")), Ok(json!(14)));
|
||||||
|
assert_eq!(
|
||||||
|
checked("invoicing.first_number", json!(1000)),
|
||||||
|
Ok(json!(1000))
|
||||||
|
);
|
||||||
|
for bad in [
|
||||||
|
json!(0),
|
||||||
|
json!(-1),
|
||||||
|
json!("0"),
|
||||||
|
json!("-2"),
|
||||||
|
json!(1.5),
|
||||||
|
json!("2.0"),
|
||||||
|
json!("x"),
|
||||||
|
json!(true),
|
||||||
|
json!(null),
|
||||||
|
] {
|
||||||
|
assert!(days(bad.clone()).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
// Every day-count key takes the same kind.
|
||||||
|
for k in [
|
||||||
|
"invoicing.first_number",
|
||||||
|
"invoicing.unsent_days",
|
||||||
|
"invoicing.reminder_days",
|
||||||
|
"bills.remind_days",
|
||||||
|
"bills.discount_warn_days",
|
||||||
|
] {
|
||||||
|
assert_eq!(checked(k, json!(7)), Ok(json!(7)), "{k}");
|
||||||
|
assert!(checked(k, json!(0)).is_err(), "{k}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Kind::Terms`: canonicalized through [`ledger_core::Terms`], so two
|
||||||
|
/// spellings of one agreement are one value (rules.md B-39).
|
||||||
|
#[test]
|
||||||
|
fn terms_keep_one_canonical_spelling() {
|
||||||
|
let terms = |s: &str| checked("invoicing.default_terms", json!(s));
|
||||||
|
assert_eq!(terms("net 30"), Ok(json!("Net 30")));
|
||||||
|
assert_eq!(terms(" NET 15 EOM "), Ok(json!("Net 15 EOM")));
|
||||||
|
assert_eq!(terms("2/10 net 30"), Ok(json!("2/10 net 30")));
|
||||||
|
assert_eq!(terms("Due On Receipt"), Ok(json!("Due on receipt")));
|
||||||
|
assert_eq!(terms("due 2026-11-15"), Ok(json!("Due 2026-11-15")));
|
||||||
|
assert_eq!(terms("2.5/10 net 30"), Ok(json!("2.5/10 net 30")));
|
||||||
|
for bad in [
|
||||||
|
"net",
|
||||||
|
"Net 4000",
|
||||||
|
"Net -5",
|
||||||
|
"2/40 net 30",
|
||||||
|
"0/10 net 30",
|
||||||
|
"2/10 net 15 eom",
|
||||||
|
"2/10 net 30 extra",
|
||||||
|
"Due 9999-13-01",
|
||||||
|
"Due 2026-1-5",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(terms(bad).is_err(), "{bad:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Kind::PaymentMethods` (A-3): a routing number is nine digits, so an
|
||||||
|
/// `ach` or `wire` method's bank-detail lines are the one text exempt from
|
||||||
|
/// the full-TIN check; every other text in the list is scanned. At most
|
||||||
|
/// eight methods, each line at most 120 characters.
|
||||||
|
#[test]
|
||||||
|
fn payment_methods_scan_every_text_but_ach_and_wire_lines() {
|
||||||
|
let methods = |v: Value| checked("invoicing.payment_methods", v);
|
||||||
|
for kind in ["ach", "wire"] {
|
||||||
|
let got = methods(json!([{
|
||||||
|
"kind": kind,
|
||||||
|
"label": "Bank transfer",
|
||||||
|
"lines": ["Routing 123456789", "Account 000123456789"],
|
||||||
|
}]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(got[0]["lines"][0], json!("Routing 123456789"), "{kind}");
|
||||||
|
assert_eq!(got[0]["lines"][1], json!("Account 000123456789"), "{kind}");
|
||||||
|
}
|
||||||
|
for bad in [
|
||||||
|
json!([{"kind": "link", "label": "123456789", "lines": []}]),
|
||||||
|
json!([{"kind": "link", "label": "Pay here", "lines": ["123456789"]}]),
|
||||||
|
json!([{"kind": "check", "label": "Check", "lines": ["pay to 123456789"]}]),
|
||||||
|
json!([{"kind": "other", "label": "Cash", "lines": [], "link": "123456789"}]),
|
||||||
|
] {
|
||||||
|
assert!(methods(bad.clone()).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
// Canonical: the kind's spelling and the texts are trimmed.
|
||||||
|
assert_eq!(
|
||||||
|
methods(json!([{"kind": "ACH", "label": " Bank ", "lines": [" 123456789 "], "qr": true}]))
|
||||||
|
.unwrap(),
|
||||||
|
json!([{"kind": "ach", "label": "Bank", "lines": ["123456789"], "qr": true}])
|
||||||
|
);
|
||||||
|
// Unknown kind and unknown field are refused.
|
||||||
|
assert!(methods(json!([{"kind": "barter", "label": "X", "lines": []}])).is_err());
|
||||||
|
assert!(
|
||||||
|
methods(json!([{"kind": "other", "label": "X", "lines": [], "note": "y"}])).is_err()
|
||||||
|
);
|
||||||
|
// At most eight methods.
|
||||||
|
let pool = |n: usize| {
|
||||||
|
Value::Array(
|
||||||
|
(0..n)
|
||||||
|
.map(|i| json!({"kind": "other", "label": format!("Method {i}"), "lines": []}))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert!(methods(pool(8)).is_ok());
|
||||||
|
assert!(methods(pool(9)).is_err());
|
||||||
|
// Each line at most 120 characters.
|
||||||
|
let long = "x".repeat(121);
|
||||||
|
assert!(
|
||||||
|
methods(json!([{"kind": "other", "label": "M", "lines": [long]}])).is_err(),
|
||||||
|
"121 characters"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `invoicing.number_prefix` is 1 to 12 visible ASCII characters: a number
|
||||||
|
/// with a space or a control character in it reads wrong, and an empty
|
||||||
|
/// prefix is no prefix.
|
||||||
|
#[test]
|
||||||
|
fn number_prefix_is_short_visible_ascii() {
|
||||||
|
let prefix = |s: &str| checked("invoicing.number_prefix", json!(s));
|
||||||
|
for ok in ["INV-", "A", "2026/", "ABCDEFGHIJKL"] {
|
||||||
|
assert_eq!(prefix(ok), Ok(json!(ok)), "{ok:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(prefix(" INV- "), Ok(json!("INV-")));
|
||||||
|
for bad in ["", " ", "ABCDEFGHIJKLM", "INV 1", "INV\t1", "Ünï"] {
|
||||||
|
assert!(prefix(bad).is_err(), "{bad:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The company's contact details are simple company keys, so the seller
|
||||||
|
/// snapshot reads them through `Company::profile`.
|
||||||
|
#[test]
|
||||||
|
fn contact_details_are_in_the_company_profile() {
|
||||||
|
let s = settings(&[
|
||||||
|
("company.name", json!("Acme LLC")),
|
||||||
|
("company.email", json!("billing@acme.example")),
|
||||||
|
("company.website", json!("https://acme.example")),
|
||||||
|
]);
|
||||||
|
let profile: Vec<(String, String)> = s.company("USD").profile;
|
||||||
|
assert!(
|
||||||
|
profile.contains(&("email".into(), "billing@acme.example".into())),
|
||||||
|
"{profile:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
profile.contains(&("website".into(), "https://acme.example".into())),
|
||||||
|
"{profile:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay checks the new kinds by shape, never by a later version's
|
||||||
|
/// bounds: a count is a positive whole number, terms any string, and a
|
||||||
|
/// method list of objects with their fields (rules.md B-28).
|
||||||
|
#[test]
|
||||||
|
fn replay_shapes_the_new_kinds() {
|
||||||
|
let count = key("invoicing.unsent_days").unwrap();
|
||||||
|
assert!(shape(count, &json!(2)).is_ok());
|
||||||
|
assert!(shape(count, &json!("2")).is_ok());
|
||||||
|
assert!(shape(count, &json!(0)).is_err());
|
||||||
|
let terms = key("invoicing.default_terms").unwrap();
|
||||||
|
assert!(shape(terms, &json!("Net 30")).is_ok());
|
||||||
|
assert!(shape(terms, &json!("a spelling this version doesn't know")).is_ok());
|
||||||
|
assert!(shape(terms, &json!(30)).is_err());
|
||||||
|
let methods = key("invoicing.payment_methods").unwrap();
|
||||||
|
assert!(shape(methods, &json!([])).is_ok());
|
||||||
|
assert!(
|
||||||
|
shape(
|
||||||
|
methods,
|
||||||
|
&json!([{"kind": "ach", "label": "Bank", "lines": ["123456789"]}])
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(shape(methods, &json!([{"kind": "ach", "label": "Bank"}])).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
@@ -62,6 +62,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
|
|||||||
PROJECTS,
|
PROJECTS,
|
||||||
DOCUMENT_LINE_PROJECTS,
|
DOCUMENT_LINE_PROJECTS,
|
||||||
DOCUMENT_STANDING,
|
DOCUMENT_STANDING,
|
||||||
|
DOCUMENTS_V2,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Feature: `period.closed` closes to members with `"rounding":
|
/// Feature: `period.closed` closes to members with `"rounding":
|
||||||
@@ -88,6 +89,19 @@ pub(crate) fn setting_feature(key: &str) -> Option<&'static str> {
|
|||||||
match key {
|
match key {
|
||||||
"policy.require_tax_category" => Some(FEATURE_REQUIRE_TAX_CATEGORY),
|
"policy.require_tax_category" => Some(FEATURE_REQUIRE_TAX_CATEGORY),
|
||||||
"books.schedule_convention" => Some(SCHEDULE_CONVENTION),
|
"books.schedule_convention" => Some(SCHEDULE_CONVENTION),
|
||||||
|
// P4b S4.2: the invoicing and bills settings, and the company's
|
||||||
|
// contact details an invoice's seller snapshot reads.
|
||||||
|
"invoicing.number_prefix"
|
||||||
|
| "invoicing.first_number"
|
||||||
|
| "invoicing.default_terms"
|
||||||
|
| "invoicing.payment_methods"
|
||||||
|
| "invoicing.footer"
|
||||||
|
| "invoicing.unsent_days"
|
||||||
|
| "invoicing.reminder_days"
|
||||||
|
| "bills.remind_days"
|
||||||
|
| "bills.discount_warn_days"
|
||||||
|
| "company.email"
|
||||||
|
| "company.website" => Some(DOCUMENTS_V2),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +188,16 @@ pub const DOCUMENT_LINE_PROJECTS: &str = "document_line_projects";
|
|||||||
/// Every other payment is written as before and needs no feature.
|
/// Every other payment is written as before and needs no feature.
|
||||||
pub const DOCUMENT_STANDING: &str = "document_standing";
|
pub const DOCUMENT_STANDING: &str = "document_standing";
|
||||||
|
|
||||||
|
/// Feature: `documents_v2` (D-52; plan P4b S4 to S7): an `invoice.created`
|
||||||
|
/// or `bill.created` carrying a number, terms, snapshots, references or
|
||||||
|
/// line accounts; a `party.updated` with an email or default terms; a
|
||||||
|
/// `settings.set` of the invoicing or bills keys or the company's contact
|
||||||
|
/// details. S4.2 writes the settings and party fields; S4.3 to S7 add the
|
||||||
|
/// document fields, sends, settlements and write-offs. An older version
|
||||||
|
/// would drop the line accounts (posting to the role accounts), the
|
||||||
|
/// discount and the fee, or refuse the unknown values, silently.
|
||||||
|
pub const DOCUMENTS_V2: &str = "documents_v2";
|
||||||
|
|
||||||
/// The line type that declares the journal format.
|
/// The line type that declares the journal format.
|
||||||
pub const FORMAT_SET: &str = "journal.format_set";
|
pub const FORMAT_SET: &str = "journal.format_set";
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user