Add the ERPNext integration foundation (Phase F1)

- New integrations module with an InvoiceSink seam and an ERPNext client built on reqwest with rustls (ring provider only; aws-lc is not in the dependency graph). Token auth, timeouts, retry with backoff on 429, 5xx and timeouts (POSTs only when idempotent), optional extra CA, and a rule that plain http is allowed only for localhost-style hosts.
- Error extraction for Frappe v1 and v2 bodies, version and India Compliance discovery, live option loaders, and a connection test that returns warnings instead of failing.
- Pure, snapshot-tested mapping from a stored invoice to a Sales Invoice (CGST+SGST, IGST, unregistered, discount, code-less rows, mirrored and series naming) that refuses to produce a payload whose totals differ from Voiced's.
- Migration M10 adds erpnext_config (the API secret is never returned to the webview), erpnext_sync and the nullable ERPNext link columns.
- Commands to get and save the config, test the connection and load options, with typed wrappers. No UI and no push yet.

The API secret is stored in the SQLite file, so backups contain it. Nothing has run against a live ERPNext yet.
This commit is contained in:
2026-10-04 19:50:24 +05:30
parent 92f952b136
commit 86b4d436fa
16 changed files with 4254 additions and 9 deletions
@@ -0,0 +1,980 @@
//! 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()))
}
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 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));
row.insert("uom".into(), json!(cfg.uom_for(&item.unit)));
row.insert("conversion_factor".into(), json!(1));
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)?));
body.insert("is_reverse_charge".into(), json!(i32::from(inv.reverse_charge)));
}
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",
"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",
"is_reverse_charge": 1,
"apply_discount_on": "Net Total",
"discount_amount": 10.0,
"items": [
{
"item_name": "Hours", "description": "Hours", "qty": 1.5, "rate": 1200.0, "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",
"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 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());
}
}