Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

One merchant account, four side projects: how I keep the payment data from turning into a mess

Most indie developers I know do not run one product. They run a VPN bot, a paid Discord or Telegram community, a small template shop and maybe a script nobody remembers launching. Each of them takes money. Sooner or late

Most indie developers I know do not run one product. They run a VPN bot, a paid Discord or Telegram community, a small template shop and maybe a script nobody remembers launching. Each of them takes money. Sooner or later someone asks a very boring question: how much did the template shop actually make in September, after refunds?

If all four projects share one payment account and one webhook handler, that question can eat an evening. This post is about structuring things so it takes a minute instead. I will use the Buhgalter payment API as the concrete example, because it has a notion of storefronts built in, but the design ideas apply to most providers.

Storefronts instead of string prefixes

The tempting shortcut is to put the project name into the order reference: vpn-10231, shop-554. It works until someone renames a project, or a junior developer forgets the prefix, or you need to group by project in a report the provider generates for you.

Buhgalter lets one merchant cabinet hold several storefronts, each with its own store_id. Revenue is split by storefront in the reports, so the provider already knows which project a payment belongs to. Your own database should mirror that:

CREATE TABLE storefronts (
  id          TEXT PRIMARY KEY,   -- store_id from the cabinet
  title       TEXT NOT NULL
);

CREATE TABLE payments (
  id            TEXT PRIMARY KEY, -- provider payment id
  store_id      TEXT NOT NULL REFERENCES storefronts(id),
  reference     TEXT NOT NULL,    -- your own order id
  amount_rub    NUMERIC NOT NULL,
  status        TEXT NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL,
  UNIQUE (store_id, reference)
);

The unique constraint on (store_id, reference) matters. Two projects can both have an order number 1001 without colliding, and a retried request inside one project still lands on the same row.

One webhook endpoint or several?

I tried both. A single endpoint that did all the work with a switch on the storefront felt neat for a week, then one project needed a slow side effect (generating a license file) and started delaying confirmations for the others.

What works better for me: the receiving endpoint stays tiny and identical for every project, and each project gets its own worker behind a queue. The receiver does three things.

  1. Checks the X-Buhgalter-Signature header, an HMAC-SHA256 of the body, before parsing anything.
  2. Writes the event to the payments table using the unique key above, so a duplicate delivery becomes a no-op.
  3. Returns 200 fast and drops a job into that project's queue, picked by store_id.

The provider retries failed deliveries up to 8 times over roughly 13 hours and keeps a delivery log you can resend from. That is generous, but it is not a reason to be slow. A receiver that answers in 50 ms never needs the retries in the first place.

import hmac, hashlib

def verify(body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Currency per project, decided once

Payments come in as rubles through SBP, an NSPK QR code or a card number transfer. The balance on the merchant side is kept in USDT by default or in rubles if you switch it, and the rate is locked when the payment is created.

For multi project setups I store the locked rate on every payment row. It sounds redundant until the day you need to explain why the VPN project's September revenue in USDT does not match a naive conversion at the end of month rate. With the rate on each row, the explanation is a SUM.

You can also pull the current USDT/RUB rate with GET /api/fx for dashboards, but never for historical numbers. History lives in your table.

Refunds belong to the same storefront

Refunds can be full or partial and the platform does not charge a fee on them. The mistake I made early on was logging refunds in a separate global table. Then the template shop looked more profitable than it was, because its refunds were counted against nobody in particular.

Put store_id on refunds too, and compute net revenue per storefront:

SELECT p.store_id,
       SUM(p.amount_rub) FILTER (WHERE p.status = 'paid') AS gross,
       COALESCE(SUM(r.amount_rub), 0)                     AS refunded
FROM payments p
LEFT JOIN refunds r ON r.payment_id = p.id
WHERE p.created_at >= date_trunc('month', now() - interval '1 month')
  AND p.created_at <  date_trunc('month', now())
GROUP BY p.store_id;

Month end without the spreadsheet archaeology

The provider side gives you reports with created, paid, conversion, turnover, fee, credited and refunds, with days counted on Moscow time, plus Excel exports and a CSV aimed at 1C accounting software. Watch out for the Moscow day boundary if your server runs in UTC: a payment at 01:30 Moscow time is still "yesterday" in a UTC query. I keep a generated column with the Moscow date so my numbers and the export line up row for row.

Once a month I diff my per storefront totals against the export. When they match, I am done. When they do not, the culprit has always been one of three things: a webhook that never got processed, a refund filed under the wrong project, or the timezone boundary above.

Testing without real money

Every project gets its own test run before going live. Requests with the X-Buhgalter-Test header create test payments, up to 200 a day, which is plenty for a full flow per storefront: create, pay, receive the webhook, refund, check the report line.

What I would tell myself two years ago

  • Give every project its own storefront from day one, even the tiny ones.
  • Make the database key (store_id, reference), not just reference.
  • Verify the signature first, store the event second, do the slow work last.
  • Save the locked rate on every payment.
  • Reconcile monthly against the provider export, on Moscow dates.

None of this is clever. It just turns "how much did project X make" into a query instead of a weekend.

If you want to see how storefronts, webhooks and exports look on the provider side, the docs are public at https://buhgalter.app/docs and the service itself is at https://buhgalter.app.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.