Skip to main content
BlogERP

ERP Expense Settlement Advance Reconciliation and Input VAT

How to reconcile a cash advance against category-tagged expense lines in an ERP, and why VAT on each line stays informational unless a tax-invoice number exists.

Matthews Wong

Software Engineer

October 202610 min read
ERP Expense Settlement Advance Reconciliation and Input VAT

Key Takeaway

To reconcile a cash advance in an ERP, store the advance as one header and the settlement as category-tagged expense lines, then require the lines to sum to the settled amount before submission. Treat input VAT on each line as an informational flag that never changes that sum, and count it as creditable only when a tax-invoice number is present.

A cash advance is the one AP document that is not finished when it is paid. Money has left the company, but nobody has said what it bought. Until the employee comes back with receipts, the advance is an open liability on the books, and the ERP has to know that.

This post covers the settlement half of that document in an ERP I built: the data model, the single invariant that keeps the header honest, the approval routing for overspend, and how input VAT is carried on each expense line without ever touching the reconciliation. It is design and data model only. There are no tax rates or amounts here, because the design does not need them.

Settlement is a reconciliation, not a second form

The tempting model is a single request with an amount, and a settlement screen that overwrites it with the real figure. That loses the one thing finance needs, which is the comparison between what was advanced and what was actually spent. I model the advance and its settlement on the same record, but they are two different numbers with two different lifecycles.

The advance amount is fixed at approval and payment. The settled amount is claimed later, backed by expense lines, and reviewed. The gap between them is the whole point: a positive gap is a surplus the employee returns, a negative gap is an overspend the company reimburses. Both need a reviewer, and the second needs a reason.

The data model: one header, many category lines

The schema below is a generic rewrite of the pattern, not the production source. The advance header carries the fixed amount, the claimed settled amount, a reconciliation flag and the optional overspend reason. Each expense line carries its own gross amount, a category, a date, and two tax-related fields that are both nullable.

enum AdvanceStatus {
  draft pending_approval approved paid
  pending_settlement_manager  // overspend: manager reviews first
  pending_settlement          // finance reviews
  settled rejected
}
enum Reconciliation { pending partial settled }

model Advance {
  id              String           @id @default(uuid())
  number          String           @unique
  amount          Decimal          @db.Decimal(15, 2)  // fixed at payment, never overwritten
  status          AdvanceStatus    @default(draft)
  settleBy        DateTime?
  reconciliation  Reconciliation?
  settledAmount   Decimal?         @db.Decimal(15, 2)  // claimed at submit, gross
  overspendReason String?
  returnReference String?          // surplus transfer reference, a string not a ledger entry
  inputVatTotal   Decimal?         @db.Decimal(15, 2)  // informational roll-up of the lines
  expenses        ExpenseLine[]
}

model ExpenseLine {
  id           String   @id @default(uuid())
  advanceId    String
  advance      Advance  @relation(fields: [advanceId], references: [id], onDelete: Cascade)
  lineNumber   Int
  description  String
  amount       Decimal  @db.Decimal(15, 2)  // GROSS, as printed on the receipt
  category     String                       // transport, lodging, meals, supplies, other
  spentOn      DateTime
  inputVat     Decimal? @db.Decimal(15, 2)  // informational: part of amount, not added to it
  taxInvoiceNo String?                      // without it the VAT is not creditable
  @@index([advanceId])
}

Two choices matter here. Money is a fixed-scale decimal column, never a float, because PostgreSQL documents numeric as the type recommended for monetary amounts. And the lines are children of the header with a cascade delete, so a resubmitted settlement replaces its lines wholesale instead of editing them one by one.

Keep the category as a short controlled vocabulary such as transport, lodging, meals, supplies and other. The category is what lets finance group an advance by spend type, and a free-text field there produces five spellings of the same thing within a month.

The one invariant: the lines are the settled amount

The settled amount on the header and the sum of the expense lines must agree before the settlement can be submitted. A header total that merely sits next to its lines is a number someone typed; a header total that is checked against its lines is a reconciliation. The service rejects a submission when the two differ by more than a tolerance of one currency unit, which absorbs rounding but nothing else. Per-line rules such as a positive amount are declared on a DTO and checked by the NestJS ValidationPipe, so the service only has to enforce the rules that span lines.

