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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user