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:
- Multiply each value by 1 (even positions, 0-based) or 2 (odd positions).
- Add
product // 36 + product % 36. - 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:
- splits on
.and base64url-decodes header and payload, restoring the stripped=padding; - if
datais a JSON string, parses it (an object is also accepted); - when a public key is supplied, verifies
RSASSA-PKCS1-v1_5with SHA-256 overheader.payload. A mismatch is reported asINVALIDand exit code 1; any otheralgis refused rather than silently trusted; - for an IRP response JSON, extracts
SignedQRCodeand warns if the response'sIrndisagrees 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
- Float amounts in output: the portal schema uses JSON numbers; exact decimal arithmetic happens before serialisation.
- Validation tolerance is configurable rather than hard-coded, since the portal's own tolerance differs across fields and over time.
- Rules are one method per concern with codes in a single catalog, so the CLI, README and tests derive from one list and a test enforces every code has a negative case.
- The tool never talks to the network; fetching the IRP public key and calling the IRP API are left to the user.