1156 lines
43 KiB
Rust
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());
|
|
}
|
|
}
|