<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[TienNg21's Newsletter: Building products]]></title><description><![CDATA[Building products]]></description><link>https://news.tienng21.com/s/building-products</link><image><url>https://substackcdn.com/image/fetch/$s_!XJnB!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b0151a8-bb4b-4122-9bf7-d8067b59d966_1254x1254.png</url><title>TienNg21&apos;s Newsletter: Building products</title><link>https://news.tienng21.com/s/building-products</link></image><generator>Substack</generator><lastBuildDate>Wed, 28 Oct 2026 01:26:49 GMT</lastBuildDate><atom:link href="https://news.tienng21.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[TienNg21]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[nghuutien0912@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[nghuutien0912@substack.com]]></itunes:email><itunes:name><![CDATA[TienNg21]]></itunes:name></itunes:owner><itunes:author><![CDATA[TienNg21]]></itunes:author><googleplay:owner><![CDATA[nghuutien0912@substack.com]]></googleplay:owner><googleplay:email><![CDATA[nghuutien0912@substack.com]]></googleplay:email><googleplay:author><![CDATA[TienNg21]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Reconciliation as Eventual Consistency]]></title><description><![CDATA[Why your ledger and the bank inevitably drift, why that's expected, and how to build an engine that detects, classifies, and converges the difference]]></description><link>https://news.tienng21.com/p/reconciliation-as-eventual-consistency</link><guid isPermaLink="false">https://news.tienng21.com/p/reconciliation-as-eventual-consistency</guid><dc:creator><![CDATA[TienNg21]]></dc:creator><pubDate>Sat, 22 Feb 2025 17:13:00 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!XJnB!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b0151a8-bb4b-4122-9bf7-d8067b59d966_1254x1254.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Your system thinks a user has 1,200,000 VND. The bank thinks they have 1,150,000. Who's right? The uncomfortable answer that every fintech engineer eventually internalises: <strong>the bank is, always, and your job is not to be right but to detect when you've drifted and converge back.</strong></p><p>This is reconciliation, and it's widely misunderstood as a boring back-office matching task &#8212; tick off the rows, flag the mismatches, done. That framing misses what's actually going on. Reconciliation is a <strong>distributed-systems problem</strong>: you and the bank are two independent replicas of the same financial reality, communicating over lossy channels (webhooks, batch files, APIs), and the question "do our records agree?" is the question "have my replica and the source-of-truth replica converged?" Seen that way, reconciliation isn't matching &#8212; it's eventual consistency with money on the line.</p><p>Having built reconciliation for a payments system, I want to reframe it properly: why your ledger and the bank inevitably diverge, why that's <em>expected</em> rather than a bug, how to build an engine that detects and classifies the drift, and how to converge &#8212; automatically where safe, with a human where not.</p><h2>Reconciliation Is Not Matching &#8212; It's Convergence</h2><p>The matching framing assumes the two systems <em>should</em> always agree and a mismatch is an error. But in a distributed system, temporary divergence is the <strong>normal, healthy state</strong>. A transfer happens at the bank at 14:00:00. The webhook telling you about it arrives at 14:00:03 &#8212; or 14:05, or after a retry at 14:30, or, if the webhook was dropped, never (until reconciliation finds it). Between the event and your learning of it, your replica is <em>correctly</em> behind. You are not wrong; you are eventually consistent.</p><p>So the goal isn't "always match." It's:</p><blockquote><p>Given two replicas that drift, <strong>detect the divergence, classify why, and converge</strong> &#8212; bringing your ledger into agreement with the source of truth.</p></blockquote><p>This is the same shape as any eventual-consistency system (DNS, replicated databases, CRDTs): independent copies, asynchronous propagation, a convergence process that reconciles them. Reconciliation is that convergence process, and money is just the payload.</p><h2>The Sources of Drift</h2><p>Before building the engine, name <em>why</em> the replicas diverge. Each cause needs different handling:</p><ul><li><p><strong>Timing (benign).</strong> The bank has an event you haven't received <em>yet</em>. Given more time, it arrives and you converge on your own. Reconciliation should not panic over these &#8212; only over divergence that <em>persists</em>.</p></li><li><p><strong>Lost events.</strong> A webhook was dropped and never retried successfully. The bank has it; you never will, unless reconciliation surfaces it. This is the main thing recon <em>recovers</em>.</p></li><li><p><strong>Your bugs.</strong> You recorded a credit that never happened, or recorded the wrong amount (a currency-unit error, a parsing slip). Your replica is genuinely wrong.</p></li><li><p><strong>Their corrections.</strong> Banks reverse, adjust, and post fees you didn't initiate. Real changes on their side you must absorb.</p></li><li><p><strong>Fraud / unexpected events.</strong> Money moved that no part of your system initiated. The scariest class &#8212; recon is often how you <em>first learn</em> of it.</p></li></ul><p>A mature reconciliation engine doesn't just find discrepancies; it tells you <em>which kind</em> each one is, because the response to "timing" (wait) is the opposite of the response to "fraud" (alarm).</p><h2>Building a Reconciliation Engine</h2><p>The core loop: pull the source of truth (the bank statement for a period), pull your ledger for the same period, and compare them by a shared key &#8212; the bank's transaction ID, which you stored when you ingested each event.</p><pre><code><code>async function reconcile(period: DateRange) {
  const statement = await bankApi.getStatement(period) // source of truth
  const ledger = await db.ledgerEntry.findMany({ where: { period } })

  const byBankId = new Map(ledger.map(e =&gt; [e.bankTransactionId, e]))
  const discrepancies: Discrepancy[] = []

  // 1. Every bank line should have a matching ledger entry
  for (const line of statement.lines) {
    const entry = byBankId.get(line.transactionId)
    if (!entry) {
      discrepancies.push({ type: 'MISSING_IN_LEDGER', line }) // we never recorded it
    } else if (entry.amount !== line.amount) {
      discrepancies.push({ type: 'AMOUNT_MISMATCH', line, entry })
    }
    byBankId.delete(line.transactionId)
  }

  // 2. Anything left in our ledger has no bank counterpart
  for (const orphan of byBankId.values()) {
    discrepancies.push({ type: 'MISSING_AT_BANK', entry: orphan })
  }

  return discrepancies
}</code></code></pre><p>Two design properties matter as much as the comparison itself:</p><ul><li><p><strong>Idempotent and resumable.</strong> A reconciliation run over a busy day processes a lot; it must be safe to re-run (it computes the same discrepancies) and able to resume if interrupted &#8212; the same idempotency discipline the rest of the money pipeline relies on.</p></li><li><p><strong>Run continuously, not once.</strong> Because timing divergence resolves itself, a discrepancy that's 30 seconds old is probably benign; one that's 24 hours old is real. Re-running reconciliation lets benign drift age out and surfaces only what <em>persists</em>.</p></li></ul><h2>Classifying Discrepancies</h2><p>The three structural outcomes map directly to the drift causes, and each implies an action:</p><ul><li><p><strong>Missing in ledger</strong> (bank has it, you don't) &#8212; typical cause: lost or late webhook. Action: ingest it to recover; if it persists past your redelivery window, it's a genuine lost event.</p></li><li><p><strong>Missing at bank</strong> (you have it, bank doesn't) &#8212; typical cause: your bug, or a not-yet-settled entry. Action: investigate hard &#8212; you may have invented money.</p></li><li><p><strong>Amount mismatch</strong> (same transaction, different number) &#8212; typical cause: currency-unit or parsing bug. Action: almost always your side; fix and correct.</p></li></ul><p>The art is layering <strong>time</strong> on top of structure. A "missing in ledger" that's seconds old is the benign timing case &#8212; wait. The same discrepancy still present after your retry/redelivery window has elapsed is a genuine lost event &#8212; recover it. Encoding that age threshold is what separates a noisy reconciliation system (screaming about every in-flight transfer) from a useful one (alerting only on true, settled divergence).</p><h2>Converging: Auto-Heal vs Escalate</h2><p>Detection is half the job; convergence is the other half. Split discrepancies by how safe automatic resolution is:</p><ul><li><p><strong>Auto-heal the safe, well-understood cases.</strong> A "missing in ledger" that exactly matches a known bank transaction format can be ingested automatically &#8212; it's the lost-webhook recovery path, and re-ingesting through your normal idempotent pipeline is safe. The system converges itself.</p></li><li><p><strong>Escalate the dangerous cases to a human.</strong> "Missing at bank" (you may have credited phantom money) and anything resembling fraud must go to a person with an audit trail. Never let software silently invent or destroy money to "fix" a mismatch &#8212; that's how a reconciliation bug becomes a financial loss.</p></li></ul><p>The guiding principle: automation handles the cases where the <em>correct</em> convergence is unambiguous; humans handle the cases where converging the wrong way would move real money incorrectly.</p><h2>The Eventual-Consistency Mindset</h2><p>Reframing reconciliation as eventual consistency changes how you build the whole system:</p><ul><li><p><strong>You stop treating divergence as failure.</strong> It's the expected steady state of two asynchronously-communicating replicas. Failure is <em>persistent</em> divergence, not divergence itself.</p></li><li><p><strong>The bank is authoritative; your ledger is a replica to keep honest.</strong> When they disagree, you converge toward the bank, not the other way around.</p></li><li><p><strong>Real-time ingestion and reconciliation are complementary, not redundant.</strong> Webhooks give you low latency; reconciliation gives you a correctness backstop that catches everything the real-time path drops. You need both &#8212; fast <em>and</em> eventually correct.</p></li></ul><h2>Pitfalls</h2><ul><li><p><strong>Treating every real-time mismatch as an incident.</strong> Without a time threshold, in-flight transfers look like errors and you drown in false alarms.</p></li><li><p><strong>Auto-correcting "missing at bank."</strong> Deleting or reversing a ledger entry because the bank "doesn't have it" may erase a legitimate not-yet-settled transaction. Investigate, don't auto-heal.</p></li><li><p><strong>Reconciling on a key that isn't stable.</strong> If you match on amount+timestamp instead of the bank's transaction ID, coincidental collisions and rounding will wreck you. Always reconcile on the stable, unique identifier.</p></li><li><p><strong>Running reconciliation only at end-of-day.</strong> Daily batch was the old world; continuous reconciliation catches drift while it's still cheap to fix.</p></li><li><p><strong>No audit trail on convergence.</strong> Every auto-heal and every manual correction must be recorded &#8212; reconciliation that quietly changes balances is itself unauditable.</p></li></ul><h2>Conclusion</h2><p>Reconciliation looks like bookkeeping but it's really distributed-systems convergence wearing an accountant's coat. You and the bank are two replicas of one financial truth, drifting apart constantly as events propagate asynchronously and occasionally get lost. The engine's job is to detect that drift, classify it by structure and age, converge the safe cases automatically, and escalate the dangerous ones &#8212; all while treating temporary divergence as normal and only <em>persistent</em> divergence as a problem.</p><p>Internalising this reframes the whole money pipeline: idempotent processing keeps ingestion clean, the outbox pattern keeps events from being lost internally, careful concurrency control keeps the ledger self-consistent, and reconciliation is the final safety net proving your replica still agrees with reality. Build all four and you can say the thing every fintech system must be able to say: the books are correct, and here's the proof.</p>]]></content:encoded></item><item><title><![CDATA[VietQR Generating Banking QR Code]]></title><description><![CDATA[If you've been to Vietnam recently, you've seen them everywhere: black-and-white QR codes taped to every coffee stand, parking booth, and street-food cart.]]></description><link>https://news.tienng21.com/p/vietqr-generating-banking-qr-code</link><guid isPermaLink="false">https://news.tienng21.com/p/vietqr-generating-banking-qr-code</guid><dc:creator><![CDATA[TienNg21]]></dc:creator><pubDate>Fri, 14 Feb 2025 17:06:00 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!XJnB!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b0151a8-bb4b-4122-9bf7-d8067b59d966_1254x1254.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you've been to Vietnam recently, you've seen them everywhere: black-and-white QR codes taped to every coffee stand, parking booth, and street-food cart. Scan one with any banking app, the recipient's name and account fill in automatically, you type the amount, and the transfer is done in seconds. That's <strong>VietQR</strong> &#8212; the national standard that turned bank transfers into the default way Vietnamese people pay each other.</p><p>What looks like magic is actually a precise, well-documented string format. There's no proprietary API you have to call to <em>generate</em> one &#8212; a VietQR code is just a structured text payload, encoded as a QR image, that any bank app knows how to read. Once you understand the structure you can <a href="https://qrbank.tienng21.com/">generate them yourself</a> &#8212; which is exactly what I built at <a href="https://qrbank.tienng21.com/">qrbank</a>. This post is the breakdown: what VietQR is built on, how the payload is structured field by field, the CRC checksum everyone gets wrong, and how to turn it all into a scannable code.</p><h2>What VietQR Actually Is</h2><p>VietQR isn't a from-scratch invention. It's a national profile layered on top of the <strong>EMVCo QR Code Specification for Payment Systems</strong> &#8212; the same global standard behind merchant QR payments in many countries &#8212; operated in Vietnam through <strong>Napas</strong>, the national payment switch. That means two things:</p><ul><li><p>The payload follows the <strong>EMVCo TLV</strong> (Tag&#8211;Length&#8211;Value) format, so the structure is standardised and parseable.</p></li><li><p>The Vietnam-specific bits &#8212; which bank, which account &#8212; live inside a Napas-defined section identified by Napas's application ID.</p></li></ul><p>So generating a VietQR code is really: build an EMVCo TLV string, fill in the Napas merchant section with the bank and account, append a checksum, and render it as a QR image.</p><h2>The EMVCo TLV Structure</h2><p>Every field in the payload is encoded as three parts concatenated together:</p><ul><li><p><strong>Tag</strong> &#8212; a 2-digit ID saying what the field is (<code>00</code>, <code>38</code>, <code>54</code>, &#8230;).</p></li><li><p><strong>Length</strong> &#8212; a 2-digit count of how many characters the value has.</p></li><li><p><strong>Value</strong> &#8212; the actual content.</p></li></ul><p>So the very first field, the payload format indicator, is <code>00</code> (tag) + <code>02</code> (length) + <code>01</code> (value) = <code>000201</code>. A parser walks the whole string this way. A tiny helper makes building them painless:</p><pre><code><code>// Tag&#8211;Length&#8211;Value: 2-digit tag, 2-digit zero-padded length, then the value
function tlv(tag: string, value: string): string {
  const length = value.length.toString().padStart(2, '0')
  return `${tag}${length}${value}`
}

