← blog

 /  3 min read

Offline desktop apps for a retail shop: ledger and receipts

The app that must never lose a sale.

A small retail business doesn't want a web app it has to keep alive. It wants something it double-clicks that never loses a sale. I built two Python desktop apps for one shop, and they taught me different things. They're client projects, so this is a tour of the decisions, not the code.

2desktop apps, one shop
3payment states: paid, partial, unpaid
6 digitsreceipt numbers, like 000042
GMT+3every timestamp, whatever the PC says
0installers, the app is a folder
1SQLite connection, WAL mode

The ledger: don't lose the data

The first app covers the day-to-day of the shop, backed by a local SQL database: inventory and stock, point-of-sale transactions for both cash and credit sales, customer accounts and payment tracking, daily earnings and expense logging, and PDF customer statements you can export.

With an app like this, the database file is the business, which drove two decisions. The first is a layered architecture that keeps data access, business logic and the UI separate, so changing a screen can't quietly break how a sale gets recorded. The second is an automatic backup every time the app starts. There's no schedule to configure or forget, the app just does it every time it opens.

UIscreens: sales, stock, customers, statementsbusiness logicwhat a sale, a payment, a stock change meansdata accessthe only code that talks to the databaselocal SQL databasethe file is the businessbackup on every start
The shape of the ledger app. A screen only talks to the layer below it, and the database file gets copied before anything else happens.

It's a plain desktop app on purpose: for a single shop, a local database and a reliable backup beat anything that needs a server to stay up.

The receipt app: same output every time

The second app is narrower. It produces receipts that look official, and it has to produce them the same way every time. You pick or type a customer, add line items (name, quantity, unit, price) and it writes a PDF. The interface is customtkinter, the PDFs are drawn with PyMuPDF, and PyInstaller packages it as a folder with an exe, so the shop installs nothing. There are three screens: new receipt, history, and saved customers.

The new receipt screen in the receipt app: a navy sidebar, customer fields, one line item row with quantity, unit and price, a green Save as PDF button and a total of 1,000.00 TL.
New receipt, filled with test data. The next number sits top right and is only spent once the PDF is saved. The total updates as you type.
The history screen: three summary cards (60 receipts, paid and pending totals), a search box and a table of receipts with customer columns blurred.
History. Three cards on top (receipts, paid, pending), a search that folds Turkish case, and one row per receipt with its status. Customer names, buildings and addresses are blurred.
The saved customers screen: a search box and a table with every row blurred, and buttons to load or delete the selected record.
Saved customers. A saved customer fills the new-receipt form in one click. Every row is blurred.

A pdf that grows to fit

Every receipt is drawn onto two fixed template halves, the header on top and the totals box at the bottom, with the middle generated. The height of the middle is computed from the items, and the page is simply the three parts stacked.

header (fixed template)items (generated)totals (fixed template)3 itemsone short pageheader (fixed template)items (generated)totals (fixed template)30 itemssame template, taller pagetop + middle + bottom
A three-item receipt and a thirty-item receipt come out of the same two templates. Only the middle changes.
middle_height = max(min_height, top_padding + content_height + bottom_padding) total_height = top_height + middle_height + bottom_height

Content_height comes from wrapping each row's text by measuring its real width in the embedded font instead of counting characters:

candidate = f"{current} {piece}" if font.text_length(candidate, fontsize) <= max_width: current = candidate # still fits on this line else: lines.append(current) # start a new line current = piece

Turkish characters need a real TrueType font embedded in the PDF, so the app looks for one in the Windows font folder once, caches the answer, and warns the user instead of quietly printing question marks.

Turkish is where the standard library stops helping

Python's upper() and lower() turn i into I and I into i, which is wrong in Turkish, where İ and ı are different letters. The app has its own casing helpers, and the history search folds case the Turkish way.

_TR_UPPER_MAP = str.maketrans({"i": "İ", "ı": "I"}) _TR_LOWER_MAP = str.maketrans({"İ": "i", "I": "ı"}) def tr_upper(text): return text.translate(_TR_UPPER_MAP).upper() def tr_lower(text): return text.translate(_TR_LOWER_MAP).lower()
inputpython defaultturkish-aware
upper of işIŞİŞ
lower of ISPARTAispartaısparta
title case of istanbulIstanbulİstanbul

Amounts are shown as 1.234,50 TL and parsed back from the same format. Formatting swaps the separators through a placeholder so the two replacements don't step on each other:

text = f"{quant:,.2f}" # 1,234.50 text = text.replace(",", "§").replace(".", ",").replace("§", ".") # 1.234,50

Every receipt also carries its total in words ("yazı ile tutar"), so there's a number-to-words converter for Turkish, from lira down to kuruş. 1234.50 becomes "Bin İki Yüz Otuz Dört Türk Lirası Elli Kuruş". Line totals and sums are computed with Decimal and half-up rounding, never floats.

Numbering and payments

Receipt numbers are sequential and kept in a one-row table. The app reads the next number, draws the PDF, and only commits the number after the file was written, so a failed export doesn't burn a number, and editing an old receipt keeps its own.

receipt_no = db.get_next_receipt_no() # read it, don't spend it yet ok = render_receipt_pdf(...) # draw and save the file if ok: db.commit_receipt_no(receipt_no) # only now the number is used up

Each receipt is paid, partially paid or unpaid, and partial payments store the amount, so the remaining balance is always known. From the history screen you can export a summary PDF for one customer with paid and remaining amounts per receipt, optionally only the unpaid ones.

Two small rules that saved me trouble. Timestamps are always stamped in GMT+3, whatever the computer's timezone is set to, so a wrong setting can't shift a receipt's date. And the database is one long-lived SQLite connection in WAL mode, with every write in its own transaction.

None of it is complicated on its own. The point is the boring guarantees: nothing lost, the same layout, the next number, the right total.