- Hardened concurrency primitives, zero-dependency resilience patterns, circuit breakers, rate limiters, and SRE logging.
+
+
+
+
+
+ Canonical statutory validation: GSTIN mod-36, PAN, IFSC, Aadhaar Verhoeff D5, UPI VPAs, and zero-allocation integer paise Money arithmetic.
+
-
-
workerpool
-
circuitbreaker
-
ratelimit
-
retry (jittered)
-
logger (slog)
-
metrics
+
+
+
+
+ Hardened concurrency primitives: bounded workerpool, circuit breakers, sliding window rate limiters, and SRE logging.
+
@@ -1287,9 +1286,9 @@
Architecture & Hexagonal Layer Map
1. Client Request
➔
- 2. india.ValidateGSTIN
+ 2. go-fintech-india: ValidateGSTIN
➔
- 3. india.Money Calc
+ 3. go-fintech-india: Money Calc
➔
4. pdf.GenerateFromTemplate
➔
@@ -1303,87 +1302,87 @@
Architecture & Hexagonal Layer Map
-
+
Core Business Accelerators
-
Six hardened packages architected for zero data races, high concurrency, and statutory precision.
+
Five hardened enterprise packages architected for zero data races, high concurrency, and direct statutory companion composition.
-
-
+
+
- import "github.com/umesh0492/go-app-kit/india"
+ import "github.com/umesh0492/go-app-kit/outbox"
- Zero-dependency statutory Indian validation algorithms, fiscal calendar arithmetic, and exact integer paise currency calculations.
+ Guarantees at-least-once message delivery without dual-write race conditions using PostgreSQL row-level lease fencing.
- - GSTIN: 15-char official Mod-36 check digit validation and 38 state/UT registries.
- - PAN & Aadhaar: Entity classification (C, P, H, F, A, T, G) and UIDAI Verhoeff $D_5$ Dihedral checksum with privacy masking.
- - Fintech Money Type: Exact integer paise arithmetic (`Add`, `Sub`, `Mul`, `MulBasisPoints`, `Percentage`, `Split`), eliminating float errors.
- - Currency & Words: Indian numbering (`12,34,567.89`) and recursive words converter supporting arbitrary Crores.
+ - Contention-Free Polling:
SELECT ... FOR UPDATE SKIP LOCKED allows $N$ worker replicas to poll without lock contention.
+ - Lease-Token Fencing: UUID fencing tokens prevent lease clobbering by stale workers.
+ - Full-Jitter Exponential Backoff: Automatic retry policy with graceful transition of poison pills to
DEAD_LETTER.
+ - Workerpool Integration: Directly powered by
go-libs/workerpool for bounded background concurrency.
- Statutory Primitives
-
+ PostgreSQL Dual-Write
+
-
-
+
+
- import "github.com/umesh0492/go-app-kit/outbox"
+ import "github.com/umesh0492/go-app-kit/pdf"
- Guarantees at-least-once message delivery without dual-write race conditions using PostgreSQL row-level lease fencing.
+ Zero-disk in-memory HTML-to-PDF compilation engine with bundled statutory GST tax invoice and payment receipt templates.
- - Contention-Free Polling:
SELECT ... FOR UPDATE SKIP LOCKED allows $N$ worker replicas to poll without lock contention.
- - Lease-Token Fencing: UUID fencing tokens prevent lease clobbering by stale workers.
- - Full-Jitter Exponential Backoff: Automatic retry policy with graceful transition of poison pills to
DEAD_LETTER.
- - Workerpool Integration: Directly powered by
go-libs/workerpool for bounded background concurrency.
+ - Zero Disk I/O: Produces an in-memory
*bytes.Buffer, eliminating temporary file leaks in containerized workloads.
+ - Embedded GST Invoice: Compliant with Indian GST rules: Supplier/Buyer GSTINs, HSN/SAC codes, CGST/SGST/IGST breakdown.
+ - Bounded Subprocess Concurrency: Instance semaphores prevent subprocess exhaustion under high traffic.
+ - Pluggable Renderer: Decoupled
Renderer interface allows mocking in unit tests or alternate render backends.
- PostgreSQL Dual-Write
-
+ Document Engine
+
@@ -1418,38 +1417,7 @@
Core Business Accelerators
-
-
-
-
-
- import "github.com/umesh0492/go-app-kit/pdf"
-
-
- Zero-disk in-memory HTML-to-PDF compilation engine with bundled statutory GST tax invoice and payment receipt templates.
-
-
- - Zero Disk I/O: Produces an in-memory
*bytes.Buffer, eliminating temporary file leaks in containerized workloads.
- - Embedded GST Invoice: Compliant with Indian GST rules: Supplier/Buyer GSTINs, HSN/SAC codes, CGST/SGST/IGST breakdown.
- - Bounded Subprocess Concurrency: Instance semaphores prevent subprocess exhaustion under high traffic.
- - Pluggable Renderer: Decoupled
Renderer interface allows mocking in unit tests or alternate render backends.
-
-
-
- Document Engine
-
-
-
-
-
+
-
+
+
+
+
+
+
+ import "github.com/umesh0492/go-fintech-india"
+
+
+ Zero-dependency statutory Indian domain primitives: Verhoeff D5, GSTIN Mod-36, UPI VPAs, IFSC banking checks, and exact integer paise currency calculations.
+
+
+ - Verhoeff D5 Dihedral: Official UIDAI Aadhaar algorithm with checksum verification and privacy masking.
+ - GSTIN Mod-36: Statutory 15-character GST validation with check digit calculation across 38 states/UTs.
+ - UPI & Banking Primitives: UPI VPA handles, IFSC branch routing, MICR, and PAN legal classification.
+ - Zero-Dependencies: Pure domain primitives with zero third-party dependencies and sub-microsecond latency.
+
+
+
+
+
@@ -1636,7 +1635,7 @@
Interactive Code Viewer
-
+
-
-
package main
+
+ package main
import (
+ "context"
"fmt"
- "github.com/umesh0492/go-app-kit/india"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ fintech "github.com/umesh0492/go-fintech-india"
+ "github.com/umesh0492/go-app-kit/audit"
+ "github.com/umesh0492/go-app-kit/notifications"
+ "github.com/umesh0492/go-app-kit/outbox"
+ "github.com/umesh0492/go-app-kit/pdf"
)
func main() {
- // 1. Exact Paise Integer Arithmetic (Zero floating-point inaccuracies)
- taxable := india.NewMoneyFromFloat(15000.50) // 1500050 paise
- cgst := taxable.Percentage(9.0) // 9% CGST
- sgst := taxable.Percentage(9.0) // 9% SGST
- total := taxable.Add(cgst).Add(sgst)
+ ctx := context.Background()
- fmt.Printf("Taxable: ₹%s\n", taxable.Format()) // "15,000.50"
- fmt.Printf("Total: ₹%s\n", total.Format()) // "17,700.59"
-
- // 2. Amount to Words (INR)
- words := india.AmountToWordsINR(total.Paise())
- fmt.Printf("In Words: %s\n", words)
-
- // 3. GSTIN Statutory Mod-36 Checksum Validation
- gstin := "27AAPFU0939F1ZV"
- if err := india.ValidateGSTIN(gstin); err != nil {
- fmt.Printf("Invalid GSTIN: %v\n", err)
- } else {
- parsed, _ := india.ParseGSTIN(gstin)
- fmt.Printf("State: %s (%s), Entity: %s\n",
- parsed.StateCode, parsed.StateName, parsed.PAN.EntityType())
+ // 1. Statutory validation via companion go-fintech-india
+ buyerGSTIN := "29AAACI1681G1ZM"
+ if err := fintech.ValidateGSTIN(buyerGSTIN); err != nil {
+ panic("invalid statutory GSTIN: " + err.Error())
}
- // 4. Aadhaar Verhoeff D5 Dihedral Checksum & Masking
- aadhaar := "234567890128"
- if india.IsValidAadhaar(aadhaar) {
- fmt.Printf("Masked Aadhaar: %s\n", india.MaskAadhaar(aadhaar)) // "XXXX-XXXX-0128"
- }
+ // 2. High-precision monetary computation in paise
+ taxable := fintech.NewMoneyFromRupees(15000) // ₹15,000.00
+ cgst := taxable.Percentage(9.0) // 9% CGST (₹1,350.00)
+ sgst := taxable.Percentage(9.0) // 9% SGST (₹1,350.00)
+ total := taxable.Add(cgst).Add(sgst) // ₹17,700.00
+
+ // 3. Compile in-memory GST Tax Invoice PDF
+ pdfBuf, _ := pdf.GenerateFromTemplate(pdf.GSTInvoiceTemplate, pdf.InvoiceData{
+ InvoiceNumber: "INV-2026-001",
+ InvoiceDate: time.Now().Format("2006-01-02"),
+ SupplierGSTIN: "27AAPFU0939F1ZV",
+ BuyerGSTIN: buyerGSTIN,
+ })
+
+ // 4. Transactional Outbox write in DB transaction (dual-write safety)
+ pool, _ := pgxpool.New(ctx, "postgres://localhost:5432/db")
+ store := outbox.NewPGStore(pool)
+ tx, _ := pool.Begin(ctx)
+ evt, _ := outbox.NewEvent("Invoice", "INV-2026-001", "Created", total)
+ _ = store.Insert(ctx, tx, *evt)
+ _ = tx.Commit(ctx)
+
+ // 5. Asynchronous audit & multi-channel notification dispatch
+ recorder, _ := audit.NewPGRecorder(audit.Config{DB: pool})
+ recorder.RecordAsync(audit.NewEvent(ctx, "INVOICE_CREATED", "Invoice", "INV-2026-001", nil, total))
+
+ broker := notifications.NewBroker(notifications.DefaultConfig())
+ _ = broker.SendAsync(ctx, notifications.Message{
+ Title: "Tax Invoice Ready: INV-2026-001",
+ Body: fmt.Sprintf("Invoice total %s generated successfully.", total.Format()),
+ Recipients: []string{"billing@enterprise.com"},
+ Channels: []notifications.Channel{notifications.ChannelEmail},
+ Attachments: []notifications.Attachment{
+ {Filename: "INV-2026-001.pdf", ContentType: "application/pdf", Data: pdfBuf.Bytes()},
+ },
+ })
}
@@ -1881,7 +1904,7 @@
Interactive Code Viewer
import (
"net/http"
"github.com/umesh0492/go-app-kit/export"
- "github.com/umesh0492/go-app-kit/india"
+ fintech "github.com/umesh0492/go-fintech-india"
)
type InvoiceExportRow struct {
@@ -1898,7 +1921,7 @@
Interactive Code Viewer
{Header: "Invoice ID", Extractor: func(r InvoiceExportRow) string { return r.ID }},
{Header: "Buyer GSTIN", Extractor: func(r InvoiceExportRow) string { return r.BuyerGSTIN }},
{Header: "Amount (INR)", Extractor: func(r InvoiceExportRow) string {
- return india.FormatINRPaise(r.AmountPaise)
+ return fintech.FormatINRPaise(r.AmountPaise)
}},
}
@@ -1923,24 +1946,24 @@
Interactive Code Viewer
import (
"context"
"github.com/umesh0492/go-app-kit/audit"
- "github.com/umesh0492/go-app-kit/india"
"github.com/umesh0492/go-app-kit/notifications"
"github.com/umesh0492/go-app-kit/outbox"
"github.com/umesh0492/go-app-kit/pdf"
+ fintech "github.com/umesh0492/go-fintech-india"
)
// ProcessInvoice combines validation, PDF generation, outbox, audit, and notifications.
func (s *InvoiceService) ProcessInvoice(ctx context.Context, req InvoiceRequest) ([]byte, error) {
- // Step 1: Statutory Validation (Fail-Fast)
- if err := india.ValidateGSTIN(req.SupplierGSTIN); err != nil {
+ // Step 1: Statutory Validation (Fail-Fast via go-fintech-india)
+ if err := fintech.ValidateGSTIN(req.SupplierGSTIN); err != nil {
return nil, err
}
- if err := india.ValidateGSTIN(req.BuyerGSTIN); err != nil {
+ if err := fintech.ValidateGSTIN(req.BuyerGSTIN); err != nil {
return nil, err
}
// Step 2: Exact Money & Total Calculation
- taxable := india.NewMoneyFromFloat(req.Amount)
+ taxable := fintech.NewMoneyFromFloat(req.Amount)
cgst := taxable.Percentage(9.0)
sgst := taxable.Percentage(9.0)
total := taxable.Add(cgst).Add(sgst)
@@ -2006,53 +2029,53 @@
Verified Statement Coverage
-
- india |
- Statutory Indian validations (GSTIN mod-36, PAN, IFSC, Aadhaar Verhoeff D5, INR Money, Aging) |
- Zero external |
- 95.5% |
-
export |
Low-memory streaming CSV exporter with Excel UTF-8 BOM & formula injection protection |
Zero external |
- 93.8% |
+ 97.5% |
notifications |
Multi-channel notification broker (SMTP Email, Slack, Webhook HMAC-SHA256 & versioning) |
go-libs/workerpool |
- 92.3% |
+ 95.6% |
outbox |
PostgreSQL transactional outbox engine with row-level locked poller & lease fencing |
jackc/pgx/v5 |
- 90.0% |
+ 93.0% |
audit |
Partitioned PostgreSQL audit logging with automated JSON diffing & append-only triggers |
jackc/pgx/v5 |
- 89.5% |
+ 93.0% |
pdf |
In-memory HTML-to-PDF compilation & embedded GST invoice templates |
Pluggable / wkhtmltopdf |
- 80.7% |
+ 90.8% |
examples/invoice_service |
Reference microservice with end-to-end integration test & exact paise math |
Internal integration |
- 82.9% |
+ 95.1% |
+
+
+ go-fintech-india Companion |
+ Independent statutory foundation (GSTIN, PAN, Aadhaar Verhoeff D5, IFSC, Money) |
+ Zero external |
+ 95.5% |
| Overall Repository |
- Verified Statement Coverage across whole repository (Zero data races) |
+ Verified Statement Coverage across core packages (Zero data races) |
Go 1.26.0 |
- 90.5% |
+ 94.1% |
@@ -2067,7 +2090,7 @@
Verified Statement Coverage
G
go-app-kit
-
v0.3.2
+
v0.4.0
Production-grade enterprise application and business domain accelerator kit for Go. Hardened statutory validations, transactional outbox, multi-channel alerts, and zero-disk document compilation.
@@ -2075,14 +2098,14 @@
Verified Statement Coverage
@@ -2112,7 +2135,7 @@
Project & Community
© 2026 Abeta-dev & umesh0492. Licensed under the MIT License.
- Built with Go 1.26.0 · Verified 90.5% Coverage · 0 Data Races
+ Built with Go 1.26.0 · Verified 94.1% Coverage · 0 Data Races
diff --git a/examples/invoice_service/README.md b/examples/invoice_service/README.md
index 01698f5..bb07af8 100644
--- a/examples/invoice_service/README.md
+++ b/examples/invoice_service/README.md
@@ -1,6 +1,6 @@
# `examples/invoice_service`
-End-to-end reference enterprise microservice demonstrating the composition of `india`, `pdf`, `outbox`, `notifications`, and `audit` packages within a production billing pipeline.
+End-to-end reference enterprise microservice demonstrating the composition of `go-fintech-india` (statutory foundation), `pdf`, `outbox`, `notifications`, and `audit` packages within a production billing pipeline.
---
@@ -19,7 +19,7 @@ sequenceDiagram
autonumber
actor Client as Billing API Client
participant Svc as InvoiceService
- participant India as india (Statutory)
+ participant India as go-fintech-india (Statutory Foundation)
participant PDF as pdf (Document)
participant Outbox as outbox (Dual-Write)
participant Audit as audit (Compliance)
@@ -82,7 +82,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/umesh0492/go-app-kit/audit"
- "github.com/umesh0492/go-app-kit/india"
+ fintech "github.com/umesh0492/go-fintech-india"
"github.com/umesh0492/go-app-kit/notifications"
"github.com/umesh0492/go-app-kit/outbox"
)
@@ -123,7 +123,7 @@ func main() {
BuyerName: "Zenith Retail Enterprises",
BuyerEmail: "accounts@zenithretail.in",
Description: "Cloud Infrastructure Migration Consulting",
- TaxableAmount: india.NewMoneyFromRupees(150000),
+ TaxableAmount: fintech.NewMoneyFromRupees(150000),
CGSTRate: 9.0,
SGSTRate: 9.0,
BankIFSC: "HDFC0000060",
diff --git a/examples/invoice_service/invoice_service_test.go b/examples/invoice_service/invoice_service_test.go
index 0fc3239..1422ed8 100644
--- a/examples/invoice_service/invoice_service_test.go
+++ b/examples/invoice_service/invoice_service_test.go
@@ -3,6 +3,8 @@ package main
import (
"context"
"encoding/json"
+ "errors"
+ "strings"
"testing"
"time"
@@ -10,17 +12,21 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/umesh0492/go-app-kit/audit"
- "github.com/umesh0492/go-app-kit/india" //nolint:staticcheck // Tests intentionally cover the compatibility façade used by the reference service.
"github.com/umesh0492/go-app-kit/notifications"
"github.com/umesh0492/go-app-kit/outbox"
"github.com/umesh0492/go-app-kit/pdf"
+ fintech "github.com/umesh0492/go-fintech-india"
)
type mockAuditRecorder struct {
events []audit.Event
+ err error
}
func (m *mockAuditRecorder) Record(ctx context.Context, event audit.Event) error {
+ if m.err != nil {
+ return m.err
+ }
m.events = append(m.events, event)
return nil
}
@@ -35,9 +41,13 @@ func (m *mockAuditRecorder) Close() {}
type mockOutboxStore struct {
events []outbox.Event
lastTx outbox.DBOperator
+ err error
}
func (m *mockOutboxStore) Insert(ctx context.Context, op outbox.DBOperator, event outbox.Event) error {
+ if m.err != nil {
+ return m.err
+ }
m.lastTx = op
m.events = append(m.events, event)
return nil
@@ -93,11 +103,11 @@ func TestProcessInvoice_EndToEnd(t *testing.T) {
svc := NewInvoiceService(ar, os, nb, pdf.WithRenderer(&mockRendererImpl{}))
suppBase := "27AAPFU0939F1Z"
- suppCheck := india.CalculateGSTINCheckDigit(suppBase)
+ suppCheck, _ := fintech.CalculateGSTINChecksum(suppBase)
supplierGSTIN := suppBase + string(suppCheck)
buyerBase := "29AAPFU0939F1Z"
- buyerCheck := india.CalculateGSTINCheckDigit(buyerBase)
+ buyerCheck, _ := fintech.CalculateGSTINChecksum(buyerBase)
buyerGSTIN := buyerBase + string(buyerCheck)
req := InvoiceRequest{
@@ -108,7 +118,7 @@ func TestProcessInvoice_EndToEnd(t *testing.T) {
BuyerName: "Deccan Enterprises Ltd",
BuyerEmail: "accounts@deccan.in",
Description: "Kubernetes Architecture Consulting",
- TaxableAmount: india.NewMoneyFromRupees(100000),
+ TaxableAmount: fintech.NewMoneyFromRupees(100000),
CGSTRate: 9.0,
SGSTRate: 9.0,
BankIFSC: "HDFC0001234",
@@ -184,19 +194,19 @@ func TestProcessInvoice_MultiItemMoney(t *testing.T) {
svc := NewInvoiceService(ar, os, nb, pdf.WithRenderer(&mockRendererImpl{}))
suppBase := "27AAPFU0939F1Z"
- suppCheck := india.CalculateGSTINCheckDigit(suppBase)
+ suppCheck, _ := fintech.CalculateGSTINChecksum(suppBase)
supplierGSTIN := suppBase + string(suppCheck)
buyerBase := "29AAPFU0939F1Z"
- buyerCheck := india.CalculateGSTINCheckDigit(buyerBase)
+ buyerCheck, _ := fintech.CalculateGSTINChecksum(buyerBase)
buyerGSTIN := buyerBase + string(buyerCheck)
item1 := InvoiceItem{
Index: 1,
Description: "Frontend Design Tokens",
Quantity: 2,
- UnitPrice: india.NewMoneyFromRupees(25000),
- TaxableAmount: india.NewMoneyFromRupees(50000),
+ UnitPrice: fintech.NewMoneyFromRupees(25000),
+ TaxableAmount: fintech.NewMoneyFromRupees(50000),
CGSTRate: 9.0,
SGSTRate: 9.0,
}
@@ -204,8 +214,8 @@ func TestProcessInvoice_MultiItemMoney(t *testing.T) {
Index: 2,
Description: "Outbox Relay Architecture",
Quantity: 1,
- UnitPrice: india.NewMoneyFromRupees(50000),
- TaxableAmount: india.NewMoneyFromRupees(50000),
+ UnitPrice: fintech.NewMoneyFromRupees(50000),
+ TaxableAmount: fintech.NewMoneyFromRupees(50000),
CGSTRate: 9.0,
SGSTRate: 9.0,
}
@@ -254,7 +264,7 @@ func TestProcessInvoice_ValidationErrors(t *testing.T) {
}
suppBase := "27AAPFU0939F1Z"
- suppCheck := india.CalculateGSTINCheckDigit(suppBase)
+ suppCheck, _ := fintech.CalculateGSTINChecksum(suppBase)
supplierGSTIN := suppBase + string(suppCheck)
// Invalid Buyer GSTIN
@@ -267,7 +277,7 @@ func TestProcessInvoice_ValidationErrors(t *testing.T) {
}
buyerBase := "29AAPFU0939F1Z"
- buyerCheck := india.CalculateGSTINCheckDigit(buyerBase)
+ buyerCheck, _ := fintech.CalculateGSTINChecksum(buyerBase)
buyerGSTIN := buyerBase + string(buyerCheck)
// Invalid Bank IFSC
@@ -286,3 +296,125 @@ type mockRendererImpl struct{}
func (m *mockRendererImpl) Render(html string, opts pdf.Options) ([]byte, error) {
return []byte("%PDF-1.4 Mock Invoice"), nil
}
+
+type mockErrRendererImpl struct{}
+
+func (m *mockErrRendererImpl) Render(html string, opts pdf.Options) ([]byte, error) {
+ return nil, errors.New("simulated pdf generation failure")
+}
+
+func TestMainFunc(t *testing.T) {
+ main()
+}
+
+func TestProcessInvoice_PipelineFailures(t *testing.T) {
+ suppBase := "27AAPFU0939F1Z"
+ suppCheck, _ := fintech.CalculateGSTINChecksum(suppBase)
+ supplierGSTIN := suppBase + string(suppCheck)
+
+ buyerBase := "29AAPFU0939F1Z"
+ buyerCheck, _ := fintech.CalculateGSTINChecksum(buyerBase)
+ buyerGSTIN := buyerBase + string(buyerCheck)
+
+ validReq := InvoiceRequest{
+ InvoiceNumber: "INV-FAIL-001",
+ SupplierGSTIN: supplierGSTIN,
+ SupplierName: "Test Supplier",
+ BuyerGSTIN: buyerGSTIN,
+ BuyerName: "Test Buyer",
+ BuyerEmail: "test@example.com",
+ Description: "Test Item",
+ BankName: "HDFC Bank",
+ BankAccount: "1234567890",
+ BankIFSC: "HDFC0000123",
+ Branch: "Koramangala",
+ TaxableAmount: fintech.NewMoneyFromRupees(1000),
+ CGSTRate: 9.0,
+ SGSTRate: 9.0,
+ }
+
+ // 1. PDF failure
+ pdfErrSvc := NewInvoiceService(
+ &mockAuditRecorder{},
+ &mockOutboxStore{},
+ &mockNotificationBroker{},
+ pdf.WithRenderer(&mockErrRendererImpl{}),
+ )
+ _, err := pdfErrSvc.ProcessInvoice(context.Background(), nil, validReq)
+ if err == nil || !strings.Contains(err.Error(), "failed to compile invoice PDF") {
+ t.Fatalf("expected pdf generation error, got: %v", err)
+ }
+
+ // 2. Audit failure
+ auditErrSvc := NewInvoiceService(
+ &mockAuditRecorder{err: errors.New("audit disk full")},
+ &mockOutboxStore{},
+ &mockNotificationBroker{},
+ pdf.WithRenderer(&mockRendererImpl{}),
+ )
+ _, err = auditErrSvc.ProcessInvoice(context.Background(), nil, validReq)
+ if err == nil || !strings.Contains(err.Error(), "audit log recording failed") {
+ t.Fatalf("expected audit failure error, got: %v", err)
+ }
+
+ // 3. Outbox failure
+ outboxErrSvc := NewInvoiceService(
+ &mockAuditRecorder{},
+ &mockOutboxStore{err: errors.New("db connection closed")},
+ &mockNotificationBroker{},
+ pdf.WithRenderer(&mockRendererImpl{}),
+ )
+ _, err = outboxErrSvc.ProcessInvoice(context.Background(), nil, validReq)
+ if err == nil || !strings.Contains(err.Error(), "outbox insert failed") {
+ t.Fatalf("expected outbox failure error, got: %v", err)
+ }
+}
+
+func TestProcessInvoice_ItemCalculationVariants(t *testing.T) {
+ suppBase := "27AAPFU0939F1Z"
+ suppCheck, _ := fintech.CalculateGSTINChecksum(suppBase)
+ supplierGSTIN := suppBase + string(suppCheck)
+
+ buyerBase := "29AAPFU0939F1Z"
+ buyerCheck, _ := fintech.CalculateGSTINChecksum(buyerBase)
+ buyerGSTIN := buyerBase + string(buyerCheck)
+
+ svc := NewInvoiceService(
+ &mockAuditRecorder{},
+ &mockOutboxStore{},
+ &mockNotificationBroker{},
+ pdf.WithRenderer(&mockRendererImpl{}),
+ )
+
+ req := InvoiceRequest{
+ InvoiceNumber: "INV-VAR-001",
+ SupplierGSTIN: supplierGSTIN,
+ SupplierName: "Test Supplier",
+ BuyerGSTIN: buyerGSTIN,
+ BuyerName: "Test Buyer",
+ BuyerEmail: "test@example.com",
+ Description: "Test Items",
+ BankName: "HDFC Bank",
+ BankAccount: "1234567890",
+ BankIFSC: "HDFC0000123",
+ Branch: "Koramangala",
+ CGSTRate: 9.0,
+ SGSTRate: 9.0,
+ Items: []InvoiceItem{
+ {
+ Index: 0, // will default to i+1
+ Quantity: 0, // will default to 1
+ UnitPrice: fintech.NewMoneyFromRupees(500),
+ Description: "Widget A",
+ },
+ },
+ }
+
+ pdfBytes, err := svc.ProcessInvoice(context.Background(), nil, req)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(pdfBytes) == 0 {
+ t.Fatalf("expected non-empty pdf bytes")
+ }
+}
diff --git a/examples/invoice_service/service.go b/examples/invoice_service/service.go
index 4552d1b..5404933 100644
--- a/examples/invoice_service/service.go
+++ b/examples/invoice_service/service.go
@@ -3,28 +3,30 @@ package main
import (
"context"
"fmt"
+ "math"
+ "strings"
"time"
"github.com/umesh0492/go-app-kit/audit"
- "github.com/umesh0492/go-app-kit/india" //nolint:staticcheck // Reference coverage intentionally exercises the compatibility façade.
"github.com/umesh0492/go-app-kit/notifications"
"github.com/umesh0492/go-app-kit/outbox"
"github.com/umesh0492/go-app-kit/pdf"
+ fintech "github.com/umesh0492/go-fintech-india"
)
// InvoiceItem models an individual invoice line item with exact monetary precision.
type InvoiceItem struct {
- Index int `json:"index"`
- Description string `json:"description"`
- HSN string `json:"hsn"`
- Quantity int `json:"quantity"`
- UnitPrice india.Money `json:"unit_price"`
- TaxableAmount india.Money `json:"taxable_amount"`
- CGSTRate float64 `json:"cgst_rate"`
- CGSTAmount india.Money `json:"cgst_amount"`
- SGSTRate float64 `json:"sgst_rate"`
- SGSTAmount india.Money `json:"sgst_amount"`
- TotalAmount india.Money `json:"total_amount"`
+ Index int `json:"index"`
+ Description string `json:"description"`
+ HSN string `json:"hsn"`
+ Quantity int `json:"quantity"`
+ UnitPrice fintech.Money `json:"unit_price"`
+ TaxableAmount fintech.Money `json:"taxable_amount"`
+ CGSTRate float64 `json:"cgst_rate"`
+ CGSTAmount fintech.Money `json:"cgst_amount"`
+ SGSTRate float64 `json:"sgst_rate"`
+ SGSTAmount fintech.Money `json:"sgst_amount"`
+ TotalAmount fintech.Money `json:"total_amount"`
}
// InvoiceRequest contains inputs for creating a GST compliant invoice.
@@ -37,7 +39,7 @@ type InvoiceRequest struct {
BuyerName string
BuyerEmail string
Description string
- TaxableAmount india.Money // migrated from float64 to india.Money (integer paise precision)
+ TaxableAmount fintech.Money // migrated from float64 to fintech.Money (integer paise precision)
CGSTRate float64
SGSTRate float64
BankIFSC string
@@ -65,6 +67,33 @@ func NewInvoiceService(ar audit.Recorder, os outbox.Store, nb notifications.Brok
}
}
+type gstinDetails struct {
+ GSTIN string
+ StateCode string
+ StateName string
+ PAN string
+}
+
+func parseGSTIN(gstin string) gstinDetails {
+ clean := strings.ToUpper(strings.TrimSpace(gstin))
+ stateCode := fintech.StateCode(clean)
+ stateName, _ := fintech.StateName(stateCode)
+ return gstinDetails{
+ GSTIN: clean,
+ StateCode: stateCode,
+ StateName: stateName,
+ PAN: fintech.ExtractPAN(clean),
+ }
+}
+
+func formatMoney(m fintech.Money) string {
+ return fintech.FormatINR(m.Paise())
+}
+
+func percentageMoney(m fintech.Money, rate float64) fintech.Money {
+ return fintech.NewMoney(int64(math.Round(float64(m.Paise()) * (rate / 100.0))))
+}
+
// ProcessInvoice executes end-to-end invoice creation, PDF generation, audit recording, and notifications.
// It persists the domain event to the outbox store using the provided active database transaction.
func (s *InvoiceService) ProcessInvoice(ctx context.Context, tx outbox.DBOperator, req InvoiceRequest) ([]byte, error) {
@@ -72,23 +101,23 @@ func (s *InvoiceService) ProcessInvoice(ctx context.Context, tx outbox.DBOperato
tx = req.Tx
}
// 1. Validate Indian localized financial credentials
- if err := india.ValidateGSTIN(req.SupplierGSTIN); err != nil {
+ if err := fintech.ValidateGSTIN(req.SupplierGSTIN); err != nil {
return nil, fmt.Errorf("invalid supplier GSTIN: %w", err)
}
- if err := india.ValidateGSTIN(req.BuyerGSTIN); err != nil {
+ if err := fintech.ValidateGSTIN(req.BuyerGSTIN); err != nil {
return nil, fmt.Errorf("invalid buyer GSTIN: %w", err)
}
- if err := india.ValidateIFSC(req.BankIFSC); err != nil {
+ if err := fintech.ValidateIFSC(req.BankIFSC); err != nil {
return nil, fmt.Errorf("invalid bank IFSC: %w", err)
}
- supplierDetails, _ := india.ParseGSTIN(req.SupplierGSTIN)
- buyerDetails, _ := india.ParseGSTIN(req.BuyerGSTIN)
- fy := india.CurrentFinancialYear()
+ supplierDetails := parseGSTIN(req.SupplierGSTIN)
+ buyerDetails := parseGSTIN(req.BuyerGSTIN)
+ fy := fintech.CurrentFY()
// 2. Compute Tax Amounts & Currency Words with integer paise precision
var items []map[string]any
- var taxableMoney, cgstMoney, sgstMoney, totalMoney india.Money
+ var taxableMoney, cgstMoney, sgstMoney, totalMoney fintech.Money
if len(req.Items) > 0 {
for i, item := range req.Items {
@@ -114,11 +143,11 @@ func (s *InvoiceService) ProcessInvoice(ctx context.Context, tx outbox.DBOperato
}
itemCGST := item.CGSTAmount
if itemCGST.IsZero() && cgstRate > 0 {
- itemCGST = itemTaxable.Percentage(cgstRate)
+ itemCGST = percentageMoney(itemTaxable, cgstRate)
}
itemSGST := item.SGSTAmount
if itemSGST.IsZero() && sgstRate > 0 {
- itemSGST = itemTaxable.Percentage(sgstRate)
+ itemSGST = percentageMoney(itemTaxable, sgstRate)
}
itemTotal := item.TotalAmount
if itemTotal.IsZero() {
@@ -147,20 +176,20 @@ func (s *InvoiceService) ProcessInvoice(ctx context.Context, tx outbox.DBOperato
"Description": desc,
"HSN": hsn,
"Quantity": qty,
- "UnitPrice": unitPrice.Format(),
- "TaxableAmount": itemTaxable.Format(),
+ "UnitPrice": formatMoney(unitPrice),
+ "TaxableAmount": formatMoney(itemTaxable),
"CGSTRate": cgstRate,
- "CGSTAmount": itemCGST.Format(),
+ "CGSTAmount": formatMoney(itemCGST),
"SGSTRate": sgstRate,
- "SGSTAmount": itemSGST.Format(),
- "TotalAmount": itemTotal.Format(),
+ "SGSTAmount": formatMoney(itemSGST),
+ "TotalAmount": formatMoney(itemTotal),
})
}
totalMoney = taxableMoney.Add(cgstMoney).Add(sgstMoney)
} else {
taxableMoney = req.TaxableAmount
- cgstMoney = taxableMoney.Percentage(req.CGSTRate)
- sgstMoney = taxableMoney.Percentage(req.SGSTRate)
+ cgstMoney = percentageMoney(taxableMoney, req.CGSTRate)
+ sgstMoney = percentageMoney(taxableMoney, req.SGSTRate)
totalMoney = taxableMoney.Add(cgstMoney).Add(sgstMoney)
items = []map[string]any{
@@ -169,18 +198,18 @@ func (s *InvoiceService) ProcessInvoice(ctx context.Context, tx outbox.DBOperato
"Description": req.Description,
"HSN": "998313",
"Quantity": 1,
- "UnitPrice": taxableMoney.Format(),
- "TaxableAmount": taxableMoney.Format(),
+ "UnitPrice": formatMoney(taxableMoney),
+ "TaxableAmount": formatMoney(taxableMoney),
"CGSTRate": req.CGSTRate,
- "CGSTAmount": cgstMoney.Format(),
+ "CGSTAmount": formatMoney(cgstMoney),
"SGSTRate": req.SGSTRate,
- "SGSTAmount": sgstMoney.Format(),
- "TotalAmount": totalMoney.Format(),
+ "SGSTAmount": formatMoney(sgstMoney),
+ "TotalAmount": formatMoney(totalMoney),
},
}
}
- totalInWords := totalMoney.Words()
+ totalInWords := fintech.InWords(totalMoney)
templateData := map[string]any{
"InvoiceNumber": req.InvoiceNumber,
@@ -192,7 +221,7 @@ func (s *InvoiceService) ProcessInvoice(ctx context.Context, tx outbox.DBOperato
"PONumber": "PO-REF-2026",
"PODate": time.Now().Format("02-Jan-2006"),
"PaymentTerms": "Due on Receipt",
- "FinancialYear": fy.Label,
+ "FinancialYear": fy.Label(),
"Supplier": map[string]string{
"Name": req.SupplierName,
"Address": "Bandra Kurla Complex, Mumbai",
@@ -211,10 +240,10 @@ func (s *InvoiceService) ProcessInvoice(ctx context.Context, tx outbox.DBOperato
"PAN": buyerDetails.PAN,
},
"Items": items,
- "SubTotal": taxableMoney.Format(),
- "TotalCGST": cgstMoney.Format(),
- "TotalSGST": sgstMoney.Format(),
- "GrandTotal": totalMoney.Format(),
+ "SubTotal": formatMoney(taxableMoney),
+ "TotalCGST": formatMoney(cgstMoney),
+ "TotalSGST": formatMoney(sgstMoney),
+ "GrandTotal": formatMoney(totalMoney),
"AmountInWords": totalInWords,
"BankDetails": map[string]string{
"BankName": req.BankName,
@@ -233,7 +262,7 @@ func (s *InvoiceService) ProcessInvoice(ctx context.Context, tx outbox.DBOperato
// 4. Record Compliance Audit Trail
auditEvt := audit.NewEvent(ctx, "INVOICE_GENERATED", "Invoice", req.InvoiceNumber, nil, map[string]any{
"invoice_number": req.InvoiceNumber,
- "grand_total": totalMoney.Format(),
+ "grand_total": formatMoney(totalMoney),
"total_paise": totalMoney.Paise(),
"currency": "INR",
"buyer_gstin": req.BuyerGSTIN,
@@ -245,7 +274,7 @@ func (s *InvoiceService) ProcessInvoice(ctx context.Context, tx outbox.DBOperato
// 5. Enqueue Domain Event to Transactional Outbox
outboxEvt, err := outbox.NewEvent("Invoice", req.InvoiceNumber, "InvoiceIssued", map[string]any{
"invoice_number": req.InvoiceNumber,
- "grand_total": totalMoney.Format(),
+ "grand_total": formatMoney(totalMoney),
"total_paise": totalMoney.Paise(),
"currency": "INR",
"buyer_email": req.BuyerEmail,
@@ -260,7 +289,7 @@ func (s *InvoiceService) ProcessInvoice(ctx context.Context, tx outbox.DBOperato
// 6. Dispatch Customer Notification with PDF Attachment
notifMsg := notifications.Message{
Title: fmt.Sprintf("Invoice %s Ready", req.InvoiceNumber),
- Body: fmt.Sprintf("Dear %s, your invoice for %s is ready. Total: ₹ %s", req.BuyerName, req.Description, totalMoney.Format()),
+ Body: fmt.Sprintf("Dear %s, your invoice for %s is ready. Total: ₹ %s", req.BuyerName, req.Description, formatMoney(totalMoney)),
Priority: notifications.PriorityHigh,
Recipients: []string{req.BuyerEmail},
Channels: []notifications.Channel{notifications.ChannelEmail},
diff --git a/export/csv_test.go b/export/csv_test.go
index 4a0a834..989c9ef 100644
--- a/export/csv_test.go
+++ b/export/csv_test.go
@@ -283,3 +283,51 @@ func TestCSVStreamer_ErrorHandling(t *testing.T) {
t.Fatalf("expected error on bad writer WriteRow")
}
}
+
+type failAfterNWriter struct {
+ writesAllowed int
+}
+
+func (f *failAfterNWriter) Write(p []byte) (int, error) {
+ if f.writesAllowed <= 0 {
+ return 0, errors.New("write limit exceeded")
+ }
+ f.writesAllowed--
+ return len(p), nil
+}
+
+func TestCSVStreamer_FlushAndWriteRows_Errors(t *testing.T) {
+ columns := []export.Column[InvoiceRecord]{
+ {Header: "ID", Extractor: func(r InvoiceRecord) string { return r.ID }},
+ }
+
+ // 1. WriteRows error propagation
+ badWriter := &errWriter{failOnWrite: true}
+ streamer := export.NewCSVStreamer(badWriter, columns, export.WithBOM(false))
+ err := streamer.WriteRows([]InvoiceRecord{{ID: "1"}})
+ if err == nil {
+ t.Fatalf("expected error from WriteRows, got nil")
+ }
+
+ // 2. WriteHeader idempotency
+ var buf bytes.Buffer
+ validStreamer := export.NewCSVStreamer(&buf, columns, export.WithBOM(false))
+ if err := validStreamer.WriteHeader(); err != nil {
+ t.Fatalf("first WriteHeader failed: %v", err)
+ }
+ if err := validStreamer.WriteHeader(); err != nil {
+ t.Fatalf("second WriteHeader failed: %v", err)
+ }
+
+ // 3. Periodic flush error during WriteRow
+ w := &failAfterNWriter{writesAllowed: 1} // header write succeeds
+ streamer3 := export.NewCSVStreamer(w, columns, export.WithBOM(false), export.WithFlushInterval(1))
+ if err := streamer3.WriteHeader(); err != nil {
+ t.Fatalf("header write failed: %v", err)
+ }
+ // Row write attempts flush with writesAllowed = 0
+ err = streamer3.WriteRow(InvoiceRecord{ID: "99"})
+ if err == nil {
+ t.Fatalf("expected error from periodic flush, got nil")
+ }
+}
diff --git a/india/README.md b/india/README.md
deleted file mode 100644
index d0e2dab..0000000
--- a/india/README.md
+++ /dev/null
@@ -1,78 +0,0 @@
-# `india`
-
-Zero-dependency statutory validation algorithms, financial calculators, and formatting helpers tailored for Indian enterprise SaaS and fintech workflows.
-
----
-
-## When to Use
-
-- **B2B Onboarding & KYC**: Validating Vendor/Buyer GSTIN, Company/Individual PAN, and Bank IFSC codes before persisting to databases.
-- **Aadhaar Identity Verification**: Validating UIDAI 12-digit Aadhaar numbers offline using the official Verhoeff Dihedral $D_5$ checksum, and generating masked privacy strings (`XXXX-XXXX-1234`) for UIDAI compliance.
-- **Tax Invoices & Billing**: Generating Indian rupee strings (`12,34,567.89`), printing statutory "Amount in Words" (Lakhs and Crores) on tax invoices, and calculating Indian Financial Years (FY 2026-27) and quarters (Q1–Q4).
-- **Customer Communication**: Validating and normalizing Indian mobile numbers (`+91`, `91`, or `0` prefixes) into clean E.164 format.
-
----
-
-## Why It Is Written Like That
-
-1. **Strictly Zero Dependencies**: Implements mathematical check digit algorithms directly in pure Go standard library without importing heavy third-party regex or validation libraries.
-2. **True Mathematical Validation**:
- - **Aadhaar**: Implements the authentic **Verhoeff algorithm** using dihedral group $D_5$ permutation and multiplication matrices rather than simple length/regex checks.
- - **GSTIN**: Implements the official GST Council **mod-36 checksum** where odd/even positional weights (1 and 2) are quotient-remainder factored into base-36 check characters.
- - **PAN**: Enforces entity-type semantics on the 4th character (`C` for Company, `P` for Individual, `F` for Firm/LLP, etc.).
-3. **Recursive Indian Numbering**: Unlike western million/billion converters, `AmountToWordsINR` follows the Indian numeral hierarchy (Units, Hundreds, Thousands, Lakhs, Crores) recursively, seamlessly supporting multi-hundred-crore amounts without array index bounds panics.
-4. **Allocation-Conscious String Parsing**: Uses byte-level indexing and string builders for phone and currency formatting to minimize GC pressure on high-throughput checkout paths.
-
----
-
-## Alternatives Evaluated
-
-- **Generic Regex Validators**: Naive regexes check pattern structure (`^[0-9]{12}$`) but cannot detect digit transpositions, single-digit typos, or forged check digits.
-- **External KYC REST APIs**: Making network calls to external APIs (e.g. Karza, Signzy, Razorpay) for initial syntax validation introduces 200–500ms network latency, API costs, and external points of failure.
-- **Western Currency Libraries**: Standard `humanize` or `accounting` libraries use thousand-grouping (`1,234,567.89`) which violates statutory Indian accounting standards requiring lakhs/crores grouping (`12,34,567.89`).
-
----
-
-## Comparison Table
-
-| Alternative | Pros | Cons | Why We Chose `go-app-kit/india` |
-|---|---|---|---|
-| **Raw Regex Checks** | Minimal code footprint | Misses 90%+ of invalid numbers due to lack of checksum validation | `go-app-kit/india` validates genuine mathematical checksums (Verhoeff $D_5$ and mod-36). |
-| **External KYC Verification APIs** | Verifies active registration with government databases | High latency (200–500ms), recurring API cost, network failure point | `go-app-kit/india` performs sub-microsecond offline syntax and checksum validation before making external API calls. |
-| **Western Formatting Packages** | Well-known in open source | Incompatible with Indian statutory formats (Lakhs/Crores grouping) | `go-app-kit/india` complies natively with Reserve Bank of India and GST Council formatting rules. |
-
----
-
-## Usage Example
-
-```go
-package main
-
-import (
- "fmt"
- "github.com/umesh0492/go-app-kit/india"
-)
-
-func main() {
- // 1. Validate GSTIN with mod-36 checksum
- gstin := "27AAPFU0939F1ZV"
- if err := india.ValidateGSTIN(gstin); err != nil {
- panic(err)
- }
- details, _ := india.ParseGSTIN(gstin)
- fmt.Printf("State: %s (Code: %s), PAN: %s\n", details.StateName, details.StateCode, details.PAN)
-
- // 2. Validate Aadhaar with Verhoeff D5 algorithm
- aadhaar := "234567890128"
- if india.IsValidAadhaar(aadhaar) {
- fmt.Println("Masked Aadhaar:", india.MaskAadhaar(aadhaar)) // "XXXX-XXXX-0128"
- }
-
- // 3. Indian Currency Formatting & Words
- paise := int64(154200050)
- fmt.Println(india.FormatINRPaise(paise)) // "15,42,000.50"
- money := india.NewMoney(paise)
- fmt.Println(money.Format()) // "15,42,000.50"
- fmt.Println(india.AmountToWordsINR(money.Rupees())) // "Fifteen Lakh Forty Two Thousand Rupees Only"
-}
-```
diff --git a/india/aadhaar.go b/india/aadhaar.go
deleted file mode 100644
index 4651da8..0000000
--- a/india/aadhaar.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package india
-
-import (
- "errors"
- "fmt"
- "strings"
-
- fintechin "github.com/umesh0492/go-fintech-india"
-)
-
-var (
- // ErrInvalidAadhaarLength indicates the Aadhaar number is not 12 digits.
- ErrInvalidAadhaarLength = fintechin.ErrInvalidAadhaarLength
- // ErrInvalidAadhaarFormat indicates the Aadhaar number contains invalid characters.
- ErrInvalidAadhaarFormat = fintechin.ErrInvalidAadhaarFormat
- // ErrInvalidAadhaarPrefix indicates the Aadhaar number starts with 0 or 1.
- ErrInvalidAadhaarPrefix = fintechin.ErrAadhaarStartsWithZeroOrOne
- // ErrAadhaarStartsWithZeroOrOne is an alias for ErrInvalidAadhaarPrefix.
- ErrAadhaarStartsWithZeroOrOne = fintechin.ErrAadhaarStartsWithZeroOrOne
- // ErrInvalidAadhaarChecksum indicates the Aadhaar number fails the Verhoeff checksum.
- ErrInvalidAadhaarChecksum = fintechin.ErrInvalidAadhaarChecksum
-)
-
-// ValidateAadhaar verifies that an Aadhaar number is 12 digits, does not start with 0 or 1,
-// and satisfies the official UIDAI Verhoeff dihedral D5 checksum.
-// Delegates statutory validation to Abeta go-fintech-india.
-func ValidateAadhaar(aadhaar string) error {
- clean := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(aadhaar), "-", ""), " ", "")
- if len(clean) != 12 {
- return ErrInvalidAadhaarLength
- }
-
- if clean[0] == '0' || clean[0] == '1' {
- return ErrInvalidAadhaarPrefix
- }
-
- if err := fintechin.ValidateAadhaar(clean); err != nil {
- switch {
- case errors.Is(err, fintechin.ErrInvalidAadhaarLength):
- return ErrInvalidAadhaarLength
- case errors.Is(err, fintechin.ErrAadhaarStartsWithZeroOrOne):
- return ErrInvalidAadhaarPrefix
- case errors.Is(err, fintechin.ErrInvalidAadhaarChecksum):
- return ErrInvalidAadhaarChecksum
- default:
- return ErrInvalidAadhaarFormat
- }
- }
-
- return nil
-}
-
-// IsValidAadhaar returns true if the Aadhaar number passes all UIDAI validation rules.
-func IsValidAadhaar(aadhaar string) bool {
- return ValidateAadhaar(aadhaar) == nil
-}
-
-// MaskAadhaar formats an Aadhaar number with privacy masking (e.g., "XXXX-XXXX-1234").
-func MaskAadhaar(aadhaar string) string {
- clean := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(aadhaar), "-", ""), " ", "")
- if len(clean) != 12 {
- return aadhaar
- }
- return fmt.Sprintf("XXXX-XXXX-%s", clean[8:])
-}
-
-// FormatAadhaar formats an Aadhaar into standard 4-digit groups (e.g., "1234 5678 9012").
-// Delegates formatting to Abeta go-fintech-india.
-func FormatAadhaar(aadhaar string) string {
- clean := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(aadhaar), "-", ""), " ", "")
- if len(clean) != 12 {
- return aadhaar
- }
- return fintechin.FormatAadhaar(clean)
-}
diff --git a/india/aging.go b/india/aging.go
deleted file mode 100644
index fa8a8c8..0000000
--- a/india/aging.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package india
-
-import (
- "time"
-)
-
-// AgingBucket classifies a past-due number of days into standard Indian accounting AP/AR aging buckets.
-// Buckets: Current (<= 0), 1–30, 31–60, 61–90, 90+.
-func AgingBucket(daysOverdue int) string {
- switch {
- case daysOverdue <= 0:
- return "Current"
- case daysOverdue <= 30:
- return "1-30"
- case daysOverdue <= 60:
- return "31-60"
- case daysOverdue <= 90:
- return "61-90"
- default:
- return "90+"
- }
-}
-
-// DaysOverdue calculates how many calendar days dueDate is past the reference time (usually today).
-// Timestamps are converted to Asia/Kolkata (IST) location before computing day boundaries,
-// preventing 5.5h/day UTC shift errors across midnight.
-// Returns 0 if dueDate is in the future or on the same calendar day.
-func DaysOverdue(dueDate, reference time.Time) int {
- dueIST := dueDate.In(istLocation)
- refIST := reference.In(istLocation)
-
- dueMidnight := time.Date(dueIST.Year(), dueIST.Month(), dueIST.Day(), 0, 0, 0, 0, istLocation)
- refMidnight := time.Date(refIST.Year(), refIST.Month(), refIST.Day(), 0, 0, 0, 0, istLocation)
-
- diff := int(refMidnight.Sub(dueMidnight).Hours() / 24)
- if diff < 0 {
- return 0
- }
- return diff
-}
diff --git a/india/aging_test.go b/india/aging_test.go
deleted file mode 100644
index ce53dc4..0000000
--- a/india/aging_test.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package india_test
-
-import (
- "encoding/json"
- "testing"
- "time"
-
- "github.com/stretchr/testify/assert"
- "github.com/umesh0492/go-app-kit/india"
-)
-
-func TestAgingMoneySerialization_NoFloat(t *testing.T) {
- type AgingBalance struct {
- Bucket string `json:"bucket"`
- Amount india.Money `json:"amount"`
- }
-
- entry := AgingBalance{
- Bucket: india.AgingBucket(45),
- Amount: india.NewMoney(1234550),
- }
-
- data, err := json.Marshal(entry)
- assert.NoError(t, err)
-
- // Assert that serialization contains NO float number on the wire
- wireStr := string(data)
- assert.NotContains(t, wireStr, "12345.50")
- assert.NotContains(t, wireStr, "12345.5")
- assert.Contains(t, wireStr, `"amount_paise":1234550`)
- assert.Contains(t, wireStr, `"formatted":"12,345.50"`)
- assert.Contains(t, wireStr, `"currency":"INR"`)
-
- var parsed AgingBalance
- err = json.Unmarshal(data, &parsed)
- assert.NoError(t, err)
- assert.Equal(t, int64(1234550), parsed.Amount.Paise())
-}
-
-func TestAgingBucket(t *testing.T) {
- assert.Equal(t, "Current", india.AgingBucket(0))
- assert.Equal(t, "Current", india.AgingBucket(-5))
- assert.Equal(t, "1-30", india.AgingBucket(1))
- assert.Equal(t, "1-30", india.AgingBucket(30))
- assert.Equal(t, "31-60", india.AgingBucket(31))
- assert.Equal(t, "31-60", india.AgingBucket(60))
- assert.Equal(t, "61-90", india.AgingBucket(61))
- assert.Equal(t, "61-90", india.AgingBucket(90))
- assert.Equal(t, "90+", india.AgingBucket(91))
- assert.Equal(t, "90+", india.AgingBucket(120))
-}
-
-func TestDaysOverdue(t *testing.T) {
- ref := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
-
- // Due in the future
- future := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
- assert.Equal(t, 0, india.DaysOverdue(future, ref))
-
- // Due today
- today := time.Date(2026, 4, 15, 8, 0, 0, 0, time.UTC)
- assert.Equal(t, 0, india.DaysOverdue(today, ref))
-
- // Due 5 days ago
- past := time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC)
- assert.Equal(t, 5, india.DaysOverdue(past, ref))
-
- // UTC shift boundary test:
- // In UTC: ref is April 14 20:00:00 UTC (April 14)
- // In IST: ref is April 15 01:30:00 IST (April 15)
- // dueDate is April 14 10:00:00 UTC (April 14 15:30:00 IST - April 14)
- // In IST, dueDate is 1 day overdue. (In naive UTC calculation, both were April 14 -> 0 days overdue).
- refUTCShift := time.Date(2026, 4, 14, 20, 0, 0, 0, time.UTC)
- dueUTCShift := time.Date(2026, 4, 14, 10, 0, 0, 0, time.UTC)
- assert.Equal(t, 1, india.DaysOverdue(dueUTCShift, refUTCShift))
-}
diff --git a/india/currency.go b/india/currency.go
deleted file mode 100644
index e59b207..0000000
--- a/india/currency.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package india
-
-import (
- "strings"
-
- fintechin "github.com/umesh0492/go-fintech-india"
-)
-
-// FormatINRPaise formats an exact integer amount in paise (1 INR = 100 paise)
-// into Indian currency format with comma placements and two decimal digits.
-// e.g. 123456789 -> "12,34,567.89", -5000 -> "-50.00"
-// Delegates formatting to Abeta go-fintech-india.
-func FormatINRPaise(paise int64) string {
- return fintechin.FormatINR(paise)
-}
-
-// AmountToWordsINR converts an integer rupee amount into words following the Indian numbering system.
-// Supports amounts up to arbitrary Crores (Crore, Lakh, Thousand, Hundred).
-// Delegates word conversion to Abeta go-fintech-india.
-func AmountToWordsINR(amount int64) string {
- if amount == 0 {
- return "Zero Rupees Only"
- }
-
- isNegative := amount < 0
- absAmount := amount
- if isNegative {
- absAmount = -absAmount
- }
-
- words := fintechin.NumberToIndianWords(absAmount)
- // Replace hyphens ("Twenty-Three" -> "Twenty Three") for standard Indian banking prose format
- words = strings.ReplaceAll(words, "-", " ")
-
- resStr := words + " Rupees Only"
- if isNegative {
- return "Minus " + resStr
- }
- return resStr
-}
diff --git a/india/doc.go b/india/doc.go
deleted file mode 100644
index 48fba26..0000000
--- a/india/doc.go
+++ /dev/null
@@ -1,7 +0,0 @@
-// Package india provides statutory validation algorithms, financial calculators,
-// and formatting helpers tailored for Indian enterprise SaaS and fintech workflows.
-//
-// Deprecated: This package is maintained solely as a backward-compatibility façade.
-// Developers should import the Abeta Indian fintech library directly for
-// statutory Indian fintech primitives (Aadhaar, GSTIN, PAN, IFSC, etc.).
-package india
diff --git a/india/fy.go b/india/fy.go
deleted file mode 100644
index fca1212..0000000
--- a/india/fy.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package india
-
-import (
- "fmt"
- "time"
-
- fintechin "github.com/umesh0492/go-fintech-india"
-)
-
-// FinancialQuarter represents Q1, Q2, Q3, or Q4 of the Indian Financial Year.
-type FinancialQuarter string
-
-const (
- // Q1 represents the first quarter (April - June).
- Q1 FinancialQuarter = "Q1"
- // Q2 represents the second quarter (July - September).
- Q2 FinancialQuarter = "Q2"
- // Q3 represents the third quarter (October - December).
- Q3 FinancialQuarter = "Q3"
- // Q4 represents the fourth quarter (January - March).
- Q4 FinancialQuarter = "Q4"
-)
-
-var istLocation = func() *time.Location {
- loc, err := time.LoadLocation("Asia/Kolkata")
- if err == nil {
- return loc
- }
- return time.FixedZone("IST", 5*3600+30*60)
-}()
-
-// ISTLocation returns the *time.Location representing Asia/Kolkata (IST: UTC+5:30).
-func ISTLocation() *time.Location {
- return istLocation
-}
-
-// FinancialYear contains Indian fiscal year details.
-type FinancialYear struct {
- Label string // e.g., "FY 2026-27"
- ShortCode string // e.g., "FY26-27"
- StartYear int // e.g., 2026
- EndYear int // e.g., 2027
- StartDate time.Time // April 1, 00:00:00 IST
- EndDate time.Time // March 31, 23:59:59 IST
- Quarter FinancialQuarter // Q1, Q2, Q3, or Q4
-}
-
-// GetFinancialYear calculates the Indian Financial Year and Quarter for any given timestamp.
-// Converts timestamps to Asia/Kolkata IST location before computing calendar year, month, or day boundaries
-// to prevent 5.5h/day UTC shift errors.
-// In India, the Financial Year begins on April 1st and ends on March 31st.
-// Delegates statutory financial year calculation to Abeta go-fintech-india.
-func GetFinancialYear(t time.Time) FinancialYear {
- tIST := t.In(istLocation)
- finFY := fintechin.FYFromDate(tIST)
- qNum := fintechin.Quarter(tIST)
-
- var q FinancialQuarter
- switch qNum {
- case 1:
- q = Q1
- case 2:
- q = Q2
- case 3:
- q = Q3
- case 4:
- q = Q4
- }
-
- startYear := finFY.Year
- endYear := startYear + 1
- shortStart := startYear % 100
- shortEnd := endYear % 100
-
- start := time.Date(startYear, time.April, 1, 0, 0, 0, 0, istLocation)
- end := time.Date(endYear, time.March, 31, 23, 59, 59, 999999999, istLocation)
-
- return FinancialYear{
- Label: finFY.Label(),
- ShortCode: fmt.Sprintf("FY%02d-%02d", shortStart, shortEnd),
- StartYear: startYear,
- EndYear: endYear,
- StartDate: start,
- EndDate: end,
- Quarter: q,
- }
-}
-
-// CurrentFinancialYear returns the Indian Financial Year for the current moment in time.
-func CurrentFinancialYear() FinancialYear {
- return GetFinancialYear(time.Now())
-}
diff --git a/india/gstin.go b/india/gstin.go
deleted file mode 100644
index def3e99..0000000
--- a/india/gstin.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package india
-
-import (
- "errors"
- "fmt"
- "strings"
-
- fintechin "github.com/umesh0492/go-fintech-india"
-)
-
-var (
- // ErrInvalidGSTINLength indicates the GSTIN does not have exactly 15 characters.
- ErrInvalidGSTINLength = fintechin.ErrInvalidGSTINLength
- // ErrInvalidGSTINFormat indicates the GSTIN contains invalid characters or regex.
- ErrInvalidGSTINFormat = fintechin.ErrInvalidGSTINFormat
- // ErrInvalidGSTIN is an alias for ErrInvalidGSTINFormat.
- ErrInvalidGSTIN = fintechin.ErrInvalidGSTINFormat
- // ErrInvalidGSTINChecksum indicates the 15th character checksum mismatch.
- ErrInvalidGSTINChecksum = fintechin.ErrInvalidGSTINChecksum
- // ErrInvalidStateCode indicates the 2-digit state code is invalid.
- ErrInvalidStateCode = fintechin.ErrInvalidStateCode
-)
-
-// GSTINDetails represents parsed information from a valid GSTIN.
-type GSTINDetails struct {
- GSTIN string
- StateCode string
- StateName string
- PAN string
- EntityNum string
- CheckDigit string
-}
-
-// ValidateGSTIN checks length, regex format, state code, and the official mod-36 checksum.
-// Delegates statutory validation to Abeta go-fintech-india.
-func ValidateGSTIN(gstin string) error {
- clean := strings.ToUpper(strings.TrimSpace(gstin))
- if len(clean) != 15 {
- return ErrInvalidGSTINLength
- }
-
- if err := fintechin.ValidateGSTIN(clean); err != nil {
- switch {
- case errors.Is(err, fintechin.ErrInvalidGSTINLength):
- return ErrInvalidGSTINLength
- case errors.Is(err, fintechin.ErrInvalidStateCode):
- return fmt.Errorf("%w: %s", ErrInvalidStateCode, clean[:2])
- case errors.Is(err, fintechin.ErrInvalidGSTINChecksum):
- expected := CalculateGSTINCheckDigit(clean[:14])
- return fmt.Errorf("%w: expected %c, got %c", ErrInvalidGSTINChecksum, expected, clean[14])
- default:
- return ErrInvalidGSTINFormat
- }
- }
-
- return nil
-}
-
-// IsValidGSTIN returns true if the GSTIN passes all validation rules.
-func IsValidGSTIN(gstin string) bool {
- return ValidateGSTIN(gstin) == nil
-}
-
-// ParseGSTIN validates and decomposes a GSTIN into its constituent components.
-func ParseGSTIN(gstin string) (*GSTINDetails, error) {
- if err := ValidateGSTIN(gstin); err != nil {
- return nil, err
- }
- clean := strings.ToUpper(strings.TrimSpace(gstin))
- stateCode := fintechin.StateCode(clean)
- stateName, _ := fintechin.StateName(stateCode)
-
- return &GSTINDetails{
- GSTIN: clean,
- StateCode: stateCode,
- StateName: stateName,
- PAN: clean[2:12],
- EntityNum: string(clean[12]),
- CheckDigit: string(clean[14]),
- }, nil
-}
-
-// CalculateGSTINCheckDigit computes the official mod-36 checksum character for the first 14 chars.
-// Delegates calculation to Abeta go-fintech-india.
-func CalculateGSTINCheckDigit(input14 string) byte {
- clean := strings.ToUpper(strings.TrimSpace(input14))
- check, err := fintechin.CalculateGSTINChecksum(clean)
- if err != nil {
- return '0'
- }
- return check
-}
diff --git a/india/ifsc.go b/india/ifsc.go
deleted file mode 100644
index e41b08a..0000000
--- a/india/ifsc.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package india
-
-import (
- "errors"
- "strings"
-
- fintechin "github.com/umesh0492/go-fintech-india"
-)
-
-var (
- // ErrInvalidIFSCLength indicates the IFSC string does not have length 11.
- ErrInvalidIFSCLength = fintechin.ErrInvalidIFSCLength
- // ErrInvalidIFSCFormat indicates the IFSC format is invalid (4 bank letters + 0 + 6 branch alphanumeric).
- ErrInvalidIFSCFormat = errors.New("ifsc format is invalid (4 bank letters + 0 + 6 branch alphanumeric)")
- // ErrInvalidIFSCBankCode indicates the first 4 characters are not alphabetic.
- ErrInvalidIFSCBankCode = fintechin.ErrInvalidIFSCBankCode
- // ErrInvalidIFSCFifthChar indicates the 5th character is not '0'.
- ErrInvalidIFSCFifthChar = fintechin.ErrInvalidIFSCFifthChar
- // ErrInvalidIFSCBranchCode indicates the last 6 characters are not alphanumeric.
- ErrInvalidIFSCBranchCode = fintechin.ErrInvalidIFSCBranchCode
-)
-
-// ValidateIFSC checks the structural validity of an Indian Financial System Code.
-// Delegates statutory validation to Abeta go-fintech-india.
-func ValidateIFSC(code string) error {
- clean := strings.ToUpper(strings.TrimSpace(code))
- if len(clean) != 11 {
- return ErrInvalidIFSCLength
- }
-
- if err := fintechin.ValidateIFSC(clean); err != nil {
- if errors.Is(err, fintechin.ErrInvalidIFSCLength) {
- return ErrInvalidIFSCLength
- }
- return ErrInvalidIFSCFormat
- }
-
- return nil
-}
-
-// IsValidIFSC returns true if the IFSC code matches RBI format.
-func IsValidIFSC(code string) bool {
- return ValidateIFSC(code) == nil
-}
-
-// GetBankCode extracts the 4-letter bank identifier prefix from an IFSC code.
-// Delegates extraction to Abeta go-fintech-india.
-func GetBankCode(code string) (string, error) {
- if err := ValidateIFSC(code); err != nil {
- return "", err
- }
- clean := strings.ToUpper(strings.TrimSpace(code))
- return fintechin.BankCode(clean), nil
-}
-
-// GetBranchCode extracts the 6-character branch identifier from an IFSC code.
-// Delegates extraction to Abeta go-fintech-india.
-func GetBranchCode(code string) (string, error) {
- if err := ValidateIFSC(code); err != nil {
- return "", err
- }
- clean := strings.ToUpper(strings.TrimSpace(code))
- return fintechin.BranchCode(clean), nil
-}
diff --git a/india/india_test.go b/india/india_test.go
deleted file mode 100644
index 7a370e1..0000000
--- a/india/india_test.go
+++ /dev/null
@@ -1,892 +0,0 @@
-package india_test
-
-import (
- "encoding/json"
- "errors"
- "strconv"
- "strings"
- "testing"
- "time"
-
- "github.com/umesh0492/go-app-kit/india"
- fintechin "github.com/umesh0492/go-fintech-india"
-)
-
-// Helper to calculate Verhoeff check digit for test generation
-func generateValidAadhaar(base11 string) string {
- verhoeffD := [10][10]int{
- {0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
- {1, 2, 3, 4, 0, 6, 7, 8, 9, 5},
- {2, 3, 4, 0, 1, 7, 8, 9, 5, 6},
- {3, 4, 0, 1, 2, 8, 9, 5, 6, 7},
- {4, 0, 1, 2, 3, 9, 5, 6, 7, 8},
- {5, 9, 8, 7, 6, 0, 4, 3, 2, 1},
- {6, 5, 9, 8, 7, 1, 0, 4, 3, 2},
- {7, 6, 5, 9, 8, 2, 1, 0, 4, 3},
- {8, 7, 6, 5, 9, 3, 2, 1, 0, 4},
- {9, 8, 7, 6, 5, 4, 3, 2, 1, 0},
- }
- verhoeffP := [8][10]int{
- {0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
- {1, 5, 7, 6, 2, 8, 3, 0, 9, 4},
- {5, 8, 0, 3, 7, 9, 6, 1, 4, 2},
- {8, 9, 1, 6, 0, 4, 3, 5, 2, 7},
- {9, 4, 5, 3, 1, 2, 6, 8, 7, 0},
- {4, 2, 8, 6, 5, 7, 3, 9, 0, 1},
- {2, 7, 9, 3, 8, 0, 6, 4, 1, 5},
- {7, 0, 4, 6, 9, 1, 3, 2, 5, 8},
- }
- verhoeffInv := [10]int{0, 4, 3, 2, 1, 5, 6, 7, 8, 9}
-
- c := 0
- l := len(base11)
- for i := 0; i < l; i++ {
- digit, _ := strconv.Atoi(string(base11[l-1-i]))
- c = verhoeffD[c][verhoeffP[(i+1)%8][digit]]
- }
- checkDigit := verhoeffInv[c]
- return base11 + strconv.Itoa(checkDigit)
-}
-
-func TestAadhaar(t *testing.T) {
- // Standard UIDAI Verhoeff generated test vectors
- validAadhaar := generateValidAadhaar("23456789012")
- validAadhaar2 := generateValidAadhaar("36759834601")
-
- t.Run("Valid Aadhaar", func(t *testing.T) {
- for _, v := range []string{validAadhaar, validAadhaar2} {
- if err := india.ValidateAadhaar(v); err != nil {
- t.Fatalf("expected valid aadhaar %s, got error: %v", v, err)
- }
- if !india.IsValidAadhaar(v) {
- t.Fatalf("expected IsValidAadhaar to return true for %s", v)
- }
- }
- })
-
- t.Run("Valid with spaces and dashes", func(t *testing.T) {
- spaced := validAadhaar[:4] + " " + validAadhaar[4:8] + " " + validAadhaar[8:]
- if err := india.ValidateAadhaar(spaced); err != nil {
- t.Fatalf("expected spaced aadhaar to pass, got: %v", err)
- }
- dashed := validAadhaar[:4] + "-" + validAadhaar[4:8] + "-" + validAadhaar[8:]
- if err := india.ValidateAadhaar(dashed); err != nil {
- t.Fatalf("expected dashed aadhaar to pass, got: %v", err)
- }
- })
-
- t.Run("Invalid Length", func(t *testing.T) {
- if err := india.ValidateAadhaar("12345"); err != india.ErrInvalidAadhaarLength {
- t.Fatalf("expected ErrInvalidAadhaarLength, got %v", err)
- }
- })
-
- t.Run("Invalid Prefix (0 or 1)", func(t *testing.T) {
- if err := india.ValidateAadhaar("012345678901"); err != india.ErrInvalidAadhaarPrefix {
- t.Fatalf("expected ErrInvalidAadhaarPrefix, got %v", err)
- }
- if err := india.ValidateAadhaar("112345678901"); err != india.ErrInvalidAadhaarPrefix {
- t.Fatalf("expected ErrInvalidAadhaarPrefix, got %v", err)
- }
- })
-
- t.Run("Non-numeric characters", func(t *testing.T) {
- if err := india.ValidateAadhaar("23456789012A"); err != india.ErrInvalidAadhaarFormat {
- t.Fatalf("expected ErrInvalidAadhaarFormat, got %v", err)
- }
- })
-
- t.Run("Invalid Checksum", func(t *testing.T) {
- // Flip the check digit from 9 to 0
- badChecksum := "234567890120"
- if err := india.ValidateAadhaar(badChecksum); err != india.ErrInvalidAadhaarChecksum {
- t.Fatalf("expected ErrInvalidAadhaarChecksum, got %v", err)
- }
- })
-
- t.Run("MaskAadhaar", func(t *testing.T) {
- masked := india.MaskAadhaar(validAadhaar)
- expectedSuffix := validAadhaar[8:]
- if masked != "XXXX-XXXX-"+expectedSuffix {
- t.Fatalf("expected XXXX-XXXX-%s, got %s", expectedSuffix, masked)
- }
- // Invalid length returns input as is
- if india.MaskAadhaar("123") != "123" {
- t.Fatalf("expected untouched for invalid length")
- }
- })
-
- t.Run("FormatAadhaar", func(t *testing.T) {
- formatted := india.FormatAadhaar(validAadhaar)
- expected := validAadhaar[:4] + " " + validAadhaar[4:8] + " " + validAadhaar[8:]
- if formatted != expected {
- t.Fatalf("expected %s, got %s", expected, formatted)
- }
- if india.FormatAadhaar("123") != "123" {
- t.Fatalf("expected untouched for invalid length")
- }
- })
-}
-
-func TestCurrency(t *testing.T) {
- t.Run("FormatINRPaise", func(t *testing.T) {
- tests := []struct {
- input int64
- expected string
- }{
- {0, "0.00"},
- {50, "0.50"},
- {1995, "19.95"},
- {200, "2.00"},
- {100000, "1,000.00"},
- {1000000, "10,000.00"},
- {10000000, "1,00,000.00"},
- {123456789, "12,34,567.89"},
- {-123456789, "-12,34,567.89"},
- {-5000, "-50.00"},
- {-1995, "-19.95"},
- {1000000000, "1,00,00,000.00"}, // 1 Crore INR
- }
-
- for _, tc := range tests {
- actual := india.FormatINRPaise(tc.input)
- if actual != tc.expected {
- t.Errorf("FormatINRPaise(%d): expected %q, got %q", tc.input, tc.expected, actual)
- }
- }
- })
-
- t.Run("AmountToWordsINR", func(t *testing.T) {
- tests := []struct {
- input int64
- expected string
- }{
- {0, "Zero Rupees Only"},
- {1, "One Rupees Only"},
- {15, "Fifteen Rupees Only"},
- {42, "Forty Two Rupees Only"},
- {105, "One Hundred Five Rupees Only"},
- {1500, "One Thousand Five Hundred Rupees Only"},
- {25000, "Twenty Five Thousand Rupees Only"},
- {100000, "One Lakh Rupees Only"},
- {5500000, "Fifty Five Lakh Rupees Only"},
- {10000000, "One Crore Rupees Only"},
- {12345678, "One Crore Twenty Three Lakh Forty Five Thousand Six Hundred Seventy Eight Rupees Only"},
- {-500, "Minus Five Hundred Rupees Only"},
- {1500000000, "One Hundred Fifty Crore Rupees Only"},
- }
-
- for _, tc := range tests {
- actual := india.AmountToWordsINR(tc.input)
- if actual != tc.expected {
- t.Errorf("AmountToWordsINR(%d): expected %q, got %q", tc.input, tc.expected, actual)
- }
- }
- })
-}
-
-func TestFinancialYear(t *testing.T) {
- t.Run("Q1 (April - June)", func(t *testing.T) {
- date := time.Date(2026, time.May, 15, 12, 0, 0, 0, time.UTC)
- fy := india.GetFinancialYear(date)
- if fy.Label != "FY 2026-27" || fy.ShortCode != "FY26-27" || fy.Quarter != india.Q1 {
- t.Fatalf("unexpected FY result: %+v", fy)
- }
- if fy.StartYear != 2026 || fy.EndYear != 2027 {
- t.Fatalf("unexpected years: %d-%d", fy.StartYear, fy.EndYear)
- }
- })
-
- t.Run("Q2 (July - September)", func(t *testing.T) {
- date := time.Date(2026, time.August, 1, 0, 0, 0, 0, time.UTC)
- fy := india.GetFinancialYear(date)
- if fy.Quarter != india.Q2 || fy.Label != "FY 2026-27" {
- t.Fatalf("unexpected FY result: %+v", fy)
- }
- })
-
- t.Run("Q3 (October - December)", func(t *testing.T) {
- date := time.Date(2026, time.November, 20, 0, 0, 0, 0, time.UTC)
- fy := india.GetFinancialYear(date)
- if fy.Quarter != india.Q3 || fy.Label != "FY 2026-27" {
- t.Fatalf("unexpected FY result: %+v", fy)
- }
- })
-
- t.Run("Q4 (January - March)", func(t *testing.T) {
- date := time.Date(2027, time.February, 10, 0, 0, 0, 0, time.UTC)
- fy := india.GetFinancialYear(date)
- if fy.Quarter != india.Q4 || fy.Label != "FY 2026-27" {
- t.Fatalf("unexpected FY result: %+v", fy)
- }
- if fy.StartYear != 2026 || fy.EndYear != 2027 {
- t.Fatalf("unexpected years: %d-%d", fy.StartYear, fy.EndYear)
- }
- })
-
- t.Run("CurrentFinancialYear", func(t *testing.T) {
- fy := india.CurrentFinancialYear()
- if fy.Label == "" || fy.Quarter == "" {
- t.Fatalf("CurrentFinancialYear should not be empty: %+v", fy)
- }
- })
-
- t.Run("UTC Shift Prevention", func(t *testing.T) {
- // 2026-03-31 19:00:00 UTC is 2026-04-01 00:30:00 IST -> FY 2026-27 Q1
- // In naive UTC, month is March 2026 -> FY 2025-26 Q4 (wrong!)
- dateQ1 := time.Date(2026, time.March, 31, 19, 0, 0, 0, time.UTC)
- fyQ1 := india.GetFinancialYear(dateQ1)
- if fyQ1.Quarter != india.Q1 || fyQ1.Label != "FY 2026-27" {
- t.Fatalf("expected FY 2026-27 Q1 in IST, got %s %s", fyQ1.Label, fyQ1.Quarter)
- }
-
- // 2026-06-30 19:00:00 UTC is 2026-07-01 00:30:00 IST -> FY 2026-27 Q2
- // In naive UTC, month is June 2026 -> Q1 (wrong!)
- dateQ2 := time.Date(2026, time.June, 30, 19, 0, 0, 0, time.UTC)
- fyQ2 := india.GetFinancialYear(dateQ2)
- if fyQ2.Quarter != india.Q2 || fyQ2.Label != "FY 2026-27" {
- t.Fatalf("expected FY 2026-27 Q2 in IST, got %s %s", fyQ2.Label, fyQ2.Quarter)
- }
- })
-}
-
-func TestGSTIN(t *testing.T) {
- // Standard GSTN test vectors:
- // 27AAPFU0939F1ZV (Maharashtra, Company PAN AAPFU0939F, check digit 'V')
- // 29AAPFU0939F1ZR (Karnataka, Company PAN AAPFU0939F, check digit 'R')
- // 07AAGCA4112E1ZL (Delhi, Company PAN AAGCA4112E, check digit 'L')
- validGSTIN := "27AAPFU0939F1ZV"
- validGSTINKarnataka := "29AAPFU0939F1ZR"
- validGSTINDelhi := "07AAGCA4112E1Z9"
-
- t.Run("Valid GSTIN Vectors", func(t *testing.T) {
- for _, gstin := range []string{validGSTIN, validGSTINKarnataka, validGSTINDelhi} {
- if err := india.ValidateGSTIN(gstin); err != nil {
- t.Fatalf("expected valid GSTIN %s, got err: %v", gstin, err)
- }
- if !india.IsValidGSTIN(gstin) {
- t.Fatalf("expected IsValidGSTIN to be true for %s", gstin)
- }
- }
-
- details, err := india.ParseGSTIN(validGSTIN)
- if err != nil {
- t.Fatalf("ParseGSTIN failed: %v", err)
- }
- if details.StateCode != "27" || details.StateName != "Maharashtra" {
- t.Fatalf("unexpected state: %s, %s", details.StateCode, details.StateName)
- }
- if details.PAN != "AAPFU0939F" {
- t.Fatalf("unexpected PAN: %s", details.PAN)
- }
- if details.EntityNum != "1" || details.CheckDigit != "V" {
- t.Fatalf("unexpected entity/checkdigit: %s/%s", details.EntityNum, details.CheckDigit)
- }
-
- ktkDetails, err := india.ParseGSTIN(validGSTINKarnataka)
- if err != nil {
- t.Fatalf("ParseGSTIN failed on Karnataka vector: %v", err)
- }
- if ktkDetails.StateCode != "29" || ktkDetails.StateName != "Karnataka" {
- t.Fatalf("unexpected Karnataka details: %+v", ktkDetails)
- }
- if ktkDetails.CheckDigit != "R" {
- t.Fatalf("expected check digit 'R' for Karnataka vector, got %s", ktkDetails.CheckDigit)
- }
- })
-
- t.Run("Invalid Length", func(t *testing.T) {
- if err := india.ValidateGSTIN("27AAPFU0939F1Z"); err != india.ErrInvalidGSTINLength {
- t.Fatalf("expected ErrInvalidGSTINLength, got %v", err)
- }
- })
-
- t.Run("Invalid Format Regex", func(t *testing.T) {
- if err := india.ValidateGSTIN("27AAPFU0939F1X0"); err != india.ErrInvalidGSTINFormat {
- t.Fatalf("expected ErrInvalidGSTINFormat, got %v", err)
- }
- })
-
- t.Run("Invalid State Code", func(t *testing.T) {
- // 98 is not a valid state code
- invalidState := "98AAPFU0939F1ZV"
- if err := india.ValidateGSTIN(invalidState); err == nil {
- t.Fatalf("expected state code error")
- }
- })
-
- t.Run("Invalid Checksum", func(t *testing.T) {
- // Flip check digit 'V' to '0'
- badCheck := "27AAPFU0939F1Z0"
- if err := india.ValidateGSTIN(badCheck); err == nil {
- t.Fatalf("expected checksum error")
- }
- })
-
- t.Run("CalculateGSTINCheckDigit RFC test vectors", func(t *testing.T) {
- if check := india.CalculateGSTINCheckDigit("27AAPFU0939F1Z"); check != 'V' {
- t.Fatalf("expected check digit 'V' for 27AAPFU0939F1Z, got %c", check)
- }
- if check := india.CalculateGSTINCheckDigit("29AAPFU0939F1Z"); check != 'R' {
- t.Fatalf("expected check digit 'R' for 29AAPFU0939F1Z, got %c", check)
- }
- if check := india.CalculateGSTINCheckDigit("07AAGCA4112E1Z"); check != '9' {
- t.Fatalf("expected check digit '9' for 07AAGCA4112E1Z, got %c", check)
- }
- if india.CalculateGSTINCheckDigit("short") != '0' {
- t.Fatalf("expected '0' for short input")
- }
- if india.CalculateGSTINCheckDigit("27AAPFU0939F1!") != '0' {
- t.Fatalf("expected '0' for invalid characters")
- }
- })
-}
-
-func TestIFSC(t *testing.T) {
- // Standard RBI IFSC test vectors
- testVectors := []struct {
- ifsc string
- bankCode string
- branchCode string
- }{
- {"HDFC0001234", "HDFC", "001234"},
- {"SBIN0000456", "SBIN", "000456"},
- {"ICIC0000002", "ICIC", "000002"},
- {"PUNB0024000", "PUNB", "024000"},
- {"KKBK0000958", "KKBK", "000958"},
- }
-
- t.Run("Valid Standard IFSC Vectors", func(t *testing.T) {
- for _, tv := range testVectors {
- if err := india.ValidateIFSC(tv.ifsc); err != nil {
- t.Fatalf("expected valid IFSC %s, got: %v", tv.ifsc, err)
- }
- if !india.IsValidIFSC(tv.ifsc) {
- t.Fatalf("expected IsValidIFSC to be true for %s", tv.ifsc)
- }
- bank, err := india.GetBankCode(tv.ifsc)
- if err != nil || bank != tv.bankCode {
- t.Fatalf("unexpected bank code for %s: got %s, want %s, err: %v", tv.ifsc, bank, tv.bankCode, err)
- }
- branch, err := india.GetBranchCode(tv.ifsc)
- if err != nil || branch != tv.branchCode {
- t.Fatalf("unexpected branch code for %s: got %s, want %s, err: %v", tv.ifsc, branch, tv.branchCode, err)
- }
- }
- })
-
- t.Run("Invalid Length", func(t *testing.T) {
- if err := india.ValidateIFSC("HDFC001"); err != india.ErrInvalidIFSCLength {
- t.Fatalf("expected ErrInvalidIFSCLength, got %v", err)
- }
- })
-
- t.Run("Invalid 5th character", func(t *testing.T) {
- if err := india.ValidateIFSC("HDFC1001234"); err != india.ErrInvalidIFSCFormat {
- t.Fatalf("expected ErrInvalidIFSCFormat, got %v", err)
- }
- })
-
- t.Run("GetBankCode/GetBranchCode on invalid code", func(t *testing.T) {
- if _, err := india.GetBankCode("invalid"); err == nil {
- t.Fatalf("expected error from GetBankCode on invalid code")
- }
- if _, err := india.GetBranchCode("invalid"); err == nil {
- t.Fatalf("expected error from GetBranchCode on invalid code")
- }
- })
-}
-
-func TestPAN(t *testing.T) {
- // Standard ITD PAN test vectors
- companyPAN := "ABCCE1234F"
- individualPAN := "AAAPZ9876C"
- firmPAN := "AAAFR1234E"
- trustPAN := "AAATT1234C"
-
- t.Run("Valid Standard PAN Vectors", func(t *testing.T) {
- for _, pan := range []string{companyPAN, individualPAN, firmPAN, trustPAN} {
- if err := india.ValidatePAN(pan); err != nil {
- t.Fatalf("expected valid PAN %s, got: %v", pan, err)
- }
- if !india.IsValidPAN(pan) {
- t.Fatalf("expected IsValidPAN to be true for %s", pan)
- }
- }
-
- details, err := india.ParsePAN(companyPAN)
- if err != nil {
- t.Fatalf("ParsePAN failed: %v", err)
- }
- if details.EntityTypeCode != "C" || details.EntityTypeName != "Company" {
- t.Fatalf("unexpected entity type: %s, %s", details.EntityTypeCode, details.EntityTypeName)
- }
- if details.SurnameInitial != "E" {
- t.Fatalf("unexpected surname initial: %s", details.SurnameInitial)
- }
-
- indivDetails, err := india.ParsePAN(individualPAN)
- if err != nil {
- t.Fatalf("ParsePAN failed: %v", err)
- }
- if indivDetails.EntityTypeCode != "P" || indivDetails.EntityTypeName != "Individual (Person)" {
- t.Fatalf("unexpected individual entity: %+v", indivDetails)
- }
-
- firmDetails, err := india.ParsePAN(firmPAN)
- if err != nil {
- t.Fatalf("ParsePAN failed on firm: %v", err)
- }
- if firmDetails.EntityTypeCode != "F" || firmDetails.EntityTypeName != "Firm / Limited Liability Partnership (LLP)" {
- t.Fatalf("unexpected firm entity: %+v", firmDetails)
- }
- })
-
- t.Run("Invalid Length", func(t *testing.T) {
- if err := india.ValidatePAN("ABCDE123"); err != india.ErrInvalidPANLength {
- t.Fatalf("expected ErrInvalidPANLength, got %v", err)
- }
- })
-
- t.Run("Invalid Format Regex", func(t *testing.T) {
- if err := india.ValidatePAN("12345ABCDE"); err != india.ErrInvalidPANFormat {
- t.Fatalf("expected ErrInvalidPANFormat, got %v", err)
- }
- })
-
- t.Run("Unknown Entity Type", func(t *testing.T) {
- // 4th char 'X' is not a registered entity type
- if err := india.ValidatePAN("ABCDX1234F"); err == nil {
- t.Fatalf("expected unknown entity type error")
- }
- })
-}
-
-func TestPhone(t *testing.T) {
- t.Run("Valid Mobile Numbers", func(t *testing.T) {
- numbers := []string{
- "9876543210",
- "+919876543210",
- "+91 98765 43210",
- "919876543210",
- "09876543210",
- "8765432109",
- "7654321098",
- "6543210987",
- }
-
- for _, num := range numbers {
- if err := india.ValidatePhone(num); err != nil {
- t.Errorf("expected valid phone for %s, got: %v", num, err)
- }
- if !india.IsValidPhone(num) {
- t.Errorf("expected IsValidPhone to be true for %s", num)
- }
- }
- })
-
- t.Run("Invalid Numbers", func(t *testing.T) {
- invalid := []string{
- "5876543210", // starts with 5
- "987654321", // 9 digits
- "98765432100", // 11 digits without 0
- "abcdefghij",
- }
-
- for _, num := range invalid {
- if err := india.ValidatePhone(num); err != india.ErrInvalidPhoneFormat {
- t.Errorf("expected ErrInvalidPhoneFormat for %s, got: %v", num, err)
- }
- }
- })
-
- t.Run("FormatE164 & FormatNational", func(t *testing.T) {
- e164, err := india.FormatE164("09876543210")
- if err != nil || e164 != "+919876543210" {
- t.Fatalf("FormatE164 failed: %s, %v", e164, err)
- }
-
- nat, err := india.FormatNational("+91 98765 43210")
- if err != nil || nat != "98765-43210" {
- t.Fatalf("FormatNational failed: %s, %v", nat, err)
- }
-
- if _, err := india.FormatE164("123"); err == nil {
- t.Fatalf("expected error on invalid phone")
- }
- if _, err := india.FormatNational("123"); err == nil {
- t.Fatalf("expected error on invalid phone")
- }
- })
-}
-
-func TestMoney(t *testing.T) {
- t.Run("Constructors & Basic Getters", func(t *testing.T) {
- m1 := india.NewMoney(150050)
- if m1.Paise() != 150050 || m1.Rupees() != 1500 || m1.Float64() != 1500.50 {
- t.Fatalf("unexpected m1 values: paise=%d, rupees=%d, float=%.2f", m1.Paise(), m1.Rupees(), m1.Float64())
- }
-
- m2 := india.NewMoneyFromRupees(500)
- if m2.Paise() != 50000 || m2.Rupees() != 500 {
- t.Fatalf("unexpected m2 from rupees: %d", m2.Paise())
- }
-
- // Float rounding checks
- m3 := india.NewMoneyFromFloat(1.995)
- if m3.Paise() != 200 {
- t.Fatalf("expected 1.995 to round to 200 paise, got %d", m3.Paise())
- }
-
- m4 := india.NewMoneyFromFloat(1234.564)
- if m4.Paise() != 123456 {
- t.Fatalf("expected 1234.564 to round to 123456 paise, got %d", m4.Paise())
- }
- })
-
- t.Run("Predicates, Abs, Negate", func(t *testing.T) {
- zero := india.NewMoney(0)
- pos := india.NewMoney(100)
- neg := india.NewMoney(-100)
-
- if !zero.IsZero() || zero.IsPositive() || zero.IsNegative() {
- t.Fatalf("zero predicate failed")
- }
- if pos.IsZero() || !pos.IsPositive() || pos.IsNegative() {
- t.Fatalf("pos predicate failed")
- }
- if neg.IsZero() || neg.IsPositive() || !neg.IsNegative() {
- t.Fatalf("neg predicate failed")
- }
-
- if neg.Abs().Paise() != 100 || pos.Abs().Paise() != 100 {
- t.Fatalf("Abs failed")
- }
- if pos.Negate().Paise() != -100 || neg.Negate().Paise() != 100 {
- t.Fatalf("Negate failed")
- }
- })
-
- t.Run("Arithmetic Operations", func(t *testing.T) {
- m1 := india.NewMoney(100000) // ₹1000.00
- m2 := india.NewMoney(25050) // ₹250.50
-
- sum := m1.Add(m2)
- if sum.Paise() != 125050 {
- t.Fatalf("Add failed: %d", sum.Paise())
- }
-
- diff := m1.Sub(m2)
- if diff.Paise() != 74950 {
- t.Fatalf("Sub failed: %d", diff.Paise())
- }
-
- mul := m2.Mul(3)
- if mul.Paise() != 75150 {
- t.Fatalf("Mul failed: %d", mul.Paise())
- }
-
- // 18% GST on ₹1000.00
- gstBps := m1.MulBasisPoints(1800) // 1800 bps = 18%
- if gstBps.Paise() != 18000 {
- t.Fatalf("MulBasisPoints failed: %d", gstBps.Paise())
- }
-
- gstPct := m1.Percentage(18.0)
- if gstPct.Paise() != 18000 {
- t.Fatalf("Percentage failed: %d", gstPct.Paise())
- }
- })
-
- t.Run("Exact Split Without Loss", func(t *testing.T) {
- m := india.NewMoney(100) // 100 paise split 3 ways
- parts, err := m.Split(3)
- if err != nil {
- t.Fatalf("Split failed: %v", err)
- }
- if len(parts) != 3 {
- t.Fatalf("expected 3 parts, got %d", len(parts))
- }
-
- var total int64
- for _, p := range parts {
- total += p.Paise()
- }
- if total != 100 {
- t.Fatalf("Split lost precision: total=%d, expected 100", total)
- }
- if parts[0].Paise() != 34 || parts[1].Paise() != 33 || parts[2].Paise() != 33 {
- t.Fatalf("unexpected split distribution: %+v", parts)
- }
-
- // Split negative
- mNeg := india.NewMoney(-100)
- negParts, err := mNeg.Split(3)
- if err != nil {
- t.Fatalf("Split negative failed: %v", err)
- }
- var negTotal int64
- for _, p := range negParts {
- negTotal += p.Paise()
- }
- if negTotal != -100 {
- t.Fatalf("Negative split lost precision: %d", negTotal)
- }
-
- // Division by zero
- if _, err := m.Split(0); err != india.ErrDivisionByZero {
- t.Fatalf("expected ErrDivisionByZero, got %v", err)
- }
- })
-
- t.Run("Formatting and Words", func(t *testing.T) {
- m := india.NewMoney(123456789) // ₹12,34,567.89
- if m.Format() != "12,34,567.89" {
- t.Fatalf("Format failed: %s", m.Format())
- }
- if m.String() != "12,34,567.89" {
- t.Fatalf("String failed: %s", m.String())
- }
-
- words := m.Words()
- if !strings.Contains(words, "Twelve Lakh Thirty Four Thousand Five Hundred Sixty Seven Rupees Only") {
- t.Fatalf("Words failed: %s", words)
- }
- })
-
- t.Run("JSON Serialization", func(t *testing.T) {
- type Invoice struct {
- Amount india.Money `json:"amount"`
- }
-
- inv := Invoice{Amount: india.NewMoney(150075)}
- data, err := json.Marshal(inv)
- if err != nil {
- t.Fatalf("Marshal failed: %v", err)
- }
- // Assert that serialization contains NO float number on the wire
- expectedJSON := `{"amount":{"amount_paise":150075,"formatted":"1,500.75","currency":"INR"}}`
- if string(data) != expectedJSON {
- t.Fatalf("unexpected JSON: %s, expected: %s", string(data), expectedJSON)
- }
- if strings.Contains(string(data), `1500.75`) {
- t.Fatalf("wire serialization must not contain float number: %s", string(data))
- }
-
- var parsed Invoice
- if err := json.Unmarshal(data, &parsed); err != nil {
- t.Fatalf("Unmarshal failed: %v", err)
- }
- if parsed.Amount.Paise() != 150075 {
- t.Fatalf("parsed amount mismatch: %d", parsed.Amount.Paise())
- }
-
- // Unmarshal integer paise representation
- intJSON := `{"amount":150075}`
- if err := json.Unmarshal([]byte(intJSON), &parsed); err != nil {
- t.Fatalf("Unmarshal integer paise failed: %v", err)
- }
- if parsed.Amount.Paise() != 150075 {
- t.Fatalf("parsed integer paise mismatch: %d", parsed.Amount.Paise())
- }
-
- // Unmarshal formatted string representation
- jsonStr := `{"amount":"12,34,567.89"}`
- if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
- t.Fatalf("Unmarshal formatted string failed: %v", err)
- }
- if parsed.Amount.Paise() != 123456789 {
- t.Fatalf("parsed formatted string mismatch: %d", parsed.Amount.Paise())
- }
-
- // Unmarshal legacy float number gracefully
- legacyFloatJSON := `{"amount":1500.75}`
- if err := json.Unmarshal([]byte(legacyFloatJSON), &parsed); err != nil {
- t.Fatalf("Unmarshal legacy float failed: %v", err)
- }
- if parsed.Amount.Paise() != 150075 {
- t.Fatalf("parsed legacy float mismatch: %d", parsed.Amount.Paise())
- }
-
- // Unmarshal object with paise field
- paiseJSON := `{"amount":{"paise":150075}}`
- if err := json.Unmarshal([]byte(paiseJSON), &parsed); err != nil {
- t.Fatalf("Unmarshal paise object failed: %v", err)
- }
- if parsed.Amount.Paise() != 150075 {
- t.Fatalf("parsed paise object mismatch: %d", parsed.Amount.Paise())
- }
-
- // Unmarshal object with formatted string
- formattedJSON := `{"amount":{"formatted":"1,500.75"}}`
- if err := json.Unmarshal([]byte(formattedJSON), &parsed); err != nil {
- t.Fatalf("Unmarshal formatted object failed: %v", err)
- }
- if parsed.Amount.Paise() != 150075 {
- t.Fatalf("parsed formatted object mismatch: %d", parsed.Amount.Paise())
- }
-
- // Unmarshal empty object returns error
- if err := json.Unmarshal([]byte(`{"amount":{}}`), &parsed); err == nil {
- t.Fatalf("expected error on empty object")
- }
-
- // Unmarshal object with invalid formatted string
- if err := json.Unmarshal([]byte(`{"amount":{"formatted":"not-a-number"}}`), &parsed); err == nil {
- t.Fatalf("expected error on invalid formatted object")
- }
-
- // Unmarshal object with malformed types
- if err := json.Unmarshal([]byte(`{"amount":{"amount_paise":"not-an-int"}}`), &parsed); err == nil {
- t.Fatalf("expected error on malformed amount_paise type")
- }
-
- // Unmarshal empty string
- var emptyMoney india.Money
- if err := emptyMoney.UnmarshalJSON([]byte("")); err != nil || emptyMoney.Paise() != 0 {
- t.Fatalf("expected empty string to unmarshal to 0")
- }
-
- // Unmarshal invalid string
- if err := json.Unmarshal([]byte(`{"amount":"invalid-num"}`), &parsed); err == nil {
- t.Fatalf("expected error on invalid money string")
- }
-
- // Unmarshal null
- if err := json.Unmarshal([]byte(`{"amount":null}`), &parsed); err != nil || parsed.Amount.Paise() != 0 {
- t.Fatalf("expected null to unmarshal to 0")
- }
- })
-
- t.Run("SQL Driver Scan and Value", func(t *testing.T) {
- m := india.NewMoney(4200)
- val, err := m.Value()
- if err != nil || val != int64(4200) {
- t.Fatalf("Value failed: %v, %v", val, err)
- }
-
- var scanned india.Money
- if err := scanned.Scan(int64(9900)); err != nil || scanned.Paise() != 9900 {
- t.Fatalf("Scan int64 failed: %v, %d", err, scanned.Paise())
- }
- if err := scanned.Scan(int32(5000)); err != nil || scanned.Paise() != 5000 {
- t.Fatalf("Scan int32 failed: %v", err)
- }
- if err := scanned.Scan(int(3000)); err != nil || scanned.Paise() != 3000 {
- t.Fatalf("Scan int failed: %v", err)
- }
- if err := scanned.Scan(12.50); err != nil || scanned.Paise() != 1250 {
- t.Fatalf("Scan float64 failed: %v", err)
- }
- if err := scanned.Scan([]byte("99.99")); err != nil || scanned.Paise() != 9999 {
- t.Fatalf("Scan bytes failed: %v", err)
- }
- if err := scanned.Scan("45.50"); err != nil || scanned.Paise() != 4550 {
- t.Fatalf("Scan string failed: %v", err)
- }
- if err := scanned.Scan(nil); err != nil || scanned.Paise() != 0 {
- t.Fatalf("Scan nil failed: %v", err)
- }
- if err := scanned.Scan(struct{}{}); err == nil {
- t.Fatalf("expected error on unsupported type")
- }
- if err := scanned.Scan("bad-number"); err == nil {
- t.Fatalf("expected error on bad number string")
- }
- if err := scanned.Scan([]byte("bad-bytes")); err == nil {
- t.Fatalf("expected error on bad bytes string")
- }
- })
-}
-
-func TestSentinelErrorParity(t *testing.T) {
- t.Run("Aadhaar sentinel parity", func(t *testing.T) {
- if !errors.Is(india.ErrInvalidAadhaarLength, fintechin.ErrInvalidAadhaarLength) {
- t.Errorf("ErrInvalidAadhaarLength does not match fintechin.ErrInvalidAadhaarLength")
- }
- if !errors.Is(india.ErrInvalidAadhaarFormat, fintechin.ErrInvalidAadhaarFormat) {
- t.Errorf("ErrInvalidAadhaarFormat does not match fintechin.ErrInvalidAadhaarFormat")
- }
- if !errors.Is(india.ErrInvalidAadhaarPrefix, fintechin.ErrAadhaarStartsWithZeroOrOne) {
- t.Errorf("ErrInvalidAadhaarPrefix does not match fintechin.ErrAadhaarStartsWithZeroOrOne")
- }
- if !errors.Is(india.ErrAadhaarStartsWithZeroOrOne, fintechin.ErrAadhaarStartsWithZeroOrOne) {
- t.Errorf("ErrAadhaarStartsWithZeroOrOne does not match fintechin.ErrAadhaarStartsWithZeroOrOne")
- }
- if !errors.Is(india.ErrInvalidAadhaarChecksum, fintechin.ErrInvalidAadhaarChecksum) {
- t.Errorf("ErrInvalidAadhaarChecksum does not match fintechin.ErrInvalidAadhaarChecksum")
- }
-
- // Verify returned error matches both
- err := india.ValidateAadhaar("123")
- if !errors.Is(err, india.ErrInvalidAadhaarLength) || !errors.Is(err, fintechin.ErrInvalidAadhaarLength) {
- t.Errorf("ValidateAadhaar length error failed parity check: %v", err)
- }
- })
-
- t.Run("GSTIN sentinel parity", func(t *testing.T) {
- if !errors.Is(india.ErrInvalidGSTINLength, fintechin.ErrInvalidGSTINLength) {
- t.Errorf("ErrInvalidGSTINLength does not match fintechin.ErrInvalidGSTINLength")
- }
- if !errors.Is(india.ErrInvalidGSTINFormat, fintechin.ErrInvalidGSTINFormat) {
- t.Errorf("ErrInvalidGSTINFormat does not match fintechin.ErrInvalidGSTINFormat")
- }
- if !errors.Is(india.ErrInvalidGSTIN, fintechin.ErrInvalidGSTINFormat) {
- t.Errorf("ErrInvalidGSTIN does not match fintechin.ErrInvalidGSTINFormat")
- }
- if !errors.Is(india.ErrInvalidGSTINChecksum, fintechin.ErrInvalidGSTINChecksum) {
- t.Errorf("ErrInvalidGSTINChecksum does not match fintechin.ErrInvalidGSTINChecksum")
- }
- if !errors.Is(india.ErrInvalidStateCode, fintechin.ErrInvalidStateCode) {
- t.Errorf("ErrInvalidStateCode does not match fintechin.ErrInvalidStateCode")
- }
-
- // Verify returned error matches both
- err := india.ValidateGSTIN("short")
- if !errors.Is(err, india.ErrInvalidGSTINLength) || !errors.Is(err, fintechin.ErrInvalidGSTINLength) {
- t.Errorf("ValidateGSTIN length error failed parity check: %v", err)
- }
- })
-
- t.Run("PAN sentinel parity", func(t *testing.T) {
- if !errors.Is(india.ErrInvalidPANLength, fintechin.ErrInvalidPANLength) {
- t.Errorf("ErrInvalidPANLength does not match fintechin.ErrInvalidPANLength")
- }
- if !errors.Is(india.ErrInvalidPANFormat, fintechin.ErrInvalidPANFormat) {
- t.Errorf("ErrInvalidPANFormat does not match fintechin.ErrInvalidPANFormat")
- }
- if !errors.Is(india.ErrUnknownEntityType, fintechin.ErrInvalidPANEntityType) {
- t.Errorf("ErrUnknownEntityType does not match fintechin.ErrInvalidPANEntityType")
- }
- if !errors.Is(india.ErrInvalidPANEntityType, fintechin.ErrInvalidPANEntityType) {
- t.Errorf("ErrInvalidPANEntityType does not match fintechin.ErrInvalidPANEntityType")
- }
-
- // Verify returned error matches both
- err := india.ValidatePAN("short")
- if !errors.Is(err, india.ErrInvalidPANLength) || !errors.Is(err, fintechin.ErrInvalidPANLength) {
- t.Errorf("ValidatePAN length error failed parity check: %v", err)
- }
- })
-
- t.Run("IFSC sentinel parity", func(t *testing.T) {
- if !errors.Is(india.ErrInvalidIFSCLength, fintechin.ErrInvalidIFSCLength) {
- t.Errorf("ErrInvalidIFSCLength does not match fintechin.ErrInvalidIFSCLength")
- }
- if !errors.Is(india.ErrInvalidIFSCBankCode, fintechin.ErrInvalidIFSCBankCode) {
- t.Errorf("ErrInvalidIFSCBankCode does not match fintechin.ErrInvalidIFSCBankCode")
- }
- if !errors.Is(india.ErrInvalidIFSCFifthChar, fintechin.ErrInvalidIFSCFifthChar) {
- t.Errorf("ErrInvalidIFSCFifthChar does not match fintechin.ErrInvalidIFSCFifthChar")
- }
- if !errors.Is(india.ErrInvalidIFSCBranchCode, fintechin.ErrInvalidIFSCBranchCode) {
- t.Errorf("ErrInvalidIFSCBranchCode does not match fintechin.ErrInvalidIFSCBranchCode")
- }
-
- // Verify returned error matches both
- err := india.ValidateIFSC("short")
- if !errors.Is(err, india.ErrInvalidIFSCLength) || !errors.Is(err, fintechin.ErrInvalidIFSCLength) {
- t.Errorf("ValidateIFSC length error failed parity check: %v", err)
- }
- })
-}
diff --git a/india/money.go b/india/money.go
deleted file mode 100644
index bbac0fc..0000000
--- a/india/money.go
+++ /dev/null
@@ -1,214 +0,0 @@
-package india
-
-import (
- "database/sql/driver"
- "encoding/json"
- "errors"
- "fmt"
- "math"
- "strings"
-
- fintechin "github.com/umesh0492/go-fintech-india"
-)
-
-var (
- // ErrInvalidMoneyFormat is returned when parsing an invalid monetary string.
- ErrInvalidMoneyFormat = errors.New("invalid money format")
- // ErrDivisionByZero is returned when splitting or dividing money by non-positive divisor.
- ErrDivisionByZero = errors.New("division by zero in money calculation")
-)
-
-// Money represents a monetary value in Indian Rupees stored as an exact integer count of paise (1 INR = 100 paise).
-// This eliminates IEEE-754 floating-point inaccuracies in financial and accounting operations.
-// Delegates underlying monetary arithmetic to Abeta go-fintech-india.
-type Money struct {
- inner fintechin.Money
-}
-
-// NewMoney creates a Money instance from an exact integer count of paise.
-func NewMoney(paise int64) Money {
- return Money{inner: fintechin.NewMoney(paise)}
-}
-
-// NewMoneyFromRupees creates a Money instance from whole rupees (e.g. 500 -> 50,000 paise).
-func NewMoneyFromRupees(rupees int64) Money {
- return Money{inner: fintechin.NewMoneyFromRupees(rupees)}
-}
-
-// NewMoneyFromFloat creates a Money instance by rounding a float64 amount in rupees to the nearest paise.
-// e.g. 15000.50 -> 1500050 paise, 1.995 -> 200 paise.
-func NewMoneyFromFloat(amount float64) Money {
- return Money{inner: fintechin.NewMoneyFromFloat(amount)}
-}
-
-// Paise returns the underlying monetary value in paise (minor units).
-func (m Money) Paise() int64 {
- return m.inner.Paise()
-}
-
-// Rupees returns the value as whole rupees (truncated towards zero).
-func (m Money) Rupees() int64 {
- return m.inner.Rupees()
-}
-
-// Float64 converts the Money value to a float64 in rupees (for interop and display).
-func (m Money) Float64() float64 {
- return m.inner.Float64()
-}
-
-// IsZero reports whether the money amount is exactly zero.
-func (m Money) IsZero() bool {
- return m.inner.IsZero()
-}
-
-// IsPositive reports whether the money amount is strictly greater than zero.
-func (m Money) IsPositive() bool {
- return m.inner.IsPositive()
-}
-
-// IsNegative reports whether the money amount is strictly less than zero.
-func (m Money) IsNegative() bool {
- return m.inner.IsNegative()
-}
-
-// Abs returns the absolute value of the Money amount.
-func (m Money) Abs() Money {
- return Money{inner: m.inner.Abs()}
-}
-
-// Negate returns the negated Money value.
-func (m Money) Negate() Money {
- return Money{inner: m.inner.Negate()}
-}
-
-// Add returns the sum m + other.
-func (m Money) Add(other Money) Money {
- return Money{inner: m.inner.Add(other.inner)}
-}
-
-// Sub returns the difference m - other.
-func (m Money) Sub(other Money) Money {
- return Money{inner: m.inner.Sub(other.inner)}
-}
-
-// Mul multiplies m by an integer factor.
-func (m Money) Mul(factor int64) Money {
- return Money{inner: m.inner.Mul(factor)}
-}
-
-// MulBasisPoints multiplies m by basis points (1 basis point = 0.01% = 0.0001, 100 bps = 1%).
-// e.g. for 9% GST (900 bps), 1000 INR (100000 paise) * 900 / 10000 = 90 INR (9000 paise).
-func (m Money) MulBasisPoints(bps int64) Money {
- return Money{inner: fintechin.NewMoney(int64(math.Round(float64(m.inner.Paise()*bps) / 10000.0)))}
-}
-
-// Percentage computes rate% of m (e.g. 9.0 for 9% GST) with standard financial rounding.
-func (m Money) Percentage(rate float64) Money {
- return Money{inner: fintechin.NewMoney(int64(math.Round(float64(m.inner.Paise()) * (rate / 100.0))))}
-}
-
-// Split divides the monetary value into n parts without losing any paise due to integer truncation.
-// The sum of the resulting slice is guaranteed to equal m.
-func (m Money) Split(n int) ([]Money, error) {
- if n <= 0 {
- return nil, ErrDivisionByZero
- }
- parts, err := m.inner.Split(n)
- if err != nil {
- return nil, err
- }
- result := make([]Money, len(parts))
- for i, p := range parts {
- result[i] = Money{inner: p}
- }
- return result, nil
-}
-
-// Format returns the standard Indian currency string (e.g. "12,34,567.89").
-func (m Money) Format() string {
- return FormatINRPaise(m.inner.Paise())
-}
-
-// String implements fmt.Stringer, returning the formatted INR currency string.
-func (m Money) String() string {
- return m.Format()
-}
-
-// Words returns the amount in words following the Indian numbering system.
-func (m Money) Words() string {
- return AmountToWordsINR(m.inner.Rupees())
-}
-
-// MarshalJSON serializes Money as a JSON object containing integer paise, formatted string, and currency.
-// This ensures that no IEEE-754 floating-point numbers are emitted on the wire.
-func (m Money) MarshalJSON() ([]byte, error) {
- return json.Marshal(struct {
- AmountPaise int64 `json:"amount_paise"`
- Formatted string `json:"formatted"`
- Currency string `json:"currency"`
- }{
- AmountPaise: m.inner.Paise(),
- Formatted: m.Format(),
- Currency: "INR",
- })
-}
-
-// UnmarshalJSON unmarshals Money from:
-// 1. Structured JSON object: {"amount_paise": 12345, "formatted": "123.45", "currency": "INR"} or {"paise": 12345}
-// 2. Integer number in paise: 12345
-// 3. String formatted currency: "123.45" or "12,34,567.89"
-// 4. Decimal float number: 1234.50
-func (m *Money) UnmarshalJSON(data []byte) error {
- s := strings.TrimSpace(string(data))
- if s == "null" || s == "" {
- m.inner = fintechin.NewMoney(0)
- return nil
- }
-
- // 1. Structured JSON object
- if strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") {
- var obj struct {
- AmountPaise *int64 `json:"amount_paise"`
- Paise *int64 `json:"paise"`
- Formatted *string `json:"formatted"`
- }
- if err := json.Unmarshal(data, &obj); err != nil {
- return fmt.Errorf("%w: %w", ErrInvalidMoneyFormat, err)
- }
- if obj.AmountPaise != nil {
- m.inner = fintechin.NewMoney(*obj.AmountPaise)
- return nil
- }
- if obj.Paise != nil {
- m.inner = fintechin.NewMoney(*obj.Paise)
- return nil
- }
- if obj.Formatted != nil {
- parsed, err := fintechin.ParseINR(*obj.Formatted)
- if err != nil {
- return fmt.Errorf("%w: %w", ErrInvalidMoneyFormat, err)
- }
- m.inner = parsed
- return nil
- }
- return ErrInvalidMoneyFormat
- }
-
- // 2. Quoted string or numeric representations delegated to fintechin.Money
- var fm fintechin.Money
- if err := json.Unmarshal(data, &fm); err != nil {
- return fmt.Errorf("%w: %w", ErrInvalidMoneyFormat, err)
- }
- m.inner = fm
- return nil
-}
-
-// Value implements driver.Valuer to persist paise as int64.
-func (m Money) Value() (driver.Value, error) {
- return m.inner.Value()
-}
-
-// Scan implements sql.Scanner to read paise from database driver.
-func (m *Money) Scan(src any) error {
- return m.inner.Scan(src)
-}
diff --git a/india/pan.go b/india/pan.go
deleted file mode 100644
index 5dd064b..0000000
--- a/india/pan.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package india
-
-import (
- "fmt"
- "strings"
-
- fintechin "github.com/umesh0492/go-fintech-india"
-)
-
-var (
- // ErrInvalidPANLength indicates the PAN string does not have length 10.
- ErrInvalidPANLength = fintechin.ErrInvalidPANLength
- // ErrInvalidPANFormat indicates the PAN format regex is invalid.
- ErrInvalidPANFormat = fintechin.ErrInvalidPANFormat
- // ErrUnknownEntityType indicates the 4th character of PAN is not a recognized entity type.
- ErrUnknownEntityType = fintechin.ErrInvalidPANEntityType
- // ErrInvalidPANEntityType is an alias for ErrUnknownEntityType.
- ErrInvalidPANEntityType = fintechin.ErrInvalidPANEntityType
-
- // Mapping of 4th character of PAN to legal entity type in India
- entityTypes = map[byte]string{
- 'A': "Association of Persons (AOP)",
- 'B': "Body of Individuals (BOI)",
- 'C': "Company",
- 'F': "Firm / Limited Liability Partnership (LLP)",
- 'G': "Government Agency",
- 'H': "Hindu Undivided Family (HUF)",
- 'J': "Artificial Juridical Person",
- 'L': "Local Authority",
- 'P': "Individual (Person)",
- 'T': "Trust",
- }
-)
-
-// PANDetails represents parsed information from a valid PAN.
-type PANDetails struct {
- PAN string
- EntityTypeCode string
- EntityTypeName string
- SurnameInitial string
-}
-
-// ValidatePAN verifies the structural validity of a 10-character Indian PAN card number.
-// Delegates statutory validation to Abeta go-fintech-india.
-func ValidatePAN(pan string) error {
- clean := strings.ToUpper(strings.TrimSpace(pan))
- if len(clean) != 10 {
- return ErrInvalidPANLength
- }
-
- if err := fintechin.ValidatePAN(clean); err != nil {
- return ErrInvalidPANFormat
- }
-
- fourthChar := clean[3]
- if _, err := fintechin.EntityType(clean); err != nil {
- return fmt.Errorf("%w: '%c' is not a valid PAN entity type", ErrUnknownEntityType, fourthChar)
- }
-
- return nil
-}
-
-// IsValidPAN returns true if the PAN passes format and entity checks.
-func IsValidPAN(pan string) bool {
- return ValidatePAN(pan) == nil
-}
-
-// ParsePAN validates and parses the PAN card components.
-func ParsePAN(pan string) (*PANDetails, error) {
- if err := ValidatePAN(pan); err != nil {
- return nil, err
- }
- clean := strings.ToUpper(strings.TrimSpace(pan))
- fourthChar := clean[3]
-
- return &PANDetails{
- PAN: clean,
- EntityTypeCode: string(fourthChar),
- EntityTypeName: entityTypes[fourthChar],
- SurnameInitial: string(clean[4]),
- }, nil
-}
diff --git a/india/phone.go b/india/phone.go
deleted file mode 100644
index b088fa9..0000000
--- a/india/phone.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package india
-
-import (
- "errors"
- "fmt"
- "regexp"
- "strings"
-)
-
-var (
- ErrInvalidPhoneFormat = errors.New("indian mobile number must be 10 digits starting with 6, 7, 8, or 9")
-
- phoneCleanRegex = regexp.MustCompile(`[^\d]`)
- validMobileRegex = regexp.MustCompile(`^[6-9]\d{9}$`)
-)
-
-// ValidatePhone validates an Indian 10-digit mobile number with optional +91, 91, or 0 prefix.
-func ValidatePhone(phone string) error {
- digits := ExtractMobileDigits(phone)
- if !validMobileRegex.MatchString(digits) {
- return ErrInvalidPhoneFormat
- }
- return nil
-}
-
-// IsValidPhone returns true if the phone number is a valid Indian mobile number.
-func IsValidPhone(phone string) bool {
- return ValidatePhone(phone) == nil
-}
-
-// ExtractMobileDigits strips prefixes (+91, 91, 0) and non-numeric characters, returning the 10-digit number.
-func ExtractMobileDigits(phone string) string {
- digits := phoneCleanRegex.ReplaceAllString(phone, "")
-
- // Handle prefixes: +91 (12 digits total) or 91 (12 digits) or 0 (11 digits)
- if len(digits) == 12 && strings.HasPrefix(digits, "91") {
- digits = digits[2:]
- } else if len(digits) == 11 && strings.HasPrefix(digits, "0") {
- digits = digits[1:]
- }
-
- return digits
-}
-
-// FormatE164 formats an Indian mobile number to E.164 international standard (e.g. "+919876543210").
-func FormatE164(phone string) (string, error) {
- if err := ValidatePhone(phone); err != nil {
- return "", err
- }
- digits := ExtractMobileDigits(phone)
- return fmt.Sprintf("+91%s", digits), nil
-}
-
-// FormatNational formats an Indian mobile number into standard national format (e.g. "098765 43210" or "98765-43210").
-func FormatNational(phone string) (string, error) {
- if err := ValidatePhone(phone); err != nil {
- return "", err
- }
- digits := ExtractMobileDigits(phone)
- return fmt.Sprintf("%s-%s", digits[:5], digits[5:]), nil
-}
diff --git a/notifications/brevo_test.go b/notifications/brevo_test.go
index 354f54d..d265fca 100644
--- a/notifications/brevo_test.go
+++ b/notifications/brevo_test.go
@@ -232,3 +232,39 @@ func TestBrevoSender_BrokerIntegration(t *testing.T) {
t.Fatal("timed out waiting for brevo message delivery")
}
}
+
+func TestBrevoSender_EdgeCases(t *testing.T) {
+ sender, err := NewBrevoSender(BrevoConfig{
+ APIKey: "xkeysib-mock",
+ SenderEmail: "sender@example.com",
+ })
+ require.NoError(t, err)
+
+ // 1. Send with cancelled context
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ err = sender.Send(ctx, Message{Recipients: []string{"test@example.com"}})
+ assert.ErrorIs(t, err, context.Canceled)
+
+ // 2. buildBrevoPayload with blank-only recipients
+ _, err = buildBrevoPayload(Message{Recipients: []string{" ", "\t"}}, "Sender", "sender@example.com")
+ assert.ErrorIs(t, err, ErrEmptyRecipients)
+
+ // 3. buildBrevoPayload with text body only (generates HTML fallback)
+ payload, err := buildBrevoPayload(Message{
+ Recipients: []string{"a@b.com"},
+ Body: "Line 1\nLine 2",
+ }, "Sender", "sender@example.com")
+ require.NoError(t, err)
+ assert.Equal(t, "
Line 1
Line 2
", payload.HTMLContent)
+ assert.Equal(t, "Line 1\nLine 2", payload.TextContent)
+
+ // 4. buildBrevoPayload with HTML body only (generates text fallback)
+ payload2, err := buildBrevoPayload(Message{
+ Recipients: []string{"a@b.com"},
+ HTMLBody: "
Hello
",
+ }, "Sender", "sender@example.com")
+ require.NoError(t, err)
+ assert.Equal(t, "
Hello
", payload2.HTMLContent)
+ assert.Equal(t, "
Hello
", payload2.TextContent)
+}
diff --git a/notifications/notifications_test.go b/notifications/notifications_test.go
index abba5ca..adbdacb 100644
--- a/notifications/notifications_test.go
+++ b/notifications/notifications_test.go
@@ -999,3 +999,171 @@ func TestWebhook_ReplayRejectionTable(t *testing.T) {
})
}
}
+
+func TestSenders_ChannelMethods(t *testing.T) {
+ slackSender := notifications.NewSlackSender(notifications.SlackConfig{})
+ if slackSender.Channel() != notifications.ChannelSlack {
+ t.Errorf("expected ChannelSlack, got %v", slackSender.Channel())
+ }
+
+ webhookSender := notifications.NewWebhookSender(notifications.WebhookConfig{})
+ if webhookSender.Channel() != notifications.ChannelWebhook {
+ t.Errorf("expected ChannelWebhook, got %v", webhookSender.Channel())
+ }
+
+ emailSender := notifications.NewEmailSender(notifications.EmailConfig{})
+ if emailSender.Channel() != notifications.ChannelEmail {
+ t.Errorf("expected ChannelEmail, got %v", emailSender.Channel())
+ }
+}
+
+func TestEmailSender_AttachmentVariants(t *testing.T) {
+ var sentAddr string
+ var sentMsg []byte
+
+ sender := notifications.NewEmailSender(notifications.EmailConfig{
+ Host: "smtp.test.local",
+ Port: 25,
+ From: "billing@app.com",
+ FromName: "Billing System",
+ SendMailFunc: func(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
+ sentAddr = addr
+ sentMsg = msg
+ return nil
+ },
+ })
+
+ // 120 bytes of data to exceed 76 chars base64 boundary
+ largeData := bytes.Repeat([]byte("ABCDEFGHIJ"), 12)
+ msg := notifications.Message{
+ ID: "msg-att",
+ Title: "Monthly Statement",
+ Body: "Please find attached statement.",
+ Recipients: []string{"user@test.com"},
+ Attachments: []notifications.Attachment{
+ {
+ Filename: "reçu_annuel.pdf", // Non-ASCII filename triggers RFC 2047 encoded-word
+ ContentType: "application/pdf",
+ Data: largeData,
+ },
+ },
+ }
+
+ err := sender.Send(context.Background(), msg)
+ if err != nil {
+ t.Fatalf("Send failed: %v", err)
+ }
+ if sentAddr != "smtp.test.local:25" {
+ t.Errorf("expected addr 'smtp.test.local:25', got: %s", sentAddr)
+ }
+ if !strings.Contains(string(sentMsg), "=?UTF-8?b?") {
+ t.Errorf("expected RFC 2047 encoded filename in MIME headers")
+ }
+ if !strings.Contains(string(sentMsg), "\r\n") {
+ t.Errorf("expected CRLF in base64 output")
+ }
+}
+
+func TestBroker_EdgeCases(t *testing.T) {
+ // 1. NewBroker with empty config defaults
+ b := notifications.NewBroker(notifications.Config{})
+ if b == nil {
+ t.Fatalf("expected non-nil broker")
+ }
+
+ // 2. Double Close
+ b.Close()
+ b.Close()
+
+ // 3. Send to closed broker returns ErrBrokerClosed
+ err := b.Send(context.Background(), notifications.Message{
+ Recipients: []string{"user@test.com"},
+ })
+ if !errors.Is(err, notifications.ErrBrokerClosed) {
+ t.Errorf("expected ErrBrokerClosed, got: %v", err)
+ }
+
+ // 4. SendAsync with canceled context returns context error
+ b2 := notifications.NewBroker(notifications.Config{Workers: 1, QueueSize: 5})
+ defer b2.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ err = b2.SendAsync(ctx, notifications.Message{Recipients: []string{"user@test.com"}})
+ if !errors.Is(err, context.Canceled) {
+ t.Errorf("expected context.Canceled, got: %v", err)
+ }
+
+ // 5. SendAsync with empty recipients executes background worker logging
+ err = b2.SendAsync(context.Background(), notifications.Message{
+ ID: "bad-async",
+ Title: "Missing Recipients",
+ Recipients: []string{},
+ })
+ if err != nil {
+ t.Fatalf("SendAsync failed: %v", err)
+ }
+ time.Sleep(50 * time.Millisecond)
+}
+
+func TestParseWebhookTimestamp_Variants(t *testing.T) {
+ // 1. Milliseconds
+ msTime, err := notifications.ParseWebhookTimestamp("1700000000000")
+ if err != nil {
+ t.Fatalf("ParseWebhookTimestamp ms failed: %v", err)
+ }
+ if msTime.Unix() != 1700000000 {
+ t.Errorf("expected unix 1700000000, got %d", msTime.Unix())
+ }
+
+ // 2. Seconds
+ sTime, err := notifications.ParseWebhookTimestamp("1700000000")
+ if err != nil {
+ t.Fatalf("ParseWebhookTimestamp s failed: %v", err)
+ }
+ if sTime.Unix() != 1700000000 {
+ t.Errorf("expected unix 1700000000, got %d", sTime.Unix())
+ }
+
+ // 3. RFC3339
+ rfcTime, err := notifications.ParseWebhookTimestamp("2026-09-20T12:00:00Z")
+ if err != nil {
+ t.Fatalf("ParseWebhookTimestamp RFC3339 failed: %v", err)
+ }
+ if rfcTime.Year() != 2026 {
+ t.Errorf("expected year 2026, got %d", rfcTime.Year())
+ }
+
+ // 4. Empty returns ErrMissingTimestamp
+ _, err = notifications.ParseWebhookTimestamp(" ")
+ if !errors.Is(err, notifications.ErrMissingTimestamp) {
+ t.Errorf("expected ErrMissingTimestamp, got: %v", err)
+ }
+
+ // 5. Invalid format returns ErrInvalidTimestamp
+ _, err = notifications.ParseWebhookTimestamp("not-a-timestamp")
+ if !errors.Is(err, notifications.ErrInvalidTimestamp) {
+ t.Errorf("expected ErrInvalidTimestamp, got: %v", err)
+ }
+}
+
+func TestWebhookVerifier_EdgeCases(t *testing.T) {
+ payload := []byte(`{"event":"test"}`)
+
+ // 1. Empty signature
+ v := notifications.NewWebhookVerifier("secret", 0)
+ if err := v.Verify("1700000000", "", payload); !errors.Is(err, notifications.ErrInvalidSignature) {
+ t.Errorf("expected ErrInvalidSignature on empty sig, got: %v", err)
+ }
+
+ // 2. Empty secret
+ vNoSecret := notifications.NewWebhookVerifier("", 5*time.Minute)
+ if err := vNoSecret.Verify("1700000000", "v1=abcd", payload); !errors.Is(err, notifications.ErrInvalidSignature) {
+ t.Errorf("expected ErrInvalidSignature on empty secret, got: %v", err)
+ }
+
+ // 3. Missing timestamp in both arg and sig
+ if err := v.Verify("", "v1=abcdef", payload); !errors.Is(err, notifications.ErrMissingTimestamp) {
+ t.Errorf("expected ErrMissingTimestamp, got: %v", err)
+ }
+}
diff --git a/outbox/outbox_test.go b/outbox/outbox_test.go
index 883ecdd..c2bfa46 100644
--- a/outbox/outbox_test.go
+++ b/outbox/outbox_test.go
@@ -237,6 +237,110 @@ func TestPGStore_InsertAndMark(t *testing.T) {
}
}
+func TestPGStore_Insert_EdgeCases(t *testing.T) {
+ mockOp := &mockDBOperator{
+ execFunc: func(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
+ return pgconn.NewCommandTag("INSERT 1"), nil
+ },
+ }
+
+ store := outbox.NewPGStore(mockOp)
+ evt, _ := outbox.NewEvent("User", "usr-1", "UserRegistered", map[string]string{"name": "Alice"})
+ evt.NextRetryAt = time.Now().Add(5 * time.Minute)
+
+ // 1. op == nil uses default db
+ if err := store.Insert(context.Background(), nil, *evt); err != nil {
+ t.Fatalf("Insert with nil op failed: %v", err)
+ }
+
+ // 2. Invalid table name
+ badStore := outbox.NewPGStore(mockOp, outbox.WithTableName("bad;table"))
+ if err := badStore.Insert(context.Background(), mockOp, *evt); !errors.Is(err, outbox.ErrInvalidTableName) {
+ t.Fatalf("expected ErrInvalidTableName, got: %v", err)
+ }
+
+ // 3. Simulated execution failure
+ failingOp := &mockDBOperator{
+ execFunc: func(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
+ return pgconn.CommandTag{}, errors.New("connection closed")
+ },
+ }
+ failingStore := outbox.NewPGStore(failingOp)
+ if err := failingStore.Insert(context.Background(), nil, *evt); err == nil {
+ t.Fatalf("expected error from failing exec, got nil")
+ }
+}
+
+func TestOutbox_AdditionalEdgeCases(t *testing.T) {
+ // 1. NewEvent with unmarshalable payload
+ _, err := outbox.NewEvent("Agg", "1", "Type", make(chan int))
+ if err == nil {
+ t.Fatalf("expected error from unmarshalable payload, got nil")
+ }
+
+ // 2. NewEvent with negative maxRetries defaults to 5
+ evt, err := outbox.NewEvent("Agg", "1", "Type", map[string]string{"k": "v"}, -1)
+ if err != nil {
+ t.Fatalf("NewEvent failed: %v", err)
+ }
+ if evt.MaxRetries != 5 {
+ t.Errorf("expected default 5 maxRetries, got %d", evt.MaxRetries)
+ }
+
+ // 3. BackoffWithJitter edge cases
+ if d := outbox.BackoffWithJitter(1, 0, 10*time.Second); d != 0 {
+ t.Errorf("expected 0 for non-positive baseDelay, got %v", d)
+ }
+ if d := outbox.BackoffWithJitter(-5, 100*time.Millisecond, 1*time.Second); d > 1*time.Second {
+ t.Errorf("expected duration within bound for negative attempt, got %v", d)
+ }
+ if d := outbox.BackoffWithJitter(40, 100*time.Millisecond, 500*time.Millisecond); d > 500*time.Millisecond {
+ t.Errorf("expected duration capped at maxDelay for large attempt, got %v", d)
+ }
+
+ // 4. NewPGStore with lease duration < 5s defaults to 60s
+ mockOp := &mockDBOperator{
+ execFunc: func(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
+ return pgconn.NewCommandTag("INSERT 1"), nil
+ },
+ }
+ store := outbox.NewPGStore(mockOp, outbox.WithLeaseDuration(1*time.Second))
+ if store == nil {
+ t.Fatalf("expected non-nil store")
+ }
+
+ // 5. Relay with BackoffMax < BackoffBase is normalized in validateConfig
+ storeMock := newMockStore()
+ pubCallCount := 0
+ publisher := outbox.PublisherFunc(func(ctx context.Context, evt outbox.Event) error {
+ pubCallCount++
+ return errors.New("transient pub failure")
+ })
+ r, err := outbox.NewRelay(outbox.RelayConfig{
+ Store: storeMock,
+ Publisher: publisher,
+ LeaseDuration: 30 * time.Second,
+ PublishTimeout: 5 * time.Second,
+ BackoffBase: 10 * time.Second,
+ BackoffMax: 2 * time.Second, // lower than base
+ })
+ if err != nil {
+ t.Fatalf("NewRelay failed: %v", err)
+ }
+ if r == nil {
+ t.Fatalf("expected non-nil relay")
+ }
+
+ // 6. Event with nil LeaseToken handled by handlePublishFailure
+ nilTokenEvt, _ := outbox.NewEvent("Order", "ord-1", "Created", map[string]string{"id": "1"}, 1)
+ nilTokenEvt.LeaseToken = nil
+ _ = storeMock.Insert(context.Background(), nil, *nilTokenEvt)
+ _, _ = r.ProcessBatch(context.Background())
+ if pubCallCount == 0 {
+ t.Errorf("expected publisher to be called")
+ }
+}
+
func TestRelay_ProcessBatch_Success(t *testing.T) {
store := newMockStore()
evt1, _ := outbox.NewEvent("Invoice", "INV-1", "InvoiceCreated", map[string]string{"inv": "1"})
@@ -663,6 +767,29 @@ func TestIsNonRetryable_Contract(t *testing.T) {
if outbox.IsNonRetryable(transientErr) {
t.Errorf("expected transient error to be retryable (false)")
}
+
+ // 8. MarkNonRetryable(nil) returns nil
+ if outbox.MarkNonRetryable(nil) != nil {
+ t.Errorf("expected MarkNonRetryable(nil) to return nil")
+ }
+
+ // 9. NonRetryableError with nil Err has default message
+ nilNre := &outbox.NonRetryableError{}
+ if nilNre.Error() != "non-retryable error" {
+ t.Errorf("expected 'non-retryable error', got %q", nilNre.Error())
+ }
+
+ // 10. NonRetryableError Unwrap
+ customWrap := errors.New("underlying root")
+ nreWithErr := &outbox.NonRetryableError{Err: customWrap}
+ if !errors.Is(nreWithErr.Unwrap(), customWrap) {
+ t.Errorf("expected Unwrap() to return underlying error")
+ }
+
+ // 11. NonRetryable method returns true
+ if !nilNre.NonRetryable() {
+ t.Errorf("expected NonRetryable() to return true")
+ }
}
func TestPGStore_MarkFailed_StatusDeadLetter(t *testing.T) {
diff --git a/pdf/export_test.go b/pdf/export_test.go
index 7e54577..8da2355 100644
--- a/pdf/export_test.go
+++ b/pdf/export_test.go
@@ -2,6 +2,8 @@ package pdf
import (
"context"
+ "strings"
+ "testing"
wkhtml "github.com/SebastiaanKlippert/go-wkhtmltopdf"
)
@@ -59,3 +61,166 @@ func (m *MockPDFGenerator) Bytes() []byte {
}
return []byte("%PDF-1.4 Mock Wkhtml")
}
+
+func TestWkhtmlGenerator_Direct(t *testing.T) {
+ // If wkhtmltopdf is not installed, we can still construct wkhtmlGenerator with an empty or simulated PDFGenerator
+ page := wkhtml.NewPageReader(strings.NewReader("test"))
+ gen := &wkhtmlGenerator{PDFGenerator: &wkhtml.PDFGenerator{}}
+ gen.AddPage(page)
+ _ = gen.Bytes()
+ _ = gen.Create()
+ _ = gen.CreateContext(context.Background())
+}
+
+func TestOptions_ConcurrencyAndContext(t *testing.T) {
+ opts := DefaultOptions()
+
+ WithMaxConcurrency(5)(&opts)
+ if opts.MaxConcurrency != 5 {
+ t.Errorf("expected MaxConcurrency 5, got %d", opts.MaxConcurrency)
+ }
+ WithMaxConcurrency(-1)(&opts)
+ if opts.MaxConcurrency != 5 {
+ t.Errorf("expected MaxConcurrency to remain 5, got %d", opts.MaxConcurrency)
+ }
+
+ type testContextKey string
+ const ctxKey testContextKey = "testKey"
+ ctx := context.WithValue(context.Background(), ctxKey, "val")
+ WithContext(ctx)(&opts)
+ if opts.Context != ctx {
+ t.Errorf("expected context to be set")
+ }
+ var nilCtx context.Context
+ WithContext(nilCtx)(&opts)
+ if opts.Context != ctx {
+ t.Errorf("expected context to remain unchanged on nil")
+ }
+
+ sem := make(chan struct{}, 3)
+ WithSemaphore(sem)(&opts)
+ if opts.Semaphore != sem {
+ t.Errorf("expected semaphore to be set")
+ }
+}
+
+func TestDefaultGenerator_Options(t *testing.T) {
+ opts := DefaultOptions()
+ opts.Title = "Statutory Invoice"
+ opts.DPI = 150
+ opts.PageSize = "Letter"
+ opts.Orientation = "Landscape"
+ opts.MarginTop = 15
+ opts.MarginBottom = 15
+ opts.MarginLeft = 20
+ opts.MarginRight = 20
+
+ gen, err := defaultGenerator(opts)
+ if err != nil {
+ // On environments without wkhtmltopdf binary, verify proper error wrapping
+ if !strings.Contains(err.Error(), ErrNoRendererAvailable.Error()) {
+ t.Errorf("expected ErrNoRendererAvailable wrapped, got: %v", err)
+ }
+ if gen != nil {
+ t.Errorf("expected nil generator on error")
+ }
+ return
+ }
+ if gen == nil {
+ t.Errorf("expected non-nil generator")
+ }
+}
+
+type mockTestRenderer struct {
+ renderFunc func(html string, opts Options) ([]byte, error)
+}
+
+func (m *mockTestRenderer) Render(html string, opts Options) ([]byte, error) {
+ if m.renderFunc != nil {
+ return m.renderFunc(html, opts)
+ }
+ return []byte("%PDF-1.4 Mock Test Renderer"), nil
+}
+
+func TestGenerateFromTemplateWithContext_Branches(t *testing.T) {
+ ctx := context.Background()
+
+ // 1. Invalid template syntax error
+ _, err := GenerateFromTemplateWithContext(ctx, "{{.Bad", nil)
+ if err == nil {
+ t.Errorf("expected template syntax error, got nil")
+ }
+
+ // 2. Successful execution with custom renderer
+ mock := &mockTestRenderer{
+ renderFunc: func(html string, opts Options) ([]byte, error) {
+ return []byte("%PDF-1.4 Template Output"), nil
+ },
+ }
+ buf, err := GenerateFromTemplateWithContext(ctx, "
{{.Title}}", map[string]string{"Title": "Invoice"}, WithRenderer(mock))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(buf.String(), "Template Output") {
+ t.Errorf("expected rendered content in buffer, got: %s", buf.String())
+ }
+}
+
+func TestWkhtmlRenderer_GetSemaphore_Branches(t *testing.T) {
+ // 1. Uninitialized renderer with non-positive MaxConcurrency -> DefaultMaxConcurrency
+ r1 := &WkhtmlRenderer{}
+ opts1 := DefaultOptions()
+ opts1.MaxConcurrency = 0
+ sem1 := r1.getSemaphore(opts1)
+ if cap(sem1) != DefaultMaxConcurrency {
+ t.Errorf("expected default semaphore cap %d, got %d", DefaultMaxConcurrency, cap(sem1))
+ }
+
+ // 2. Uninitialized renderer with positive MaxConcurrency
+ r2 := &WkhtmlRenderer{}
+ opts2 := DefaultOptions()
+ opts2.MaxConcurrency = 8
+ sem2 := r2.getSemaphore(opts2)
+ if cap(sem2) != 8 {
+ t.Errorf("expected semaphore cap 8, got %d", cap(sem2))
+ }
+
+ // 3. Renderer with custom Semaphore field
+ customSem := make(chan struct{}, 3)
+ r3 := &WkhtmlRenderer{Semaphore: customSem}
+ if got := r3.getSemaphore(opts1); got != customSem {
+ t.Errorf("expected custom renderer semaphore")
+ }
+}
+
+type legacyGeneratorWithoutCreateContext struct{}
+
+func (l *legacyGeneratorWithoutCreateContext) AddPage(p *wkhtml.PageReader) {}
+func (l *legacyGeneratorWithoutCreateContext) Create() error { return nil }
+func (l *legacyGeneratorWithoutCreateContext) Bytes() []byte { return []byte("%PDF-1.4 Legacy") }
+
+func TestWkhtmlRenderer_Render_LegacyGeneratorAndConcurrency(t *testing.T) {
+ restore := SetGeneratorFactoryForTesting(func(opts Options) (pdfGenerator, error) {
+ return &legacyGeneratorWithoutCreateContext{}, nil
+ })
+ defer restore()
+
+ // 1. Renders via legacy Create() (non-CreateContext)
+ r := NewWkhtmlRenderer(2)
+ data, err := r.Render("legacy", DefaultOptions())
+ if err != nil {
+ t.Fatalf("Render failed: %v", err)
+ }
+ if string(data) != "%PDF-1.4 Legacy" {
+ t.Errorf("unexpected output: %s", string(data))
+ }
+
+ // 2. Generate with custom concurrency != DefaultMaxConcurrency without custom renderer
+ buf, err := Generate("concurrency test", WithMaxConcurrency(4))
+ if err != nil {
+ t.Fatalf("Generate with custom concurrency failed: %v", err)
+ }
+ if buf.String() != "%PDF-1.4 Legacy" {
+ t.Errorf("unexpected buffer: %s", buf.String())
+ }
+}
diff --git a/scripts/check_version.sh b/scripts/check_version.sh
index 6bfcb8e..c61c20f 100755
--- a/scripts/check_version.sh
+++ b/scripts/check_version.sh
@@ -104,10 +104,10 @@ done
# Only current-package installation examples must use the documented current
# release. Historical changelog/baseline evidence is intentionally immutable.
-wrong_module_versions="$(grep -rnE "${MODULE}@v${SEMVER_PATTERN}" README.md CONTRIBUTING.md SECURITY.md docs audit export examples india notifications outbox pdf 2>/dev/null | grep -v "${MODULE}@v${expected_version}" || true)"
+wrong_module_versions="$(grep -rnE "${MODULE}@v${SEMVER_PATTERN}" README.md CONTRIBUTING.md SECURITY.md docs audit export examples notifications outbox pdf 2>/dev/null | grep -v "${MODULE}@v${expected_version}" || true)"
[[ -z "${wrong_module_versions}" ]] || fail "current documentation contains a mismatched ${MODULE} version:\n${wrong_module_versions}"
-declare -a packages=(india pdf notifications outbox audit export)
+declare -a packages=(outbox pdf notifications audit export)
actual_package_count=0
for package in "${packages[@]}"; do
[[ -f "${package}/README.md" ]] || fail "missing ${package}/README.md"
diff --git a/scripts/verify_changelog_symbols.sh b/scripts/verify_changelog_symbols.sh
index f66abe2..08fb00a 100755
--- a/scripts/verify_changelog_symbols.sh
+++ b/scripts/verify_changelog_symbols.sh
@@ -32,6 +32,9 @@ FAILED=0
# Whitelist of known non-symbol technical terms/acronyms appearing in bullets
WHITELIST="PostgreSQL|RFC|MIME|HMAC|SHA256|DDL|CSV|JSON|SQL|GST|GSTIN|PAN|Aadhaar|IFSC|INR|BOM|UTF8|API|UUID|CPU|RAM|HTTP|SMTP|PGRecorder|StreamWriter|ExportConfig|Compiler|InvoiceData|EmailMessage"
+# Extract packages documented under '### Removed' to avoid auditing symbols of discontinued packages
+REMOVED_PACKAGES=$(awk '/^### Removed/{flag=1; next} /^###|^##/{flag=0} flag && /^[-*]/' "$CHANGELOG_FILE" | sed -n -E 's/^[[:space:]]*[-*][[:space:]]*([^:]+):.*/\1/p' | tr -d '`' | tr -d '[:space:]')
+
while IFS= read -r bullet; do
[ -z "$bullet" ] && continue
echo " -> Auditing bullet: $bullet"
@@ -43,6 +46,12 @@ while IFS= read -r bullet; do
PREFIX=$(echo "$PREFIX" | tr -d '`' | tr -d '[:space:]')
fi
+ # Skip auditing for packages that were explicitly documented as removed
+ if [ -n "$PREFIX" ] && echo "$REMOVED_PACKAGES" | grep -qw "$PREFIX" 2>/dev/null; then
+ echo " ℹ️ Skipped removed package: '$PREFIX'"
+ continue
+ fi
+
# Verify package directory exists and contains Go files
if [ -n "$PREFIX" ]; then
if [ ! -d "$PREFIX" ] || ! ls "$PREFIX"/*.go >/dev/null 2>&1; then