Books by finzkoDevelopers

Allocating payments

A payment and the documents it settles are separate things. You record the money once, then apply it — in whole or in parts, now or later. Whatever a payment does not settle is an advance. This page walks a complete integration end to end, with the exact bodies on the wire.

The two ceilings

Every allocation is checked against both of these. The first is the one integrations forget:

CeilingMeaning
payment.unapplied_balancethe payment's own value minus what is already applied
target.outstandingthe invoice or bill balance

Because /apply is meant to be called repeatedly, allocations accumulate. A ₹5,000 receipt cannot be applied twice for ₹5,000 just because both invoices have room.


Pattern A — create and apply in one call

Use this when you already know the split. One round trip, one idempotency key, one transaction — the batch is validated up front, so you get a single 400 rather than a half-applied payment.

Request

POST /api/public/v1/payments
Host: api.books.finzko.com
Authorization: Bearer aibk_pat_xxxxxxxxxxxxxxxxxxxxxxxx
Idempotency-Key: rcpt-2026-07-27-ram-001
Content-Type: application/json

{
  "flow":             "INCOMING",
  "contact_id":       "8590d61f-7a71-4050-84f2-ef34b3a3f951",
  "date":             "2026-07-27T00:00:00",
  "amount":           9733,
  "mode":             "UPI",
  "payment_type":     "REGULAR",
  "reference_number": "620821345575",
  "description":      "UPI settlement 27 Jul",
  "currency_code":    "INR",
  "exchange_rate":    1.0,
  "bank_charges":     0.0,
  "tds_amount":       0.0,
  "bank_account_id":  "b1f9c2e4-1a55-4c07-9f31-2d7a10c4e881",
  "allocations": [
    { "invoice_id": "e1c8a0d2-...-856",  "amount": 6195 },
    { "invoice_id": "a7b3f914-...-2358", "amount": 3538 }
  ]
}

bank_account_id comes from GET /accounts filtered to type BANK or CASH. Omit it only in a single-bank org — multi-bank setups must specify, or the money lands in the wrong ledger.

Response — 201

{
  "code": 0,
  "message": "The payment has been created.",
  "payment": {
    "payment_id":         "6d348a2e-bd90-4787-ac9c-fe07d6145526",
    "flow":               "INCOMING",
    "payment_type":       "REGULAR",
    "contact_id":         "8590d61f-7a71-4050-84f2-ef34b3a3f951",
    "contact_name":       "Ram",
    "date":               "2026-07-27T00:00:00",
    "amount":             9733.0,
    "mode":               "UPI",
    "reference_number":   "620821345575",
    "description":        "UPI settlement 27 Jul",
    "currency_code":      "INR",
    "exchange_rate":      1.0,
    "bank_charges":       0.0,
    "tds_amount":         0.0,
    "bank_account_id":    "b1f9c2e4-1a55-4c07-9f31-2d7a10c4e881",
    "allocations": [
      { "allocation_id": "3052b92b-...", "invoice_id": "e1c8a0d2-...-856",  "bill_id": null, "amount": 6195.0 },
      { "allocation_id": "9a71c4d0-...", "invoice_id": "a7b3f914-...-2358", "bill_id": null, "amount": 3538.0 }
    ],
    "created_time":       "2026-07-27T09:14:02.118Z",
    "last_modified_time": "2026-07-27T09:14:02.118Z"
  }
}

Sum the allocations[].amount and subtract from amount to get the unapplied balance — here, zero.

Error — allocations exceed the payment

HTTP/1.1 400 Bad Request

{
  "code": "validation.invalid_value",
  "message": "Sum of allocations (12000) exceeds payment amount (9733).",
  "request_id": "req_01J8Z3Q..."
}

Nothing is created. Fix the split and resend with the same key.


Pattern B — record now, apply later

Use this when the money arrives before you know what it settles — a bank feed, a UPI sweep, a customer paying on account. Omit allocations entirely.

Step 1 — record the receipt as an advance

