Skip to main content
BlogERP

ERP Expense Settlement: Rekonsiliasi Advance dan Input VAT

Cara merekonsiliasi cash advance dengan expense line per kategori di ERP, dan kenapa VAT di tiap line hanya informasional kecuali ada nomor faktur pajak.

Matthews Wong

Software Engineer

Oktober 202610 menit baca
ERP Expense Settlement: Rekonsiliasi Advance dan Input VAT

Ringkasan Utama

Untuk merekonsiliasi cash advance di ERP, simpan advance sebagai satu header dan settlement sebagai expense line per kategori, lalu wajibkan total line sama dengan settled amount sebelum submit. Perlakukan input VAT di tiap line sebagai flag informasional yang tidak mengubah total itu, dan hitung sebagai creditable hanya jika ada nomor faktur pajak.

Cash advance adalah satu-satunya dokumen AP yang belum selesai saat sudah dibayar. Uang sudah keluar dari perusahaan, tetapi belum ada yang menyatakan untuk apa uang itu dipakai. Sebelum karyawan kembali membawa kwitansi, advance masih berupa liabilitas terbuka di pembukuan, dan ERP harus tahu hal itu.

Tulisan ini membahas sisi settlement dari dokumen tersebut di sebuah ERP yang saya bangun: data model, satu invariant yang menjaga header tetap jujur, routing approval untuk overspend, dan bagaimana input VAT dibawa di tiap expense line tanpa pernah menyentuh rekonsiliasi. Isinya hanya desain dan data model. Tidak ada tarif atau nominal pajak di sini, karena desainnya memang tidak membutuhkannya.

Settlement adalah rekonsiliasi, bukan form kedua

Model yang menggoda adalah satu request dengan satu amount, lalu layar settlement yang menimpanya dengan angka sebenarnya. Cara itu menghilangkan hal yang paling dibutuhkan finance, yaitu perbandingan antara yang di-advance dan yang benar-benar dibelanjakan. Saya memodelkan advance dan settlement-nya di record yang sama, tetapi keduanya adalah dua angka dengan dua lifecycle berbeda.

Advance amount dikunci saat approval dan pembayaran. Settled amount diklaim kemudian, didukung expense line, lalu direview. Selisih keduanya adalah intinya: selisih positif berarti surplus yang dikembalikan karyawan, selisih negatif berarti overspend yang diganti perusahaan. Keduanya butuh reviewer, dan yang kedua butuh alasan.

Data model: satu header, banyak line per kategori

Schema di bawah adalah penulisan ulang generik dari pola ini, bukan source produksi. Header advance menyimpan amount yang dikunci, settled amount yang diklaim, flag rekonsiliasi, dan alasan overspend yang opsional. Tiap expense line menyimpan gross amount sendiri, kategori, tanggal, serta dua field terkait pajak yang keduanya 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])
}

Dua pilihan penting di sini. Uang memakai kolom decimal dengan scale tetap, bukan float, karena dokumentasi PostgreSQL merekomendasikan tipe numeric untuk nilai moneter. Lalu line adalah child dari header dengan cascade delete, sehingga settlement yang di-resubmit mengganti seluruh line sekaligus, bukan mengedit satu per satu.

Jadikan kategori sebagai vocabulary pendek yang terkontrol, misalnya transport, lodging, meals, supplies, dan other. Kategori inilah yang memungkinkan finance mengelompokkan advance per jenis belanja, dan field teks bebas akan menghasilkan lima ejaan berbeda untuk hal yang sama dalam sebulan.

Satu invariant: line adalah settled amount

Settled amount di header dan jumlah expense line harus sama sebelum settlement bisa di-submit. Total header yang hanya berdiri di samping line-nya adalah angka yang diketik orang; total header yang dicek terhadap line-nya adalah rekonsiliasi. Service menolak submission jika keduanya berbeda lebih dari toleransi satu satuan mata uang, yang cukup untuk rounding dan tidak lebih. Aturan per line seperti amount yang harus positif dideklarasikan di DTO dan dicek oleh ValidationPipe NestJS, sehingga service hanya perlu menegakkan aturan yang melintasi beberapa line.

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) },
    },
  });
}

Ini tidak bisa dijadikan constraint di database. Dokumentasi PostgreSQL menyebut CHECK constraint tidak bisa mereferensikan data tabel selain row yang sedang ditulis, dan penjumlahan header terhadap child persis seperti itu. Jadi aturannya ada di service, di method yang sama yang menentukan status berikutnya, dan komentar di kode saya menyebut risiko yang ditutupnya: tanpa guard itu, submitter bisa mengklaim satu angka di header sementara line-nya berjumlah lain, dan finance akan menyetujui header dengan audit trail yang rusak.