tlv('00', '01')  // &#8594; "000201"
tlv('53', '704') // &#8594; "5303704"  (currency VND)</code></code></pre><p>Some tags are <strong>templates</strong> &#8212; their value is itself a sequence of nested TLV fields. The Napas merchant-account section (tag <code>38</code>) is the important one.</p><h2>Building the Payload Field by Field</h2><p>Here are the fields a VietQR transfer payload uses, in order:</p><ul><li><p><code>00</code> &#8212; Payload format indicator: <code>01</code></p></li><li><p><code>01</code> &#8212; Point of initiation method: <code>11</code> (static) or <code>12</code> (dynamic)</p></li><li><p><code>38</code> &#8212; Merchant account info (Napas template, nested)</p></li><li><p><code>53</code> &#8212; Currency: VND = ISO 4217 <code>704</code></p></li><li><p><code>54</code> &#8212; Transaction amount (optional)</p></li><li><p><code>58</code> &#8212; Country code: <code>VN</code></p></li><li><p><code>62</code> &#8212; Additional data (optional, e.g. message)</p></li><li><p><code>63</code> &#8212; CRC checksum (computed last)</p></li></ul><p>The nested <strong>tag 38</strong> is the heart of it: <code>00</code> = Napas GUID <code>A000000727</code>, <code>01</code> = beneficiary (bank BIN + account number), <code>02</code> = service code <code>QRIBFTTA</code> (transfer to account).</p><pre><code><code>function buildVietQR(opts: {
  bankBin: string      // e.g. "970415" for Vietinbank
  accountNumber: string
  amount?: number
  message?: string
}): string {
  const beneficiary = tlv('00', opts.bankBin) + tlv('01', opts.accountNumber)
  const merchantInfo =
    tlv('00', 'A000000727') +
    tlv('01', beneficiary) +
    tlv('02', 'QRIBFTTA')

  let payload =
    tlv('00', '01') +
    tlv('01', opts.amount ? '12' : '11') +
    tlv('38', merchantInfo) +
    tlv('53', '704') +
    (opts.amount ? tlv('54', String(opts.amount)) : '') +
    tlv('58', 'VN')

  if (opts.message) {
    payload += tlv('62', tlv('08', opts.message))
  }

  payload += '6304'
  return payload + crc16(payload)
}</code></code></pre><h2>The CRC-16 Checksum (the Part Everyone Gets Wrong)</h2><p>Tag <code>63</code> is a 4-character <strong>CRC-16 checksum</strong> that lets a scanner verify the code wasn't corrupted. The subtle rule everyone misses: <strong>the CRC is calculated over the entire payload </strong><em><strong>including</strong></em><strong> the CRC field's tag and length (</strong><code>6304</code><strong>), but not the checksum value itself.</strong></p><p>The algorithm is <strong>CRC-16/CCITT-FALSE</strong>: polynomial <code>0x1021</code>, initial value <code>0xFFFF</code>, no reflection.</p><pre><code><code>function crc16(data: string): string {
  let crc = 0xffff
  for (let i = 0; i &lt; data.length; i++) {
    crc ^= data.charCodeAt(i) &lt;&lt; 8
    for (let j = 0; j &lt; 8; j++) {
      crc = crc &amp; 0x8000 ? (crc &lt;&lt; 1) ^ 0x1021 : crc &lt;&lt; 1
      crc &amp;= 0xffff
    }
  }
  return crc.toString(16).toUpperCase().padStart(4, '0')
}</code></code></pre><p>Get any parameter wrong &#8212; wrong polynomial, forgetting to include <code>6304</code>, reflecting the bits &#8212; and the string still <em>looks</em> like a valid VietQR code but every banking app rejects it. It's the single most common bug, invisible until you scan with a real app.</p><h2>Generating the QR Image</h2><p>Once you have the payload string, feed it to any QR library at error-correction level <strong>M</strong>. You can try the whole thing live at <a href="https://qrbank.tienng21.com/">qrbank.tienng21.com</a>.</p><pre><code><code>import QRCode from 'qrcode'

