◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Business
⟐ Business/2026-10-23Intermediate

Store Sales CSVs Come in Different Shapes: Reduce Them to Four Columns, Fix the Month-End Rate, and Keep the Raw Totals

A small Python script that reduces App Store and Google Play sales CSVs to four columns (date, app, amount, currency) and totals them by month and app. It never drops unreadable rows silently, fixes one exchange rate per month, and prints raw per-currency totals so you can reconcile.

sales reportingApp Store93Google Play41Pythonindie developer45monetization48

✦ Premium Article

Every month begins the same way: download sales CSVs from each store's console, paste them into a spreadsheet. With one app that is fine. As the number of apps grows, the fact that every store names its columns and writes its dates differently starts to cost real time.

One month, the total I had pasted did not match the total shown in the store console. The gap was small, and I could not tell where it had gone. In the end the cause was a refund row that I had removed by hand because it was negative. If you add numbers without any way to check that they are right, the mistake cannot be seen from the numbers alone.

What follows is the procedure I built after that: reduce each store's CSV to four common columns, convert at one fixed month-end rate, and print the raw per-currency totals next to the converted ones so the two can be reconciled. Column names in store CSVs can change, so the script is written on the assumption that you open your own CSV and edit the mapping.

Reduce everything to four columns

For monthly, per-app revenue you need only four things: when (the month), which app, how much, and in which currency. Store CSVs carry many more columns, but these four are enough.

Each store gets a small mapping saying "in this CSV, the date is in that column, the app name in this one". Adding a store means adding one entry.

Common columnWhat to watch for
MonthUse the first seven characters of the date. 2026/09/05 and 2026-09-05 both become 2026-09
AppDisplay names can differ between stores. If they do, map them to one name
AmountStrip thousands separators and hold the value as Decimal, not a float. Keep refunds negative
CurrencyUpper-case it. One CSV I handled had it in lower case

Holding amounts as Decimal keeps rounding from drifting from month to month after conversion. With a few dozen rows the error is invisible, but later, when a total fails to match, you can no longer trace the cause.

The whole script

Save this as merge_sales.py. It needs only a column mapping and a file of fixed exchange rates. The column names in MAPPING are examples. Open the first line of your own CSV and rewrite them to match.

"""store ごとに形式の違う売上 CSV を共通 4 列に寄せ、アプリ別・月別に集計する。"""
import csv, sys, json
from collections import defaultdict
from decimal import Decimal, InvalidOperation
 
# Edit this mapping by opening your own CSV (these names are examples)
MAPPING = {
    "storeA": {"date": "Date", "app": "App", "amount": "Net", "currency": "Cur"},
    "storeB": {"date": "Order Charged Date", "app": "Product", "amount": "Payout", "currency": "Currency"},
}
 
def load(path, store):
    m = MAPPING[store]
    rows, bad = [], []
    with open(path, newline="", encoding="utf-8-sig") as f:
        for n, r in enumerate(csv.DictReader(f), start=2):
            try:
                rows.append({
                    "month": r[m["date"]].strip()[:7].replace("/", "-"),
                    "app": r[m["app"]].strip(),
                    "amount": Decimal(r[m["amount"]].replace(",", "")),
                    "currency": r[m["currency"]].strip().upper(),
                    "store": store,
                })
            except (KeyError, InvalidOperation) as e:
                bad.append((path, n, repr(e)))   # never drop silently; keep the line number
    return rows, bad
 
def main(rates_path, *pairs):
    rates = json.load(open(rates_path))          # {"2026-09": {"USD": "150.0", "JPY": "1"}}
    totals, raw_sum, bad_all = defaultdict(Decimal), defaultdict(Decimal), []
    for p in pairs:
        store, path = p.split("=", 1)
        rows, bad = load(path, store)
        bad_all += bad
        for r in rows:
            rate = Decimal(rates[r["month"]][r["currency"]])   # a missing rate raises KeyError and stops
            totals[(r["month"], r["app"])] += r["amount"] * rate
            raw_sum[(store, r["currency"])] += r["amount"]
    for (month, app), v in sorted(totals.items()):
        print(f"{month}  {app:<12} {v:>12,.0f} JPY")
    print("-- raw totals per store and currency (for reconciliation)")
    for (store, cur), v in sorted(raw_sum.items()):
        print(f"{store}  {cur}  {v}")
    print(f"-- unreadable rows: {len(bad_all)}")
    for b in bad_all: print("  ", *b)
 
if __name__ == "__main__":
    main(*sys.argv[1:])

Exchange rates live in one JSON file, one entry per month. Why one fixed rate per month rather than per transaction date is covered below.

{"2026-09": {"USD": "150", "JPY": "1"}}

Run it by passing the rates file and store=path pairs.

python3 merge_sales.py rates.json storeA=a.csv storeB=b.csv
✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦You can turn sales CSVs whose column names and date formats differ by store into one four-column shape and build a per-app, per-month table in your home currency yourself
✦You can avoid scripts that silently drop unreadable rows or missing exchange rates, and check every month that the totals are right by comparing raw per-currency sums
✦You can stop copying numbers into a spreadsheet at the start of each month and close the previous month by running one script
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⟐ Business2026-04-20
From Rork Prompt to App Store Revenue: A Case Study
A practical record of taking a Rork app from planning through App Store review, launch, and revenue improvements: total costs, review pitfalls, and a stated-assumption estimate of ad revenue at 100 DAU.
⟐ Business2026-09-17
A review asks for a refund. Your answer changes depending on the store
On iOS you cannot refund a purchase as the developer. On Google Play you can, from Play Console, and it cannot be undone. Here is how I word each reply, and how I stopped forgetting to revoke access afterwards.
⟐ Business2026-08-20
August 31 Means Something Different Depending on Whether You Plan to Ship an Update
Two separate conditions land on Google Play's August 31 date: API 36 to submit an update, and API 35 to stay discoverable to new users. Here is how I sorted six apps, and who actually gets the extension form.