Saya perlu jujur soal buktinya. Guard itu lahir dari penalaran tentang kegagalan, bukan dari insiden yang bisa saya laporkan. Penalarannya cukup sederhana untuk dipercaya, tetapi itu keputusan desain, bukan hasil pengukuran.

Jangan biarkan angka VAT bocor ke penjumlahan ini. Menyelesaikan settlement dengan line net-of-VAT membuat rekonsiliasi tidak cocok dengan kwitansi dan uang tunai yang benar-benar dikeluarkan karyawan. Settled amount selalu gross, dan porsi VAT hanyalah label pada line yang sudah ada di dalam gross itu.

Overspend, underspend, dan siapa yang approve

Perbandingan antara settled amount dan advance amount menentukan rute. Advance hanya bisa di-settle setelah berstatus paid. Settled di atas advance berarti overspend: alasan wajib diisi dan settlement masuk ke department manager lebih dulu. Selain itu langsung ke finance. Di kedua jalur, penolakan mengembalikan advance ke paid dengan alasan penolakan tersimpan, sehingga karyawan bisa resubmit.

SituasiFlag rekonsiliasiStatus berikutnyaSyarat tambahan
Settled sama dengan advancesettledpending_settlement (finance)Line berjumlah sama dengan settled amount
Settled di bawah advancepartialpending_settlement (finance)Referensi opsional untuk pengembalian surplus
Settled di atas advancesettledpending_settlement_manager, lalu financeAlasan overspend wajib diisi
Ditolak di salah satu langkahtidak berubahpaid (resubmit)Alasan penolakan disimpan, approval sebelumnya dibersihkan saat resubmit

Flag rekonsiliasi punya tiga nilai: pending, partial, dan settled. Di kode, flag ini diisi saat submission, dan partial berarti settled amount di bawah advance, jadi ada surplus yang harus dikembalikan. Service juga menerima referensi opsional untuk transfer pengembalian surplus. Referensi itu disimpan; pengembaliannya tidak dimodelkan sebagai ledger entry, yang saya bahas di bagian batasan.

Input VAT: informasional di tiap line, creditable hanya dengan nomor faktur

Tiap expense line bisa membawa porsi input VAT dan nomor faktur pajak. Saya memperlakukan keduanya sebagai dua fakta terpisah tentang line yang sama. Jumlah VAT bersifat informasional: ia menyatakan berapa bagian dari gross yang merupakan VAT, untuk rekap input VAT. Ia tidak mengubah amount line, settled amount, maupun rekonsiliasi. Nomor faktur-lah yang menentukan apakah VAT itu creditable. Tanpa nomor, tanpa kredit.

Header mendapat roll-up VAT dari semua line dalam transaksi yang sama dengan settlement, dan kodenya eksplisit bahwa roll-up itu tidak pernah masuk ke pengecekan di atasnya. Nested write yang mengganti line berjalan sebagai satu transaksi di Prisma, sehingga resubmission yang gagal tidak meninggalkan header yang menunjuk ke separuh line lama.

// 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);

Komentar di schema menyatakan aturan creditable, tetapi roll-up header di service saya menjumlahkan semua line, dengan atau tanpa nomor faktur. Jadi angka di header adalah total informasional, dan total creditable harus dihitung saat read dengan mensyaratkan nomor tersebut. Saya akan menamai kolom header sesuai maknanya.

Di mana tiap fakta disimpan

Sebagian besar desain ini adalah memutuskan tabel mana yang memiliki fakta mana. Ini pembagian yang akhirnya saya pakai, beserta alasan tiap fakta ditaruh di tempatnya.

FaktaDisimpan diAlasan
Advance amountHeader, dikunci saat pembayaranItu yang dibayarkan; settlement tidak boleh menimpanya
Settled amountHeader, diklaim saat submitDibandingkan dengan advance untuk memilih rute
Kategori dan tanggal belanjaExpense lineMemungkinkan finance mengelompokkan advance per jenis belanja
Porsi input VATExpense line, nullableInformasional; tidak pernah masuk ke settled amount
Nomor faktur pajakExpense line, nullableSatu-satunya yang membuat VAT itu creditable

