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.
507 lines
17 KiB
Rust
507 lines
17 KiB
Rust
//! 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}");
|
|
}
|
|
}
|
|
}
|