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:
+50
-42
@@ -6,8 +6,6 @@ import {
|
||||
Grid,
|
||||
InlineNotification,
|
||||
NumberInput,
|
||||
RadioButton,
|
||||
RadioButtonGroup,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea,
|
||||
@@ -29,6 +27,7 @@ import type {
|
||||
} from "../lib/types";
|
||||
import { computeLineAmount, computeTotals } from "../lib/invoice";
|
||||
import { amountInWords } from "../lib/numberToWords";
|
||||
import { gstinFullError } from "../lib/validators";
|
||||
import { addDays, formatAmount, todayIso } from "../lib/format";
|
||||
import { buildPdfProps, exportPdfFromProps } from "../lib/pdf";
|
||||
import { InvoicePreview } from "../components/InvoicePreview";
|
||||
@@ -41,6 +40,7 @@ interface NewInvoiceProps {
|
||||
}
|
||||
|
||||
export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
||||
const today = todayIso();
|
||||
const [invoiceDate, setInvoiceDate] = useState(today);
|
||||
const [dueDate, setDueDate] = useState(addDays(today, settings.paymentTermsDays));
|
||||
@@ -55,13 +55,11 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
const [placeOfSupply, setPlaceOfSupply] = useState(settings.vendorStateCode);
|
||||
const [saveClient, setSaveClient] = useState(false);
|
||||
|
||||
const [items, setItems] = useState<InvoiceItem[]>([{ ...EMPTY_ITEM }]);
|
||||
const [items, setItems] = useState<InvoiceItem[]>([newItem()]);
|
||||
const [discount, setDiscount] = useState(0);
|
||||
|
||||
const [taxesApplicable, setTaxesApplicable] = useState(settings.defaultTaxType !== "none");
|
||||
const [taxType, setTaxType] = useState<TaxType>(
|
||||
settings.defaultTaxType === "none" ? "cgst_sgst" : settings.defaultTaxType,
|
||||
);
|
||||
const registered = settings.gstRegistration !== "unregistered";
|
||||
const [reverseCharge, setReverseCharge] = useState(false);
|
||||
const [taxRate, setTaxRate] = useState(settings.defaultTaxRate);
|
||||
|
||||
const [banks, setBanks] = useState<BankAccount[]>([]);
|
||||
@@ -122,7 +120,14 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
};
|
||||
}, [signaturePath]);
|
||||
|
||||
const effectiveTaxType: TaxType = taxesApplicable ? taxType : "none";
|
||||
// Rust derives the tax type from the supplier state and the place of supply; this
|
||||
// mirrors that rule so the preview and the value sent for the cross-check agree.
|
||||
const effectiveTaxType: TaxType = !registered
|
||||
? "none"
|
||||
: !placeOfSupply || placeOfSupply === settings.vendorStateCode
|
||||
? "cgst_sgst"
|
||||
: "igst";
|
||||
const clientGstinError = gstinFullError(clientGstin);
|
||||
const totals = useMemo(
|
||||
() => computeTotals(items, discount, effectiveTaxType, taxRate),
|
||||
[items, discount, effectiveTaxType, taxRate],
|
||||
@@ -159,7 +164,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
const updateItem = (index: number, patch: Partial<InvoiceItem>) =>
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
|
||||
const addItem = () => setItems((prev) => [...prev, { ...EMPTY_ITEM }]);
|
||||
const addItem = () => setItems((prev) => [...prev, newItem()]);
|
||||
const removeItem = (index: number) =>
|
||||
setItems((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== index)));
|
||||
|
||||
@@ -179,7 +184,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
setClientGstin("");
|
||||
setPoNumber("");
|
||||
setSaveClient(false);
|
||||
setItems([{ ...EMPTY_ITEM }]);
|
||||
setItems([newItem()]);
|
||||
setDiscount(0);
|
||||
setNotes("");
|
||||
setInvoiceDate(todayIso());
|
||||
@@ -193,6 +198,10 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
if (clientGstinError) {
|
||||
setError(`Client GSTIN: ${clientGstinError}`);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(null);
|
||||
@@ -206,27 +215,18 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
clientGstin,
|
||||
poNumber,
|
||||
placeOfSupplyStateCode: placeOfSupply,
|
||||
subtotal: totals.subtotal,
|
||||
discount: totals.discount,
|
||||
discount,
|
||||
taxType: effectiveTaxType,
|
||||
taxRate,
|
||||
cgstAmount: totals.cgst,
|
||||
sgstAmount: totals.sgst,
|
||||
igstAmount: totals.igst,
|
||||
total: totals.total,
|
||||
amountInWords: words,
|
||||
reverseCharge: registered && reverseCharge,
|
||||
bankAccountId: bankId,
|
||||
bankSnapshot: bank ? JSON.stringify(bank) : "",
|
||||
signaturePath,
|
||||
notes,
|
||||
saveClient,
|
||||
items: items.map((item, index) => ({
|
||||
...item,
|
||||
sortOrder: index,
|
||||
amount: computeLineAmount(item),
|
||||
})),
|
||||
draftId: null,
|
||||
items: items.map((item, index) => ({ ...item, sortOrder: index })),
|
||||
};
|
||||
const invoice = await api.createInvoice(input);
|
||||
const invoice = await api.issueInvoice(input, {});
|
||||
const props = await buildPdfProps(invoice, settings, bank);
|
||||
const path = await exportPdfFromProps(props, invoice.number);
|
||||
if (path) setSaved(path);
|
||||
@@ -336,6 +336,8 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
labelText="Client GSTIN (optional)"
|
||||
value={clientGstin}
|
||||
onChange={(e) => setClientGstin(e.target.value.toUpperCase())}
|
||||
invalid={Boolean(clientGstinError)}
|
||||
invalidText={clientGstinError}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
@@ -391,6 +393,16 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
onChange={(e) => updateItem(index, { description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{registered ? (
|
||||
<div style={{ flex: "0 1 120px" }}>
|
||||
<TextInput
|
||||
id={`hsn-${index}`}
|
||||
labelText="HSN/SAC"
|
||||
value={item.hsnSac ?? ""}
|
||||
onChange={(e) => updateItem(index, { hsnSac: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ flex: "0 1 150px" }}>
|
||||
<Select
|
||||
id={`mode-${index}`}
|
||||
@@ -479,26 +491,14 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
onChange={(_e, { value }) => setDiscount(Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<Toggle
|
||||
id="taxes"
|
||||
labelText="Taxes applicable"
|
||||
toggled={taxesApplicable}
|
||||
onToggle={setTaxesApplicable}
|
||||
/>
|
||||
</Column>
|
||||
{taxesApplicable ? (
|
||||
{registered ? (
|
||||
<>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<RadioButtonGroup
|
||||
legendText="Tax treatment"
|
||||
name="taxType"
|
||||
valueSelected={taxType}
|
||||
onChange={(value) => setTaxType(value as TaxType)}
|
||||
>
|
||||
<RadioButton id="t-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
|
||||
<RadioButton id="t-igst" labelText="IGST (inter-state)" value="igst" />
|
||||
</RadioButtonGroup>
|
||||
<p className="voiced-page__subtitle" style={{ marginBottom: 0 }}>
|
||||
{effectiveTaxType === "igst"
|
||||
? "IGST (inter-state supply)"
|
||||
: "CGST + SGST (supply within the supplier's state)"}
|
||||
</p>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<NumberInput
|
||||
@@ -511,6 +511,14 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
|
||||
onChange={(_e, { value }) => setTaxRate(Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<Toggle
|
||||
id="reverseCharge"
|
||||
labelText="Tax payable on reverse charge"
|
||||
toggled={reverseCharge}
|
||||
onToggle={setReverseCharge}
|
||||
/>
|
||||
</Column>
|
||||
</>
|
||||
) : null}
|
||||
</Grid>
|
||||
|
||||
Reference in New Issue
Block a user