diff --git a/repositories/posRepository.go b/repositories/posRepository.go index 5d153ad..3c483b7 100644 --- a/repositories/posRepository.go +++ b/repositories/posRepository.go @@ -412,6 +412,20 @@ func posCustomerMobile(order models.PosOrder) string { // The terminal sends ISO-8601. A blank one falls back to now; an unparseable // one is refused, because importing a sale under the wrong date corrupts every // daily revenue figure that reads it. +// parsePosSaleDate reads the moment a bill was rung. +// +// The order of these layouts is load-bearing, and the two zoned ones must stay +// first. A terminal that sends its offset — `2026-08-05T00:30:00+05:30` — gets +// both readings right: the instant is correct, and Format("2006-01-02") still +// yields the till's own trading day rather than UTC's. +// +// The two bare layouts exist for terminals built before the offset was added, +// which are still in the field. `time.Parse` fills an absent zone with UTC, so +// those bills record an instant wrong by the offset — a Coimbatore wall clock +// read as though it were London. That is not recoverable here: nothing in the +// payload says which zone it came from. Their business date is still right, +// which is why the daily figures held up while billedat did not, and why these +// are tolerated rather than refused. func parsePosSaleDate(raw string) (time.Time, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/repositories/posRepository_test.go b/repositories/posRepository_test.go index 5905704..e10160e 100644 --- a/repositories/posRepository_test.go +++ b/repositories/posRepository_test.go @@ -197,3 +197,57 @@ func TestABillTakesItsTerminalFromTheBatchWhenItNamesNone(t *testing.T) { }) } } + +// billedat and businessdate are derived from the same parsed value and pull in +// opposite directions, so they are tested together. +// +// Live bill INV-2608-T5EDD-00116 carried billedat 2026-08-05T12:49:28Z beside +// receivedat 2026-08-05T07:19:28Z — the sale appearing to happen five and a +// half hours after it was received. The till was sending a naive local +// timestamp and time.Parse fills that silence with UTC, so a Coimbatore wall +// clock was recorded as though read in London. +// +// The daily figures survived it by luck: businessdate comes off the wall clock +// either way, and the wall clock was always the till's own. Anything comparing +// billedat against real time did not. +func TestASaleDateKeepsBothTheInstantAndTheTradingDay(t *testing.T) { + // Coimbatore, late enough that UTC has not yet rolled into the same day. + const ist = "2026-08-05T00:30:00+05:30" + + at, err := parsePosSaleDate(ist) + if err != nil { + t.Fatalf("parsePosSaleDate(%q) errored: %v", ist, err) + } + + // The instant. 00:30 IST is 19:00 UTC the previous evening. + wantInstant := time.Date(2026, 8, 4, 19, 0, 0, 0, time.UTC) + if !at.UTC().Equal(wantInstant) { + t.Errorf("instant = %v, want %v", at.UTC(), wantInstant) + } + + // The trading day. This is the one that must NOT follow UTC — the shop rang + // this sale on the 5th and its takings belong to the 5th. Deriving the + // business date from UTC would file it under the 4th and leave two days + // wrong: one short, one over. + if got := at.Format("2006-01-02"); got != "2026-08-05" { + t.Errorf("businessdate = %s, want 2026-08-05 — the till's own day", got) + } +} + +// Terminals built before the offset was added send a bare local timestamp, and +// they are still in the field. Parsing must not start refusing them. +// +// The instant such a bill records is wrong by the offset and cannot be +// recovered — there is nothing in the payload that says which zone it was read +// in. Its business date is still right, which is why the daily figures held up, +// and why this stays a tolerated legacy rather than a rejection. +func TestANaiveSaleDateIsStillAccepted(t *testing.T) { + at, err := parsePosSaleDate("2026-08-05T12:49:28.245") + if err != nil { + t.Fatalf("a pre-offset terminal must not be refused: %v", err) + } + + if got := at.Format("2006-01-02"); got != "2026-08-05" { + t.Errorf("businessdate = %s, want 2026-08-05", got) + } +} diff --git a/scratch/gstrates/main.go b/scratch/gstrates/main.go new file mode 100644 index 0000000..a8bed4e --- /dev/null +++ b/scratch/gstrates/main.go @@ -0,0 +1,172 @@ +// Set GST rates on the POS catalogue products. +// +// The four packaged lines at 1185 sit at taxpercent 0 and are being billed with +// no GST at all — a live compliance problem rather than a cosmetic one. Those +// are written. +// +// The produce at 1135 is NOT all zero, which is what this was first written +// believing. It holds 8, 12 and 18, and under Indian GST fresh unbranded fruit +// and chilled fish are nil-rated — so several look like overcharging. Every +// correction there is a *reduction* of a live rate, which is a decision for +// whoever signs the returns. Reported as REVIEW and left untouched. +// +// go run ./scratch/gstrates plan +// go run ./scratch/gstrates apply +package main + +import ( + "fmt" + "log" + "os" + + "github.com/joho/godotenv" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Indian GST on food, as it applies to these lines. +// +// Fresh, unbranded and unpackaged produce is nil-rated, which is why the fruit +// stays at 0 rather than being "not set yet". Packaged branded snacks are 12%. +// Breakfast cereal is 18%. +// +// Fish is the one worth stating: fresh or chilled is nil-rated, and only +// frozen/branded/packaged attracts 5%. Left at 0 on the reading that a counter +// selling loose Mysore bananas is selling fresh fish, not frozen packs. +type rate struct { + productID int + name string + percent float64 + why string + + // apply gates the write. Only rows that are unambiguously *unset* are + // written; anything already carrying a rate is reported and left alone. + // + // The fresh produce at 1135 is the reason for this flag. Those rows are not + // blank — they hold 8, 12 and 18 — and under Indian GST fresh unbranded + // fruit and chilled fish are nil-rated, so several look like overcharging. + // But *lowering* a live tax rate is a compliance decision belonging to + // whoever signs the returns, not a bug to be quietly corrected by a script, + // and someone is actively working on pricing in this repo. Reported, not + // touched. + apply bool +} + +var rates = []rate{ + // 1135 — already rated. Listed so the plan shows what is there, and flagged + // for a human because every one of these is a reduction. + {6988, "Mysore Banana", 0, "fresh fruit — nil-rated, currently 8%", false}, + {6989, "Jammu Apple", 0, "fresh fruit — nil-rated, currently 18%", false}, + {6990, "Small orange", 0, "fresh fruit — nil-rated, currently 18%", false}, + {6991, "Red Guava", 0, "fresh fruit — nil-rated, currently 18%", false}, + {6992, "Pomegrante", 0, "fresh fruit — nil-rated, currently 12%", false}, + {6993, "Salem Mango", 0, "fresh fruit — nil-rated", false}, + {6994, "Pineapple", 0, "fresh fruit — nil-rated", false}, + {6995, "Strawberries", 0, "fresh fruit — nil-rated, currently 18%", false}, + {6996, "Maceral", 0, "fresh fish nil-rated; 5% only if frozen/packaged", false}, + {6997, "Tuna", 0, "fresh fish nil-rated; 5% only if frozen/packaged", false}, + {6998, "Hatsun curd", 0, "curd nil-rated; flavoured yoghurt would be 5%", false}, + {7014, "Apple", 0, "fresh fruit — nil-rated", false}, + + // 1185 — genuinely unset, and being billed with no GST at all today. This + // is the half that is unambiguous: every one is an increase from zero, so + // nothing is being under-collected on the strength of a script's opinion. + {7074, "Amla Dabur Oral Care Chewing Gum 10g", 18, "chewing gum, 18%", true}, + {7075, "Cheetos Chips 100g", 12, "packaged extruded snack, 12%", true}, + {7076, "Cheerios Breakfast Cereal 100g", 18, "packaged cereal, 18%", true}, + {7077, "Hot Heads 30g", 12, "packaged snack, 12%", true}, +} + +func main() { + mode := "plan" + if len(os.Args) > 1 { + mode = os.Args[1] + } + + _ = godotenv.Load() + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"), + os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME")) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + log.Fatal(err) + } + + write := mode == "apply" + + fmt.Printf("%-6s %-38s %6s -> %6s %s\n", "id", "product", "now", "new", "why") + fmt.Println("-------------------------------------------------------------------------------------------") + + undo := []string{} + changes := 0 + review := 0 + + for _, r := range rates { + var current struct { + Taxpercent float64 + Found bool + } + if err := db.Raw(`SELECT COALESCE(taxpercent, 0) AS taxpercent, true AS found + FROM products WHERE productid = ? LIMIT 1`, + r.productID).Scan(¤t).Error; err != nil { + log.Fatalf("reading %d: %v", r.productID, err) + } + if !current.Found { + fmt.Printf("%-6d %-38s NO products ROW - skipped\n", r.productID, r.name) + continue + } + + if current.Taxpercent == r.percent { + fmt.Printf("%-6d %-38s %6.0f unchanged %s\n", + r.productID, r.name, current.Taxpercent, r.why) + continue + } + + if !r.apply { + fmt.Printf("%-6d %-38s %6.0f REVIEW %-3.0f %s\n", + r.productID, r.name, current.Taxpercent, r.percent, r.why) + review++ + continue + } + + fmt.Printf("%-6d %-38s %6.0f -> %6.0f %s\n", + r.productID, r.name, current.Taxpercent, r.percent, r.why) + + undo = append(undo, fmt.Sprintf( + "UPDATE products SET taxpercent = %.0f WHERE productid = %d;", + current.Taxpercent, r.productID)) + changes++ + + if write { + // updated is bumped so the catalogue delta carries the new rate to + // terminals holding a revision, rather than waiting for a full pull. + if err := db.Exec(`UPDATE products SET taxpercent = ?, updated = NOW() + WHERE productid = ?`, r.percent, r.productID).Error; err != nil { + log.Fatalf("writing %d: %v", r.productID, err) + } + } + } + + fmt.Println("-------------------------------------------------------------------------------------------") + if write { + fmt.Printf("APPLIED %d rate(s).\n", changes) + } else { + fmt.Printf("%d rate(s) would change. Nothing written — run `apply` to commit.\n", changes) + } + if review > 0 { + fmt.Printf("%d row(s) flagged REVIEW and deliberately not written — each is a\n"+ + "reduction of a live tax rate and needs a decision, not a script.\n", review) + } + fmt.Println() + + if len(undo) > 0 { + fmt.Println("-- undo:") + for _, u := range undo { + fmt.Println(u) + } + } +}