POST /api/public/v1/payments
Authorization: Bearer aibk_pat_xxxxxxxxxxxxxxxxxxxxxxxx
Idempotency-Key: rcpt-2026-07-27-ram-001
Content-Type: application/json

{
  "flow":             "INCOMING",
  "contact_id":       "8590d61f-7a71-4050-84f2-ef34b3a3f951",
  "date":             "2026-07-27T00:00:00",
  "amount":           5000,
  "mode":             "UPI",
  "payment_type":     "ADVANCE",
  "reference_number": "620821345575",
  "bank_account_id":  "b1f9c2e4-1a55-4c07-9f31-2d7a10c4e881"
}
HTTP/1.1 201 Created

{
  "code": 0,
  "message": "The payment has been created.",
  "payment": {
    "payment_id":   "6d348a2e-bd90-4787-ac9c-fe07d6145526",
    "flow":         "INCOMING",
    "payment_type": "ADVANCE",
    "contact_id":   "8590d61f-7a71-4050-84f2-ef34b3a3f951",
    "contact_name": "Ram",
    "date":         "2026-07-27T00:00:00",
    "amount":       5000.0,
    "mode":         "UPI",
    "allocations":  [],
    "created_time": "2026-07-27T09:14:02.118Z",
    "last_modified_time": "2026-07-27T09:14:02.118Z"
  }
}

allocations: [] — the full ₹5,000 sits on the customer-advance liability ledger. Not on Accounts Receivable: A/R only ever carries what a payment actually settles.

Step 2 — apply it to an invoice

The body accepts exactly three fields and rejects anything else (extra: forbid): invoice_id or bill_id, plus amount. Idempotency-Key is required.

POST /api/public/v1/payments/6d348a2e-bd90-4787-ac9c-fe07d6145526/apply
Authorization: Bearer aibk_pat_xxxxxxxxxxxxxxxxxxxxxxxx
Idempotency-Key: apply-rcpt2337-gov856-001
Content-Type: application/json

{
  "invoice_id": "e1c8a0d2-...-856",
  "amount":     5000
}
HTTP/1.1 200 OK

{
  "code": 0,
  "message": "The allocation has been recorded.",
  "payment": {
    "payment_id":   "6d348a2e-bd90-4787-ac9c-fe07d6145526",
    "flow":         "INCOMING",
    "payment_type": "ADVANCE",
    "contact_id":   "8590d61f-7a71-4050-84f2-ef34b3a3f951",
    "contact_name": "Ram",
    "amount":       5000.0,
    "allocations": [
      { "allocation_id": "3052b92b-...", "invoice_id": "e1c8a0d2-...-856", "bill_id": null, "amount": 5000.0 }
    ],
    "last_modified_time": "2026-08-24T04:53:04.902Z"
  },
  "allocation": {
    "allocated_amount": 5000.0,
    "round_off_amount": 0.0,
    "invoice_id":       "e1c8a0d2-...-856"
  }
}

The top-level allocation object tells you what this call did; payment.allocations[] is the running total. Read allocated_amount, not your requested amount — they differ when a round-off cap applies.

Step 3 — the call that used to succeed and now does not

The receipt is spent. The second invoice has ₹3,538 outstanding, so the target ceiling is satisfied — but the payment ceiling is not:

POST /api/public/v1/payments/6d348a2e-bd90-4787-ac9c-fe07d6145526/apply
Idempotency-Key: apply-rcpt2337-inv2358-001

{
  "invoice_id": "a7b3f914-...-2358",
  "amount":     5000
}
HTTP/1.1 400 Bad Request

{
  "code": "validation.invalid_value",
  "message": "Amount 5000.00 exceeds this payment's unapplied balance 0.00. The payment is worth 5000.00 and 5000.00 is already applied.",
  "details": {
    "payment_amount":    5000.0,
    "already_applied":   5000.0,
    "unapplied_balance": 0.0,
    "requested":         5000.0
  },
  "request_id": "req_01J8Z3Q..."
}

Nothing is written when a call is refused — there is no partial state to unwind. Branch on details.unapplied_balance: retry with that amount if it is greater than zero, otherwise record another receipt.


