<!-- Source: https://blnkfinance.com/blog/building-an-inventory-system-with-a-ledger -->

[Blog](https://blnkfinance.com/blog) [Build](https://blnkfinance.com/blog/build)

Sep 11, 2026 6 min read

# Building a Correct Inventory System on a Double-Entry Ledger

-   Praise Philemon Co-founder & Head of Revenue

![Line drawing of a closed cube labeled 10 next to an open cube labeled 2](https://cdn.sanity.io/images/06pses5q/production/46edc01b563bf3f15b2fe3ab9d5bfd132f3ec74c-1024x576.jpg?w=1600&fit=max&auto=format)

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](https://docs.blnkfinance.com/ledgers/introduction) 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"
        }
      }
    }
    ```
    
    | Field | What it does |
    | --- | --- |
    | seller_id | Scopes this SKU to the seller or store that listed it. Details managed in a separate table. |
    | item_code | The parent item. Pull every iPhone 16 regardless of color, seller, etc. |
    | sku | This version only. White 128GB would be IP16-WHT-128. |
    
3.  Use balances to track each state
    
    Each state becomes a [balance](https://docs.blnkfinance.com/balances/introduction) 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"
    }
    ```
    
    | Field | What it does |
    | --- | --- |
    | ledger_id | Which group this balance sits in. |
    | identity_id | Which 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](https://docs.blnkfinance.com/transactions/bulk-transactions) 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
    }
    ```
    
    | Field | What it does |
    | --- | --- |
    | atomic: true | All three succeed together or none of them do. If available is short, none of the legs post. |
    | inflight: true | Reserves 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_overdraft | On sold → in transit and in transit → delivered only. Available cannot go negative. |
    | source / destination | Required. You can reconstruct where the seller’s units went. |
    
2.  Commit each state when it is done
    
    Things happen before a state changes. [Inflight](https://docs.blnkfinance.com/transactions/inflight/creating-inflight) holds each transaction until they do, then you apply it on the ledger.
    
    | Move | Before you commit |
    | --- | --- |
    | Available → Sold | Customer starts checkout. Customer makes payment. Payment succeeds. |
    | Sold → In transit | Item picked up. Item packaged and handed over to the delivery partner. Delivery processing starts. |
    | In transit → Delivered | Partner 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](https://docs.blnkfinance.com/transactions/inflight/updating-inflight) 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](https://docs.blnkfinance.com/search/db/filtering) 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](https://docs.blnkfinance.com/balances/historical-balances):

<!-- 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](https://www.blnkfinance.com/blog/double-entry-for-developers), immutable ledger that sits underneath and holds correctness at every step.

If you want to give it a try, start here: [Blnk Cloud](https://cloud.blnkfinance.com).

## Related posts

-   [
    
    Sep 7, 2026Build
    
    ### Why one payment does not always equal one transaction.
    
    Emmanuella Etop-Essien
    
    ](https://blnkfinance.com/blog/why-one-payment-does-not-always-equal-one-transaction)
-   [
    
    Sep 3, 2026Build
    
    ### Building a BaaS Ledger, Part 2: Transaction Workflows
    
    Praise Philemon
    
    ](https://blnkfinance.com/blog/how-to-build-a-baas-ledger-part-2)
-   [
    
    Sep 1, 2026Build
    
    ### How to build an AI billing ledger
    
    Emmanuella Etop-Essien
    
    ](https://blnkfinance.com/blog/how-to-build-an-ai-llm-billing-ledger)