Perhatikan bahwa tidak ada yang membutuhkan tarif pajak. Desain ini hanya perlu tahu apakah porsi VAT dicatat dan apakah ada nomor faktur yang mendukungnya. Tarif mana yang berlaku, dan apa yang creditable menurut aturan terkini, adalah soal tax compliance yang tempatnya di referensi bertanggal dan bersumber, bukan di schema.

Yang akan saya ubah, dan batasannya

Setelah membaca ulang service saya sendiri, inilah bagian yang akan saya rapikan, ditulis sebagai batasan, bukan disembunyikan.

  • Jumlah line dihitung dengan number JavaScript dan toleransi, sementara kolomnya decimal. Menjumlahkan line dengan tipe decimal dan mensyaratkan kecocokan persis akan menghapus toleransi itu.
  • Kategori adalah string bebas dengan vocabulary di komentar. Enum database atau tabel lookup akan mencegah salah ketik.
  • Flag rekonsiliasi bernilai settled saat submission, sebelum finance approve. Status sebenarnya ada di kolom status, jadi namanya menyesatkan dan flag itu akan saya ganti namanya.
  • Pengembalian surplus hanya berupa string referensi, bukan ledger entry, jadi tidak ada yang merekonsiliasi uang yang dikembalikan dengan pembukuan.

Tidak satu pun dari ini diukur terhadap data produksi. Semuanya temuan dari membaca kode, dan keputusan desain di intinya, line harus sama dengan header, tidak bergantung pada perbaikan tersebut.

Checklist hari Senin

  1. Simpan advance amount dan settled amount di dua kolom terpisah dan jangan pernah menimpa yang pertama.
  2. Pakai decimal dengan scale tetap untuk setiap kolom uang, di header maupun di line.
  3. Tolak submission settlement jika jumlah line dan settled amount berbeda, di service, karena CHECK constraint tidak bisa melihat row lain.
  4. Wajibkan alasan untuk overspend, dan arahkan overspend ke manager sebelum finance.
  5. Taruh porsi input VAT dan nomor faktur pajak di line, keduanya nullable, dan jauhkan VAT dari semua penjumlahan rekonsiliasi.
  6. Hitung total creditable saat read dengan mensyaratkan nomor faktur, dan ganti line sekaligus dalam satu nested write saat resubmission.

Bacaan terkait di situs ini

Settlement hanya bisa dipercaya jika line-nya berjumlah sama dengan header-nya dan tidak ada hal lain yang boleh menggeser jumlah itu. Rekonsiliasikan gross terhadap advance, arahkan overspend ke reviewer, dan bawa VAT sebagai flag di line yang baru creditable ketika ada nomor faktur yang mendukungnya.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Bagaimana cara merekonsiliasi cash advance dengan pengeluaran di ERP?

Simpan advance amount yang tetap di header dan catat settlement sebagai expense line, masing-masing dengan gross amount, kategori, dan tanggal. Sebelum settlement di-submit, cek bahwa jumlah line sama dengan settled amount, lalu bandingkan angka itu dengan advance. Selisihnya berupa surplus yang dikembalikan atau overspend yang diganti.

Kenapa input VAT tidak mengubah settled amount?

Settled amount bersifat gross: itu yang benar-benar dikeluarkan karyawan dan yang tertera di kwitansi. Porsi VAT sudah ada di dalam gross itu, jadi disimpan sebagai label di line untuk rekap input VAT. Jika dikurangkan, rekonsiliasi akan berbeda dari uang tunai yang keluar dari karyawan.

Kapan input VAT di expense line dianggap creditable?

Dalam desain ini, hanya ketika line itu juga membawa nomor faktur pajak. Jumlah VAT saja adalah pencatatan informasional. Apakah suatu nominal benar-benar bisa dikreditkan menurut aturan terkini adalah soal pajak, jadi cek ke sumber resmi, bukan ke schema.

Bisakah constraint database memastikan expense line berjumlah sama dengan header?

Tidak lewat CHECK constraint. Dokumentasi PostgreSQL menyebut CHECK constraint tidak bisa mereferensikan data tabel selain row yang sedang ditulis, sedangkan penjumlahan header terhadap line membutuhkan row lain. Tegakkan di service, di method yang sama yang menentukan status berikutnya, atau dengan trigger.

Apa yang sebaiknya terjadi saat karyawan overspend dari cash advance?

Wajibkan alasan tertulis dan arahkan settlement ke department manager sebelum direview finance. Jika settlement ditolak, kembalikan advance ke status paid dengan alasan tersimpan agar karyawan bisa resubmit dengan line yang sudah dikoreksi.