async function submitSettlement(advanceId: string, userId: string, dto: SettleDto) {
  const advance = await db.advance.findUnique({ where: { id: advanceId } });
  if (!advance) throw new NotFoundException();
  if (advance.ownerId !== userId) throw new ForbiddenException("Settle your own advance");
  if (advance.status !== "paid") throw new BadRequestException("Pay first, then settle");

  // The invariant: header total must equal the sum of its lines.
  // A CHECK constraint cannot see sibling rows, so this lives here.
  const lineSum = dto.expenses.reduce((acc, e) => acc + Number(e.amount), 0);
  if (Math.abs(lineSum - dto.settledAmount) > ONE_CURRENCY_UNIT) {
    throw new BadRequestException("Lines do not add up to the settled amount");
  }

  const overspend = dto.settledAmount > Number(advance.amount);
  if (overspend && !dto.overspendReason?.trim()) {
    throw new BadRequestException("Say why the advance was exceeded");
  }

  // Informational only. Nothing above reads this value.
  const inputVatTotal = dto.expenses.reduce((acc, e) => acc + (Number(e.inputVat) || 0), 0);

  await db.advance.update({
    where: { id: advanceId },
    data: {
      status: overspend ? "pending_settlement_manager" : "pending_settlement",
      reconciliation: dto.settledAmount >= Number(advance.amount) ? "settled" : "partial",
      settledAmount: dto.settledAmount,
      overspendReason: dto.overspendReason ?? null,
      inputVatTotal: inputVatTotal > 0 ? inputVatTotal : null,
      // One nested write = one transaction: old lines out, new lines in, or neither.
      expenses: { deleteMany: {}, create: dto.expenses.map(toLine) },
    },
  });
}

This cannot be a database constraint. PostgreSQL documents that CHECK constraints cannot reference table data other than the row being written, and a header-versus-children sum is exactly that. So the rule lives in the service, in the same method that decides the next status, and the comment in my code names the risk it closes: without it, a submitter could claim one figure on the header while the lines add up to another, and finance would approve the header with a broken audit trail.

I should be exact about the evidence. That guard came from reasoning about the failure, not from an incident I can report. The reasoning is simple enough to trust, but it is a design decision, not a measurement.

Do not let the VAT figure leak into this sum. Settling on net-of-VAT lines makes the reconciliation disagree with the receipts and the cash the employee actually spent. The settled amount is always gross, and the VAT portion is a label on a line that is already inside that gross.

Overspend, underspend and who approves

The comparison between the settled and advanced amounts decides the route. An advance may only be settled once it is in the paid state. Settled above the advance means overspend: a reason is mandatory and the settlement goes to the department manager first. Anything else goes straight to finance. In both cases a rejection sends the advance back to paid with the rejection reason stored, so the employee can resubmit.

SituationReconciliation flagNext statusExtra requirement
Settled equals the advancesettledpending_settlement (finance)Lines sum to the settled amount
Settled below the advancepartialpending_settlement (finance)Optional reference for the surplus return
Settled above the advancesettledpending_settlement_manager, then financeOverspend reason is mandatory
Rejected at either stepunchangedpaid (resubmit)Rejection reason stored, previous approvals cleared on resubmit

The reconciliation flag has three values: pending, partial and settled. In the code it is set at submission, and partial means the settled amount is below the advance, so a surplus is owed back. The service also accepts an optional reference for the surplus return transfer. It stores that reference; it does not model the return as a ledger entry, which I come back to in the limits.

Input VAT: informational on every line, creditable only with an invoice number

Each expense line can carry an input VAT portion and a tax-invoice number. I treat these as two separate facts about the same line. The VAT amount is informational: it says how much of the gross was VAT, for the input-VAT recap. It does not change the line amount, the settled amount or the reconciliation. The invoice number is what decides whether that VAT counts as creditable. No number, no credit.

The header gets a roll-up of the line VAT in the same transaction as the settlement, and the code is explicit that the roll-up never feeds the checks above it. The nested write that replaces the lines runs as one transaction in Prisma, so a failed resubmission cannot leave a header pointing at half of an old set of lines.

// Wrong: settle on net-of-VAT lines. The total now disagrees with the receipts
// and with the cash the employee actually spent.
const settled = lines.reduce((acc, l) => acc + (l.amount - (l.inputVat ?? 0)), 0);

