6 min read

Building a Correct Inventory System on a Double-Entry Ledger

Line drawing of a closed cube labeled 10 next to an open cube labeled 2

Most marketplaces need sellers. Without them, there is nothing to sell, and buyers have no reason to sign up. A seller stays when they trust you with their business and their growth. A lot of that trust shows up in inventory: what you list, what you promise a buyer, and whether they can actually fulfill it.

Get the stock wrong and their reputation takes the hit. Revenue goes down, growth stalls, and they leave or switch to another platform.

For some developers, v1 is usually an items table with a qty column. Checkout subtracts, a return adds. Maybe later, you split the quantity field into available and reserved, but that only tells you what the system knows right now. When a seller argues with the number or disputes an order, you open the row, see qty = 31, and cannot walk them through what happened or how you arrived at 31.

In this article, you’ll learn how to correctly track and manage inventory using a double-entry ledger: one balance per state for each item, every change as a move between those states, and a snapshot when a seller asks what happened.

Setting up your ledger design

If you’re using an items table, it’ll look something like this:

reduceAvailableStockOnSale.ts
async function reduceAvailableStockOnSale(sellerId, sku, qtySold) {
  await db.query(
    `
    UPDATE items
    SET qty = qty - $1
    WHERE seller_id = $2
    AND sku = $3
    AND status = 'available'
    `,
    [qtySold, sellerId, sku],
  );
}

await reduceAvailableStockOnSale('sel_9f2a', 'IP16-BLK-128', 3);

The problem is that most inventory units (SKU) are not all in one state. Of the same SKU you can have some available, some pending delivery, and some returned but not yet back on the shelf. One items table cannot hold all that. The usual fix is adding another table per state or keeping audit logs, and the query that fetches where the stock is gets harder every time your product expands.

This is where a ledger becomes necessary. It allows you to track each state per SKU per seller. For example, if an SKU had 30 available and sold 5 to different buyers, a ledger records each sale as a transfer from the SKU AVAILABLE balance to its SOLD balance.

