Skip to content

fix(i18n): use lang_customer_title for Customer heading in transaction modal - #478

Open
siddheshbandgar wants to merge 768 commits into
Posnic:developfrom
siddheshbandgar:fix/customer-transaction-heading
Open

fix(i18n): use lang_customer_title for Customer heading in transaction modal#478
siddheshbandgar wants to merge 768 commits into
Posnic:developfrom
siddheshbandgar:fix/customer-transaction-heading

Conversation

@siddheshbandgar

Copy link
Copy Markdown

Fixes #446

What changed

The transaction modal heading in frontend/modals/customer.html used the lang_Addexpensing translation key to render the word "Customer":

<lang class="lang_Addexpensing">Customer</lang>

That key is meant to mean "Add" (it is used correctly in the expense modal as "Add Cash Book Entry"), so no single translation could satisfy both uses. In Tamil it rendered as புதிய ("new"), producing "New Transaction" instead of "Customer Transaction".

This PR switches the heading to the existing lang_customer_title key, which already translates correctly to "Customer" (Tamil: வாடிக்கையாளர்), so the heading now reads correctly in every language. No translation-file change is needed because lang_customer_title already exists in the language packs.

Changes

  • frontend/modals/customer.html: replace lang_Addexpensing with lang_customer_title in the transaction modal heading.

Verification

  • lang_Addexpensing no longer appears in the customer modal but remains in place for the expense "Add" button where it is correct.
  • One markup change; no language file edits required.

sridharkalaibala and others added 30 commits August 29, 2026 00:02
The field said "Email", and a cashier standing at a new till will
reasonably type the login they use every day. That can never work: this
step connects the TILL to a shop and is authenticated against the
shop's activation account, which is keyed on the email the business
signed up with. A staff username and PIN are for signing in to the app
afterwards.

The failure gave nothing away either - "invalid credentials", with no
hint that the credentials were the right ones for the wrong thing.
It posted to api.posnic.com/user.php - a PHP script on a box that has
now been deleted, and which had already stopped answering HTTPS, so the
button failed silently for anyone who used it.

Now it mints against the website's own service, with the base
overridable through POSNIC_SITE_URL and SSO_URL kept so a local website
can still be tested. JSON rather than form_params, since a Node service
answers now; the headers are unchanged so the two sides can deploy
separately.

Two small things fixed while here. The old link joined
'https://www.posnic.com/' to '/ssoauth.html' and so carried a double
slash in every link it ever produced. And the token now goes through
encodeURIComponent, which the raw concatenation skipped.

