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:
+11
-2
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
BankAccount,
|
||||
Client,
|
||||
DraftSummary,
|
||||
Invoice,
|
||||
InvoiceInput,
|
||||
InvoiceSeries,
|
||||
@@ -27,10 +28,18 @@ export const api = {
|
||||
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
||||
|
||||
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
||||
createInvoice: (input: InvoiceInput) => invoke<Invoice>("create_invoice", { input }),
|
||||
issueInvoice: (input: InvoiceInput, renderPrefs: Record<string, unknown>) =>
|
||||
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
||||
cancelInvoice: (id: number, reason: string) =>
|
||||
invoke<Invoice>("cancel_invoice", { id, reason }),
|
||||
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
||||
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
||||
deleteInvoice: (id: number) => invoke<void>("delete_invoice", { id }),
|
||||
|
||||
saveDraft: (id: number | null, payload: unknown) =>
|
||||
invoke<number>("save_draft", { id, payload }),
|
||||
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
|
||||
getDraft: (id: number) => invoke<unknown>("get_draft", { id }),
|
||||
deleteDraft: (id: number) => invoke<void>("delete_draft", { id }),
|
||||
|
||||
importAsset: (sourcePath: string, kind: string) =>
|
||||
invoke<string>("import_asset", { sourcePath, kind }),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeLineAmount, computeTotals } from "./invoice";
|
||||
import { EMPTY_ITEM, type InvoiceItem } from "./types";
|
||||
|
||||
// These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview
|
||||
// and the stored totals stay identical.
|
||||
|
||||
const fixed = (amount: number): InvoiceItem => ({ ...EMPTY_ITEM, mode: "fixed", amount });
|
||||
const rate = (r: number, quantity: number): InvoiceItem => ({
|
||||
...EMPTY_ITEM,
|
||||
mode: "rate",
|
||||
rate: r,
|
||||
quantity,
|
||||
unit: "hour",
|
||||
});
|
||||
|
||||
describe("computeTotals", () => {
|
||||
it("splits 18% on 7310.00 into 657.90 CGST and SGST", () => {
|
||||
const t = computeTotals([fixed(7310)], 0, "cgst_sgst", 18);
|
||||
expect(t.subtotal).toBe(7310);
|
||||
expect(t.cgst).toBe(657.9);
|
||||
expect(t.sgst).toBe(657.9);
|
||||
expect(t.igst).toBe(0);
|
||||
expect(t.total).toBe(8625.8);
|
||||
});
|
||||
|
||||
it("rounds odd paise half up for IGST and per head for CGST/SGST", () => {
|
||||
const igst = computeTotals([fixed(33.33)], 0, "igst", 5);
|
||||
expect(igst.igst).toBe(1.67);
|
||||
expect(igst.total).toBe(35);
|
||||
const split = computeTotals([fixed(33.33)], 0, "cgst_sgst", 5);
|
||||
expect([split.cgst, split.sgst]).toEqual([0.83, 0.83]);
|
||||
expect(split.total).toBe(34.99);
|
||||
});
|
||||
|
||||
it("handles fractional quantities", () => {
|
||||
const t = computeTotals([rate(1200, 1.5)], 0, "none", 0);
|
||||
expect(t.subtotal).toBe(1800);
|
||||
expect(t.total).toBe(1800);
|
||||
expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98);
|
||||
});
|
||||
|
||||
it("clamps the discount to the subtotal", () => {
|
||||
const items = [fixed(100), fixed(50.5)];
|
||||
const over = computeTotals(items, 999.99, "igst", 18);
|
||||
expect(over.discount).toBe(150.5);
|
||||
expect(over.taxable).toBe(0);
|
||||
expect(over.total).toBe(0);
|
||||
expect(computeTotals(items, -5, "none", 0).discount).toBe(0);
|
||||
expect(computeTotals(items, 50.5, "none", 0).taxable).toBe(100);
|
||||
});
|
||||
|
||||
it("ignores the rate when no tax applies", () => {
|
||||
const t = computeTotals([fixed(1000)], 0, "none", 18);
|
||||
expect(t.taxTotal).toBe(0);
|
||||
expect(t.total).toBe(1000);
|
||||
});
|
||||
});
|
||||
+32
-20
@@ -1,12 +1,23 @@
|
||||
import { round2 } from "./format";
|
||||
import type { InvoiceItem, TaxType } from "./types";
|
||||
|
||||
// The preview must show exactly what Rust stores (src-tauri/src/gst.rs), so money is
|
||||
// computed in integer paise with round-half-up, the same way.
|
||||
|
||||
const toPaise = (rupees: number) => Math.round((Number.isFinite(rupees) ? rupees : 0) * 100);
|
||||
|
||||
function linePaise(item: InvoiceItem): number {
|
||||
if (item.mode === "rate") {
|
||||
return Math.round(toPaise(item.rate || 0) * (item.quantity || 0));
|
||||
}
|
||||
return toPaise(item.amount || 0);
|
||||
}
|
||||
|
||||
/** Round-half-up division for non-negative integers. */
|
||||
const divRound = (num: number, den: number) => Math.floor((num + den / 2) / den);
|
||||
|
||||
/** Amount for a single line: a fixed total, or rate × quantity. */
|
||||
export function computeLineAmount(item: InvoiceItem): number {
|
||||
if (item.mode === "rate") {
|
||||
return round2((item.rate || 0) * (item.quantity || 0));
|
||||
}
|
||||
return round2(item.amount || 0);
|
||||
return linePaise(item) / 100;
|
||||
}
|
||||
|
||||
export interface Totals {
|
||||
@@ -26,29 +37,30 @@ export function computeTotals(
|
||||
taxType: TaxType,
|
||||
taxRate: number,
|
||||
): Totals {
|
||||
const subtotal = round2(items.reduce((sum, item) => sum + computeLineAmount(item), 0));
|
||||
const discountValue = round2(Math.min(Math.max(discount || 0, 0), subtotal));
|
||||
const taxable = round2(subtotal - discountValue);
|
||||
const subtotal = items.reduce((sum, item) => sum + linePaise(item), 0);
|
||||
const discountPaise = Math.min(Math.max(toPaise(discount), 0), Math.max(subtotal, 0));
|
||||
const taxable = subtotal - discountPaise;
|
||||
const rateBp = Math.round((Number.isFinite(taxRate) ? taxRate : 0) * 100);
|
||||
|
||||
let cgst = 0;
|
||||
let sgst = 0;
|
||||
let igst = 0;
|
||||
if (taxType === "cgst_sgst") {
|
||||
cgst = round2((taxable * (taxRate / 2)) / 100);
|
||||
sgst = round2((taxable * (taxRate / 2)) / 100);
|
||||
cgst = divRound(taxable * rateBp, 20_000);
|
||||
sgst = cgst;
|
||||
} else if (taxType === "igst") {
|
||||
igst = round2((taxable * taxRate) / 100);
|
||||
igst = divRound(taxable * rateBp, 10_000);
|
||||
}
|
||||
const taxTotal = round2(cgst + sgst + igst);
|
||||
const taxTotal = cgst + sgst + igst;
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
discount: discountValue,
|
||||
taxable,
|
||||
cgst,
|
||||
sgst,
|
||||
igst,
|
||||
taxTotal,
|
||||
total: round2(taxable + taxTotal),
|
||||
subtotal: subtotal / 100,
|
||||
discount: discountPaise / 100,
|
||||
taxable: taxable / 100,
|
||||
cgst: cgst / 100,
|
||||
sgst: sgst / 100,
|
||||
igst: igst / 100,
|
||||
taxTotal: taxTotal / 100,
|
||||
total: (taxable + taxTotal) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
+14
-2
@@ -17,12 +17,24 @@ function blobToBase64(blob: Blob): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
function vendorFromSnapshot(snapshot: string | undefined, live: Settings): Settings {
|
||||
if (!snapshot) return live;
|
||||
try {
|
||||
return { ...live, ...(JSON.parse(snapshot) as Partial<Settings>) };
|
||||
} catch {
|
||||
return live;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildPdfProps(
|
||||
invoice: Invoice,
|
||||
settings: Settings,
|
||||
bank: BankAccount | null,
|
||||
): Promise<InvoicePdfProps> {
|
||||
const logoDataUri = settings.logoPath ? await api.readAssetDataUri(settings.logoPath) : null;
|
||||
// Re-exports use the vendor details frozen at issue; live settings are only a fallback for
|
||||
// legacy rows without a snapshot.
|
||||
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
|
||||
const logoDataUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
|
||||
const signatureDataUri = invoice.signaturePath
|
||||
? await api.readAssetDataUri(invoice.signaturePath)
|
||||
: null;
|
||||
@@ -37,7 +49,7 @@ export async function buildPdfProps(
|
||||
}
|
||||
|
||||
return {
|
||||
vendor: settings,
|
||||
vendor,
|
||||
bank: resolvedBank,
|
||||
logoDataUri,
|
||||
signatureDataUri,
|
||||
|
||||
+56
-13
@@ -19,7 +19,7 @@ export interface Settings {
|
||||
currency: string;
|
||||
onboarded: boolean;
|
||||
theme: string;
|
||||
gstRegistration: string;
|
||||
gstRegistration: GstRegistration;
|
||||
defaultHsnSac: string;
|
||||
signatoryName: string;
|
||||
signatoryDesignation: string;
|
||||
@@ -65,9 +65,41 @@ export interface InvoiceItem {
|
||||
quantity: number;
|
||||
amount: number;
|
||||
sortOrder: number;
|
||||
/** Optional so older callers that build items by hand keep compiling; stored as "" when absent. */
|
||||
hsnSac?: string;
|
||||
}
|
||||
|
||||
export type GstRegistration = "unregistered" | "regular";
|
||||
|
||||
/** What the webview sends to issue an invoice; totals, tax and words are derived in Rust. */
|
||||
export interface InvoiceInput {
|
||||
invoiceDate: string;
|
||||
dueDate: string;
|
||||
clientId: number | null;
|
||||
clientName: string;
|
||||
clientAddress: string;
|
||||
clientGstin: string;
|
||||
poNumber: string;
|
||||
placeOfSupplyStateCode: string;
|
||||
discount: number;
|
||||
taxType: TaxType;
|
||||
taxRate: number;
|
||||
reverseCharge: boolean;
|
||||
bankAccountId: number | null;
|
||||
signaturePath: string | null;
|
||||
notes: string;
|
||||
saveClient: boolean;
|
||||
draftId: number | null;
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
export type InvoiceStatus = "draft" | "issued" | "cancelled";
|
||||
|
||||
/** An invoice as stored: all amounts are derived on the Rust side. */
|
||||
export interface Invoice {
|
||||
id: number;
|
||||
number: string;
|
||||
seriesId: number | null;
|
||||
invoiceDate: string;
|
||||
dueDate: string;
|
||||
clientId: number | null;
|
||||
@@ -89,18 +121,25 @@ export interface InvoiceInput {
|
||||
bankSnapshot: string;
|
||||
signaturePath: string | null;
|
||||
notes: string;
|
||||
saveClient: boolean;
|
||||
status: InvoiceStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
docType: "invoice" | "tax_invoice";
|
||||
reverseCharge: boolean;
|
||||
/** JSON of the supplier details at issue time; empty when unknown. */
|
||||
vendorSnapshot: string;
|
||||
snapshotOrigin: string;
|
||||
cancelledAt: string | null;
|
||||
cancelReason: string;
|
||||
archivedPdfSha256: string | null;
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
export interface Invoice extends Omit<InvoiceInput, "saveClient"> {
|
||||
export interface DraftSummary {
|
||||
id: number;
|
||||
number: string;
|
||||
seriesId: number | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
items: InvoiceItem[];
|
||||
clientName: string | null;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
export interface InvoiceSummary {
|
||||
@@ -110,7 +149,7 @@ export interface InvoiceSummary {
|
||||
dueDate: string;
|
||||
clientName: string;
|
||||
total: number;
|
||||
status: string;
|
||||
status: InvoiceStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -122,10 +161,12 @@ export const EMPTY_ITEM: InvoiceItem = {
|
||||
quantity: 1,
|
||||
amount: 0,
|
||||
sortOrder: 0,
|
||||
hsnSac: "",
|
||||
};
|
||||
|
||||
/** GST state codes, same list as STATES in src-tauri/src/gst.rs (from India Compliance). */
|
||||
export const INDIAN_STATES: { code: string; name: string }[] = [
|
||||
{ code: "01", name: "Jammu & Kashmir" },
|
||||
{ code: "01", name: "Jammu and Kashmir" },
|
||||
{ code: "02", name: "Himachal Pradesh" },
|
||||
{ code: "03", name: "Punjab" },
|
||||
{ code: "04", name: "Chandigarh" },
|
||||
@@ -149,18 +190,20 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
|
||||
{ code: "22", name: "Chhattisgarh" },
|
||||
{ code: "23", name: "Madhya Pradesh" },
|
||||
{ code: "24", name: "Gujarat" },
|
||||
{ code: "26", name: "Dadra & Nagar Haveli and Daman & Diu" },
|
||||
{ code: "26", name: "Dadra and Nagar Haveli and Daman and Diu" },
|
||||
{ code: "27", name: "Maharashtra" },
|
||||
{ code: "29", name: "Karnataka" },
|
||||
{ code: "30", name: "Goa" },
|
||||
{ code: "31", name: "Lakshadweep" },
|
||||
{ code: "31", name: "Lakshadweep Islands" },
|
||||
{ code: "32", name: "Kerala" },
|
||||
{ code: "33", name: "Tamil Nadu" },
|
||||
{ code: "34", name: "Puducherry" },
|
||||
{ code: "35", name: "Andaman & Nicobar Islands" },
|
||||
{ code: "35", name: "Andaman and Nicobar Islands" },
|
||||
{ code: "36", name: "Telangana" },
|
||||
{ code: "37", name: "Andhra Pradesh" },
|
||||
{ code: "38", name: "Ladakh" },
|
||||
{ code: "96", name: "Other Countries" },
|
||||
{ code: "97", name: "Other Territory" },
|
||||
];
|
||||
|
||||
export const UNIT_LABELS: Record<LineUnit, string> = {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
|
||||
|
||||
// Same GSTINs as the Rust tests in src-tauri/src/gst.rs.
|
||||
describe("GSTIN checksum", () => {
|
||||
it("accepts valid GSTINs", () => {
|
||||
expect(gstinChecksumError("27AAPFU0939F1ZV")).toBeUndefined();
|
||||
expect(gstinChecksumError("07AAGFF2194N1Z1")).toBeUndefined();
|
||||
expect(gstinChecksumError("29ABCDE1234F1ZW")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a wrong check digit and bad shapes", () => {
|
||||
expect(gstinChecksumError("29ABCDE1234F1Z5")).toMatch(/expected W/);
|
||||
expect(gstinChecksumError("27AAPFU0939F1ZX")).toMatch(/check digit/);
|
||||
expect(gstinChecksumError("27AAPFU0939F1Z")).toMatch(/15-character/);
|
||||
});
|
||||
|
||||
it("cross-checks the PAN and the state", () => {
|
||||
expect(gstinFullError("27AAPFU0939F1ZV", "AAPFU0939F")).toBeUndefined();
|
||||
expect(gstinFullError("27AAPFU0939F1ZV", "AAPFU0939G")).toMatch(/PAN/);
|
||||
expect(gstinFullError("")).toBeUndefined();
|
||||
expect(vendorGstinError("unregistered", "", "", "29")).toBeUndefined();
|
||||
expect(vendorGstinError("regular", "", "", "27")).toMatch(/needs a GSTIN/);
|
||||
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "29")).toMatch(/state code/);
|
||||
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "27")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,50 @@ export function gstinError(value: string): string | undefined {
|
||||
return GSTIN_RE.test(v) ? undefined : "Enter a valid 15-character GSTIN";
|
||||
}
|
||||
|
||||
const GSTIN_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
|
||||
/** Standard GSTIN mod-36 check digit. Returns an error message, or undefined when valid. */
|
||||
export function gstinChecksumError(value: string): string | undefined {
|
||||
const v = norm(value);
|
||||
if (!GSTIN_RE.test(v)) return "Enter a valid 15-character GSTIN";
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const product = GSTIN_CHARS.indexOf(v[i]) * (i % 2 === 0 ? 1 : 2);
|
||||
sum += Math.floor(product / 36) + (product % 36);
|
||||
}
|
||||
const expected = GSTIN_CHARS[(36 - (sum % 36)) % 36];
|
||||
return v[14] === expected ? undefined : `GSTIN check digit looks wrong (expected ${expected})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full GSTIN check: shape, check digit and, when a PAN is given, that the GSTIN
|
||||
* contains that PAN. Empty (or "NA") is valid; the field is optional.
|
||||
*/
|
||||
export function gstinFullError(value: string, pan = ""): string | undefined {
|
||||
const v = norm(value);
|
||||
if (!v || v === "NA") return undefined;
|
||||
const base = gstinError(v) ?? gstinChecksumError(v);
|
||||
if (base) return base;
|
||||
if (pan.trim() && v.slice(2, 12) !== norm(pan)) return "GSTIN does not contain the PAN";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Supplier GSTIN rules for a registered business; an unregistered one needs none. */
|
||||
export function vendorGstinError(
|
||||
registration: string,
|
||||
gstin: string,
|
||||
pan: string,
|
||||
stateCode: string,
|
||||
): string | undefined {
|
||||
if (registration === "unregistered") return undefined;
|
||||
const g = norm(gstin);
|
||||
if (!g) return "A registered business needs a GSTIN";
|
||||
const base = gstinFullError(g, pan);
|
||||
if (base) return base;
|
||||
if (g.slice(0, 2) !== stateCode) return `GSTIN starts with ${g.slice(0, 2)}, but your state code is ${stateCode}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function emailError(value: string): string | undefined {
|
||||
if (!value.trim()) return undefined;
|
||||
return EMAIL_RE.test(value.trim()) ? undefined : "Enter a valid email address";
|
||||
|
||||
+101
-33
@@ -23,8 +23,14 @@ import {
|
||||
import { Add, Save, TrashCan } from "@carbon/icons-react";
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
import { INDIAN_STATES, type BankAccount, type Settings, type TaxType } from "../lib/types";
|
||||
import { emailError, ifscError, panError, phoneError } from "../lib/validators";
|
||||
import {
|
||||
INDIAN_STATES,
|
||||
type BankAccount,
|
||||
type GstRegistration,
|
||||
type Settings,
|
||||
type TaxType,
|
||||
} from "../lib/types";
|
||||
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
|
||||
const blankBank = (): BankAccount => ({
|
||||
@@ -70,7 +76,19 @@ export default function AppSettings({
|
||||
const setBank = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
|
||||
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
|
||||
const registered = form.gstRegistration !== "unregistered";
|
||||
const gstinProblem = vendorGstinError(
|
||||
form.gstRegistration,
|
||||
form.vendorGstin,
|
||||
form.vendorPan,
|
||||
form.vendorStateCode,
|
||||
);
|
||||
|
||||
const saveAll = async () => {
|
||||
if (gstinProblem) {
|
||||
setError(gstinProblem);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
@@ -140,6 +158,22 @@ export default function AppSettings({
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Vendor details</h3>
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<RadioButtonGroup
|
||||
legendText="GST registration"
|
||||
name="s-gstReg"
|
||||
orientation="horizontal"
|
||||
valueSelected={form.gstRegistration}
|
||||
onChange={(value) => {
|
||||
set("gstRegistration", value as GstRegistration);
|
||||
if (value === "unregistered") set("defaultTaxType", "none");
|
||||
else if (form.defaultTaxType === "none") set("defaultTaxType", "cgst_sgst");
|
||||
}}
|
||||
>
|
||||
<RadioButton id="s-gr-un" labelText="Unregistered" value="unregistered" />
|
||||
<RadioButton id="s-gr-reg" labelText="Regular" value="regular" />
|
||||
</RadioButtonGroup>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<TextInput
|
||||
id="s-vendorName"
|
||||
@@ -187,14 +221,18 @@ export default function AppSettings({
|
||||
invalidText={panError(form.vendorPan)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="s-vendorGstin"
|
||||
labelText="GSTIN (blank if unregistered)"
|
||||
value={form.vendorGstin}
|
||||
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
|
||||
/>
|
||||
</Column>
|
||||
{registered ? (
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="s-vendorGstin"
|
||||
labelText="GSTIN"
|
||||
value={form.vendorGstin}
|
||||
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
|
||||
invalid={Boolean(gstinProblem)}
|
||||
invalidText={gstinProblem}
|
||||
/>
|
||||
</Column>
|
||||
) : null}
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<Select
|
||||
id="s-vendorState"
|
||||
@@ -207,6 +245,22 @@ export default function AppSettings({
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="s-signatoryName"
|
||||
labelText="Authorised signatory name"
|
||||
value={form.signatoryName}
|
||||
onChange={(e) => set("signatoryName", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="s-signatoryDesignation"
|
||||
labelText="Signatory designation"
|
||||
value={form.signatoryDesignation}
|
||||
onChange={(e) => set("signatoryDesignation", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={8}>
|
||||
<ImagePicker
|
||||
kind="logo"
|
||||
@@ -229,17 +283,29 @@ export default function AppSettings({
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Defaults</h3>
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<NumberInput
|
||||
id="s-taxRate"
|
||||
label="Default tax rate (%)"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={form.defaultTaxRate}
|
||||
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
{registered ? (
|
||||
<>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<NumberInput
|
||||
id="s-taxRate"
|
||||
label="Default tax rate (%)"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={form.defaultTaxRate}
|
||||
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput
|
||||
id="s-hsn"
|
||||
labelText="Default HSN/SAC"
|
||||
value={form.defaultHsnSac}
|
||||
onChange={(e) => set("defaultHsnSac", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
</>
|
||||
) : null}
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<NumberInput
|
||||
id="s-terms"
|
||||
@@ -261,18 +327,20 @@ export default function AppSettings({
|
||||
<SelectItem value="g100" text="Carbon dark (g100)" />
|
||||
</Select>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<RadioButtonGroup
|
||||
legendText="Default tax treatment"
|
||||
name="s-taxType"
|
||||
valueSelected={form.defaultTaxType}
|
||||
onChange={(value) => set("defaultTaxType", value as TaxType)}
|
||||
>
|
||||
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
|
||||
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
|
||||
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
|
||||
</RadioButtonGroup>
|
||||
</Column>
|
||||
{registered ? (
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<RadioButtonGroup
|
||||
legendText="Default tax treatment"
|
||||
name="s-taxType"
|
||||
valueSelected={form.defaultTaxType}
|
||||
onChange={(value) => set("defaultTaxType", value as TaxType)}
|
||||
>
|
||||
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
|
||||
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
|
||||
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
|
||||
</RadioButtonGroup>
|
||||
</Column>
|
||||
) : null}
|
||||
</Grid>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Button,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
Modal,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -10,9 +11,10 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tag,
|
||||
TextInput,
|
||||
} from "@carbon/react";
|
||||
import { Download, TrashCan } from "@carbon/icons-react";
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { Download, Close } from "@carbon/icons-react";
|
||||
import { api } from "../lib/api";
|
||||
import { formatAmount, formatDate } from "../lib/format";
|
||||
import { exportInvoicePdf } from "../lib/pdf";
|
||||
@@ -23,6 +25,8 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -52,16 +56,15 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (row: InvoiceSummary) => {
|
||||
const ok = await confirm(`Delete invoice ${row.number}? This cannot be undone.`, {
|
||||
title: "Delete invoice",
|
||||
kind: "warning",
|
||||
});
|
||||
if (!ok) return;
|
||||
const onCancel = async () => {
|
||||
if (!cancelTarget) return;
|
||||
try {
|
||||
await api.deleteInvoice(row.id);
|
||||
await api.cancelInvoice(cancelTarget.id, reason);
|
||||
setCancelTarget(null);
|
||||
setReason("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setCancelTarget(null);
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
@@ -99,7 +102,15 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
|
||||
<TableCell>{formatDate(row.invoiceDate)}</TableCell>
|
||||
<TableCell>{row.clientName || "—"}</TableCell>
|
||||
<TableCell>₹{formatAmount(row.total)}</TableCell>
|
||||
<TableCell>{row.status}</TableCell>
|
||||
<TableCell>
|
||||
{row.status === "cancelled" ? (
|
||||
<Tag type="red" size="sm">
|
||||
Cancelled
|
||||
</Tag>
|
||||
) : (
|
||||
row.status
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
<Button
|
||||
@@ -111,14 +122,19 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
|
||||
>
|
||||
{busyId === row.id ? "Exporting…" : "PDF"}
|
||||
</Button>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={TrashCan}
|
||||
onClick={() => onDelete(row)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{row.status === "issued" ? (
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Close}
|
||||
onClick={() => {
|
||||
setReason("");
|
||||
setCancelTarget(row);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -127,6 +143,26 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={cancelTarget !== null}
|
||||
danger
|
||||
modalHeading={`Cancel invoice ${cancelTarget?.number ?? ""}?`}
|
||||
primaryButtonText="Cancel invoice"
|
||||
secondaryButtonText="Keep invoice"
|
||||
onRequestClose={() => setCancelTarget(null)}
|
||||
onRequestSubmit={onCancel}
|
||||
>
|
||||
<p style={{ marginBottom: "1rem" }}>
|
||||
The invoice stays in the list as cancelled and its number is not reused.
|
||||
</p>
|
||||
<TextInput
|
||||
id="cancelReason"
|
||||
labelText="Reason (optional)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+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>
|
||||
|
||||
+71
-34
@@ -15,9 +15,9 @@ import {
|
||||
TextInput,
|
||||
} from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import type { BankAccount, Settings, TaxType } from "../lib/types";
|
||||
import type { BankAccount, GstRegistration, Settings, TaxType } from "../lib/types";
|
||||
import { INDIAN_STATES } from "../lib/types";
|
||||
import { emailError, ifscError, panError, phoneError } from "../lib/validators";
|
||||
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
|
||||
interface OnboardingProps {
|
||||
@@ -73,9 +73,22 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
|
||||
const bankSet = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
|
||||
setBank((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const registered = form.gstRegistration !== "unregistered";
|
||||
const gstinProblem = vendorGstinError(
|
||||
form.gstRegistration,
|
||||
form.vendorGstin,
|
||||
form.vendorPan,
|
||||
form.vendorStateCode,
|
||||
);
|
||||
const businessInvalid = useMemo(
|
||||
() => Boolean(panError(form.vendorPan) || emailError(form.vendorEmail) || phoneError(form.vendorPhone)),
|
||||
[form.vendorPan, form.vendorEmail, form.vendorPhone],
|
||||
() =>
|
||||
Boolean(
|
||||
panError(form.vendorPan) ||
|
||||
emailError(form.vendorEmail) ||
|
||||
phoneError(form.vendorPhone) ||
|
||||
gstinProblem,
|
||||
),
|
||||
[form.vendorPan, form.vendorEmail, form.vendorPhone, gstinProblem],
|
||||
);
|
||||
|
||||
const finish = async () => {
|
||||
@@ -110,6 +123,22 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
|
||||
adjust anything that has changed.
|
||||
</p>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<RadioButtonGroup
|
||||
legendText="GST registration"
|
||||
name="ob-gstReg"
|
||||
orientation="horizontal"
|
||||
valueSelected={form.gstRegistration}
|
||||
onChange={(value) => {
|
||||
set("gstRegistration", value as GstRegistration);
|
||||
if (value === "unregistered") set("defaultTaxType", "none");
|
||||
else if (form.defaultTaxType === "none") set("defaultTaxType", "cgst_sgst");
|
||||
}}
|
||||
>
|
||||
<RadioButton id="ob-gr-un" labelText="Unregistered" value="unregistered" />
|
||||
<RadioButton id="ob-gr-reg" labelText="Regular" value="regular" />
|
||||
</RadioButtonGroup>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<TextInput
|
||||
id="vendorName"
|
||||
@@ -158,14 +187,18 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
|
||||
invalidText={panError(form.vendorPan)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="vendorGstin"
|
||||
labelText="GSTIN (leave blank if not registered)"
|
||||
value={form.vendorGstin}
|
||||
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
|
||||
/>
|
||||
</Column>
|
||||
{registered ? (
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
id="vendorGstin"
|
||||
labelText="GSTIN"
|
||||
value={form.vendorGstin}
|
||||
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
|
||||
invalid={Boolean(gstinProblem)}
|
||||
invalidText={gstinProblem}
|
||||
/>
|
||||
</Column>
|
||||
) : null}
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<Select
|
||||
id="vendorState"
|
||||
@@ -287,16 +320,18 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
|
||||
onChange={(_e, { value }) => setPadding(Number(value) || 3)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<NumberInput
|
||||
id="taxRate"
|
||||
label="Default tax rate (%)"
|
||||
min={0}
|
||||
max={100}
|
||||
value={form.defaultTaxRate}
|
||||
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
{registered ? (
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<NumberInput
|
||||
id="taxRate"
|
||||
label="Default tax rate (%)"
|
||||
min={0}
|
||||
max={100}
|
||||
value={form.defaultTaxRate}
|
||||
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
) : null}
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<NumberInput
|
||||
id="terms"
|
||||
@@ -307,18 +342,20 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
|
||||
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<RadioButtonGroup
|
||||
legendText="Default tax treatment"
|
||||
name="taxType"
|
||||
valueSelected={form.defaultTaxType}
|
||||
onChange={(value) => set("defaultTaxType", value as TaxType)}
|
||||
>
|
||||
<RadioButton id="tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
|
||||
<RadioButton id="tt-igst" labelText="IGST (inter-state)" value="igst" />
|
||||
<RadioButton id="tt-none" labelText="No tax" value="none" />
|
||||
</RadioButtonGroup>
|
||||
</Column>
|
||||
{registered ? (
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<RadioButtonGroup
|
||||
legendText="Default tax treatment"
|
||||
name="taxType"
|
||||
valueSelected={form.defaultTaxType}
|
||||
onChange={(value) => set("defaultTaxType", value as TaxType)}
|
||||
>
|
||||
<RadioButton id="tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
|
||||
<RadioButton id="tt-igst" labelText="IGST (inter-state)" value="igst" />
|
||||
<RadioButton id="tt-none" labelText="No tax" value="none" />
|
||||
</RadioButtonGroup>
|
||||
</Column>
|
||||
) : null}
|
||||
</Grid>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user