In Blnk that looks like this:

  1. Use a ledger to group states together

    A ledger is a group of balances. Create one for each state in your system: available, sold, in transit, delivered, and returned. With this design, reading the available ledger gives you the available stock across all SKUs in your product.

    Request
    const availableLedger = await blnk.Ledgers.create({
      name: 'SKUs Available',
    });
    
    const soldLedger = await blnk.Ledgers.create({
      name: 'SKUs Sold',
    });
    
    const inTransitLedger = await blnk.Ledgers.create({
      name: 'SKUs In-transit',
    });
    
    const deliveredLedger = await blnk.Ledgers.create({
      name: 'SKUs Delivered',
    });
    
    const returnedLedger = await blnk.Ledgers.create({
      name: 'SKUs Returned',
    });
    200 OK
    {
      "ledger_id": "ldg_82da565c-b912-45dd-a148-9e7ce8f09000",
      "name": "SKUs Available",
      "created_at": "2026-03-12T09:14:18.558281542Z"
    }
  2. Create an identity per SKU

    Use identities to register SKUs in the ledger. An SKU refers to the version of an item you count on its own, e.g. an iPhone 16 Black and iPhone 16 White are two SKUs, so they get two identities, and are tracked as separate inventory.

    Request
    const sku = await blnk.Identity.create({
      identity_type: 'organization',
      organization_name: 'iPhone 16 Black 128GB',
      category: 'electronics',
      meta_data: {
        seller_id: 'sel_9f2a',
        item_code: 'IP16',
        sku: 'IP16-BLK-128',
        color: 'black',
        storage: '128GB',
        seller_details: {
          first_name: 'Kemi',
          last_name: 'Adeyemi',
        },
      },
    });
    200 OK
    {
      "identity_id": "idt_5cd5a916-b755-4cf0-8991-99ec040df268",
      "identity_type": "organization",
      "organization_name": "iPhone 16 Black 128GB",
      "category": "electronics",
      "created_at": "2026-03-12T09:14:22.118244338Z",
      "meta_data": {
        "seller_id": "sel_9f2a",
        "item_code": "IP16",
        "sku": "IP16-BLK-128",
        "color": "black",
        "storage": "128GB",
        "seller_details": {
          "first_name": "Kemi",
          "last_name": "Adeyemi"
        }
      }
    }
    FieldWhat it does
    seller_idScopes this SKU to the seller or store that listed it. Details managed in a separate table.
    item_codeThe parent item. Pull every iPhone 16 regardless of color, seller, etc.
    skuThis version only. White 128GB would be IP16-WHT-128.
  3. Use balances to track each state

    Each state becomes a balance on the same SKU identity. This iPhone 16 Black 128GB becomes one identity with five balances in their respective ledgers (step 1): available, sold, in transit, delivered, and returned.

    When units move from one state to another, you record a movement between those balances.

    Request
    const available = await blnk.LedgerBalances.create({
      ledger_id: availableLedger.data.ledger_id,
      identity_id: sku.data.identity_id,
      currency: 'UNIT',
    });
    
    const sold = await blnk.LedgerBalances.create({
      ledger_id: soldLedger.data.ledger_id,
      identity_id: sku.data.identity_id,
      currency: 'UNIT',
    });
    
    const inTransit = await blnk.LedgerBalances.create({
      ledger_id: inTransitLedger.data.ledger_id,
      identity_id: sku.data.identity_id,
      currency: 'UNIT',
    });
    
    const delivered = await blnk.LedgerBalances.create({
      ledger_id: deliveredLedger.data.ledger_id,
      identity_id: sku.data.identity_id,
      currency: 'UNIT',
    });
    
    const returned = await blnk.LedgerBalances.create({
      ledger_id: returnedLedger.data.ledger_id,
      identity_id: sku.data.identity_id,
      currency: 'UNIT',
    });
    200 OK
    {
      "balance_id": "bln_07bd1f8c-80e4-4109-9df7-3a98e2352b71",
      "ledger_id": "ldg_82da565c-b912-45dd-a148-9e7ce8f09000",
      "identity_id": "idt_5cd5a916-b755-4cf0-8991-99ec040df268",
      "currency": "UNIT",
      "balance": 0,
      "created_at": "2026-03-12T09:14:26.238244338Z"
    }
    FieldWhat it does
    ledger_idWhich group this balance sits in.
    identity_idWhich SKU does this belong to.

Record movement between states

With your ledger set up, you can start to manage your inventory as a movement between states. Let's use a sale as our example.

