Files
Voiced/src-tauri/src/integrations/erpnext/mapping.rs
T
soubarna b6cf46b883 style(src-tauri): cargo fmt
Pure mechanical rustfmt reformat (default config, no rustfmt.toml).
2026-10-05 17:19:13 +05:30

1156 lines
43 KiB
Rust

//! 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<Vendor> {
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<String>],
/// 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::<f64>()
.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<String, String> {
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<TaxType, String> {
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<i64, String> {
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<String> {
let mut used: HashSet<String> = 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<String, Value>, 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<Vec<Value>, 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<Value, String> {
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<Vec<Value>, 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<BuiltRequest, String> {
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<BuiltRequest, String> {
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<BuiltRequest, String> {
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<String>],
submit: bool,
) -> Result<BuiltRequest, String> {
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());
}
}