Skip to content
heapbyte - A name of excellence

Architecture · 22 September 2026

A balance is a cache: paying money out of a Shopify business

Two payout requests, four seconds apart, from the same seller, for the same nine hundred kronor. A team leader submits one from a phone at a match; the club treasurer submits the other from a laptop, neither knowing about the other. Both read the balance. Both see nine hundred available. Both are approved the following Tuesday by somebody working down a queue. Eighteen hundred kronor leaves an account that held nine hundred, and nothing in the system is wrong — because the system has no opinion about money it has already promised.

7 min read
Written by the HeapByte engineering team

A balance is a cache paying money out of a Shopify business

Getting the seller onto the order is the easy half

It is worth clearing this out of the way, because it is the part people expect to be hard and it is not. A seller identifier can ride from a QR scan through to a finished order using mechanisms Shopify supports directly: attributes set on a cart carry over to the resulting order, cart permalinks accept a ref parameter that surfaces as a referral code in the order's conversion summary, and CustomerVisit records the referral source and UTM parameters alongside it.

None of that requires the buyer to create an account, which matters when the buyer is somebody's aunt buying a tub of biscuits from a fundraising link and will not be back for a year. Use the attribute. It is the boring, correct answer, and the order arrives knowing who sold it.

The problem starts one step later, when the business now owes somebody money.

Shopify will pay a creator. It will not pay a club

Shopify does have a payout mechanism, and an article that pretended otherwise would be wrong. Shopify Collabs pays affiliate commissions to creators automatically, on the merchant's schedule, through a Hyperwallet account on the creator's side, with a 2.9% processing fee on each automatic payment. Shopify Product Network commissions accrue monthly and pay out after month end. If your arrangement is a brand paying individual creators for referred sales, that is built, supported, and cheaper than anything you would write.

It stops being the right shape when the counterparty is not an individual with a Hyperwallet account. A sports club is an organisation with teams inside it, leaders who can act for those teams, members who sell, and people who belong to more than one. Money is owed to the club, or to the team, or to the seller, depending on the arrangement — and somebody at the club has to approve a request before it is paid, because that is how the club's own governance works.

Shopify's clearest statement on this is in the multi-entity documentation: you cannot use it to split payments between businesses or manage commission-based arrangements between separate business owners. That is the boundary, in Shopify's words.

A balance is a cache

Here is the part that looks like a data-modelling preference and is actually a correctness requirement.

The obvious design stores a balance on each seller: add to it when an order is attributed, subtract from it when a payout goes out. It is simple, it is fast to read, and it is wrong for two reasons that only show up once real money moves through it.

The first is that a stored balance has no history. When a seller disputes their figure — and they will, because the figure is their money — you can tell them what it is but not how it got there. Every correction that was ever applied has already been absorbed into a single number, and the reasoning is gone. A ledger inverts that: adjustments and payouts are records, the balance is derived by summing them, and the answer to why is it this much is a list.

The second is the one at the top of this article. If a balance is only decremented when a payout is paid, then the window between requesting and paying is a window in which the money appears to be available twice.

js
const COUNTS_AGAINST_BALANCE = new Set(["requested", "approved", "paid"]);

/**
 * What this seller could request right now.
 *
 * A payout that is merely *requested* already counts against the balance. That
 * is the whole reservation: the money is spoken for the moment someone asks for
 * it, not when it is approved, because approval happens later and by a human.
 */
export function availableMinor(entries) {
  let total = 0;

  for (const e of entries) {
    if (!Number.isInteger(e.amountMinor)) {
      throw new Error(`amountMinor must be an integer, got ${e.amountMinor}`);
    }

    if (e.type === "earning" || e.type === "adjustment") {
      total += e.amountMinor;
    } else if (e.type === "payout") {
      // A rejected request releases its reservation. A paid one never does.
      if (COUNTS_AGAINST_BALANCE.has(e.status)) total -= e.amountMinor;
    } else {
      throw new Error(`unknown entry type: ${e.type}`);
    }
  }

  return total;
}

/**
 * Build the entry that records a payout request.
 *
 * Returns a new entry to append. It never mutates a balance and never edits an
 * existing row, because the history is the record — a corrected balance tells
 * you what somebody thinks is true now, and a ledger tells you how it got there.
 */
export function requestPayout(entries, amountMinor, { reference }) {
  if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
    throw new Error(`payout amount must be a positive integer, got ${amountMinor}`);
  }
  if (!reference) throw new Error("a payout request needs a reference");

  const available = availableMinor(entries);
  if (amountMinor > available) {
    throw new Error(`payout of ${amountMinor} exceeds available balance of ${available}`);
  }

  return { type: "payout", status: "requested", amountMinor, reference };
}
The reservation is the reason requested sits in that set alongside approved and paid. A payout is money spoken for from the moment somebody asks, not from the moment somebody agrees — and the gap between those two events is however long it takes a human to open the admin. Without it, two requests submitted in that window each read the same balance and both pass. Amounts are integer minor units throughout: a balance that has been through a float is a balance somebody will eventually dispute, and they will be right.

Three words of set membership

The reservation is three words of set membership and it is the entire defence.

The hierarchy does not fit in tags

There is a tempting shortcut here worth naming, because it is the first thing most people reach for: encode the structure in Shopify customer tags. club:westside, team:u14, role:leader. It works for about a month.

It fails because tags are a flat list of strings with no relationships, no history and no constraints. A person on two teams becomes ambiguous. A team moving between clubs becomes a find-and-replace across customer records. There is nowhere to put the things that are true of a team rather than of a person, and nowhere to record that a leader's authority started in March. The case study's own lesson is that organisational hierarchies should not be encoded in Shopify customer tags alone, and the operative word is alone — tags are fine as a denormalised label for filtering, and hopeless as the source of truth.

So the structure lives in the application's own tables, and Shopify holds the order and the attribute that points back into it. That division — Shopify owns the sale, the application owns the relationships — is the same division that governs where a computed price is allowed to live, read from the other end.

What this does not do

It does not move money. The ledger records what is owed and what has been requested, approved and marked paid; the payment itself happens through a bank or a payment provider, and marking a payout paid is a human asserting that it went out. Closing that loop automatically is a separate build with its own compliance surface, and for most operations at this scale it is not worth it.

It is not accounting. An append-only ledger of obligations is not double-entry bookkeeping, it does not know about tax, and the finance system remains authoritative for anything that reaches a return. Say so in writing before somebody assumes otherwise.

And it does not settle who is owed what. The ledger enforces that the arithmetic is honest and the history survives. Whether a team's share is forty percent or half, and whether a leader can approve their own request, are decisions the organisation makes and the software records.

When this needs an engineer

Often it does not. If you are paying individual creators a commission on referred sales, Shopify Collabs does it and the 2.9% is almost certainly cheaper than building. If you have a handful of affiliates and pay them by bank transfer once a quarter from a spreadsheet, that is a proportionate answer and the spreadsheet is not the problem. If nobody has ever disputed a figure, you do not yet have the problem this solves.

It becomes engineering when the counterparty has structure, when approving a payment is a workflow rather than a decision one person makes, and when somebody will eventually ask how a number was arrived at and deserves a better answer than the current value of a field. That was the case for a fundraising platform attributing purchases to individual sellers across clubs and teams — the seller QR platform sits among our other custom Shopify applications, and it is custom Shopify app development rather than configuration because the thing being modelled is an organisation, and Shopify models a shop.

Send us the store and the symptom.

Insights

Apply this to your store.

An audit turns the general principle into a specific list of changes, ordered by what actually pays back.