diff --git a/crates/ledger-core/src/parties.rs b/crates/ledger-core/src/parties.rs index 9bc47ba..fce0ae0 100644 --- a/crates/ledger-core/src/parties.rs +++ b/crates/ledger-core/src/parties.rs @@ -51,6 +51,9 @@ pub enum Role { PayoutPlatform, /// Taxes the company (the IRS, a state). TaxAgency, + /// Regulates or registers the company without taxing it (a secretary + /// of state, a division of corporations, a licensing board). + GovernmentAgency, /// Holds the company's money or credit (a bank, a card issuer). FinancialInstitution, /// Rents property to the company (gets a 1099-MISC for rent). @@ -59,7 +62,7 @@ pub enum Role { impl Role { /// Every role, in display order. - pub const ALL: [Role; 9] = [ + pub const ALL: [Role; 10] = [ Role::Vendor, Role::Contractor, Role::Customer, @@ -67,6 +70,7 @@ impl Role { Role::Member, Role::PayoutPlatform, Role::TaxAgency, + Role::GovernmentAgency, Role::FinancialInstitution, Role::Landlord, ]; @@ -81,6 +85,7 @@ impl Role { Role::Member => "member", Role::PayoutPlatform => "payout_platform", Role::TaxAgency => "tax_agency", + Role::GovernmentAgency => "government_agency", Role::FinancialInstitution => "financial_institution", Role::Landlord => "landlord", } diff --git a/crates/ledger-core/tests/spec_parties.rs b/crates/ledger-core/tests/spec_parties.rs index 62d6102..8e970f3 100644 --- a/crates/ledger-core/tests/spec_parties.rs +++ b/crates/ledger-core/tests/spec_parties.rs @@ -326,7 +326,7 @@ fn gets_1099_is_contractor_and_landlord_only() { #[test] fn role_keys_round_trip() { let all: BTreeSet = Role::ALL.into_iter().collect(); - assert_eq!(all.len(), 9, "nine distinct roles"); + assert_eq!(all.len(), 10, "ten distinct roles"); for r in [ Role::Vendor, Role::Contractor, @@ -335,6 +335,7 @@ fn role_keys_round_trip() { Role::Member, Role::PayoutPlatform, Role::TaxAgency, + Role::GovernmentAgency, Role::FinancialInstitution, Role::Landlord, ] { @@ -343,6 +344,7 @@ fn role_keys_round_trip() { } assert_eq!(Role::PayoutPlatform.key(), "payout_platform"); assert_eq!(Role::TaxAgency.key(), "tax_agency"); + assert_eq!(Role::GovernmentAgency.key(), "government_agency"); assert_eq!(Role::FinancialInstitution.key(), "financial_institution"); assert_eq!(Role::Contractor.key(), "contractor"); assert_eq!(Role::parse("boss"), None); diff --git a/crates/ledger-gui/src/pages/parties.rs b/crates/ledger-gui/src/pages/parties.rs index e8d68ca..6cfa2eb 100644 --- a/crates/ledger-gui/src/pages/parties.rs +++ b/crates/ledger-gui/src/pages/parties.rs @@ -94,6 +94,7 @@ pub fn role_label(r: Role) -> &'static str { Role::Member => "Member", Role::PayoutPlatform => "Payout platform", Role::TaxAgency => "Tax agency", + Role::GovernmentAgency => "Government agency", Role::FinancialInstitution => "Bank or card issuer", Role::Landlord => "Landlord", } @@ -109,6 +110,7 @@ pub fn role_noun(r: Role) -> &'static str { Role::Member => "a member", Role::PayoutPlatform => "a payout platform", Role::TaxAgency => "a tax agency", + Role::GovernmentAgency => "a government agency", Role::FinancialInstitution => "a bank or card issuer", Role::Landlord => "a landlord", } @@ -134,6 +136,8 @@ pub fn party_icon(p: &Party) -> &'static str { let has = |x| r.contains(&x); if has(Role::TaxAgency) { icon::BANK + } else if has(Role::GovernmentAgency) { + icon::SEAL_CHECK } else if has(Role::FinancialInstitution) { icon::BUILDING_OFFICE } else if has(Role::PayoutPlatform) { diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 0f056ad..e9f77e4 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -5669,7 +5669,7 @@ impl BooksServer { #[tool( name = "books.add_party", - description = "Add a party to the directory: anyone the company deals with (a contractor, landlord, payout platform, tax agency, bank or card issuer, member, employee). Name it as on its documents (unique among parties not archived); give its roles: contractor, employee, member, payout_platform, tax_agency, financial_institution, landlord. Vendor and customer aren't given by hand: books.add_vendor and books.add_customer add those records and link them to the party of the same name. Never put a full TIN or SSN in any party field: the engine refuses nine-digit numbers. Journaled as party.added; returns the party." + description = "Add a party to the directory: anyone the company deals with (a contractor, landlord, payout platform, tax agency, government agency, bank or card issuer, member, employee). Name it as on its documents (unique among parties not archived); give its roles: contractor, employee, member, payout_platform, tax_agency, government_agency, financial_institution, landlord. Vendor and customer aren't given by hand: books.add_vendor and books.add_customer add those records and link them to the party of the same name. Never put a full TIN or SSN in any party field: the engine refuses nine-digit numbers. Journaled as party.added; returns the party." )] async fn add_party( &self, @@ -5740,7 +5740,7 @@ impl BooksServer { #[tool( name = "books.set_party_roles", - description = "Give a party roles (add) or take them away (remove): contractor, employee, member, payout_platform, tax_agency, financial_institution, landlord. Vendor and customer follow their records and can't be set by hand. Adds apply before removes; a role it already has (or lacks) is left alone. Removing a party's last 1099 role (contractor, landlord) needs its W-9 facts cleared first (books.set_w9 clear), and removing employee its W-4 facts. All or nothing: a refusal changes nothing. Each change is journaled as party.role_set. Returns the party." + description = "Give a party roles (add) or take them away (remove): contractor, employee, member, payout_platform, tax_agency, government_agency, financial_institution, landlord. Vendor and customer follow their records and can't be set by hand. Adds apply before removes; a role it already has (or lacks) is left alone. Removing a party's last 1099 role (contractor, landlord) needs its W-9 facts cleared first (books.set_w9 clear), and removing employee its W-4 facts. All or nothing: a refusal changes nothing. Each change is journaled as party.role_set. Returns the party." )] async fn set_party_roles( &self, diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index db84ef1..8290e31 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -2601,7 +2601,7 @@ pub struct AddPartyParams { /// surrounding space) among parties not archived; never a full TIN. pub name: String, /// Roles: contractor, employee, member, payout_platform, tax_agency, - /// financial_institution, landlord. Not vendor or customer: those come + /// government_agency, financial_institution, landlord. Not vendor or customer: those come /// with their records (`books.add_vendor`, `books.add_customer`, which /// link to the party of the same name). May be empty. #[serde(default)] diff --git a/crates/ledger-mcp/tests/spec_parties.rs b/crates/ledger-mcp/tests/spec_parties.rs index 8e79b39..7a7371e 100644 --- a/crates/ledger-mcp/tests/spec_parties.rs +++ b/crates/ledger-mcp/tests/spec_parties.rs @@ -1079,6 +1079,7 @@ fn parties_lists_by_id_and_filters_by_role() { let a = m.add_party("Acme Design", &["contractor"]); let i = m.add_party("IRS", &["tax_agency"]); let b = m.add_party("Bluevine", &["financial_institution"]); + let u = m.add_party("Utah Division of Corporations", &["government_agency"]); let all = m.read_only("books.parties", json!({})); assert!(all["year"].is_i64(), "defaults to this year"); let ids: Vec = all["parties"] @@ -1087,7 +1088,7 @@ fn parties_lists_by_id_and_filters_by_role() { .iter() .map(|p| p["party_id"].as_u64().unwrap()) .collect(); - assert_eq!(ids, [a, i, b]); + assert_eq!(ids, [a, i, b, u]); let row = &all["parties"][0]; assert_eq!(row["name"], "Acme Design"); assert_eq!(row["label"], "Acme Design"); @@ -1098,6 +1099,10 @@ fn parties_lists_by_id_and_filters_by_role() { let agencies = m.read_only("books.parties", json!({"role": "tax_agency"})); assert_eq!(agencies["parties"].as_array().unwrap().len(), 1); assert_eq!(agencies["parties"][0]["party_id"], json!(i)); + // A regulator is its own role, not a tax agency. + let regulators = m.read_only("books.parties", json!({"role": "government_agency"})); + assert_eq!(regulators["parties"].as_array().unwrap().len(), 1); + assert_eq!(regulators["parties"][0]["party_id"], json!(u)); m.refuse("books.parties", json!({"role": "wizard"})); } diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 2319da9..7a96906 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -354,7 +354,9 @@ goes by id). A number that is one party's id and another party's name is refused, never guessed. **Roles:** `vendor`, `contractor`, `customer`, `employee`, `member`, -`payout_platform`, `tax_agency`, `financial_institution`, `landlord`. +`payout_platform`, `tax_agency`, `government_agency` (regulators and +registries that don't tax: a division of corporations, a licensing board), +`financial_institution`, `landlord`. Vendor and customer follow the records and are never set by hand. **Rules the engine enforces** (each refusal changes nothing, the journal diff --git a/docs/parties-and-attachments.md b/docs/parties-and-attachments.md index 6870d38..3e6c9e1 100644 --- a/docs/parties-and-attachments.md +++ b/docs/parties-and-attachments.md @@ -25,7 +25,8 @@ Party { name: String, // legal or trade name, as on documents display_name: Option, roles: BTreeSet, // set by hand: contractor, employee, member, payout_platform, - // tax_agency, financial_institution, landlord (vendor and + // tax_agency, government_agency, financial_institution, + // landlord (vendor and // customer follow the records; see all_roles) website: Option, address: Option, // business address; no personal PII beyond it