// Right: settle on gross. VAT is a label on a line that is already inside that gross.
const settled = lines.reduce((acc, l) => acc + l.amount, 0);

// Right: the creditable total is a read-time question with two conditions.
const creditable = lines.filter((l) => l.inputVat && l.taxInvoiceNo);
const informationalOnly = lines.filter((l) => l.inputVat && !l.taxInvoiceNo);

The schema comment states the creditable rule, but the header roll-up in my service adds every line, with or without an invoice number. So the header figure is the informational total, and the creditable total has to be computed at read time by requiring the number. I would name the header column accordingly.

Where each fact lives

Most of the design is deciding which table owns which fact. This is the split I settled on, and the reason each fact sits where it does.

FactLives onWhy
Advance amountHeader, fixed at paymentIt is what was paid out; settlement must never overwrite it
Settled amountHeader, claimed at submitCompared with the advance to pick the route
Spend category and dateExpense lineLets finance group an advance by type of spend
Input VAT portionExpense line, nullableInformational; never enters the settled amount
Tax-invoice numberExpense line, nullableThe only thing that makes that VAT creditable

Notice that nothing here needs a tax rate. The design only needs to know whether a VAT portion was recorded and whether an invoice number backs it. Which rate applies, and what is creditable under current rules, is a tax-compliance question that belongs in a dated, sourced reference rather than in a schema.

What I would do differently, and the limits

Reading my own service back, these are the places I would tighten, listed as limits rather than hidden.

  • The line sum is computed with JavaScript numbers and a tolerance, while the columns are decimals. Summing the lines with a decimal type and requiring an exact match would remove the tolerance.
  • The category is a free string with the vocabulary in a comment. A database enum or a lookup table would stop typos.
  • The reconciliation flag reads settled at submission, before finance has approved. The status column carries the real state, so the name is misleading and I would rename the flag.
  • A surplus return is a stored reference string, not a ledger entry, so nothing reconciles the returned cash against the books.

None of these has been measured against production data. They are findings from reading the code, and the design choice at the centre, lines must equal the header, does not depend on fixing them.

Monday checklist

  1. Store the advance amount and the settled amount in two separate columns and never overwrite the first.
  2. Use a fixed-scale decimal for every money column, on the header and on the lines.
  3. Reject a settlement submission when the line sum and the settled amount differ, in the service, since a CHECK constraint cannot see other rows.
  4. Require a reason for overspend, and route overspend to a manager before finance.
  5. Put the input VAT portion and the tax-invoice number on the line, both nullable, and keep the VAT out of every reconciliation sum.
  6. Compute the creditable total at read time by requiring the invoice number, and replace lines wholesale in one nested write on resubmission.

Related reading on this site

A settlement is only trustworthy when its lines add up to its header and nothing else is allowed to move that sum. Reconcile gross against the advance, route overspend to a reviewer, and carry VAT as a flag on the line that becomes creditable only when an invoice number backs it.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

How do you reconcile a cash advance against expenses in an ERP?

Keep the advance amount fixed on the header and record the settlement as expense lines, each with a gross amount, category and date. Before the settlement is submitted, check that the lines sum to the settled amount, then compare that figure with the advance. The difference is either a surplus to return or an overspend to reimburse.

Why does input VAT not change the settled amount?

The settled amount is gross: it is what the employee actually spent and what the receipts show. The VAT portion is already inside that gross, so it is stored as a label on the line for the input-VAT recap. Subtracting it would make the reconciliation disagree with the cash that left the employee.

When is input VAT on an expense line creditable?

In this design, only when the line also carries a tax-invoice number. The VAT amount alone is informational bookkeeping. Whether a given amount is actually creditable under current rules is a tax question, so check it against official sources rather than the schema.

Can a database constraint enforce that expense lines add up to the header?

Not a CHECK constraint. PostgreSQL documents that CHECK constraints cannot reference table data other than the row being written, and a header-versus-lines sum needs the other rows. Enforce it in the service, in the same method that decides the next status, or with a trigger.

What should happen when an employee overspends a cash advance?

Require a written reason and route the settlement to the department manager before finance reviews it. If the settlement is rejected, return the advance to the paid state with the reason stored so the employee can resubmit corrected lines.