Compare commits
2 Commits
11595ad415
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5864204d32 | ||
|
|
d0c3cb751e |
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
180
scratch/gstrates/main.go
Normal file
180
scratch/gstrates/main.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// 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 — fresh produce, nil-rated under Indian GST.
|
||||
//
|
||||
// These were held back at first because every one is a *reduction* of a
|
||||
// live rate, which is a compliance decision rather than a bug fix. Released
|
||||
// on the owner's explicit instruction after that was put to them.
|
||||
//
|
||||
// Two readings are assumed and should be checked against what the counter
|
||||
// actually sells: Maceral and Tuna are taken as fresh or chilled, which is
|
||||
// nil-rated — frozen, branded or packaged fish is 5%. Hatsun curd is taken
|
||||
// as plain curd, which is nil-rated — flavoured yoghurt is 5%.
|
||||
{6988, "Mysore Banana", 0, "fresh fruit — nil-rated, currently 8%", true},
|
||||
{6989, "Jammu Apple", 0, "fresh fruit — nil-rated, currently 18%", true},
|
||||
{6990, "Small orange", 0, "fresh fruit — nil-rated, currently 18%", true},
|
||||
{6991, "Red Guava", 0, "fresh fruit — nil-rated, currently 18%", true},
|
||||
{6992, "Pomegrante", 0, "fresh fruit — nil-rated, currently 12%", true},
|
||||
{6993, "Salem Mango", 0, "fresh fruit — nil-rated", true},
|
||||
{6994, "Pineapple", 0, "fresh fruit — nil-rated", true},
|
||||
{6995, "Strawberries", 0, "fresh fruit — nil-rated, currently 18%", true},
|
||||
{6996, "Maceral", 0, "fresh fish nil-rated; 5% only if frozen/packaged", true},
|
||||
{6997, "Tuna", 0, "fresh fish nil-rated; 5% only if frozen/packaged", true},
|
||||
{6998, "Hatsun curd", 0, "curd nil-rated; flavoured yoghurt would be 5%", true},
|
||||
{7014, "Apple", 0, "fresh fruit — nil-rated", true},
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user