Round-off: small overshoots are absorbed

Exceed a ceiling by no more than the organisation's paymentRoundOffTolerance (default ₹1.00) and the allocation is capped at the balance, with the remainder posted to the Round Off ledger. Beyond the tolerance it is a real mismatch and returns 400.

POST /payments/{payment_id}/apply

{ "invoice_id": "a7b3f914-...", "amount": 1180.50 }   // invoice balance 1180.00
HTTP/1.1 200 OK

{
  "code": 0,
  "message": "The allocation has been recorded. Rounded off ₹0.50 (within paymentRoundOffTolerance).",
  "payment": { "...": "..." },
  "allocation": {
    "allocated_amount": 1180.0,
    "round_off_amount": 0.5,
    "invoice_id":       "a7b3f914-..."
  }
}

Note allocated_amount is 1180.00, not 1180.50. See Round-off.


Retries are safe

Re-sending an allocation you already made is a no-op, not an error — so a timeout you retry cannot double-credit a customer. The same (payment, document, amount) returns 200 and writes nothing further:

{
  "code": 0,
  "message": "The allocation has been recorded.",
  "allocation": {
    "allocated_amount": 5000.0,
    "round_off_amount": 0.0,
    "invoice_id":       "e1c8a0d2-...-856",
    "already_applied":  true
  }
}

This is separate from Idempotency-Key: the key protects the create, this protects the apply. Applying a different amount to a document you have already applied to is refused — that is an edit, and it must go through unapply so the ledger and the document move together:

HTTP/1.1 409 Conflict

{
  "code": "conflict.already_allocated",
  "message": "This payment is already applied to this invoice for 2,000.00. Unapply it first before applying a different amount.",
  "details": {
    "applied_amount": 2000.0,
    "requested":      5000.0
  }
}

Invoices and bills answer identically — same status, same code. The conflict is checked beforethe two ceilings, so you get the actionable message (“unapply first”) rather than “the payment has nothing left”.


Reading a payment's position

GET /api/public/v1/payments/6d348a2e-bd90-4787-ac9c-fe07d6145526
Authorization: Bearer aibk_pat_xxxxxxxxxxxxxxxxxxxxxxxx
{
  "code": 0,
  "message": "success",
  "payment": {
    "payment_id":  "6d348a2e-bd90-4787-ac9c-fe07d6145526",
    "amount":      9733.0,
    "allocations": [
      { "allocation_id": "3052b92b-...", "invoice_id": "e1c8a0d2-...-856", "bill_id": null, "amount": 6195.0 }
    ]
  }
}

There is no unapplied_balance field. Derive it:

unapplied = payment.amount - sum(a.amount for a in payment.allocations)
#           9733.0        - 6195.0                                  = 3538.0

That figure is also exactly the amount still sitting as an advance for this customer, so you do not need a second call to report their advance position.


Advances, in ledger terms

StateLedger effect
Receipt recorded, unallocatedDr Bank · Cr Advances from Customers
Applied to an invoiceDr Advances from Customers · Cr Accounts Receivable
Un-applied againDr Accounts Receivable · Cr Advances from Customers

A partly-applied payment splits: the settled part is on A/R, the rest is an advance. There is no state in which a payment is “on account” in full while also reducing an invoice.

Unapply reverses posted vouchers with a mirror entry — it never deletes them, so the ledger keeps saying an application happened and was later undone. Do not try to correct an allocation by unapplying more than you applied; read the payment first and reconcile against the derived unapplied balance.


Integration checklist

  • Send Idempotency-Key on every create and every apply.
  • Send bank_account_id explicitly if the org has more than one bank.
  • Read allocation.allocated_amount, not your requested amount.
  • Treat already_applied: true as success.
  • On 400, branch on details.unapplied_balance rather than re-sending blindly.
  • On 409 conflict.already_allocated, unapply before changing an amount — do not retry with a different figure.
  • Derive unapplied balance from amount − Σ allocations; never assume it is zero.
  • Prefer Pattern A when the split is known — it is atomic.