The outbound call gains a timeout: without one, a website that accepts
the connection and never answers holds the till's request open.
posnic.com is fronted by CloudFront, which forwards only /api/* to the
service. A bare /ssoauth answers 403 from the CDN, so the token could be
minted and never used - the same failure the old /ssoauth.html had.

Caught by testing through posnic.com rather than loopback, where both
halves had passed.
The CDN was observed serving a stale 404 on the consume path, so the
no-store headers are a behaviour worth holding rather than a detail.
The alternative to typing the owner's password into a machine that then
downloads the whole business. Somebody allowed to add a device issues a
code, reads it out, and it dies in ten minutes - so a staff member can
set a till up without ever holding the credential that opens everything.

No new question is added: pairing reuses the same hidden gateway field
as the rest of the step, because where a shop syncs was never something
to ask a shopkeeper.

A refused code ends the attempt rather than looping. It is single use
and short lived, so retrying only burns the customer's ten minutes and
tells them nothing.

Both paths stay side by side. An estate mid-rollout has tills being
installed either way, and removing the old one first would strand
whoever is holding a laptop at the time.
The welcome puts a new shop on the features page on purpose: maybeShowIntro
sets that hash so the dialog is standing in front of the switches it is
describing. The cost, which only shows up watching a real signup, is that
dismissing it leaves a shop ninety seconds old looking at a settings screen -
the one place a shop that has just signed up has no reason to be.

The button names where it goes. Now it goes there.

Only that button. "Save & show me around" is a walk around where these switches
live and a page change would cut it off at the first step, so the two part
company after the save they share - which needs the tour flag read before the
branch that clears it, or every tour would be cut short instead. "Not now"
decided nothing and asked to be left alone. A failed save goes nowhere at all,
the same rule the tour already followed: a shop that did not save must not be
walked away from the screen still holding its choices.

Switching demo data off during the welcome starts a purge behind a modal
progress bar. A hash change would not stop that work, but it would leave the
bar floating over the sale screen and land its finishing message somewhere it
makes no sense - so while the bar is up, the sale screen waits for it.
Both dialogs fade, so hiding one is asynchronous and it is still on screen when
the navigation runs. Changing the page under a modal mid-close is how a
backdrop gets left behind, greying out the screen it was meant to reveal. The
tour already hits this and waits a fixed 400ms; waiting for the event is the
same idea without the guess.

Waited for by visibility rather than blindly, because hidden.bs.modal fires
once: a dialog that closed before this line ran has already fired it, and
binding then waits for something that is never coming.

Then a floor under all of it. A transition that never fires - a detached
element, reduced motion, a stylesheet that did not load - would leave the shop
on the settings page having pressed a button that names somewhere else.
Arriving late is a blemish; not arriving is the bug being fixed.

And exactly once, since the floor and a transition can both win: two setHash
calls push two history entries and Back then appears not to work.
Measured across six currencies of the retail dataset: 252 image slots and 42
distinct images. Every photograph is byte-identical between INR, GBP, USD, EUR,
JPY and AED - which is right, because a tin of tomatoes looks the same in
Dubai.

They were staged under the dataset id, and that carries the currency:
"GBP-retail-v1", "INR-retail-v1". So a box serving shops in six countries held
six copies of the same picture, and every install paid to write 3MB of bytes
that were already on the disk beside it.

Keyed by trade AND a hash of the image set now, rather than by trade alone. If
a currency ever does ship its own photographs it gets its own directory
automatically - so this shares what is identical rather than assuming it always
will be, which is the assumption that holds right up until somebody uploads a
localized product shot and every other country quietly gets it.

The proportions are worth writing down, because they say where the time is: per
trade the payload is 3.0MB of images against 472KB of JSON, and the whole
published library is 8.8GB. The documents a demo install writes are about
200KB. Writing those is already milliseconds - so the way to make demo install
fast is not a faster import, it is not writing the pictures twice.
countries.json carries a `tax` array per country, and for 128 of the 246 that
array is exactly one row: {"tax_name": "0% Tax", "tax_value": 0}. The installer
inserted it. So a shop in Angola was provisioned with a real tax record, a tax
column on its receipts, and the clear implication that we know the Angolan
regime.

That is not a researched finding that the country has no consumption tax. It is
missing data wearing the costume of an answer.

Owner's decision: those shops get no tax, and are told they can switch it on in
Settings. An honest gap beats a confident zero.

The order of preference is the whole design. A sourced entry in the new
country_tax_rates.json wins; failing that, countries.json is used WHERE IT
CARRIES A REAL RATE, because 118 countries do and dropping them while the
sourced table is still being filled in would break far more than it fixed;
failing that, nothing. So the table takes over country by country as it is
researched, and on the day it is empty this still removes every fake zero and
changes nothing else - which is what makes it safe to ship before the research
is done.

The ARRAY is judged, not each row. A 0% band beside real rates is a genuine
zero-rated band - India ships one next to its six GST slabs and a shop selling
exempt goods has to be able to pick it. The first version filtered row by row
and quietly took India from seven tax records to six; the requirement that
India be unchanged is what caught it, and there is now a test pinned against
the real countries.json that would catch it again.

A rate is only usable if it carries sourceUrl and checkedAt. Checked, not
trusted: the cost of a wrong rate is a shop filing returns against it.
regime:"none" is kept distinct from unverified - both produce an empty shop but
only one of them is knowledge.

web-api says the same thing as the installer, so the signup preview cannot
promise a tax setup the install then declines to create, and account.html now
shows the note instead of falling silent - an empty tax setup with no
explanation reads as a broken install.
Every rate here was fetched and read from the cited page during this change.
Nothing was written from memory, and the countries whose sites refused an
automated read - Australia, Canada and Saudi Arabia return 403 or 404, and
Singapore renders its rate in JavaScript - are deliberately ABSENT rather than
filled in from recollection. They fall back to countries.json, which is what
they were using anyway.

Sources: gov.uk, tax.gov.ae, ird.govt.nz, sars.gov.za, nta.go.jp,
estv.admin.ch, skatteetaten.no, rd.go.th, and the European Commission's Your
Europe table for DE, FR, IE, ES, IT and NL.

It immediately corrected four countries whose shipped rates were years stale:

  South Africa  14%  ->  15%          raised in 2018
  Netherlands   6%   ->  9% reduced   raised in 2019
  Japan         8%   ->  10% + 8%     the standard rate was missing entirely
  Switzerland   2.5/3.7/7.7 -> 2.6/3.8/8.1   raised in 2024

Japan is the one worth noticing: the shipped data had only the 8% reduced rate,
so every Japanese shop was provisioned without a standard rate at all.

The first pass then dropped the super-reduced bands from France, Italy and
Spain, because the table I read listed standard and reduced only. That is the
same shape of mistake as the India zero row - a real band disappearing because
the replacement was less complete than what it replaced - and it was caught by
diffing every sourced country against what it had before. The 2.1% and 4% bands
are back.

verified gates whether a rate is USED. reviewer records a person having signed
it off and is null on every entry here, because nobody has. The file keeps the
two apart on purpose; they are different claims.
Owner, looking at a British-currency demo: "everything should be very local.
including customer, supplier, sales, purchase and etc."

He was looking at a shop priced in pounds selling to Anand Kumar of Chennai and
buying from Balaji Distributors of Madurai. Those names are a hardcoded list in
demo-seed.js and they were every country's demo - and every country's real
signup. A shop in Tokyo that ticked "sample data" got customers from Tamil Nadu
with phone numbers beginning 98400.

Every currency dataset already ships customers.json and suppliers.json, placed
in its own city: "London Cash Customer", "Tokyo Family Account", "Dubai Direct
Imports". The zip was being opened and those two files walked straight past,
because only data.json was ever read out of it.

Now they are read, carried through the pack, and preferred by the seeder. The
built-in Indian list remains as the fallback for a shop with no dataset, which
is what it was always for.

Only a name, a phone and a city are taken. Balances, credit terms and tax
registration on a demo customer are claims about a business that does not
exist. The dataset's own walk-in is dropped because the installer makes one for
every shop already, and two of them is a list nobody can tell apart.

Null rather than an empty list when a dataset ships no people, so the seeder
can tell "this one has none" from "this one has none I could use" - the second
would leave a shop with no customers at all, which is worse than foreign ones.

That walk-in filter silently matched nothing at first: the regex had a literal
BACKSPACE byte where its word boundary should have been, invisible in every
terminal view of the file. A NUL landed in another file the same way earlier
today, so there is now a test that fails on any control character in the
source. Both were written by a generator, and both read as correct.
Two optional additions, both inert unless asked for, so every production shard
takes exactly the path it took before.

SHARD_INSTANCE narrows the control query to the shops assigned to one machine.
Necessary the moment a second machine runs a shard: without it both would try
to open every shop in the fleet and each would fail on the databases that live
on the other one.

SHARD_REGISTRY_FILE replaces the control database with a local file. The demo
estate needs it, and the reason is blast radius: reading the control registry
means holding the control credential AND TENANT_SECRET_KEY, the key that
unseals every shop's secrets across the whole fleet. The demo box is a public
machine whose logins are printed on its own login page, serving shops that
contain nothing but sample data. Putting the fleet master key there to serve
those would be a poor trade.

The file carries its own plaintext secrets, which is the posture the
single-shop demo already has - its JWT_SECRET sits in a 0600 .env beside it.
What does NOT change is the isolation: every shop still gets its own scope and
its own keys, a shop with no JWT_SECRET is dropped at load rather than
discovered on its first request, and currentSecret still refuses to fall
through to the environment. That refusal is the whole reason a shared process
is safe.

This is what makes fifty demos possible on a 1907MB box with no swap. One
process per currency is 111MB each and 5.5GB in total; one shard is about 125MB
plus roughly 4MB a shop.
Fifty demo shops share one public hostname and are chosen by a cookie, so the
front end has to name the shop some other way. Rewriting Host would do it and
would also put an internal hostname into every absolute URL the application
generates - links to a name that resolves for nobody. A header of its own costs
nothing and cannot leak into a page.

Unset - which is every production shard - and the Host header is used exactly
as before.
Owner, looking at the dashboard: "best selling produces wth 0 quantity ?"

The product names were right and every quantity and amount beside them was
zero. In every shop, including India, since the day demo sales existed.

The dashboard sums items.item_quantity and items.total_amount - the names
sales.helper.js writes when a real sale is rung up, and that 189 places in the
application read. The demo seeder wrote `quantity` and `total` only. So a demo
sale looked completely correct on the sale screen and on the receipt, and was
invisible to anything that aggregated over its line items.

Both spellings are written now. The aliases are what the sale screen and the
receipt read, and dropping them to tidy up would trade one silent emptiness for
another.

The test that would have caught it runs the dashboard's own pipeline in
miniature - unwind the lines, group by name, sum the two fields - and asserts
the result is not all zeroes. Removing either field turns it red.
Owner: "within our eco system i want to know where its comes from. if user
comes desktop app then name that too."

Every link out to signup was the bare URL, so somebody who spent ten minutes in
the public demo and then signed up was indistinguishable from somebody who
typed the address. Both landed as "direct". The one channel we control end to
end was the only one we could not measure.

The source is DERIVED from runtime-info, which already knows: demo:true is the
public demo, mode:desktop is the Electron till, mode:cloud is a shop already
running Posnic, mode:local is a community install. A build flag or a user-agent
sniff would be a second answer to a question the runtime already answers, and
the two drift the first time somebody packages the app differently.

The demo also carries WHICH COUNTRY's demo it was, from the cookie the demo
routing sets. "They came from the UAE demo" is a different fact from "they came
from the demo", and it is the one that says whether fifty localized shops earn
what they cost to run.

Links marked data-posnic-signup are rewritten wherever they appear, so one
added later on a page nobody remembers still carries it - and a failure to
reach runtime-info leaves them exactly as authored, because a missing
measurement must never cost a signup.

Separately, and by the owner's instruction: "if customer credit enabled then
show. otherwise dont show." A shop that never sells on account cannot have
dues, so the panel could only ever say nobody owes anything - which reads as a
feature that is broken rather than one the shop does not use. Best Selling
takes the whole row when it goes. The gates are re-applied when the dashboard
opens, because its markup is fetched after applyModuleSidebar has already run.
On 28 August a live white-label customer - a horticultural corporation with
five branches, 265 products and 87 sales going back to April 2025 - had every
bit of it replaced by a fresh shop and demo data.

Nobody deleted anything. Their tenant row was set provisioned:false, the
provisioner read that as a new signup, and called /api/install/add against
their live database. This service did exactly what it was asked.

That is the whole failure. Install is a CREATE and nothing checked whether
there was already something to destroy.

The check belongs here rather than only in the caller, because the caller that
did it WAS trusted and the next one will be too. A branch is the right thing to
look for: every installed shop has one and no empty database does, so a
non-zero count means somebody's shop is on the other end of the call.

The refusal says how many branches it found, because "refused" with no reason
is a thing somebody forces past. force:true exists for the deliberate rebuild
of a shop known to be empty, and must be exactly true - no truthy string.

A count that cannot be taken at all is treated as empty. A brand-new database
can fail that read simply because the collection does not exist yet, and
refusing then would block every genuine signup - a worse failure than the one
being prevented.

The customer's data was recovered from a backup taken 22 minutes earlier. There
is no version of this where that recovery was guaranteed.
The guard called countExistingBranches unconditionally, so a repository that
does not implement it - an older one, or a test double - threw a TypeError
before the catch could apply, and every install failed. Nine tests said so and
I pushed before reading them.

An unanswerable question is treated as empty, deliberately: refusing then would
block genuine signups, which is a worse failure than the one being prevented.
Bumps the api-patch-and-minor group with 16 updates in the /api directory:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1111.0` | `3.1121.0` |
| [axios](https://github.com/axios/axios) | `1.19.0` | `1.20.0` |
| [html-to-text](https://github.com/html-to-text/node-html-to-text) | `10.0.0` | `10.0.1` |
| [joi](https://github.com/hapijs/joi) | `18.2.3` | `18.2.5` |
| [mongodb](https://github.com/mongodb/node-mongodb-native) | `7.5.0` | `7.6.0` |
| [mongoose](https://github.com/Automattic/mongoose) | `9.9.2` | `9.9.4` |
| [morgan](https://github.com/expressjs/morgan) | `1.11.0` | `1.12.0` |
| [multer](https://github.com/expressjs/multer) | `2.2.0` | `2.3.0` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `9.0.5` | `9.0.6` |
| [pdfkit](https://github.com/foliojs/pdfkit) | `0.19.1` | `0.20.1` |
| [unzipper](https://github.com/ZJONSSON/node-unzipper) | `0.10.14` | `0.12.5` |
| [uuid](https://github.com/uuidjs/uuid) | `14.0.1` | `14.0.2` |
| [@redocly/cli](https://github.com/Redocly/redocly-cli) | `2.46.1` | `2.49.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.2.0` | `26.4.0` |
| [eslint](https://github.com/eslint/eslint) | `10.8.1` | `10.9.1` |
| [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) | `30.4.2` | `30.5.0` |



Updates `@aws-sdk/client-s3` from 3.1111.0 to 3.1121.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1121.0/clients/client-s3)

Updates `axios` from 1.19.0 to 1.20.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](axios/axios@v1.19.0...v1.20.0)

Updates `html-to-text` from 10.0.0 to 10.0.1
- [Changelog](https://github.com/html-to-text/node-html-to-text/blob/master/CHANGELOG.md)
- [Commits](html-to-text/node-html-to-text@10.0.0...10.0.1)

Updates `joi` from 18.2.3 to 18.2.5
- [Commits](hapijs/joi@v18.2.3...v18.2.5)

Updates `mongodb` from 7.5.0 to 7.6.0
- [Release notes](https://github.com/mongodb/node-mongodb-native/releases)
- [Changelog](https://github.com/mongodb/node-mongodb-native/blob/main/HISTORY.md)
- [Commits](mongodb/node-mongodb-native@v7.5.0...v7.6.0)

Updates `mongoose` from 9.9.2 to 9.9.4
- [Release notes](https://github.com/Automattic/mongoose/releases)
- [Changelog](https://github.com/Automattic/mongoose/blob/master/CHANGELOG.md)
- [Commits](Automattic/mongoose@9.9.2...9.9.4)

Updates `morgan` from 1.11.0 to 1.12.0
- [Release notes](https://github.com/expressjs/morgan/releases)
- [Changelog](https://github.com/expressjs/morgan/blob/master/HISTORY.md)
- [Commits](expressjs/morgan@1.11.0...1.12.0)

Updates `multer` from 2.2.0 to 2.3.0
- [Release notes](https://github.com/expressjs/multer/releases)
- [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md)
- [Commits](expressjs/multer@v2.2.0...v2.3.0)

Updates `nodemailer` from 9.0.5 to 9.0.6
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](nodemailer/nodemailer@v9.0.5...v9.0.6)

Updates `pdfkit` from 0.19.1 to 0.20.1
- [Release notes](https://github.com/foliojs/pdfkit/releases)
- [Changelog](https://github.com/foliojs/pdfkit/blob/master/CHANGELOG.md)
- [Commits](foliojs/pdfkit@v0.19.1...v0.20.1)

Updates `unzipper` from 0.10.14 to 0.12.5
- [Release notes](https://github.com/ZJONSSON/node-unzipper/releases)
- [Commits](https://github.com/ZJONSSON/node-unzipper/commits)

Updates `uuid` from 14.0.1 to 14.0.2
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](uuidjs/uuid@v14.0.1...v14.0.2)

Updates `@redocly/cli` from 2.46.1 to 2.49.0
- [Release notes](https://github.com/Redocly/redocly-cli/releases)
- [Commits](https://github.com/Redocly/redocly-cli/compare/@redocly/cli@2.46.1...@redocly/cli@2.49.0)

Updates `@types/node` from 26.2.0 to 26.4.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 10.8.1 to 10.9.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](eslint/eslint@v10.8.1...v10.9.1)

Updates `jest` from 30.4.2 to 30.5.0
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.0/packages/jest)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1121.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: axios
  dependency-version: 1.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: html-to-text
  dependency-version: 10.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: api-patch-and-minor
- dependency-name: joi
  dependency-version: 18.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: api-patch-and-minor
- dependency-name: mongodb
  dependency-version: 7.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: mongoose
  dependency-version: 9.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: api-patch-and-minor
- dependency-name: morgan
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: multer
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: nodemailer
  dependency-version: 9.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: api-patch-and-minor
- dependency-name: pdfkit
  dependency-version: 0.20.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: unzipper
  dependency-version: 0.12.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: uuid
  dependency-version: 14.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: api-patch-and-minor
- dependency-name: "@redocly/cli"
  dependency-version: 2.49.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: "@types/node"
  dependency-version: 26.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: eslint
  dependency-version: 10.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: jest
  dependency-version: 30.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Nobody could answer "how much of the app is translated?". The build falls back
to the English already inside the <lang> tag when a key is missing, which is
right - it is why a half-translated language is usable rather than broken - but
it is also invisible. Tamil has been 88% translated for months and reads as
finished.

Three numbers, none of which were known before:

  the UI uses 648 DISTINCT keys, not the 1,408 <lang> tags - a key on six
  screens is one string to translate;

  Tamil answers 573 of them, so 75 screens' worth of words are showing English
  to somebody who asked for Tamil;

  and ta.json carries 313 entries nothing references - 35% of the file - so
  handing a translator "the whole file" asks for a third more work than the
  product has words in it.

An empty string counts as untranslated, or generating a skeleton would report a
language as finished.

--min gates a release without failing ordinary runs; --json is for CI;
--missing <lang> prints the list to hand to a translator.

Background and the path this serves: Intranet docs/MULTI_LANGUAGE_ARCHITECTURE.md
Owner: "okay start with tamil."

The app decided its language by comparing a page FILENAME - sixty-three copies
of

    PosnicPro.local.get('language_herf') === 'ta_dashboard.html'

across fifteen files, each a two-way branch with the Tamil written inline
beside the English. A third language did not fit anywhere in that, which is why
there has never been one. This is stage L1 of Intranet
docs/MULTI_LANGUAGE_ARCHITECTURE.md and changes no behaviour.

PosnicPro.i18n now holds a language CODE, derived from the old filename so
existing Tamil shops are migrated rather than quietly reset to English on
upgrade, and t(key, english) answers with the translation or the English it was
handed. That fallback is the whole safety property: before the pack loads, if
it 404s, if a key is missing or a language is half done, a real English word
ships - never "undefined", which the HTML build did for months.

63 branches became 82 t() calls and 2 branches. The two left are not text:
Tamil needs its own type sizes on the sale screen and dashboard.

WHAT THIS TURNED UP. sales.js held nine Tamil strings as mojibake - UTF-8 bytes
saved as cp1252 - so the most used screen in the product had been showing
Tamil shopkeepers "பà¯à®¤à®¿à®¯" where it meant "புதிய". Invisible in review,
unmistakable to the person using it. Moving the words to ta.json fixed them,
and the build now refuses a pack containing mojibake so it cannot return.

ta.json goes from 894 keys to 582 by dropping every entry nothing references -
a third of the file. The pack shrinks 71KB to 43KB, and a translator is now
asked for the 657 strings the product actually has rather than a third more.

The words are fetched at runtime from languages/<code>.json. English fetches
nothing at all, because it is in the markup and in every t() call.

Not retranslated, deliberately: two forms of "Edit" are kept because the sale
screen says திருத்த and every other screen says திருத்தப்பட்ட. Changing what a
shopkeeper reads is a decision for someone who speaks Tamil.

Tamil is 89% complete, and it always was - 75 keys have been falling back to
English silently. tests/tools/i18n-coverage.js now counts t() calls as well as
HTML, so that number is visible rather than discovered.
The build translated at build time, which meant writing a COMPLETE SECOND COPY
of every page for each language: 2.3MB of duplicated markup to deliver 43KB of
Tamil, and roughly 23MB by the tenth language before anyone had typed a word of
the eleventh.

Output is now 2.3MB whatever happens - one tree, and adding a language costs
the 43KB of its pack. That is the whole point of the exercise, and it is the
number the owner asked to hold down.

Pages keep their keys and their English:

    <lang class="lang_item_name">Item name</lang>

and PosnicPro.i18n.apply() swaps the words in at load. English is therefore not
"the default language" but the text physically in the file: the app renders
correctly with no dictionary, offline, on first run, and if every fetch fails.

TWO PLACES THE TAG CANNOT LIVE, and both were checked rather than assumed.
Inside <title> and <option> the parser builds no element for it, so a title
would have shown the customer literal angle brackets in their tab. There the
key is hoisted onto the parent as data-t. A survey of all 1,408 tags found
exactly 4 titles and 53 options and nothing in a textarea or an attribute.

1,351 places that held bare text now hold an inline element around it, so
lang { display: contents } removes its box entirely - spacing is unchanged on
screens nobody is going to re-check by hand. No CSS matched `lang` or any
`.lang_*` class before this, which was also checked rather than assumed.

Switching language no longer navigates. It used to load ta_dashboard.html and
lose whatever was on screen; there is one page now, so it fetches the words and
redraws in place.

The build also sweeps per-language pages an older build left behind. A stale
one still loads and still looks right, frozen at whatever the app said that
day, which is worse than a page that is simply missing.

Found while doing this: the earlier codemod only looked at modules/js, so
PosnicPro.js had quietly kept three filename comparisons and three Tamil words
of its own. The tests now scan the whole script tree, because a test scoped
more narrowly than the problem finds nothing and says so confidently.

Runtime behaviour is tested against a real DOM rather than by matching source
text - the change is about what the page looks like after load.
Pages ship in English and the words arrive by fetch, which is what made the
output stop multiplying - but it also means a Tamil shop that loses its
connection silently reverts to English, on a till, in front of a customer.

The worker now caches the pack on FIRST USE, with the same
stale-while-revalidate treatment the reference data already gets: answered
instantly from cache, refreshed behind the answer, swept by the versioned cache
name on release.

Cached on first use rather than precached, deliberately. Precaching every
language would put back exactly the weight this work removed - a shop should
carry the one language it chose, so the tenth language costs the other nine
shops nothing.

DELIVERY NEEDED NO CODE. The owner suggested keeping packs in S3 and
downloading on demand. The asset channel already ships frontend/public signed,
staged and auto-reverting, and the packs are written into that tree, so they
are already signed and delivered by construction - no second, weaker door. A
test pins that the bundler still walks the whole tree rather than a list that
could quietly forget them.

One of my own test expectations was wrong and is worth recording: I expected
/uploads/languages/x.json to match the pack pattern. It does not, and should
not - "x" is not a language code and that path is customer uploads. The
pattern was right; the expectation was not.
The last place a new language still meant editing code was the menu itself:
header.html carried one hand-written <a> per language. It is now built from
languages/index.json, which the build publishes from the language list, so
adding Hindi is languages/hi.json plus one line of config and nothing else.

One English entry stays in the markup deliberately. If the list cannot be read
the menu must still offer something - losing the OTHER languages is a bug,
leaving a shop with no way to choose at all is a worse one.

The click handler is delegated, because entries that did not exist when the
page loaded would otherwise be dead.

The list carries display names written in the language itself. Somebody looking
for Tamil is looking for "தமிழ்", not for "Tamil".

Proved rather than asserted: I added a language end to end - published, offered
in the menu, under its own name - and the only thing it dirtied was its own
JSON file. No HTML, no module JS, no build code. Then put it back and checked
the tree was as it started.

Also fixed while here: buildCss looped the languages and wrote the SAME path
each time, because the filename never carried the language. Every stylesheet
was rewritten once per language for no effect - harmless at two, and pointless
work that grows with every language added.

L1-L5 of Intranet docs/MULTI_LANGUAGE_ARCHITECTURE.md are now done. Output is
2.3MB whatever happens, and a language costs the 43KB of its pack.
--missing prints key names, which is the wrong thing to hand a person: nobody
can translate "lang_conversion_factor_title" without being told it says
"Conversion factor" and sits on the item screen.

--worksheet ta writes a JSON file with the English and the screen beside every
blank. --merge ta takes it back, sorted, without the duplicate keys and lost
ordering that hand-merging produces.

Blank entries are skipped rather than written. An empty string counts as
untranslated everywhere else in this system, so writing one would report the
language as finished while showing the customer nothing.

A merged value that looks like mojibake is refused outright, with the advice to
save as UTF-8. That is the exact failure that had been live on the sale screen
for who knows how long, and a worksheet round-tripped through the wrong editor
is precisely how it would come back.

The round trip was tested end to end - worksheet out, two strings filled in,
merged, coverage moved 582 to 584 - and then reverted.
A <lang> tag broken over three lines in the source gave back the newlines and
the indentation with it, so the worksheet showed

  "Your shop was set up with one
                trade's sample products. Pick a different"

which is hard to read and would have been translated with the whitespace baked
into the result. The sentence is what matters; the markup's shape is not.
--worksheet writes a file on its way to a person and back. Half-filled, it is
not the translation and not the language file; the finished words belong in
frontend/languages/<code>.json after --merge.
Signed-off-by: Sridhar Bala <sridharkalaibala@gmail.com>
siddheshbandgar and others added 23 commits September 4, 2026 10:59
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge part d of the Swahili worksheet: 426 keys covering the register
session, receiving, quotes, roles and permissions, loyalty tiers, variants
and the roster. Vocabulary follows the existing pack - sanduku la fedha for
the till session, rejista for the terminal, ankara, nukuu ya bei, akiba.

Brand names keep their own spelling rather than being transliterated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge the remaining German and Thai worksheets. Both packs now answer every
key the interface asks for.

Two labels the load-time revert had left in English for good: the ITEM /
CUSTOMER / ACTION badge on a command-palette result, and the Movement
dropdown's own label on Stock Activity once a movement is picked. Both sit
in a top-level object literal, where a t() call runs before any pack exists,
so they take the same shape as the report titles - the key travels with the
English in the config, and the render, which runs long after the packs land,
does the asking.

The coverage scanner now reads a key as a word. It matched anything between
the quotes of <lang class="...">, so a render site building that class from
a variable was harvested as a key literally named "' + kindKey + '". Four
such phantoms were already sitting in the English map; they are gone, and
the pattern can no longer mint another.

Also mark .husky/pre-commit executable. It has a shebang and the suite
checks for that, so the hook was failing CI for everyone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge the last Swahili worksheet.

Three chips on the campaign editor insert a merge field into the message
template, and what they show is exactly what they insert. They were wrapped
in <lang>, so they went out to every translator and came back as
"{points} puntos", "Daraja {tier}", "{name} (jina)" - a chip that no longer
says what it does, in fifteen languages. The tags come off, the keys go, and
both sweep tools now read a lone {token} as data rather than prose, so the
next pass will not tag them again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge the Italian, Dutch and Sinhala worksheets. Italian and Dutch now
answer every key the interface asks for; Sinhala reaches 84%.

Six translators in a row reported the same fight and each lost it
differently: "Provider Way2sms", "TextLocal", "Msimbo wa Pharmacode",
"Stile Turbo C", a Razorpay tab prefixed with the Arabic for gateway. None
of them was careless. The validator rejects a value identical to its
English, which is right - that is what a skipped row looks like - but it had
no exemption for a proper noun, so changing the name was the only way to
pass. A payment tab that no longer names the payment provider.

languages/_glossary.json now carries the names, every pack spells them the
same way, and a test holds the line. Descriptive names are deliberately not
on that list: Soft Dark and Diamond are words, and a theme picker in Thai
should read in Thai. The two brands whose English was misspelled take the
casing their companies use.

The user permission grid says Read/Write and Read/Delete. It said R/Write,
the R abbreviating Read, and four translators read that as Receiving or
Return - the three packs that guessed wrong now match their own word for
Read. The table sits in .table-responsive; the two saved characters were
never worth the ambiguity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge part f for Arabic, Spanish, French, Hindi and Indonesian, and fill
Tamil's last four keys.

Three panels a shopkeeper reads were built by an inline <script> inside a
page, which is a seam neither sweep covered: the HTML tagger strips <script>
before it looks, and the JS tagger only walks static/script/js. The desktop
card under the sign-in form, the boot watchdog shown when the till will not
load, and the desktop tools button were all English in every language. They
carry keys now, and the gaps report reads what an inline script builds
rather than throwing it away.

With that, and with an onclick handler no longer misread as prose - it holds
a > that the text scan took for the end of a tag - the count of English no
pack can reach is zero. The guard says zero instead of "at most two".

tests/tools/i18n-screen.js answers the question the owner actually asked.
Coverage says how much of a language is done; this says which SCREEN is
still English, worst first, so an hour spent on a language can be spent
where it shows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sridharkalaibala

Copy link
Copy Markdown
Contributor

Thanks for rebasing the focused one-line heading correction. The refreshed run has five green repository jobs, but Desktop tests failed and Packaging is still running, so I cannot approve yet. Please inspect the Desktop failure and push the smallest relevant fix (or document evidence that the failure is unrelated to this PR), then request review again once the required checks are green.

@sridharkalaibala sridharkalaibala left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Desktop failure is caused by this key change: lang_customer_title already means plural "Customers" in the navigation, while the transaction heading is singular. Please use the existing singular lang_newcustomer_title key and run node tests/tools/i18n-coverage.js --write-english so the unused lang_Addexpensing entry is removed. I opened #482 with that focused correction and its 45-test i18n suite passing.

@sridharkalaibala

Copy link
Copy Markdown
Contributor

@siddheshbandgar seems something messed up due to my force push. please tell me your changes (commit ids). I will cherry pick. or you may create new PR. thanks for understanding!

@siddheshbandgar

Copy link
Copy Markdown
Author

Hi @sridharkalaibala, thanks for the heads up! My change was a single one-line fix in frontend/modals/customer.html — replacing the lang_Addexpensing translation key with lang_customer_title in the transaction modal heading, so it renders "Customer" correctly in every language (e.g. Tamil: வாடிக்கையாளர்).

Since the original commit was lost in the force-push, rather than hunting for the commit ID I'll open a fresh PR with just this one-line change against the current develop. Thanks for understanding!

@sridharkalaibala

Copy link
Copy Markdown
Contributor

Thanks for the update. Please do not recreate the PR with lang_customer_title: that key is plural elsewhere and was the cause of the Desktop failure. The transaction heading needs the existing singular lang_newcustomer_title key, plus the generated-English-map update. PR #482 already contains that focused correction and has green checks, so please check its merge status before opening a duplicate; if it remains open, a new PR should use that same singular-key fix against develop.

@siddheshbandgar

Copy link
Copy Markdown
Author

Thanks @sridharkalaibala for the detailed guidance — fully understood.

Thanks again for the force-push mixup patience and for landing the correct correction in #482!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One translation key labels both a Customer heading and an Add button

6 participants