Design notes

Pipeline

CSV/XLSX --mapping--> invoice dict --schema--> business rules --> issues
                                  \--> IRN hash
IRP response / JWT --qr--> payload, signature check, PNG/SVG

Everything operates on plain dicts shaped like the portal JSON, so a file produced by another tool validates the same way as one produced by build.

Schema first, rules second

validate_invoice runs the JSON Schema and returns its violations if there are any. Only a schema-clean document reaches _Checker, which can therefore index fields without defensive code. This also mirrors the portal, where a malformed payload is rejected before business validation. The schema (data/einvoice-1.1.schema.json, Draft 7) carries types, enums, patterns (document number, dates, pincode, HSN digits), required fields and ranges (round-off within +-99.99). The mapping layer reuses it to decide how to coerce each cell and to order output keys.

GSTIN checksum

The 15th character is a mod-36 check over the first 14, with alphabet 0-9A-Z mapping to 0..35:

  1. Multiply each value by 1 (even positions, 0-based) or 2 (odd positions).
  2. Add product // 36 + product % 36.
  3. Check value = (36 - sum % 36) % 36.

Worked example for 27AAPFU0939F1Z?:

i char value x factor digit sum
0 2 2 2 2
1 7 7 14 14
2 A 10 10 10
3 A 10 20 20
4 P 25 25 25
5 F 15 30 30
6 U 30 30 30
7 0 0 0 0
8 9 9 9 9
9 3 3 6 6
10 9 9 9 9
11 F 15 30 30
12 1 1 1 1
13 Z 35 70 35

Sum 221, 221 % 36 = 5, (36 - 5) % 36 = 31, which is V. So the GSTIN is 27AAPFU0939F1ZV.

IRN

The IRN is the lower-case hex SHA-256 of the upper-cased concatenation GSTIN + FY + DocType + DocNo with no separators. For GSTIN 29AAGCB7383J1Z4, FY 2025-26, type INV, number INV/001 the hashed string is 29AAGCB7383J1Z42025-26INVINV/001 and the IRN 72242e6a...ba03b99. The financial year runs April to March: 31/03/2025 is 2024-25, 01/04/2025 is 2025-26. This lets you detect a document that would be rejected as a duplicate before upload. Duplicate-number detection in validate uses the same key without hashing.

Tax regime and place of supply

The regime is decided from supply type, then seller state versus place of supply:

Supply Result
SEZWOP, EXPWOP all taxes zero
SEZWP, EXPWP IGST only
B2B, DEXP, seller Stcd = Pos CGST+SGST (IGST only with IgstOnIntra = Y)
B2B, DEXP, otherwise IGST

build uses the same table to compute amounts, and validate uses it to check them. To avoid noise, when a regime rule (2121-2124) fires, the matching per-amount mismatch (2142) is suppressed for those fields.

Worked example: seller in Maharashtra (27), buyer GSTIN in Rajasthan (08), Pos 08, qty 5 x 600, 5% discount, 28% GST, 12% cess. Gross 3000.00, discount 150.00, assessable 2850.00, IGST 798.00, cess 342.00, item total 3990.00. The invoice totals are the same, round-off 0.00.

Rounding and tolerances

build computes in Decimal with half-up rounding to paise at every stored amount (so 0.5025 becomes 0.50, matching what a person would write on the invoice). Floats appear only when serialising. The validator compares floats with a small epsilon on top of the rupee tolerance, so abs(a - b) <= tol is inclusive at the boundary: a 1.00 difference passes, 1.01 fails. Invoice-level checks use per-field sums of item values, so rounding differences in individual items (a few paise each) do not accumulate into false failures.

Round-off is derived as round(total) - total; with total = 10.55, TotInvVal = 11.00 and RndOffAmt = 0.45.

Reporting window

An invoice must be reported within window_days of its date. With today 15/11/2025 and a 30-day window, 16/10/2025 passes (30 days) and 15/10/2025 fails (31 days). Future dates are always rejected. today is configurable so results are reproducible and so you can validate a backlog as of a given date.

Signed QR

The IRP signs {"data": "<json string>", "iss": "NIC"} as an RS256 JWT, and that token is the QR payload. qr decode:

  1. splits on . and base64url-decodes header and payload, restoring the stripped = padding;
  2. if data is a JSON string, parses it (an object is also accepted);
  3. when a public key is supplied, verifies RSASSA-PKCS1-v1_5 with SHA-256 over header.payload. A mismatch is reported as INVALID and exit code 1; any other alg is refused rather than silently trusted;
  4. for an IRP response JSON, extracts SignedQRCode and warns if the response's Irn disagrees with the QR's.

Rendering uses segno with error correction level M. Signed tokens are around 800-1200 characters, which fits within QR version 40 (the sample is 891 characters, version 24), but scan reliability on a printed invoice depends on size: use --scale 4 or more at 300 dpi and a quiet zone of at least 2 modules (the default).

Decisions