//! Pure builders from Voiced data to ERPNext request bodies. No I/O, no clock, no floats in the money //! path: every amount is rebuilt in integer paise with the same `gst` functions that issued the invoice, //! checked against what was stored, and only then written out as a decimal. use super::config::{ErpnextConfig, NamingMode}; use crate::gst::{self, TaxType, Totals}; use crate::models::{Client, Invoice, InvoiceItem}; use serde_json::{json, Map, Number, Value}; use std::collections::HashSet; pub const SALES_INVOICE_V1: &[&str] = &["api", "resource", "Sales Invoice"]; pub const SALES_INVOICE_V2: &[&str] = &["api", "v2", "document", "Sales Invoice"]; pub const CUSTOMER: &[&str] = &["api", "resource", "Customer"]; pub const ADDRESS: &[&str] = &["api", "resource", "Address"]; /// Frappe's `item_name` column is a 140-character Data field. const ITEM_NAME_MAX: usize = 140; /// Where to POST and what. `idempotent` says whether repeating the POST is safe (a mirrored name makes /// a repeat a 409), which decides whether the client may retry after a timeout or 5xx. #[derive(Debug, Clone, PartialEq)] pub struct BuiltRequest { pub path: &'static [&'static str], pub body: Value, pub idempotent: bool, } /// Supplier facts as frozen on the invoice when it was issued. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Vendor { pub registered: bool, pub state_code: String, } impl Vendor { pub fn from_snapshot(snapshot: &str) -> Option { let v: Value = serde_json::from_str(snapshot).ok()?; Some(Vendor { registered: v.get("gstRegistration")?.as_str()? != "unregistered", state_code: v.get("vendorStateCode")?.as_str()?.to_string(), }) } } pub struct InvoiceContext<'a> { pub invoice: &'a Invoice, pub config: &'a ErpnextConfig, pub vendor: &'a Vendor, /// Name of the Customer document on the site (after find-or-create). pub customer: &'a str, pub customer_address: Option<&'a str>, /// ERPNext item code per invoice row, e.g. from a preset; `None` falls back to the default item code, /// then to a code-less row. pub item_codes: &'a [Option], /// India Compliance is installed on the site. pub india_compliance: bool, /// Insert and submit in one POST (`docstatus: 1`); otherwise a draft lands for review. pub submit: bool, } // ---- money formatting ---- /// `123456` -> `"1234.56"`, exact. pub fn paise_to_decimal(paise: i64) -> String { let sign = if paise < 0 { "-" } else { "" }; let abs = paise.unsigned_abs(); format!("{sign}{}.{:02}", abs / 100, abs % 100) } /// `scaled / 10^scale` as a trimmed decimal: `(1800, 3)` -> `"1.8"`, `(9000, 3)` -> `"9"`. fn scaled_decimal(scaled: i64, scale: u32) -> String { let div = 10u64.pow(scale); let sign = if scaled < 0 { "-" } else { "" }; let abs = scaled.unsigned_abs(); let (whole, frac) = (abs / div, abs % div); if frac == 0 { return format!("{sign}{whole}"); } let frac = format!("{frac:0width$}", width = scale as usize); format!("{sign}{whole}.{}", frac.trim_end_matches('0')) } /// A JSON number built from an exact decimal string. The nearest double prints back as the same /// shortest decimal, so nothing drifts. fn decimal_number(decimal: &str) -> Value { decimal .parse::() .ok() .and_then(Number::from_f64) .map(Value::Number) .unwrap_or_else(|| Value::String(decimal.to_string())) } pub(crate) fn money(paise: i64) -> Value { decimal_number(&paise_to_decimal(paise)) } fn div_round(num: i128, den: i128) -> i64 { ((num + den / 2) / den) as i64 } // ---- states, categories, units ---- /// India Compliance's `"29-Karnataka"` form, from the Voiced state code. pub fn place_of_supply_label(code: &str) -> Result { gst::STATES .iter() .find(|(c, _)| *c == code) .map(|(c, name)| format!("{c}-{name}")) .ok_or_else(|| format!("\"{code}\" is not a GST state code")) } pub fn state_name(code: &str) -> Result<&'static str, String> { gst::STATES .iter() .find(|(c, _)| *c == code) .map(|(_, n)| *n) .ok_or_else(|| format!("\"{code}\" is not a GST state code")) } pub fn gst_category_label(category: &str) -> Result<&'static str, String> { match category { "registered_regular" => Ok("Registered Regular"), "composition" => Ok("Registered Composition"), "unregistered" => Ok("Unregistered"), "sez" => Ok("SEZ"), "overseas" => Ok("Overseas"), other => Err(format!("Unknown GST category \"{other}\"")), } } fn parse_tax_type(s: &str) -> Result { match s { "none" => Ok(TaxType::None), "cgst_sgst" => Ok(TaxType::CgstSgst), "igst" => Ok(TaxType::Igst), other => Err(format!("Unknown tax type \"{other}\"")), } } /// The text Voiced stores in `remarks` in series naming mode, used to find the invoice again. pub fn remarks_marker(number: &str) -> String { format!("Voiced invoice {number}") } // ---- Sales Invoice ---- /// Recomputes the totals the way `issue_invoice` did and refuses to continue if they differ from what /// was stored. This is the guarantee that ERPNext's grand total equals Voiced's. fn verified_totals(inv: &Invoice) -> Result<(TaxType, i64, Totals), String> { let tax_type = parse_tax_type(&inv.tax_type)?; let rate_bp = (inv.tax_rate * 100.0).round() as i64; let totals = gst::compute_totals( &inv.items, gst::rupees_to_paise(inv.discount), tax_type, rate_bp, ); let stored = [ (totals.cgst, inv.cgst_amount, "CGST"), (totals.sgst, inv.sgst_amount, "SGST"), (totals.igst, inv.igst_amount, "IGST"), (totals.total, inv.total, "total"), ]; for (computed, stored, what) in stored { if computed != gst::rupees_to_paise(stored) { return Err(format!( "Invoice {}: the recomputed {what} ({}) differs from the stored value ({}); it was not sent to ERPNext.", inv.number, paise_to_decimal(computed), stored )); } } Ok((tax_type, rate_bp, totals)) } /// Quantity in thousandths. ERPNext keeps three decimals on `qty`, so a finer quantity would change the amount. fn qty_milli(item: &InvoiceItem, row: usize) -> Result { let scaled = item.quantity * 1000.0; let milli = scaled.round(); if !scaled.is_finite() || (scaled - milli).abs() > 1e-6 || milli < 0.0 { return Err(format!( "Line {row}: quantity {} has more than 3 decimals, which ERPNext would round", item.quantity )); } Ok(milli as i64) } fn first_line_and_rest(s: &str) -> (&str, &str) { match s.split_once('\n') { Some((head, tail)) => (head, tail), None => (s, ""), } } /// Appends " (2)", " (3)" ... to the first line of repeated descriptions. ERPNext rejects repeated /// rows ("Item None entered multiple times"), and the printed invoice is not affected. fn distinct_descriptions(items: &[InvoiceItem]) -> Vec { let mut used: HashSet = HashSet::new(); items .iter() .enumerate() .map(|(i, item)| { let trimmed = item.description.trim(); let full = if trimmed.is_empty() { format!("Item {}", i + 1) } else { trimmed.to_string() }; let (head, tail) = first_line_and_rest(&full); let mut candidate = full.clone(); let mut n = 2; while used.contains(&candidate.to_lowercase()) { let head = format!("{head} ({n})"); candidate = if tail.is_empty() { head } else { format!("{head}\n{tail}") }; n += 1; } used.insert(candidate.to_lowercase()); candidate }) .collect() } fn truncate_chars(s: &str, max: usize) -> String { s.chars().take(max).collect() } fn set_if(map: &mut Map, key: &str, value: &str) { if !value.trim().is_empty() { map.insert(key.to_string(), json!(value.trim())); } } fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result, String> { let cfg = ctx.config; let inv = ctx.invoice; if inv.items.is_empty() { return Err(format!("Invoice {} has no lines.", inv.number)); } let descriptions = distinct_descriptions(&inv.items); let mut rows = Vec::with_capacity(inv.items.len()); for (i, item) in inv.items.iter().enumerate() { let n = i + 1; let milli = qty_milli(item, n)?; let (rate_paise, qty_scaled) = if item.mode == "rate" { (gst::rupees_to_paise(item.rate), milli) } else { (gst::line_amount_paise(item), 1000) }; // ERPNext computes amount = round(qty * rate); it has to land on the paise Voiced printed. let erp_amount = div_round(rate_paise as i128 * qty_scaled as i128, 1000); if erp_amount != gst::line_amount_paise(item) { return Err(format!( "Line {n}: ERPNext would compute {} but Voiced printed {}", paise_to_decimal(erp_amount), paise_to_decimal(gst::line_amount_paise(item)) )); } let code = ctx .item_codes .get(i) .and_then(|c| c.as_deref()) .map(str::trim) .filter(|c| !c.is_empty()) .or_else(|| Some(cfg.default_item_code.trim()).filter(|c| !c.is_empty())); let code_less = code.is_none(); let mut row = Map::new(); match code { Some(code) => { row.insert("item_code".into(), json!(code)); } None => { if cfg.income_account.trim().is_empty() { return Err(format!( "Line {n} has no ERPNext item code, so an income account is required in the ERPNext settings." )); } let (head, _) = first_line_and_rest(&descriptions[i]); row.insert( "item_name".into(), json!(truncate_chars(head, ITEM_NAME_MAX)), ); } } row.insert("description".into(), json!(descriptions[i])); row.insert("qty".into(), decimal_number(&scaled_decimal(qty_scaled, 3))); row.insert("rate".into(), money(rate_paise)); let uom = cfg.uom_for(&item.unit); row.insert("uom".into(), json!(uom)); row.insert("conversion_factor".into(), json!(1)); if code_less { // Without an item, ERPNext falls back to the stock UOM "Nos" and then refuses a fractional stock qty. row.insert("stock_uom".into(), json!(uom)); } set_if(&mut row, "income_account", &cfg.income_account); set_if(&mut row, "cost_center", &cfg.cost_center); if apply_gst { set_if(&mut row, "gst_hsn_code", &item.hsn_sac); } rows.push(Value::Object(row)); } Ok(rows) } fn tax_row(account: &str, head: &str, rate: &str) -> Result { if account.trim().is_empty() { return Err(format!( "The {head} account is not set in the ERPNext settings." )); } Ok(json!({ "charge_type": "On Net Total", "account_head": account.trim(), "rate": decimal_number(rate), "description": format!("{head} @ {rate}%"), })) } fn tax_rows( cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state: &str, ) -> Result, String> { match tax_type { TaxType::None => Ok(Vec::new()), TaxType::Igst => Ok(vec![tax_row( &cfg.igst_account, "IGST", &scaled_decimal(rate_bp, 2), )?]), TaxType::CgstSgst => { // rate_bp is in hundredths of a percent; half of it in thousandths of a percent is rate_bp * 5. let half = scaled_decimal(rate_bp * 5, 3); let second = gst::second_head_label(supplier_state); let second_account = if second == "UTGST" && !cfg.utgst_account.trim().is_empty() { &cfg.utgst_account } else { &cfg.sgst_account }; Ok(vec![ tax_row(&cfg.cgst_account, "CGST", &half)?, tax_row(second_account, second, &half)?, ]) } } } pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result { let inv = ctx.invoice; let cfg = ctx.config; if inv.status != "issued" { return Err(format!( "Invoice {} is {}; only issued invoices are sent to ERPNext.", inv.number, inv.status )); } if ctx.customer.trim().is_empty() { return Err("The ERPNext customer is not resolved.".into()); } if cfg.company.trim().is_empty() { return Err("No ERPNext company is selected in the settings.".into()); } let (tax_type, rate_bp, totals) = verified_totals(inv)?; let gst_fields = ctx.india_compliance && ctx.vendor.registered; let mut body = Map::new(); match cfg.naming_mode { NamingMode::Mirror => { body.insert("name".into(), json!(inv.number)); } NamingMode::Series => { if cfg.naming_series.trim().is_empty() { return Err( "ERPNext series naming is selected but no naming series is set.".into(), ); } body.insert("naming_series".into(), json!(cfg.naming_series.trim())); } } body.insert("company".into(), json!(cfg.company.trim())); body.insert("customer".into(), json!(ctx.customer.trim())); body.insert("posting_date".into(), json!(inv.invoice_date)); // Without this ERPNext rewrites posting_date to today. body.insert("set_posting_time".into(), json!(1)); if !inv.due_date.trim().is_empty() && inv.due_date >= inv.invoice_date { body.insert("due_date".into(), json!(inv.due_date)); } set_if(&mut body, "po_no", &inv.po_number); set_if(&mut body, "selling_price_list", &cfg.selling_price_list); body.insert("ignore_pricing_rule".into(), json!(1)); // So ERPNext's grand total is Voiced's total, not a rounded one. body.insert("disable_rounded_total".into(), json!(1)); let remarks = match (cfg.naming_mode, inv.notes.trim()) { (NamingMode::Series, "") => remarks_marker(&inv.number), (NamingMode::Series, notes) => format!("{}\n{notes}", remarks_marker(&inv.number)), (NamingMode::Mirror, notes) => notes.to_string(), }; set_if(&mut body, "remarks", &remarks); // Addresses are sent whenever known; under India Compliance a missing one makes it recompute the // taxes and replace ours. set_if(&mut body, "company_address", &cfg.company_address); if let Some(addr) = ctx.customer_address { set_if(&mut body, "customer_address", addr); } if gst_fields { let pos = if inv.place_of_supply_state_code.trim().is_empty() { ctx.vendor.state_code.as_str() } else { inv.place_of_supply_state_code.trim() }; body.insert("place_of_supply".into(), json!(place_of_supply_label(pos)?)); // Live finding (India Compliance 15.32): a reverse-charge sales invoice must carry negative tax rows on the // separate "... RCM" accounts, so ERPNext's total would exclude the tax Voiced's total includes. The flag // is therefore never sent as 1; the push reports a warning for such invoices instead. body.insert("is_reverse_charge".into(), json!(0)); } if totals.discount > 0 { body.insert("apply_discount_on".into(), json!("Net Total")); body.insert("discount_amount".into(), money(totals.discount)); } body.insert("items".into(), Value::Array(item_rows(ctx, gst_fields)?)); body.insert( "taxes".into(), Value::Array(tax_rows(cfg, tax_type, rate_bp, &ctx.vendor.state_code)?), ); if ctx.submit { body.insert("docstatus".into(), json!(1)); } let mirror = cfg.naming_mode == NamingMode::Mirror; Ok(BuiltRequest { path: if mirror { SALES_INVOICE_V2 } else { SALES_INVOICE_V1 }, body: Value::Object(body), idempotent: mirror, }) } // ---- Customer and Address ---- fn has_gstin(client: &Client) -> bool { let g = client.gstin.trim(); !g.is_empty() && !g.eq_ignore_ascii_case("NA") } pub fn build_customer( client: &Client, cfg: &ErpnextConfig, india_compliance: bool, ) -> Result { let name = client.name.trim(); if name.is_empty() { return Err("The client has no name.".into()); } let mut body = Map::new(); body.insert("customer_name".into(), json!(name)); body.insert("customer_type".into(), json!("Company")); set_if(&mut body, "customer_group", &cfg.customer_group); set_if(&mut body, "territory", &cfg.territory); if india_compliance { body.insert( "gst_category".into(), json!(gst_category_label(&client.gst_category)?), ); let taxable_registration = matches!( client.gst_category.as_str(), "registered_regular" | "composition" | "sez" ); if taxable_registration && has_gstin(client) { let gstin = client.gstin.trim().to_ascii_uppercase(); gst::validate_gstin(&gstin).map_err(|e| format!("Client GSTIN: {e}"))?; body.insert("gstin".into(), json!(gstin)); } } Ok(BuiltRequest { path: CUSTOMER, body: Value::Object(body), idempotent: false, }) } /// `customer` is the name ERPNext returned when the Customer was created (a duplicate becomes "X - 1"). pub fn build_address( client: &Client, customer: &str, india_compliance: bool, ) -> Result { if customer.trim().is_empty() { return Err("The ERPNext customer is not resolved.".into()); } let line1 = if client.address_line1.trim().is_empty() { first_line_and_rest(client.address.trim()).0.trim() } else { client.address_line1.trim() }; if line1.is_empty() { return Err("The client address needs a first line.".into()); } if client.city.trim().is_empty() { return Err("The client address needs a city.".into()); } let mut body = Map::new(); body.insert("address_title".into(), json!(client.name.trim())); body.insert("address_type".into(), json!("Billing")); body.insert("address_line1".into(), json!(line1)); set_if(&mut body, "address_line2", &client.address_line2); body.insert("city".into(), json!(client.city.trim())); // The state must match India Compliance's list exactly; it is also what the GSTIN's first digits imply. let state = client.state_code.trim(); if state.is_empty() { if india_compliance { return Err("The client address needs a state.".into()); } } else { body.insert("state".into(), json!(state_name(state)?)); } set_if(&mut body, "pincode", &client.pincode); body.insert("country".into(), json!("India")); if india_compliance { body.insert( "gst_category".into(), json!(gst_category_label(&client.gst_category)?), ); if has_gstin(client) && matches!( client.gst_category.as_str(), "registered_regular" | "composition" | "sez" ) { body.insert( "gstin".into(), json!(client.gstin.trim().to_ascii_uppercase()), ); } } body.insert( "links".into(), json!([{ "link_doctype": "Customer", "link_name": customer.trim() }]), ); Ok(BuiltRequest { path: ADDRESS, body: Value::Object(body), idempotent: false, }) } #[cfg(test)] mod tests { use super::*; use crate::commands::invoice::{get_invoice_impl, issue_invoice_impl}; use crate::models::InvoiceInput; use rusqlite::Connection; use std::path::Path; const GSTIN: &str = "27AAPFU0939F1ZV"; fn registered() -> Connection { let conn = crate::db::open_in_memory().unwrap(); conn.execute( "UPDATE app_settings SET gst_registration = 'regular', vendor_gstin = ?1, vendor_state_code = '27', vendor_pan = 'AAPFU0939F', default_tax_type = 'cgst_sgst'", [GSTIN], ) .unwrap(); conn } fn issue(conn: &mut Connection, extra: Value) -> Invoice { let mut base = json!({ "invoiceDate": "2026-04-01", "dueDate": "2026-05-01", "clientName": "Client Ltd", "taxType": "cgst_sgst", "taxRate": 18.0, "items": [{"description": "Design", "mode": "fixed", "amount": 7310.0}], }); for (k, v) in extra.as_object().unwrap() { base[k] = v.clone(); } let input: InvoiceInput = serde_json::from_value(base).unwrap(); let inv = issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({})).unwrap(); get_invoice_impl(conn, inv.id).unwrap() } fn cfg() -> ErpnextConfig { ErpnextConfig { company: "Test Co".into(), company_address: "Test Co-Billing".into(), income_account: "Sales - AC".into(), cgst_account: "Output CGST - AC".into(), sgst_account: "Output SGST - AC".into(), utgst_account: "Output UTGST - AC".into(), igst_account: "Output IGST - AC".into(), ..Default::default() } } fn vendor(inv: &Invoice) -> Vendor { Vendor::from_snapshot(&inv.vendor_snapshot).unwrap() } fn build( inv: &Invoice, cfg: &ErpnextConfig, ic: bool, codes: &[Option], submit: bool, ) -> Result { let v = vendor(inv); build_sales_invoice(&InvoiceContext { invoice: inv, config: cfg, vendor: &v, customer: "Client Ltd", customer_address: Some("Client Ltd-Billing"), item_codes: codes, india_compliance: ic, submit, }) } /// What ERPNext computes for an `On Net Total` invoice with rounding disabled: round-half-up on every /// row amount and tax amount. Returns the grand total in paise. fn erpnext_grand_total(body: &Value) -> i64 { let paise = |v: &Value| (v.as_f64().unwrap() * 100.0).round() as i128; let mut total_amount: i128 = 0; for row in body["items"].as_array().unwrap() { let qty_milli = (row["qty"].as_f64().unwrap() * 1000.0).round() as i128; total_amount += (paise(&row["rate"]) * qty_milli + 500) / 1000; } let discount = body.get("discount_amount").map(paise).unwrap_or(0); let net = total_amount - discount; let mut taxes: i128 = 0; for row in body["taxes"].as_array().unwrap() { let rate_milli = (row["rate"].as_f64().unwrap() * 1000.0).round() as i128; taxes += (net * rate_milli + 50_000) / 100_000; } (net + taxes) as i64 } fn client() -> Client { serde_json::from_value(json!({ "name": "Client Ltd", "gstin": "29AABCU9603R1ZJ", "stateCode": "29", "addressLine1": "12 MG Road", "addressLine2": "Floor 3", "city": "Bengaluru", "pincode": "560001", "gstCategory": "registered_regular", })) .unwrap() } #[test] fn decimals_are_exact() { assert_eq!(paise_to_decimal(0), "0.00"); assert_eq!(paise_to_decimal(5), "0.05"); assert_eq!(paise_to_decimal(731_000), "7310.00"); assert_eq!(paise_to_decimal(-1234), "-12.34"); assert_eq!(scaled_decimal(900, 2), "9"); assert_eq!(scaled_decimal(925, 2), "9.25"); assert_eq!(scaled_decimal(1500, 3), "1.5"); assert_eq!(scaled_decimal(2625, 3), "2.625"); assert_eq!(money(731_005).to_string(), "7310.05"); assert_eq!( money(i64::from(u32::MAX) * 100 + 7).to_string(), "4294967295.07" ); } #[test] fn every_state_code_has_the_india_compliance_label() { assert_eq!(place_of_supply_label("29").unwrap(), "29-Karnataka"); assert_eq!(place_of_supply_label("01").unwrap(), "01-Jammu and Kashmir"); assert_eq!(place_of_supply_label("97").unwrap(), "97-Other Territory"); assert_eq!(place_of_supply_label("96").unwrap(), "96-Other Countries"); for (code, name) in gst::STATES { assert_eq!( place_of_supply_label(code).unwrap(), format!("{code}-{name}") ); } assert!(place_of_supply_label("28").is_err()); } #[test] fn intra_state_cgst_sgst_mirrored_with_india_compliance() { let mut conn = registered(); let inv = issue(&mut conn, json!({})); let req = build(&inv, &cfg(), true, &[None], false).unwrap(); assert_eq!(req.path.join("/"), "api/v2/document/Sales Invoice"); assert!(req.idempotent); assert_eq!( req.body, json!({ "name": inv.number, "company": "Test Co", "customer": "Client Ltd", "posting_date": "2026-04-01", "set_posting_time": 1, "due_date": "2026-05-01", "ignore_pricing_rule": 1, "disable_rounded_total": 1, "company_address": "Test Co-Billing", "customer_address": "Client Ltd-Billing", "place_of_supply": "27-Maharashtra", "is_reverse_charge": 0, "items": [{ "item_name": "Design", "description": "Design", "qty": 1.0, "rate": 7310.0, "uom": "Nos", "stock_uom": "Nos", "conversion_factor": 1, "income_account": "Sales - AC", }], "taxes": [ { "charge_type": "On Net Total", "account_head": "Output CGST - AC", "rate": 9.0, "description": "CGST @ 9%" }, { "charge_type": "On Net Total", "account_head": "Output SGST - AC", "rate": 9.0, "description": "SGST @ 9%" }, ], }) ); assert_eq!(erpnext_grand_total(&req.body), inv.total_paise); assert_eq!(inv.total_paise, 862_580); } #[test] fn inter_state_igst_with_discount_hsn_and_reverse_charge() { let mut conn = registered(); let inv = issue( &mut conn, json!({ "placeOfSupplyStateCode": "29", "taxType": "igst", "taxRate": 5.0, "reverseCharge": true, "discount": 10.0, "notes": "Thank you", "poNumber": "PO-77", "items": [ {"description": "Hours", "mode": "rate", "rate": 1200.0, "quantity": 1.5, "unit": "hour", "hsnSac": "998314"}, {"description": "Fee", "mode": "fixed", "amount": 33.33}, ], }), ); let req = build(&inv, &cfg(), true, &[None, None], false).unwrap(); assert_eq!( req.body, json!({ "name": inv.number, "company": "Test Co", "customer": "Client Ltd", "posting_date": "2026-04-01", "set_posting_time": 1, "due_date": "2026-05-01", "po_no": "PO-77", "ignore_pricing_rule": 1, "disable_rounded_total": 1, "remarks": "Thank you", "company_address": "Test Co-Billing", "customer_address": "Client Ltd-Billing", "place_of_supply": "29-Karnataka", // Reverse charge is never sent as 1 (see build_sales_invoice); the push warns instead. "is_reverse_charge": 0, "apply_discount_on": "Net Total", "discount_amount": 10.0, "items": [ { "item_name": "Hours", "description": "Hours", "qty": 1.5, "rate": 1200.0, "uom": "Hour", "stock_uom": "Hour", "conversion_factor": 1, "income_account": "Sales - AC", "gst_hsn_code": "998314", }, { "item_name": "Fee", "description": "Fee", "qty": 1.0, "rate": 33.33, "uom": "Nos", "stock_uom": "Nos", "conversion_factor": 1, "income_account": "Sales - AC", }, ], "taxes": [ { "charge_type": "On Net Total", "account_head": "Output IGST - AC", "rate": 5.0, "description": "IGST @ 5%" }, ], }) ); // (1833.33 - 10.00) * 5% = 91.1665 -> 91.17; ERPNext's grand total must equal Voiced's 1914.50. assert_eq!(inv.total_paise, 191_450); assert_eq!(erpnext_grand_total(&req.body), inv.total_paise); } #[test] fn discount_with_cgst_sgst_keeps_the_grand_total() { let mut conn = registered(); let inv = issue( &mut conn, json!({ "discount": 123.45, "taxRate": 12.0, "items": [ {"description": "A", "mode": "rate", "rate": 99.99, "quantity": 3.333, "unit": "minute"}, {"description": "B", "mode": "fixed", "amount": 1000.01}, ]}), ); let req = build(&inv, &cfg(), true, &[None, None], false).unwrap(); assert_eq!(req.body["discount_amount"].to_string(), "123.45"); assert_eq!(req.body["items"][0]["uom"], "Minute"); assert_eq!(erpnext_grand_total(&req.body), inv.total_paise); } #[test] fn unregistered_vendor_sends_no_tax_rows_and_no_gst_fields() { let mut conn = crate::db::open_in_memory().unwrap(); let inv = issue( &mut conn, json!({ "taxType": "none", "taxRate": 0.0, "items": [{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}] }), ); let req = build(&inv, &cfg(), true, &[None], false).unwrap(); assert_eq!(req.body["taxes"], json!([])); for key in ["place_of_supply", "is_reverse_charge"] { assert!(req.body.get(key).is_none(), "{key}"); } assert!(req.body["items"][0].get("gst_hsn_code").is_none()); assert_eq!(erpnext_grand_total(&req.body), 50_000); assert_eq!(inv.total_paise, 50_000); } #[test] fn plain_erpnext_registered_vendor_gets_tax_rows_but_no_india_compliance_fields() { let mut conn = registered(); let inv = issue( &mut conn, json!({ "items": [{"description": "Design", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}] }), ); let req = build(&inv, &cfg(), false, &[None], false).unwrap(); assert_eq!(req.body["taxes"].as_array().unwrap().len(), 2); assert!( req.body.get("place_of_supply").is_none() && req.body.get("is_reverse_charge").is_none() ); assert!(req.body["items"][0].get("gst_hsn_code").is_none()); } #[test] fn code_less_rows_get_distinct_descriptions() { let mut conn = registered(); let inv = issue( &mut conn, json!({ "items": [ {"description": "Consulting\nMarch", "mode": "fixed", "amount": 100.0}, {"description": "consulting\nMarch", "mode": "fixed", "amount": 100.0}, {"description": "Consulting\nMarch", "mode": "fixed", "amount": 100.0}, {"description": "Other", "mode": "fixed", "amount": 1.0}, ]}), ); let req = build(&inv, &cfg(), true, &[None, None, None, None], false).unwrap(); let rows = req.body["items"].as_array().unwrap(); assert_eq!(rows[0]["description"], "Consulting\nMarch"); assert_eq!(rows[0]["item_name"], "Consulting"); assert_eq!(rows[1]["description"], "consulting (2)\nMarch"); assert_eq!(rows[1]["item_name"], "consulting (2)"); assert_eq!(rows[2]["item_name"], "Consulting (3)"); assert_eq!(rows[3]["item_name"], "Other"); } #[test] fn only_code_less_rows_pin_the_stock_uom() { // Live finding: without an item ERPNext defaults the stock UOM to "Nos" and then refuses a fractional // quantity of hours; an item-coded row keeps the item's own stock UOM. let mut conn = registered(); let inv = issue( &mut conn, json!({ "items": [ { "description": "Coded", "mode": "rate", "rate": 100.0, "unit": "hour", "quantity": 1.5 }, { "description": "Not coded", "mode": "rate", "rate": 100.0, "unit": "minute", "quantity": 2.5 }, ]}), ); let codes = [Some("SVC".to_string()), None]; let req = build(&inv, &cfg(), false, &codes, false).unwrap(); let rows = req.body["items"].as_array().unwrap(); assert!(rows[0].get("stock_uom").is_none()); assert_eq!( ( rows[0]["uom"].as_str(), rows[0]["conversion_factor"].as_i64() ), (Some("Hour"), Some(1)) ); assert_eq!( (rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()), (Some("Minute"), Some("Minute")) ); } #[test] fn code_less_rows_need_an_income_account() { let mut conn = registered(); let inv = issue(&mut conn, json!({})); let mut c = cfg(); c.income_account.clear(); let err = build(&inv, &c, true, &[None], false).unwrap_err(); assert!(err.contains("income account"), "{err}"); // With an item code the row does not need one. let ok = build(&inv, &c, true, &[Some("SERVICE".into())], false).unwrap(); assert!(ok.body["items"][0].get("income_account").is_none()); } #[test] fn item_code_rows_use_the_code_and_fall_back_to_the_default() { let mut conn = registered(); let inv = issue( &mut conn, json!({ "items": [ {"description": "Logo design", "mode": "fixed", "amount": 100.0}, {"description": "Hosting", "mode": "rate", "rate": 10.0, "quantity": 2.0, "unit": "session"}, ]}), ); let mut c = cfg(); let req = build(&inv, &c, true, &[Some("DESIGN-01".into()), None], false).unwrap(); let rows = req.body["items"].as_array().unwrap(); assert_eq!(rows[0]["item_code"], "DESIGN-01"); assert!(rows[0].get("item_name").is_none()); assert_eq!(rows[0]["description"], "Logo design"); assert_eq!(rows[1]["item_name"], "Hosting"); c.default_item_code = "SERVICES".into(); let req = build(&inv, &c, true, &[Some("DESIGN-01".into()), None], false).unwrap(); assert_eq!(req.body["items"][1]["item_code"], "SERVICES"); assert!(req.body["items"][1].get("item_name").is_none()); assert_eq!(req.body["items"][1]["uom"], "Nos"); } #[test] fn series_mode_uses_the_series_endpoint_and_puts_the_number_in_remarks() { let mut conn = registered(); let inv = issue(&mut conn, json!({ "notes": "Net 30" })); let mut c = cfg(); c.naming_mode = NamingMode::Series; c.naming_series = "SINV-.YY.-".into(); let req = build(&inv, &c, true, &[None], false).unwrap(); assert_eq!(req.path.join("/"), "api/resource/Sales Invoice"); assert!(!req.idempotent); assert!(req.body.get("name").is_none()); assert_eq!(req.body["naming_series"], "SINV-.YY.-"); assert_eq!( req.body["remarks"], format!("Voiced invoice {}\nNet 30", inv.number) ); c.naming_series.clear(); assert!(build(&inv, &c, true, &[None], false).is_err()); } #[test] fn submit_adds_docstatus_and_drafts_do_not() { let mut conn = registered(); let inv = issue(&mut conn, json!({})); assert!(build(&inv, &cfg(), true, &[None], false) .unwrap() .body .get("docstatus") .is_none()); assert_eq!( build(&inv, &cfg(), true, &[None], true).unwrap().body["docstatus"], 1 ); } #[test] fn fractional_gst_rates_and_union_territory_suppliers() { let mut conn = registered(); let inv = issue(&mut conn, json!({ "taxRate": 5.25 })); let req = build(&inv, &cfg(), true, &[None], false).unwrap(); assert_eq!(req.body["taxes"][0]["rate"], json!(2.625)); assert_eq!(req.body["taxes"][0]["description"], "CGST @ 2.625%"); assert_eq!(erpnext_grand_total(&req.body), inv.total_paise); // A supplier in a union territory without a legislature charges UTGST, not SGST. let mut ut = inv.clone(); ut.vendor_snapshot = ut .vendor_snapshot .replace("\"vendorStateCode\":\"27\"", "\"vendorStateCode\":\"04\""); let req = build(&ut, &cfg(), true, &[None], false).unwrap(); assert_eq!(req.body["taxes"][1]["account_head"], "Output UTGST - AC"); assert_eq!(req.body["taxes"][1]["description"], "UTGST @ 2.625%"); } #[test] fn totals_that_disagree_with_the_stored_invoice_are_refused() { let mut conn = registered(); let mut inv = issue(&mut conn, json!({})); inv.total += 0.01; let err = build(&inv, &cfg(), true, &[None], false).unwrap_err(); assert!(err.contains("differs from the stored value"), "{err}"); let mut inv = issue(&mut conn, json!({})); inv.items[0].quantity = 1.0005; inv.items[0].mode = "rate".into(); inv.items[0].rate = 7310.0; assert!(build(&inv, &cfg(), true, &[None], false).is_err()); } #[test] fn cancelled_invoices_and_missing_settings_are_refused() { let mut conn = registered(); let mut inv = issue(&mut conn, json!({})); let mut c = cfg(); c.cgst_account.clear(); assert!(build(&inv, &c, true, &[None], false) .unwrap_err() .contains("CGST account")); c.company.clear(); assert!(build(&inv, &c, true, &[None], false) .unwrap_err() .contains("company")); inv.status = "cancelled".into(); assert!(build(&inv, &cfg(), true, &[None], false) .unwrap_err() .contains("only issued")); } #[test] fn due_date_before_the_invoice_date_is_left_out() { let mut conn = registered(); let mut inv = issue(&mut conn, json!({})); inv.due_date = "2026-03-01".into(); assert!(build(&inv, &cfg(), true, &[None], false) .unwrap() .body .get("due_date") .is_none()); inv.due_date.clear(); assert!(build(&inv, &cfg(), true, &[None], false) .unwrap() .body .get("due_date") .is_none()); } #[test] fn customer_payload_under_india_compliance() { let mut c = cfg(); c.customer_group = "Commercial".into(); c.territory = "India".into(); let req = build_customer(&client(), &c, true).unwrap(); assert_eq!(req.path.join("/"), "api/resource/Customer"); assert_eq!( req.body, json!({ "customer_name": "Client Ltd", "customer_type": "Company", "customer_group": "Commercial", "territory": "India", "gst_category": "Registered Regular", "gstin": "29AABCU9603R1ZJ", }) ); // Plain ERPNext gets no GST fields. let plain = build_customer(&client(), &c, false).unwrap(); assert!(plain.body.get("gstin").is_none() && plain.body.get("gst_category").is_none()); } #[test] fn customer_gst_categories_map_and_bad_gstin_is_refused() { for (voiced, erp) in [ ("registered_regular", "Registered Regular"), ("composition", "Registered Composition"), ("unregistered", "Unregistered"), ("sez", "SEZ"), ("overseas", "Overseas"), ] { let mut c = client(); c.gst_category = voiced.into(); if voiced == "unregistered" || voiced == "overseas" { c.gstin = String::new(); } let req = build_customer(&c, &cfg(), true).unwrap(); assert_eq!(req.body["gst_category"], erp); } let mut unreg = client(); unreg.gst_category = "unregistered".into(); assert!(build_customer(&unreg, &cfg(), true) .unwrap() .body .get("gstin") .is_none()); let mut bad = client(); bad.gstin = "29AABCU9603R1Z0".into(); assert!(build_customer(&bad, &cfg(), true) .unwrap_err() .contains("GSTIN")); } #[test] fn address_payload_links_the_customer_and_uses_india_compliance_state_names() { let req = build_address(&client(), "Client Ltd - 1", true).unwrap(); assert_eq!(req.path.join("/"), "api/resource/Address"); assert_eq!( req.body, json!({ "address_title": "Client Ltd", "address_type": "Billing", "address_line1": "12 MG Road", "address_line2": "Floor 3", "city": "Bengaluru", "state": "Karnataka", "pincode": "560001", "country": "India", "gst_category": "Registered Regular", "gstin": "29AABCU9603R1ZJ", "links": [{ "link_doctype": "Customer", "link_name": "Client Ltd - 1" }], }) ); let mut jk = client(); jk.state_code = "01".into(); assert_eq!( build_address(&jk, "X", true).unwrap().body["state"], "Jammu and Kashmir" ); } #[test] fn address_falls_back_to_the_composed_text_and_checks_required_parts() { let mut c = client(); c.address_line1.clear(); c.address = "5 Park Street\nKolkata, West Bengal - 700016".into(); assert_eq!( build_address(&c, "X", false).unwrap().body["address_line1"], "5 Park Street" ); let mut no_city = client(); no_city.city.clear(); assert!(build_address(&no_city, "X", true).is_err()); let mut no_state = client(); no_state.state_code.clear(); assert!(build_address(&no_state, "X", true).is_err()); assert!(build_address(&no_state, "X", false) .unwrap() .body .get("state") .is_none()); assert!(build_address(&client(), " ", true).is_err()); } }