Let's say you sell 2 units of the iPhone 16 in one checkout. The full journey goes from available to sold, in transit, and finally delivered. Between each state is a bit of delay and required conditions that must be met.

  1. Record the three movements as a bulk

    With Blnk, we'll record the group of movements as a single bulk request:

    Request
    const orderId = 'ord_4412';
    
    const sale = await blnk.Transactions.createBulk({
      atomic: true,
      inflight: true,
      transactions: [
        {
          amount: 2,
          precision: 1,
          currency: 'UNIT',
          reference: `${orderId}_sold`,
          source: available.data.balance_id,
          destination: sold.data.balance_id,
          description: 'Checkout paid',
          meta_data: {
            order_id: orderId,
            sku: 'IP16-BLK-128',
          },
        },
        {
          amount: 2,
          precision: 1,
          currency: 'UNIT',
          reference: `${orderId}_in_transit`,
          source: sold.data.balance_id,
          destination: inTransit.data.balance_id,
          allow_overdraft: true,
          description: 'Picked up for delivery',
          meta_data: {
            order_id: orderId,
            sku: 'IP16-BLK-128',
          },
        },
        {
          amount: 2,
          precision: 1,
          currency: 'UNIT',
          reference: `${orderId}_delivered`,
          source: inTransit.data.balance_id,
          destination: delivered.data.balance_id,
          allow_overdraft: true,
          description: 'Customer confirmed delivery',
          meta_data: {
            order_id: orderId,
            sku: 'IP16-BLK-128',
          },
        },
      ],
    });
    200 OK
    {
      "batch_id": "bulk_c62f200b-905f-4983-a349-cadd279234aa",
      "status": "inflight",
      "transaction_count": 3
    }
    FieldWhat it does
    atomic: trueAll three succeed together or none of them do. If available is short, none of the legs post.
    inflight: trueReserves each move until its condition is met. Available → sold waits on payment, but the 2 units are held so another checkout cannot take them.
    allow_overdraftOn sold → in transit and in transit → delivered only. Available cannot go negative.
    source / destinationRequired. You can reconstruct where the seller’s units went.
  2. Commit each state when it is done

    Things happen before a state changes. Inflight holds each transaction until they do, then you apply it on the ledger.

    MoveBefore you commit
    Available → SoldCustomer starts checkout. Customer makes payment. Payment succeeds.
    Sold → In transitItem picked up. Item packaged and handed over to the delivery partner. Delivery processing starts.
    In transit → DeliveredPartner delivers the item. Customer confirms delivery. Partner confirms delivery.

    For each movement, you commit when all its conditions are met.

    commitSold.ts
    const soldMove = await blnk.Transactions.getByReference(`${orderId}_sold`);
    
    await blnk.Transactions.updateStatus(soldMove.data.transaction_id, {
      status: 'commit',
    });

    _in_transit and _delivered are the same call when those conditions are met.

  3. Correcting a mistake

    Say the amount was 3, not 2. You do not edit the inflight record. Void the bulk. That releases the reserved units and resets the balances. Then you write a new transaction with the right amount.

    The ledger keeps both writes, so you can show the error and the fix. An items table would have overwritten the number and left no trail.

    voidAndRetry.ts
    await blnk.Transactions.updateStatus(sale.data.batch_id, {
      status: 'void',
    });
    
    const retryId = `${orderId}_v2`;
    
    const sale = await blnk.Transactions.createBulk({
      atomic: true,
      inflight: true,
      transactions: [
        {
          amount: 3,
          precision: 1,
          currency: 'UNIT',
          reference: `${retryId}_sold`,
          source: available.data.balance_id,
          destination: sold.data.balance_id,
          description: 'Checkout paid',
          meta_data: {
            order_id: retryId,
            sku: 'IP16-BLK-128',
          },
        },
        {
          amount: 3,
          precision: 1,
          currency: 'UNIT',
          reference: `${retryId}_in_transit`,
          source: sold.data.balance_id,
          destination: inTransit.data.balance_id,
          allow_overdraft: true,
          description: 'Picked up for delivery',
          meta_data: {
            order_id: retryId,
            sku: 'IP16-BLK-128',
          },
        },
        {
          amount: 3,
          precision: 1,
          currency: 'UNIT',
          reference: `${retryId}_delivered`,
          source: inTransit.data.balance_id,
          destination: delivered.data.balance_id,
          allow_overdraft: true,
          description: 'Customer confirmed delivery',
          meta_data: {
            order_id: retryId,
            sku: 'IP16-BLK-128',
          },
        },
      ],
    });

Extract any snapshot from your ledger

Let's say the seller raises a dispute and asks how their available got to 31. With your ledger, you can replay from that date and show them every move that took it down to 31.

If it is a pending delivery, those units are on sold or in transit. They are not missing.

Filter on meta_data.sku and you get the full history:

filterHistory.ts
const history = await blnk.Search.filter(
  {
    filters: [
      {
        field: 'meta_data.sku',
        operator: 'eq',
        value: 'IP16-BLK-128',
      },
    ],
    sort_by: 'created_at',
    sort_order: 'desc',
    include_count: true,
  },
  'transactions',
);

If they claim their registered available inventory was 50 on April 1st, 2026, you can show them what your system actually had recorded on that day. Get the SKU's available balance at that timestamp:

getAt.ts
const atConfirm = await blnk.LedgerBalances.getAt(available.data.balance_id, {
  timestamp: '2026-04-01T09:00:00.000000Z',
});

Conclusion

The ledger does not replace your inventory management product. It is what makes that product reliable and defensible. The best architecture has your product logic — states, conditions, insights, and admin UX — built on top of a double-entry, immutable ledger that sits underneath and holds correctness at every step.

If you want to give it a try, start here: Blnk Cloud.

Related posts