Releases: TheCoderAS/nizkhata
Releases · TheCoderAS/nizkhata
Release list
NizKhata Android v1.12.0
Any-currency workspaces (1.12.0) (#121)
* Foundation for any-currency workspaces: one catalogue, one formatter
A workspace keeps its books in exactly one currency, chosen at creation.
Everything about how a figure is written now follows from that currency,
through one catalogue, instead of being assumed Indian in dozens of places.
core/currency.dart CurrencySpec per ISO code: symbol, decimals, digit
grouping (South Asian lakh/crore, or thousands), and
the usual FY start. 32 currencies; an unknown code
still formats sensibly.
formatMoney symbol, decimals and grouping from the spec. A
million dollars is $1,000,000.00, not $10,00,000.00.
formatMoneyCompact K/L/Cr for South Asian currencies, K/M/B otherwise.
roundMoney rounds to the active currency's minor units, read
from MoneyContext, which the workspace controller
keeps in step on every change. Whole yen; three
places for the dinar, which two would have lost.
formatDate the phone's convention, always an English variant.
"Sep 20, 2026" in the US, "20 Sept 2026" as before.
Every default is INR, so every existing workspace and all 277 existing
tests are unaffected — the suite passes untouched, which is the proof
the change is invisible to current users. 20 new tests pin the rest.
This lands first so the parallel work that follows builds against one
API rather than several.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4
* Accounts, ledgers and overview screens follow the workspace currency
Eleven screens and the widget sync in main.dart each worked out the
currency for themselves with `activeWorkspace?.baseCurrency ?? 'INR'`.
They now ask WorkspaceController.currency, the one place that answers.
The account form and detail sheet assumed an Indian bank:
- The credit limit field had a hardcoded rupee prefix. It now shows
the workspace's own symbol ($, AED, and so on).
- IFSC and CIF showed for every bank account. They now show only in an
INR workspace. Elsewhere the form asks for one optional "Routing
code" (sort code, BSB, SWIFT or ABA number). It is kept in the
account's existing `ifsc` field, which needs no change to the model
or the rules. A workspace's currency never changes, so in any one
workspace the field only ever means one thing. Outside India the
form never writes `cif`, so saving cannot clear a stored value
through a field it did not show. The detail sheet labels the rows
the same way the form does.
Other India assumptions fixed along the way:
- The ledger labelled tax lines "Tax / GST" everywhere. That label is
now used only in INR workspaces; elsewhere it reads "Tax".
- Budgets wrote a January-start financial year as a span ("FY
2026-27"). That is wrong for a calendar year, which is the default
for most non-INR currencies. It now reads "FY 2026".
- The calendar wrote dates day-first whatever the phone's locale. It
now follows the phone, so a US phone shows "September 20". Indian,
British and Australian phones show exactly what they did before.
An INR workspace looks exactly as before. The detail rows and the bank
fields of the saved data now come from two small functions, so tests
can check them without Firestore. New tests cover the form at 360dp
(IFSC/CIF in INR, the routing code in USD, the symbol on the credit
limit in INR/USD/AED/JPY), what gets saved, and the detail rows. Five of
the widget tests fail on the previous form.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4
* Keep the day-field counter note on the function it describes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4
* Ask for the currency when a workspace is created
A workspace's currency is now permanent, so it can no longer be guessed.
Until now both ways of creating one went through createPersonalWorkspace,
which wrote 'INR' and an April financial year for everyone.
createPersonalWorkspace now takes the currency as a required argument and
builds the document through newWorkspaceFields, a pure function that
writes the chosen code, takes fyStartMonth from that currency's usual
year, and refuses a code the app does not offer.
First sign-in: a person with no workspace is no longer given one
silently. AuthController sets needsWorkspace, and the router holds them
on a new /welcome screen (every other route, deep links included, waits
behind it) until they confirm a currency. The phone's region preselects
one, falling back to INR, and the screen says plainly it can't be changed
later. A failed creation stays on the screen with a retry; back leaves
the app as it would from any root screen, and the person returns here on
next launch; "Use a different account" signs out. People who already
belong to a workspace, including anyone who joined one by invite, never
see the screen. If the membership check answers from an empty offline
cache, onboarding now fails instead of treating that as "no workspace".
Profile's New workspace dialog asks for the currency too, preselected
from the active workspace's, with the same note.
widgets/currency_picker.dart is the shared piece: a searchable sheet
(code, name or symbol) listing kCurrencies with code, name and symbol,
a CurrencyField that opens it, and the lock note.
24 new tests at 360dp phone width cover the preselection, the payload,
the redirect gate, the picker, the first-run screen and the dialog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4
* Lock the workspace currency; keep shared entries in their own; test the app in CI
A workspace keeps its books in one currency, fixed at creation. This makes
that true at every layer, and stops the shared ledger from mislabelling
amounts between people whose books are in different currencies.
CI. The "Flutter analyze and test" job runs pub get, analyze and the full
test suite in mobile/, using the same subosito/flutter-action and stable
channel as the APK build. flutter analyze fails on infos by default, so it
runs with --no-fatal-infos: warnings and errors still fail the job. The pub
cache is keyed on pubspec.yaml, since no lock file is tracked.
Rules. baseCurrency must be a three-letter upper-case code on create and
cannot change on update, for the owner too. Workspaces from before the field
existed are read as INR everywhere, so the rule compares effective currencies
(missing = INR): those workspaces stay editable and may have 'INR' written in,
but can never become anything else.
Settings. The currency dropdown becomes a read-only field (symbol, code,
name) saying it was set at creation. updateWorkspace no longer takes a
currency at all, so no screen can try.
Shared ledger. New entries (expenses, settlements, re-sends) carry the
creator's workspace currency; an entry without one is INR, which every
existing entry is. Entries show in their own currency, and balances are kept
per partner per currency, since a rupee and a dollar do not net. Accepting,
settling and resolving write into the reader's own books, so they are only
allowed from a workspace in the entry's currency: the mutation refuses with
SharedCurrencyMismatch, and the screen explains and disables the action
instead of posting $50 as 50 rupees. Rejecting stays open everywhere. The
sharedEntries rule validates currency when present but does not require it,
so app versions already installed keep working. Neither party can change it
afterwards.
The rules tests honour FIRESTORE_EMULATOR_HOST, so a second checkout can
run them on another port.
Tests: Flutter 314 passed (17 new). Rules 48 passed (10 new); with the old
rules, 5 of the new ones fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4
* Money, debts, dues and reports speak the workspace's currency
Debts, dues, transactions, reports and the line editor now take the
currency from WorkspaceController.currency instead of assuming India.
Currency
Ten scattered `baseCurrency ?? 'INR'` fallbacks now read
WorkspaceController.currency. The five amount fields that were hardwired
to "₹ " (line amount, TDS, debt repayment, due payment, a debt's opening
amount) now show the workspace's symbol. The trailing space is kept
because Flutter puts no gap after a prefix, and it keeps INR fields
exactly as they were.
India-only tax, INR workspaces only
Line editor: TDS is India's tax deducted at source, so only INR
workspaces are asked for it. Elsewhere the field is hidden but never
cleared: a line that already carries TDS keeps it through any edit. Of
the tax heads, only "Perquisite" is specific to Indian income tax, so
only that one is dropped for other currencies. A head a line already
carries is still offered, so an old line never shows a blank head.
Reports: the tax pack PDF is working papers for an Indian CA, with a TDS
column on every table and a Form 26AS cross-check. Relabelling it would
not change that, so other currencies get no pack and no TDS. Every
currency still gets taxable totals by head, plus a CSV of them to hand
to an accountant.
Recurring titles
{MMM} and {MMMM} follow AppLocale.date, as formatDate() already does,
so a month token and {DATE} in the same title always spell the month
the same way. With the default en_IN locale, output is unchanged
("Sept").
Found on the way
The line sheet disposed its draft as soon as its future resolved, while
the sheet was still sliding away and rebuilding its fields with that
draft's controllers. It now disposes once the sheet has actually gone.
The Type, Head and Purpose pickers use the width they already have
(isExpanded), so a long label stays inside the field on a 360dp phone
with a large system font.
Tests: 26 new, 323 in total, all passing. Widget tests run at 360dp
against light fakes of the two Firestore controllers. The non-INR
expectations fail on the previous code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4
* Tidy what the merged currency work left behind
One financial-year lookup on the workspace controller replaces twenty
scattered 'fyStartMonth ?? 4' fallbacks; the line editor calls a tax line
'Tax' outside INR, as the ledger already did; the login tagline and the
More screen stop assuming rupees.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4
* Read world statement formats; draw any currency and script in PDFs
Statement import:
- Amounts had every comma stripped, so a European "1.234,56" imported as
1.23456 and "12,50" as 1250, with no error. ColumnMapping now carries a
decimal separator, detected from the figures in the amount, debit, credit
and balance columns. Each figure votes only when its shape is decisive
(both marks: the last is the decimal; a repeated mark is grouping; a lone
mark with 1, 2 or 4+ digits after it is the decimal); "1,234" and "1.234"
abstain, and ',' wins only outright, so Indian, US and British files read
exactly as before. buildImportRows detects it itself when the mapping has
none, and parseAmountText refuses a figure carrying the other mark's
decimals rather than misreading it.
- Currency signs (any Unicode Sc sign, with prefixes such as S$ and HK$) and
the ISO codes the app knows are stripped from amount cells, as whole words
only. Trailing-minus debits ("1.234,56-"), U+2212 minus and space or
apostrophe digit grouping are read.
- Delimiter detection now picks the delimiter that splits most lines into the
same number of fields, not the one that occurs most often, so a ';' file
whose descriptions and decimal commas hold as many commas still splits on
';'.
- A new mapping breaks date-order ties the way the phone writes dates
(en_US month-first, otherwise day-first); saved profiles keep their order.
- The mapping step shows the detected decimal mark, lets it be changed, and
saves it in the per-account profile. The row editor reads "12,50" as 12.50.
PDFs and messages:
- The khata and tax-pack PDFs are set in an embedded Noto Sans (static
Regular and Bold instances of google/fonts' variable Noto Sans with Noto
Sans Arabic merged in, subset to Latin, Devanagari, Arabic and the
currency signs, no hinting or layout tables; about 139 KB a weight, OFL
licence alongside). Helvetica could draw neither the rupee sign nor any
non-Latin name.
- Money in the PDFs and in the share and reminder messages follows the
workspace currency's symbol, decimals and grouping; the 'Rs' and en_IN
special cases are gone, and pdfSafe no longer turns the rupee sign into
'Rs '.
- Text is laid out with a text direction set, which is what makes Syncfusion
join Arabic letters and order them right to left. Devanagari draws but is
not shaped (conjuncts show the virama); the short-i sign is reordered so
simple syllables read right. Characters outside the font draw as '?'
rather than vanishing. Text bounds no longer have fixed heights, since
Syncfusion silently drops a line taller than its bounds.
- PdfTypeface.load() reads the font; a PDF built before it has loaded falls
back to Helvetica, with ISO codes for the signs Helvetica lacks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4
* Guard against hardcoded rupees, load the PDF font up front, 1.12.0
A source scan fails the suite if any screen falls back to INR, prefixes an
amount with a literal rupee, or assumes an April financial year. The PDF
font is read at startup and awaited before each PDF is built, so the first
PDF in a session carries the currency's own sign.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4
---------
Co-authored-by: Claude <noreply@anthropic.com>
NizKhata Android v1.11.6
Withdraw a card bill the ledger no longer supports (#120)
Reported from a real workspace: a Flipkart Axis card showing "Nothing to
pay" on its statement of 14 Sept, while the dues list asked for
₹2,233.00 against that same statement.
The card screen was right. On 14 Sept the card was ₹1,561 in credit:
626 + 608 + 1,584 − 585 − 3,794 = −1,561
The bill was right too, when it was raised — 2,233 is that sum without
the 3,794 cashback, which was entered afterwards and dated back inside
the cycle. What was wrong was that nothing reconciled the two.
statementDuePlans keeps an unpaid bill in step with the ledger, but the
"nothing owed" check ran BEFORE the existing bill was looked up:
if (owed <= 0.005) continue; // returns here
final existing = byId[id]; // never reached
So while the figure stayed positive the bill self-corrected, and the
moment the statement fell to zero or into credit it was abandoned at
whatever it last said — asking for money that was never owed, and
inflating the payable total by exactly that.
The check now happens first: a bill standing against a statement that
owes nothing is withdrawn, status cancelled, the same mechanism the
manual Cancel action uses, so it leaves both the dues list and the
payable total.
Two bills are deliberately left alone. One already cancelled, and one
that has been part paid — money has moved against that, and withdrawing
it quietly would hide the fact rather than surface it.
Six tests carry the reported figures: 2,233 at the moment it was raised,
−1,561 once the cashback lands, the withdrawal itself, and the two cases
that must not be touched. Removing the fix fails the withdrawal test.
277 mobile tests pass.
Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4
Co-authored-by: Claude <noreply@anthropic.com>
NizKhata Android v1.11.5
Close four findings from the pentest of the released build (#119) Tested v1.11.4 as shipped (sha256 a5e9d489…, the release asset), with androguard and a hand-written AXML parser rather than guesswork. What the binary says about itself drove these fixes. The ledger was being backed up in the clear. The manifest sets no allowBackup, so Android's default of TRUE applied, and Firestore keeps the entire ledger in an unencrypted offline cache. Between them, an adb backup or a device transfer carried the lot off the phone. Now allowBackup="false" plus a data_extraction_rules.xml that excludes both the cloud-backup and device-transfer transports. Three rules holes, each of which the rules matrix now covers: An unverified email was treated as an identity. authEmail() read request.auth.token.email without checking email_verified, and it gates invite claiming for both workspaces and the shared ledger. The client only offers Google today, whose emails are verified — but the client is not the boundary. Enable any other provider on the project and an invite addressed to someone else becomes claimable by anyone who can register that address. members.invite was a route to promotion. Its holder could set ANY non-owner membership's roleId, their own included, so a custom "Manager" role with members.invite and no roles.manage could raise itself to Admin. It may still admit and re-role others; it may no longer re-role itself. System roles could be renamed. isOwnerRole() identifies the Owner role by name and isSystem, and roles.manage could change both — disarming the one guard that keeps the Owner role on the workspace owner. Permissions stay editable; the identity does not. Minting new system roles is now the owner's alone, since delete refuses isSystem and anyone could otherwise leave undeletable junk. scope.own was advisory. It gated reads only, so a role holding it alongside a module's .manage permission could read just its own records while editing and deleting everybody's. scopedWrite now applies the same gate to create, update and delete on transactions, dues, debts and contacts. 38 rules tests pass against the Firestore emulator, 9 of them new. Reverting firestore.rules fails 6 of them, so they are load-bearing. Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4 Co-authored-by: Claude <noreply@anthropic.com>
NizKhata Android v1.11.4
Release builds are obfuscated, and CI proves it every time (#118) The release build never passed --obfuscate, so the AOT snapshot shipped with this repo's own source paths in it: screens/debt_detail.dart, data/mutations.dart, data/permissions.dart, 78 of them. That is a map of the data model and of the permission checks the client believes in, handed to anyone who unzips the APK. It is the obvious starting point for probing the Firestore rules for a gap the client happens to cover and the server does not. The build now runs with --obfuscate --split-debug-info=build/symbols. A flag is easy to lose in a later edit and nothing would look wrong, so the pipeline no longer takes it on trust. A step after the build unzips the APKs, reads libapp.so, and greps it for this repo's own source paths, read off the source tree at build time so the check cannot drift out of step with it. Any hit fails the release and names the paths. Verified both ways against a synthetic binary: it fails on one carrying the paths and passes on one without. The symbols are what makes an obfuscated stack trace readable again, so they are kept as a build artifact for 90 days, keyed by commit. They are deliberately NOT attached to the release: publishing them beside the APK would hand back exactly what obfuscating it removed. Worth being plain about the limit: this raises the cost of reading the app, it is not a security boundary. The boundary is the Firestore rules, which run on the server and have their own test matrix in CI. Obfuscation buys time against someone mapping the app; it does not make a weak rule strong. Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4 Co-authored-by: Claude <noreply@anthropic.com>
NizKhata Android v1.11.3
One figure for what is in the bank, on both screens that show it (#117) The dashboard tile was changed to count bank accounts only. The workspace summary on More was not, because it had its own fold over every account rather than sharing the dashboard's. So the same idea read ₹85.2K on one screen and (₹75,836.28) on the other, the difference being a drawn credit card. Both now call bankBalanceTotal, and both are labelled "In bank accounts", because two lines that sound like the same thing must not be able to disagree. The "Accounts" count above it still counts every account. That is a count, not money, so there is nothing for it to contradict; and net worth in Reports is still where a card's balance nets off against what you hold, which is the figure that question actually belongs to. A test walks the screens and fails on a hand-rolled fold over every account balance, since a third copy would be this same bug again. Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4 Co-authored-by: Claude <noreply@anthropic.com>
NizKhata Android v1.11.2
The dashboard's headline counts what is in the bank (#116) "In accounts" added up every account, so a drawn credit card subtracted from it. A figure meant to answer "how much have I got" was quietly reduced by money owed, which is the opposite of what it is for. It now counts bank accounts only. Cash in hand is out too: it is real money, but it is not in an account, and the question the tile answers is about the bank. An overdrawn bank account still counts against it, since being in the red at the bank is genuinely less money in the bank. Renamed to "In bank accounts" to match. Leaving it as "In accounts" over a bank-only figure would be a quiet lie the moment a workspace has a card or a cash account in it. Net worth is untouched. That is the figure where a card's balance SHOULD net off, and it still does. Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4 Co-authored-by: Claude <noreply@anthropic.com>
NizKhata Android v1.11.1
A repaid debt reads as settled, and the Undo bar goes away (#115)
* A repaid debt reads as settled, and the Undo bar goes away
Three things, two of them bugs visible on one screen.
The Outstanding filter was reading the debt's stored `status` field, which
is only written when a debt is settled through the repayment sheet. Clear
a debt any other way — a repayment line typed on an ordinary transaction,
an imported statement, an edit — and the field still says "open" while
the balance says zero. A fully repaid informal loan sat in the
Outstanding list showing a dash for its balance.
Status now follows the money. The stored field can still close a debt
early, for one written off or settled in kind, but it can no longer keep
a cleared one open. That is the same rule dues already use, and it fixes
the filter, the status badge on the row, the badge on the contact page,
the detail sheet, and the open-debt count.
An overpaid debt deliberately still counts as outstanding: a negative
balance is a discrepancy worth seeing, not something to file away.
The "Due deleted / Undo" bar never went away, and Flutter is why:
persist = persist ?? action != null;
Any SnackBar with an action defaults to never timing out, so the six
second duration this bar was given had been ignored since the day it
gained an Undo button. It now passes persist: false, and carries a close
button, because a bar that can linger needs a way out that is not a
guessed swipe. A test pins the framework default too, so if that ever
changes the workaround can go.
On the accounts list, a tap now opens the ledger rather than the metadata
sheet. That sheet has not been dropped: it moves into the long-press menu
alongside the account's other actions, where it is reachable rather than
being what a tap lands on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4
* Leave the accounts list alone: swipe already opens the ledger
Tap was changed to open the ledger, which duplicated the left swipe that
already does exactly that, and cost the metadata sheet its place. Both
are back as they were: tap opens the account's details, swipe left opens
the ledger, and the long-press menu is unchanged.
The two fixes this branch carries are untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4
---------
Co-authored-by: Claude <noreply@anthropic.com>
NizKhata Android v1.11.0
A card statement with a Debit/Credit column imports instead of readin…
NizKhata Android v1.10.2
A card bill that has already been paid is not raised again (#113) Setting up a billing cycle mid month raised last month's bill, because the live statement at that point is the previous one. That is right when the bill is genuinely unpaid, and wrong the rest of the time: most people have paid it weeks ago, so what landed was a demand for money already sent, dated in the past, arriving overdue on the day they set the feature up. Before raising a bill for the first time, payments credited to the card since the statement date are counted against it, and it is skipped when they cover it. Credits only. Purchases made after the statement are not netted off, because paying a bill settles it whatever you spend the next day; that spending belongs to the next statement. A refund counts, as it does at the bank. This only decides whether the bill is raised at all. Once it exists its amount stays what was billed, and part payments are recorded against it in the usual way rather than quietly shrinking what it asks for. Four tests around the day a cycle is set up: an unpaid bill from before that day is still raised, a paid one is not, a short payment leaves it standing at the billed amount, and spending after a full payment does not revive it. Two of them fail without the check. Claude-Session: https://claude.ai/code/session_016jkAcgzuuxTfBiAgMLWgC4 Co-authored-by: Claude <noreply@anthropic.com>
NizKhata Android v1.10.1
A settled debt is not a payable, and the action button stops sitting …