Add issue/cancel lifecycle, GST derivation and integer-paise totals

issue_invoice allocates the number, freezes vendor and bank details and
stores server-computed totals in one transaction. Issued invoices are
cancelled, not deleted. Tax heads derive from supplier state and place of
supply (a disagreeing choice is rejected); unregistered suppliers issue a
plain Invoice. Adds GSTIN checksum validation, canonical India Compliance
state list, server-side drafts, series validation, relative and
magic-byte-checked asset paths, and GST settings. PDF re-exports use the
frozen vendor snapshot.
This commit is contained in:
2026-10-04 04:15:58 +05:30
parent d6ec6b2a17
commit 5597fb791d
19 changed files with 2403 additions and 327 deletions
+506
View File
@@ -0,0 +1,506 @@
//! GST rules: state codes, tax-type derivation, GSTIN validation, integer-paise
//! totals and amount in words. Everything here is a pure function.
use crate::models::InvoiceItem;
use serde::{Deserialize, Serialize};
/// GST state codes, copied from India Compliance (`STATE_NUMBERS` in
/// `india_compliance/gst_india/constants/__init__.py`), including 96 and 97.
pub const STATES: &[(&str, &str)] = &[
("01", "Jammu and Kashmir"),
("02", "Himachal Pradesh"),
("03", "Punjab"),
("04", "Chandigarh"),
("05", "Uttarakhand"),
("06", "Haryana"),
("07", "Delhi"),
("08", "Rajasthan"),
("09", "Uttar Pradesh"),
("10", "Bihar"),
("11", "Sikkim"),
("12", "Sampleachal Pradesh"),
("13", "Nagaland"),
("14", "Manipur"),
("15", "Mizoram"),
("16", "Tripura"),
("17", "Meghalaya"),
("18", "Assam"),
("19", "West Bengal"),
("20", "Jharkhand"),
("21", "Odisha"),
("22", "Chhattisgarh"),
("23", "Madhya Pradesh"),
("24", "Gujarat"),
("26", "Dadra and Nagar Haveli and Daman and Diu"),
("27", "Maharashtra"),
("29", "Karnataka"),
("30", "Goa"),
("31", "Lakshadweep Islands"),
("32", "Kerala"),
("33", "Tamil Nadu"),
("34", "Puducherry"),
("35", "Andaman and Nicobar Islands"),
("36", "Telangana"),
("37", "Andhra Pradesh"),
("38", "Ladakh"),
("96", "Other Countries"),
("97", "Other Territory"),
];
pub fn state_exists(code: &str) -> bool {
STATES.iter().any(|(c, _)| *c == code)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaxType {
None,
CgstSgst,
Igst,
}
impl TaxType {
pub fn as_str(self) -> &'static str {
match self {
TaxType::None => "none",
TaxType::CgstSgst => "cgst_sgst",
TaxType::Igst => "igst",
}
}
}
/// An unregistered supplier cannot charge GST. Otherwise the tax head follows the
/// place of supply, which defaults to the supplier's own state when left empty.
pub fn derive_tax_type(registration: &str, supplier_state: &str, pos_state: &str) -> TaxType {
if registration == "unregistered" {
return TaxType::None;
}
let pos = if pos_state.trim().is_empty() { supplier_state } else { pos_state };
if pos == supplier_state {
TaxType::CgstSgst
} else {
TaxType::Igst
}
}
/// Union territories without a legislature: Chandigarh, Dadra & Nagar Haveli and
/// Daman & Diu, Lakshadweep, Andaman & Nicobar Islands, Ladakh.
#[cfg_attr(not(test), allow(dead_code))] // used through `second_head_label`, which the PDF layer will call
pub fn is_ut_without_legislature(code: &str) -> bool {
matches!(code, "04" | "26" | "31" | "35" | "38")
}
/// UTGST replaces SGST only when the supplier is in one of those territories.
#[cfg_attr(not(test), allow(dead_code))] // consumed by the PDF layer in a later step
pub fn second_head_label(supplier_state: &str) -> &'static str {
if is_ut_without_legislature(supplier_state) {
"UTGST"
} else {
"SGST"
}
}
pub fn doc_type(registration: &str) -> &'static str {
if registration == "unregistered" {
"invoice"
} else {
"tax_invoice"
}
}
const GSTIN_CHARS: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
fn gstin_shape_ok(b: &[u8]) -> bool {
b.len() == 15
&& b[0..2].iter().all(u8::is_ascii_digit)
&& b[2..7].iter().all(u8::is_ascii_uppercase)
&& b[7..11].iter().all(u8::is_ascii_digit)
&& b[11].is_ascii_uppercase()
&& (b[12].is_ascii_uppercase() || (b'1'..=b'9').contains(&b[12]))
&& b[13] == b'Z'
&& (b[14].is_ascii_digit() || b[14].is_ascii_uppercase())
}
/// Standard GSTIN mod-36 check character over the first 14 characters.
fn gstin_check_char(first14: &[u8]) -> u8 {
let sum: usize = first14
.iter()
.enumerate()
.map(|(i, ch)| {
let value = GSTIN_CHARS.iter().position(|c| c == ch).unwrap_or(0);
let product = value * if i % 2 == 0 { 1 } else { 2 };
product / 36 + product % 36
})
.sum();
GSTIN_CHARS[(36 - sum % 36) % 36]
}
pub fn validate_gstin(g: &str) -> Result<(), String> {
let g = g.trim().to_ascii_uppercase();
if g.len() != 15 || !g.is_ascii() {
return Err("GSTIN must be 15 characters".into());
}
let b = g.as_bytes();
if !gstin_shape_ok(b) {
return Err("GSTIN format is invalid".into());
}
if !state_exists(&g[0..2]) {
return Err(format!("GSTIN has an unknown state code {}", &g[0..2]));
}
let expected = gstin_check_char(&b[0..14]);
if b[14] != expected {
return Err(format!(
"GSTIN check digit is wrong (expected {})",
expected as char
));
}
Ok(())
}
/// Characters 3 to 12 of a GSTIN are the holder's PAN.
pub fn gstin_matches_pan(gstin: &str, pan: &str) -> bool {
let gstin = gstin.trim().to_ascii_uppercase();
let pan = pan.trim().to_ascii_uppercase();
gstin.len() == 15 && gstin.is_ascii() && pan.len() == 10 && gstin[2..12] == pan
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Totals {
pub subtotal: i64,
pub discount: i64,
pub taxable: i64,
pub cgst: i64,
pub sgst: i64,
pub igst: i64,
pub total: i64,
}
pub fn rupees_to_paise(rupees: f64) -> i64 {
(rupees * 100.0).round() as i64
}
/// Round-half-up integer division for non-negative operands.
fn div_round(num: i128, den: i128) -> i64 {
((num + den / 2) / den) as i64
}
/// Amount of a single line in paise. Rate lines multiply the rounded rate by the
/// (possibly fractional) quantity and round half up once.
pub fn line_amount_paise(item: &InvoiceItem) -> i64 {
if item.mode == "rate" {
let rate = rupees_to_paise(item.rate);
(rate as f64 * item.quantity).round() as i64
} else {
rupees_to_paise(item.amount)
}
}
pub fn compute_totals(
items: &[InvoiceItem],
discount_paise: i64,
tax_type: TaxType,
rate_bp: i64,
) -> Totals {
let subtotal: i64 = items.iter().map(line_amount_paise).sum();
let discount = discount_paise.clamp(0, subtotal.max(0));
let taxable = subtotal - discount;
let (mut cgst, mut sgst, mut igst) = (0, 0, 0);
match tax_type {
TaxType::CgstSgst => {
cgst = div_round(taxable as i128 * rate_bp as i128, 20_000);
sgst = cgst;
}
TaxType::Igst => igst = div_round(taxable as i128 * rate_bp as i128, 10_000),
TaxType::None => {}
}
Totals {
subtotal,
discount,
taxable,
cgst,
sgst,
igst,
total: taxable + cgst + sgst + igst,
}
}
const ONES: [&str; 20] = [
"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven",
"Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen",
];
const TENS: [&str; 10] = [
"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety",
];
fn two_digits(n: u64) -> String {
if n < 20 {
return ONES[n as usize].to_string();
}
let ones = n % 10;
let mut out = TENS[(n / 10) as usize].to_string();
if ones > 0 {
out.push(' ');
out.push_str(ONES[ones as usize]);
}
out
}
fn three_digits(n: u64) -> String {
let hundreds = n / 100;
let rest = n % 100;
let mut out = String::new();
if hundreds > 0 {
out.push_str(ONES[hundreds as usize]);
out.push_str(" Hundred");
}
if rest > 0 {
if !out.is_empty() {
out.push(' ');
}
out.push_str(&two_digits(rest));
}
out
}
/// Whole number to words in the Indian system (thousand, lakh, crore).
fn whole_to_words(n: u64) -> String {
if n == 0 {
return "Zero".to_string();
}
let mut parts: Vec<String> = Vec::new();
let crore = n / 10_000_000;
let mut rest = n % 10_000_000;
if crore > 0 {
parts.push(format!("{} Crore", whole_to_words(crore)));
}
let lakh = rest / 100_000;
rest %= 100_000;
if lakh > 0 {
parts.push(format!("{} Lakh", two_digits(lakh)));
}
let thousand = rest / 1000;
rest %= 1000;
if thousand > 0 {
parts.push(format!("{} Thousand", two_digits(thousand)));
}
if rest > 0 {
parts.push(three_digits(rest));
}
parts.join(" ")
}
/// Same output as `amountInWords` in `src/lib/numberToWords.ts`,
/// e.g. 2_500_000 paise -> "Indian Rupees Twenty Five Thousand Only".
pub fn amount_in_words(paise: i64) -> String {
let value = paise.unsigned_abs();
let rupees = value / 100;
let paise = value % 100;
let mut out = format!("Indian Rupees {}", whole_to_words(rupees));
if paise > 0 {
out.push_str(&format!(" and {} Paise", two_digits(paise)));
}
out.push_str(" Only");
out
}
#[cfg(test)]
mod tests {
use super::*;
fn rate_item(rate: f64, quantity: f64) -> InvoiceItem {
InvoiceItem {
id: None,
description: String::new(),
mode: "rate".into(),
rate,
unit: "hour".into(),
quantity,
amount: 0.0,
sort_order: 0,
hsn_sac: String::new(),
}
}
fn fixed_item(amount: f64) -> InvoiceItem {
InvoiceItem {
mode: "fixed".into(),
amount,
..rate_item(0.0, 0.0)
}
}
#[test]
fn state_list_has_special_codes_and_unique_codes() {
assert_eq!(STATES.len(), 38);
assert!(STATES.contains(&("96", "Other Countries")));
assert!(STATES.contains(&("97", "Other Territory")));
assert!(STATES.contains(&("29", "Karnataka")));
let mut codes: Vec<_> = STATES.iter().map(|(c, _)| *c).collect();
codes.dedup();
assert_eq!(codes.len(), STATES.len());
}
#[test]
fn tax_type_derivation() {
assert_eq!(derive_tax_type("unregistered", "29", "27"), TaxType::None);
assert_eq!(derive_tax_type("unregistered", "29", ""), TaxType::None);
assert_eq!(derive_tax_type("regular", "29", "29"), TaxType::CgstSgst);
assert_eq!(derive_tax_type("regular", "29", ""), TaxType::CgstSgst);
assert_eq!(derive_tax_type("regular", "29", "27"), TaxType::Igst);
assert_eq!(derive_tax_type("regular", "29", "96"), TaxType::Igst);
}
#[test]
fn tax_type_serde_strings() {
assert_eq!(serde_json::to_string(&TaxType::CgstSgst).unwrap(), "\"cgst_sgst\"");
assert_eq!(serde_json::from_str::<TaxType>("\"igst\"").unwrap(), TaxType::Igst);
assert_eq!(serde_json::from_str::<TaxType>("\"none\"").unwrap(), TaxType::None);
}
#[test]
fn union_territory_labels() {
for code in ["04", "26", "31", "35", "38"] {
assert!(is_ut_without_legislature(code));
assert_eq!(second_head_label(code), "UTGST");
}
for code in ["07", "29", "34", "01", "96"] {
assert!(!is_ut_without_legislature(code));
assert_eq!(second_head_label(code), "SGST");
}
}
#[test]
fn document_type() {
assert_eq!(doc_type("unregistered"), "invoice");
assert_eq!(doc_type("regular"), "tax_invoice");
}
#[test]
fn gstin_checksum() {
assert_eq!(validate_gstin("27AAPFU0939F1ZV"), Ok(()));
assert_eq!(validate_gstin("07AAGFF2194N1Z1"), Ok(()));
assert_eq!(validate_gstin("29AAGCB7383J1Z4"), Ok(()));
assert_eq!(validate_gstin("29abcde1234f1zw"), Ok(()));
// The algorithm gives W for this body, so a trailing 5 is a typo.
assert_eq!(validate_gstin("29ABCDE1234F1ZW"), Ok(()));
assert!(validate_gstin("29ABCDE1234F1Z5").unwrap_err().contains("check digit"));
assert!(validate_gstin("27AAPFU0939F1ZX").unwrap_err().contains("check digit"));
}
#[test]
fn gstin_rejects_bad_shapes() {
assert!(validate_gstin("").unwrap_err().contains("15 characters"));
assert!(validate_gstin("27AAPFU0939F1Z").unwrap_err().contains("15 characters"));
assert!(validate_gstin("27AAPFU0939F1ZVV").unwrap_err().contains("15 characters"));
assert!(validate_gstin("2XAAPFU0939F1ZV").unwrap_err().contains("format"));
assert!(validate_gstin("27AAPFU0939F1AV").unwrap_err().contains("format"));
// Valid shape and checksum but state 99 does not exist.
let body = b"99AAPFU0939F1Z";
let mut g = String::from_utf8(body.to_vec()).unwrap();
g.push(gstin_check_char(body) as char);
assert!(validate_gstin(&g).unwrap_err().contains("state code"));
}
#[test]
fn pan_cross_check() {
assert!(gstin_matches_pan("27AAPFU0939F1ZV", "AAPFU0939F"));
assert!(gstin_matches_pan("27AAPFU0939F1ZV", " aapfu0939f "));
assert!(!gstin_matches_pan("27AAPFU0939F1ZV", "AAPFU0939G"));
assert!(!gstin_matches_pan("27AAPFU0939F1ZV", ""));
}
#[test]
fn totals_cgst_sgst_18_percent() {
let t = compute_totals(&[fixed_item(7310.0)], 0, TaxType::CgstSgst, 1800);
assert_eq!(t.subtotal, 731_000);
assert_eq!(t.cgst, 65_790);
assert_eq!(t.sgst, 65_790);
assert_eq!(t.igst, 0);
assert_eq!(t.total, 731_000 + 131_580);
}
#[test]
fn totals_igst_5_percent_rounds_half_up() {
// 33.33 x 5% = 1.6665 -> 1.67; split in two heads it would be 0.83 + 0.83.
let igst = compute_totals(&[fixed_item(33.33)], 0, TaxType::Igst, 500);
assert_eq!(igst.igst, 167);
assert_eq!(igst.total, 3333 + 167);
let split = compute_totals(&[fixed_item(33.33)], 0, TaxType::CgstSgst, 500);
assert_eq!((split.cgst, split.sgst), (83, 83));
assert_eq!(split.total, 3333 + 166);
}
#[test]
fn totals_fractional_hours() {
let t = compute_totals(&[rate_item(1200.0, 1.5)], 0, TaxType::None, 0);
assert_eq!(t.subtotal, 180_000);
assert_eq!(t.total, 180_000);
// 99.99 x 2.5 = 249.975 -> 249.98
assert_eq!(line_amount_paise(&rate_item(99.99, 2.5)), 24_998);
}
#[test]
fn totals_discount_is_clamped() {
let items = [fixed_item(100.0), fixed_item(50.5)];
let over = compute_totals(&items, 99_999, TaxType::Igst, 1800);
assert_eq!(over.discount, 15_050);
assert_eq!(over.taxable, 0);
assert_eq!(over.total, 0);
let negative = compute_totals(&items, -500, TaxType::None, 0);
assert_eq!(negative.discount, 0);
assert_eq!(negative.total, 15_050);
let partial = compute_totals(&items, 5050, TaxType::None, 0);
assert_eq!(partial.taxable, 10_000);
}
#[test]
fn totals_with_no_tax_ignore_the_rate() {
let t = compute_totals(&[fixed_item(1000.0)], 0, TaxType::None, 1800);
assert_eq!((t.cgst, t.sgst, t.igst), (0, 0, 0));
assert_eq!(t.total, 100_000);
}
#[test]
fn words_match_the_typescript_implementation() {
// Expected strings were produced by running src/lib/numberToWords.ts.
let cases: &[(i64, &str)] = &[
(0, "Indian Rupees Zero Only"),
(1, "Indian Rupees Zero and One Paise Only"),
(100, "Indian Rupees One Only"),
(1950, "Indian Rupees Nineteen and Fifty Paise Only"),
(10_000, "Indian Rupees One Hundred Only"),
(10_101, "Indian Rupees One Hundred One and One Paise Only"),
(
99_999,
"Indian Rupees Nine Hundred Ninety Nine and Ninety Nine Paise Only",
),
(100_000, "Indian Rupees One Thousand Only"),
(
1_234_567,
"Indian Rupees Twelve Thousand Three Hundred Forty Five and Sixty Seven Paise Only",
),
(10_000_000, "Indian Rupees One Lakh Only"),
(10_000_005, "Indian Rupees One Lakh and Five Paise Only"),
(
123_456_789,
"Indian Rupees Twelve Lakh Thirty Four Thousand Five Hundred Sixty Seven and Eighty Nine Paise Only",
),
(1_000_000_000, "Indian Rupees One Crore Only"),
(1210, "Indian Rupees Twelve and Ten Paise Only"),
(65_790, "Indian Rupees Six Hundred Fifty Seven and Ninety Paise Only"),
(
8_625_820,
"Indian Rupees Eighty Six Thousand Two Hundred Fifty Eight and Twenty Paise Only",
),
(
99_999_999_999,
"Indian Rupees Ninety Nine Crore Ninety Nine Lakh Ninety Nine Thousand Nine Hundred Ninety Nine and Ninety Nine Paise Only",
),
(250_000_000_000, "Indian Rupees Two Hundred Fifty Crore Only"),
];
for (paise, expected) in cases {
assert_eq!(amount_in_words(*paise), *expected, "paise {paise}");
}
}
}