const payload = buildVietQR({
  bankBin: '970415',
  accountNumber: '113366668888',
  amount: 50000,
  message: 'Thanh toan',
})

await QRCode.toFile('vietqr.png', payload, {
  errorCorrectionLevel: 'M',
  margin: 2,
})</code></code></pre><h2>Static vs Dynamic Codes</h2><p>The point-of-initiation method (tag <code>01</code>) is a small field with a big UX impact:</p><ul><li><p><code>11</code><strong> &#8212; static</strong>: no amount baked in. The payer enters the amount. Print once, stick on the wall, reuse forever. Perfect for a shop or donation jar.</p></li><li><p><code>12</code><strong> &#8212; dynamic</strong>: amount fixed in the code. One code per transaction. Perfect for checkout and invoices.</p></li></ul><p>The only structural difference is whether you include tag <code>54</code>, but choosing correctly is the difference between a reusable poster and a per-order checkout flow.</p><h2>Testing and Pitfalls</h2><ul><li><p><strong>Always verify with a real banking app</strong>, not just a QR decoder. Only a bank app confirms the payment fields are valid.</p></li><li><p><strong>Use the correct Napas BIN</strong>, not the bank's SWIFT or hotline number. The wrong 6-digit ID routes nowhere.</p></li><li><p><strong>Lengths must match exactly.</strong> An off-by-one shifts the entire parse and silently corrupts everything after it.</p></li><li><p><strong>Compute the CRC last, over </strong><code>&#8230;6304</code><strong>.</strong> It's always the CRC.</p></li></ul><h2>Conclusion</h2><p>VietQR feels like infrastructure magic, but under the hood it's a disciplined, fully open format: EMVCo TLV fields, a Napas merchant section carrying the bank and account, and a CRC-16 checksum stitching it together. There's no secret API &#8212; once you can build the TLV string and compute the checksum correctly, you can generate a valid, scannable transfer code entirely on your own. Try it live at <a href="https://qrbank.tienng21.com/">qrbank.tienng21.com</a>.</p><p>The next time you scan a QR at a street stall, you'll know precisely what those black squares are saying.</p><p>If you're building on top of VietQR and need to handle what happens <em>after</em> the money arrives &#8212; routing, attribution, and reconciliation &#8212; my post on <a href="https://news.tienng21.com/p/virtual-accounts-in-fintech?r=3mtxbo">Virtual Accounts in Fintech</a> covers exactly that infrastructure layer.</p>]]></content:encoded></item><item><title><![CDATA[Virtual Accounts in Fintech]]></title><description><![CDATA[How unique account numbers eliminate payment attribution, simplify reconciliation, and make money movement reliable at scale]]></description><link>https://news.tienng21.com/p/virtual-accounts-in-fintech</link><guid isPermaLink="false">https://news.tienng21.com/p/virtual-accounts-in-fintech</guid><dc:creator><![CDATA[TienNg21]]></dc:creator><pubDate>Thu, 09 Jan 2025 16:25:00 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!XJnB!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5b0151a8-bb4b-4122-9bf7-d8067b59d966_1254x1254.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Imagine you run a platform that receives bank transfers from thousands of users (U are a very successfuly bulder :_)). Every day money lands in your company&#8217;s bank account, and for each transfer you have to answer one deceptively hard question: <em>&#8220;who sent this, and what is it for?&#8221;</em> Bank transfers carry a memo field, so you ask users to type a reference code <code>ORDER12345</code> and you hope they get it right. Normally in Vietnam, <a href="https://payos.vn/">PayOS</a> or <a href="https://sepay.vn/">SePay</a> support very well. And we use QR code so it&#8217;s mostly correct, but what happen when they typo it, omit it, or write &#8220;NGUYEN HUU TIEN chuyen tien&#8221;, or enter the wrong amount and now you have a pile of unattributed payments and a human manually matching them to orders (increase the workload for CS).</p><p>This is the problem <strong>virtual accounts</strong> solve, and it&#8217;s one of the most useful patterns in fintech that most engineers have never had to think about. I don&#8217;t ever heard about that before I spent a chunk of my time at a personal-finance startup deep in virtual-account infrastructure &#8212; provisioning them, routing transactions through them, and reconciling the results &#8212; and it reshaped how I think about money movement. This post is what I learned: what a virtual account actually is, how you provision them at scale, how transactions route through them, and why reconciliation is the whole point.</p><h2>The Problem Virtual Accounts Solve</h2><p>The core issue is <strong>attribution</strong>. A traditional company bank account is a single destination. Every incoming transfer lands in the same place, and the only thing distinguishing one payment from another is whatever the sender typed in the memo. Free-text memos are a catastrophe for automation:</p><ul><li><p>Users mistype reference codes, so your matching logic fails.</p></li><li><p>Two users can coincidentally use the same memo (very big issue - from the architecture aspect).</p></li><li><p>Refunds, partial payments, and overpayments have no clean linkage.</p></li><li><p>Reconciliation becomes a manual, error-prone, end-of-day chore.</p></li></ul><p>What you actually want is for the <em>destination account number itself</em> to identify the payer &#8212; so attribution happens automatically the instant money arrives, with no memo parsing and no guessing. That&#8217;s exactly what a virtual account gives you.</p><h2>What a Virtual Account Actually Is</h2><p>A <strong>virtual account (VA)</strong> is a unique account number that maps to a single real &#8220;master&#8221; settlement account at a partner bank. The bank issues you a range of account numbers; each one looks and behaves like a normal account number to the sender, but all the money actually pools into one physical account you control.</p><pre><code><code>                       &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;
  User A &#8594; VA 9988001 &#8594;&#9474;                            &#9474;
  User B &#8594; VA 9988002 &#8594;&#9474;   Master settlement acct   &#9474; &#8594; your ledger
  User C &#8594; VA 9988003 &#8594;&#9474;        (one real acct)     &#9474;
                       &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;</code></code></pre><p>The magic is the <strong>1:1 mapping</strong> between a VA number and a user (or an invoice, or an order). When a transfer arrives at <code>9988002</code>, you don&#8217;t need to read the memo &#8212; the destination number <em>is</em> the identity. Attribution is structural, not textual.</p><p>There are two common provisioning models:</p><ul><li><p><strong>Per-user VAs</strong> &#8212; each user gets one permanent VA. Good for wallets, recurring deposits, personal finance.</p></li><li><p><strong>Per-transaction VAs</strong> &#8212; a fresh VA is minted for each invoice or order, often with an expiry. Good for one-off checkouts where you want a payment to map to exactly one order.</p></li></ul><h2>Provisioning: Minting Accounts at Scale</h2><p>Provisioning is where the engineering starts. You&#8217;re typically issued a <strong>BIN-prefixed number range</strong> by the bank &#8212; say, every account starting <code>9988</code> followed by your suffix. Your job is to allocate suffixes uniquely and durably.</p><pre><code><code>async function provisionVirtualAccount(userId: string): Promise&lt;VirtualAccount&gt; {
  // Reuse if this user already has one (per-user model)
  const existing = await db.virtualAccount.findUnique({ where: { userId } })
  if (existing) return existing

  // Allocate the next suffix atomically &#8212; no two users may collide
  const suffix = await allocateNextSuffix() // DB sequence or counter, transactional
  const accountNumber = `${BANK_BIN_PREFIX}${suffix.padStart(7, '0')}`

  // Register it with the partner bank so transfers to it are accepted
  await bankApi.registerVirtualAccount({ accountNumber, userId })

  return db.virtualAccount.create({
    data: { userId, accountNumber, status: 'ACTIVE' },
  })
}</code></code></pre><p>The non-negotiables here:</p><ul><li><p><strong>Uniqueness must be guaranteed at the database level</strong> &#8212; a sequence or a unique constraint, never an application-side &#8220;find max + 1&#8221; that races under load. A duplicated VA means two users&#8217; money becomes indistinguishable, which is the exact failure mode you set out to prevent.</p></li><li><p><strong>Registration with the bank is a side effect that can fail</strong> &#8212; wrap provisioning so a half-created VA (allocated locally but not registered) is retried or rolled back, not left dangling.</p></li></ul><h2>Transaction Routing: From VA to Ledger</h2><p>When money hits a VA, the bank notifies you &#8212; almost always by <strong>webhook</strong>. The payload says &#8220;account <code>9988002</code> received 500,000 VND.&#8221; Routing is the act of turning that raw event into a ledger entry attributed to the right user:</p><pre><code><code>async function handleIncomingTransfer(event: BankWebhookEvent) {
  const va = await db.virtualAccount.findUnique({
    where: { accountNumber: event.accountNumber },
  })
  if (!va) {
    // Money arrived at a VA we don't recognise &#8212; quarantine, don't drop
    return quarantineUnmatchedPayment(event)
  }

  await db.ledgerEntry.create({
    data: {
      userId: va.userId,
      type: 'CREDIT',
      amount: event.amount,
      reference: event.bankTransactionId, // idempotency key
    },
  })
}</code></code></pre><p>Two things make or break this:</p><ul><li><p><strong>Never drop an unmatched payment.</strong> If a transfer arrives for an account you can&#8217;t resolve, quarantine it for investigation. Money must never silently vanish &#8212; that&#8217;s a regulatory and trust failure, not just a bug.</p></li><li><p><strong>The bank&#8217;s transaction ID is your idempotency key.</strong> Banks retry webhooks. Without idempotency you&#8217;ll double-credit a user every time a webhook is redelivered.</p></li></ul><h2>Reconciliation: The Real Payoff</h2><p>Here&#8217;s the part people underestimate. Your system has its own record of what <em>should</em> have happened (your ledger). The bank has its own record of what <em>actually</em> happened (the settlement account statement). <strong>Reconciliation is proving those two agree</strong> &#8212; every credit in your ledger corresponds to a real transfer in the bank&#8217;s statement, and vice versa.</p><p>VAs make this tractable because every payment is pre-attributed. A daily reconciliation job pulls the bank statement and matches each line against your ledger by transaction ID:</p><pre><code><code>async function reconcileDay(date: string) {
  const statement = await bankApi.getStatement(date) // source of truth: the bank
  const ledger = await db.ledgerEntry.findMany({ where: { date } })

  const byBankId = new Map(ledger.map(e =&gt; [e.reference, e]))

  for (const line of statement.lines) {
    const entry = byBankId.get(line.transactionId)
    if (!entry)
      flagMissingInLedger(line) // bank has it, we don't
    else if (entry.amount !== line.amount) flagMismatch(entry, line)
    byBankId.delete(line.transactionId)
  }
  for (const orphan of byBankId.values()) flagMissingAtBank(orphan) // we have it, bank doesn't
}</code></code></pre><p>Three failure classes fall out of this, and naming them is half the job:</p><ul><li><p><strong>Missing in ledger</strong> &#8212; the bank received money you never recorded (a dropped webhook). Recover it.</p></li><li><p><strong>Missing at bank</strong> &#8212; you recorded a credit the bank has no record of. A bug or a fraudulent event; investigate hard.</p></li><li><p><strong>Amount mismatch</strong> &#8212; same transaction, different number. Almost always a parsing or currency-unit error on your side.</p></li></ul><p>A clean reconciliation run &#8212; zero discrepancies &#8212; is the daily proof that your money movement is correct. That report is what lets finance, auditors, and regulators trust the system.</p><h2>Webhooks, Idempotency, and Eventual Consistency</h2><p>Money systems are distributed systems, and the bank is a partner you don&#8217;t control. Internalise three things:</p><ul><li><p><strong>Webhooks are at-least-once.</strong> Design every handler to be idempotent on the bank&#8217;s transaction ID. Re-processing the same event must produce the same ledger, not a second credit.</p></li><li><p><strong>The bank is the source of truth, your ledger is a replica.</strong> When they disagree, the statement wins. Your reconciliation job exists precisely to detect and resolve that drift.</p></li><li><p><strong>Embrace eventual consistency.</strong> A transfer may show in a webhook seconds after it lands, or minutes. Build for the gap &#8212; pending states, retries, and a reconciliation backstop that catches anything the real-time path missed.</p></li></ul><h2>Edge Cases That Bite</h2><p>The happy path is easy; the money is in the edges:</p><ul><li><p><strong>Overpayment / underpayment</strong> &#8212; for per-transaction VAs, decide up front whether a wrong amount is accepted, refunded, or held.</p></li><li><p><strong>Late payment after expiry</strong> &#8212; money can arrive at an expired per-transaction VA. You still received it; you still must handle it.</p></li><li><p><strong>Refunds and reversals</strong> &#8212; a credit can be clawed back. Your ledger needs the inverse entry, reconciled like any other.</p></li><li><p><strong>Currency and minor units</strong> &#8212; store amounts in the smallest unit (e.g. integer VND/cents). Floating-point money is a bug waiting to happen.</p></li></ul><h2>Conclusion</h2><p>Virtual accounts are a small idea with an outsized payoff: make the <em>destination account number</em> carry identity, and attribution stops being a memo-parsing guessing game. Provisioning gives every user or invoice a unique, bank-registered number; routing turns incoming transfers into attributed ledger entries idempotently; and reconciliation proves, every single day, that your records and the bank&#8217;s records tell the same story.</p><p>What stuck with me most is the mindset shift. In most software a dropped event is an inconvenience; in money movement it&#8217;s an unreconciled discrepancy that someone <em>will</em> notice. Building with virtual accounts taught me to treat the bank as the source of truth, my ledger as a replica to keep honest, and reconciliation as the non-negotiable safety net underneath it all.</p><p></p><p></p>]]></content:encoded></item></channel></rss>