<?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]]></title><description><![CDATA[My personal Substack]]></description><link>https://news.tienng21.com</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</title><link>https://news.tienng21.com</link></image><generator>Substack</generator><lastBuildDate>Tue, 27 Oct 2026 22:13:58 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[Using git commit fixup to simplify commit squashing]]></title><description><![CDATA[Stop manually squashing commits &#8212; use --fixup with autosquash to clean up your history automatically]]></description><link>https://news.tienng21.com/p/using-git-commit-fixup-to-simplify</link><guid isPermaLink="false">https://news.tienng21.com/p/using-git-commit-fixup-to-simplify</guid><dc:creator><![CDATA[TienNg21]]></dc:creator><pubDate>Fri, 21 Mar 2025 17:19: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>We've all been there. You're working on a feature branch, making small incremental commits with messages like "wip", "fix typo", "update imports". Before you know it, you have 15 commits for what should logically be one clean PR. Squashing these commits manually is tedious and error-prone.</p><p>Enter <code>git commit --fixup</code> &#8212; a powerful flag that, combined with <code>git rebase --autosquash</code>, lets Git automatically arrange and squash your messy commits into a clean history. Here's how to use it.</p><h2>What is git commit --fixup?</h2><p><code>git commit --fixup</code> creates a new commit that is explicitly marked as a fixup for an earlier commit. The key difference from <code>--amend</code>:</p><ul><li><p><code>git commit --amend</code>: rewrites the most recent commit in place (changes its hash).</p></li><li><p><code>git commit --fixup</code>: creates a <em>new</em> commit prefixed with <code>fixup!</code>, which <code>rebase --autosquash</code> will later merge into its target automatically.</p></li></ul><p>When combined with <code>git rebase -i --autosquash</code>, you can:</p><ul><li><p>Add forgotten files to an earlier commit</p></li><li><p>Fix typos or bugs that belong to a previous commit</p></li><li><p>Squash multiple related commits into one &#8212; automatically, without manual reordering</p></li></ul><h2>The Traditional Problem</h2><p>Consider this common scenario:</p><pre><code><code>git commit -m "wip: add authentication"
git commit -m "fix: typo in auth"
git commit -m "add tests for auth"
git commit -m "update auth logic"
git commit -m "fix off-by-one error"
git commit -m "wip: add authorization"
git commit -m "refactor auth module"</code></code></pre><p>Seven commits for what should be one cohesive "Add authentication" feature. Without <code>--fixup</code>, you'd open <code>git rebase -i</code> and manually mark each commit as <code>squash</code> or <code>fixup</code> &#8212; repetitive and easy to get wrong.</p><h2>The Basic Fixup Workflow</h2><p>Here's the pattern:</p><pre><code><code># 1. Make your original commit
git commit -m "feat: add user authentication"

# 2. Later, realise you forgot something &#8212; add it as a fixup
git add password-reset.ts
git commit --fixup=HEAD

# 3. When ready to clean up, run autosquash
git rebase -i --autosquash HEAD~2</code></code></pre><p>The <code>--fixup=HEAD</code> flag creates a commit named <code>fixup! feat: add user authentication</code>. When you run <code>rebase --autosquash</code>, Git automatically moves that commit directly after its target and marks it <code>fixup</code> in the interactive editor &#8212; you just save and close.</p><h2>Step-by-Step: Fixup with autosquash</h2><p>Say your log looks like this:</p><pre><code><code>abc1234 feat: implement login
def5678 fixup! feat: implement login
ghi9012 wip: update docs
jkl3456 fixup! feat: implement login</code></code></pre><p>Run:</p><pre><code><code>git rebase -i --autosquash HEAD~4</code></code></pre><p>Git automatically opens the editor pre-arranged as:</p><pre><code><code>pick abc1234 feat: implement login
fixup def5678 fixup! feat: implement login
fixup jkl3456 fixup! feat: implement login
pick ghi9012 wip: update docs</code></code></pre><p>Save and close &#8212; Git squashes all three login commits into one and leaves the docs commit untouched. No manual reordering needed.</p><h2>Real-World Example: Bug Fix with Multiple Passes</h2><pre><code><code># Initial fix
git commit -m "fix: handle null pointer in user service"

# Test reveals another case
vim user.service.ts
git add user.service.ts
git commit --fixup=HEAD

# Add a regression test
git add user.test.ts
git commit --fixup=HEAD

# Log now shows:
# abc1234 fix: handle null pointer in user service
# def5678 fixup! fix: handle null pointer in user service
# ghi9012 fixup! fix: handle null pointer in user service

# Clean up
git rebase -i --autosquash HEAD~3
# All three combined into one clean commit automatically</code></code></pre><h2>Real-World Example: Multi-Step Feature</h2><p>You want this final history:</p><ul><li><p><code>feat: add authentication system</code></p></li><li><p><code>refactor: clean up auth flow</code></p></li></ul><p>But your current log is:</p><pre><code><code>f47ac8 refactor: clean up auth flow
ba3d92 wip: add logout
8c7e1a wip: add token refresh
4a5b6c fix: handle edge case
9c8d7e wip: add login form
2a3b4c wip: setup auth context</code></code></pre><p>Run <code>git rebase -i HEAD~6</code> and arrange:</p><pre><code><code>pick   2a3b4c wip: setup auth context
squash 9c8d7e wip: add login form
fixup  4a5b6c fix: handle edge case
fixup  8c7e1a wip: add token refresh
squash ba3d92 wip: add logout
pick   f47ac8 refactor: clean up auth flow</code></code></pre><p>Git squashes the first five into one, opens the editor so you can write a clean message like <code>feat: add authentication system</code>, then keeps the refactor commit separate.</p><h2>Best Practices</h2><h3>Set autosquash globally</h3><p>Save yourself from typing <code>--autosquash</code> every time:</p><pre><code><code>git config --global rebase.autoSquash true</code></code></pre><p>Now <code>git rebase -i HEAD~N</code> automatically honours <code>fixup!</code> commits.</p><h3>Write good original messages</h3><p>Since fixup commits inherit the target's message, a descriptive original message means you won't need to edit anything when squashing:</p><pre><code><code># Bad &#8212; you'll need to rewrite this during squash
git commit -m "fix"

# Good &#8212; already describes the final state
git commit -m "fix: handle null pointer in auth service when user not found"</code></code></pre><h3>Test after rebasing</h3><pre><code><code>git rebase -i --autosquash HEAD~5
npm test
# If tests fail, recover from reflog:
git reflog
git reset --hard HEAD@{1}</code></code></pre><h3>Back up before large rebases</h3><pre><code><code>git branch backup-before-cleanup
git rebase -i HEAD~10
# If something goes wrong:
git reset --hard backup-before-cleanup</code></code></pre><h2>Common Pitfalls</h2><ul><li><p><strong>Don't rebase public history.</strong> If commits are already on a shared branch, use merge instead. Check first: <code>git log origin/main..HEAD</code>. If the output is empty, don't rebase.</p></li><li><p><strong>Force-push safely.</strong> After rebasing a feature branch, use <code>git push --force-with-lease</code> instead of <code>--force</code> to avoid overwriting a teammate's push.</p></li><li><p><strong>Don't squash too aggressively.</strong> Three focused commits reviewable on their own are better than one giant commit that's impossible to review.</p></li><li><p><strong>Resolve conflicts carefully.</strong> If a conflict arises mid-rebase, edit the file, stage it with <code>git add</code>, then <code>git rebase --continue</code>. If it gets messy, <code>git rebase --abort</code> returns you to where you started.</p></li></ul><h2>Troubleshooting</h2><p><strong>Git doesn't recognise </strong><code>--fixup</code><strong>:</strong> ensure you're on Git 2.24 or later (<code>git --version</code>).</p><p><strong>Squashed commit has the wrong message:</strong></p><pre><code><code>git commit --amend   # if it's the most recent commit
# or
git rebase -i HEAD~2  # to edit an earlier commit interactively</code></code></pre><p><strong>Unexpected conflicts during rebase:</strong> abort, review what changed between the commits you're squashing, and try again with a tighter range.</p><h2>Summary</h2><p><code>git commit --fixup</code> combined with <code>rebase --autosquash</code> is the fastest way to keep your commit history clean without the manual overhead of interactive rebase every time. The workflow is simple: commit normally, mark corrections with <code>--fixup</code> as you go, then run one <code>rebase --autosquash</code> command before opening your PR. Git does the reordering and squashing for you.</p>]]></content:encoded></item><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[Understanding Git DAG]]></title><description><![CDATA[A mental model that turns Git from a set of memorized commands into a predictable, powerful tool you can reason about]]></description><link>https://news.tienng21.com/p/understanding-git-dag</link><guid isPermaLink="false">https://news.tienng21.com/p/understanding-git-dag</guid><dc:creator><![CDATA[TienNg21]]></dc:creator><pubDate>Sat, 25 Jan 2025 06: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>Every time you run <code>git commit</code>, <code>git merge</code>, or <code>git rebase</code>, you&#8217;re manipulating a Directed Acyclic Graph (DAG). Yet most developers interact with Git through commands without understanding what&#8217;s happening beneath the surface. This abstraction works fine until something breaks &#8212; you encounter a merge conflict, accidentally rewrite public history, or find yourself in &#8220;detached HEAD&#8221; state with no idea how you got there.</p><p>Understanding Git&#8217;s DAG structure transforms Git from a set of memorized commands into a predictable, powerful tool. Instead of asking &#8220;what command do I run?&#8221; you&#8217;ll ask &#8220;what structure do I want to create?&#8221; This mental model helps you reason about complex situations, debug issues, and perform advanced operations with confidence.</p><p>In this guide, we&#8217;ll explore Git&#8217;s internal graph structure from first principles. You&#8217;ll learn how commits, branches, and references fit together, how to visualize the DAG, and how to manipulate it safely.</p><h2>What Is Git&#8217;s DAG?</h2><p>Git stores your project&#8217;s history as a Directed Acyclic Graph where commits are nodes and parent relationships are edges. Let&#8217;s break down what this actually means and why it matters.</p><h3>What Makes It a DAG?</h3><p>A Directed Acyclic Graph has three defining properties: <strong>direction</strong> (edges point from child to parent), <strong>no cycles</strong> (you can&#8217;t follow parent pointers back to a commit you&#8217;ve already visited), and <strong>connectivity</strong> (every commit except the initial one has at least one parent). These aren&#8217;t just mathematical trivia &#8212; they fundamentally shape how Git works.</p><p>The direction of edges means there&#8217;s always a clear &#8220;backward&#8221; path from any commit to the beginning of history. When you run <code>git log</code>, Git follows these parent pointers backward, showing you increasingly older commits. The absence of cycles guarantees this process terminates. And connectivity ensures every commit is traceable back to the project&#8217;s origin.</p><p>Consider what happens when you create a merge commit with two parents. Git stores this as a single node with two edges pointing backward, one to each parent. The graph structure makes parallel development natural and explicit.</p><h3>Git&#8217;s Object Storage</h3><p>Git implements this DAG using four core object types: <strong>blobs</strong> (file contents), <strong>trees</strong> (directory listings), <strong>commits</strong> (snapshots with metadata), and <strong>tags</strong> (pointers to commits). Each commit object contains a SHA-1 hash, the tree hash, parent commit hashes, timestamps, and the commit message.</p><p>The hash uniquely identifies the commit based on its content &#8212; if anything changes, the hash changes. This content-addressable storage means Git can detect corruption and guarantee integrity. Here&#8217;s what a commit object looks like internally:</p><pre><code><code>tree 3a4b5c6d7e8f0g1h2i3j4k5l6m7n8o9p0q1r2s
parent 9z8y7x6w5v4u3t2s1r0q9o8n7m6l5k4j3i2h1g
author Jane Developer &lt;jane@example.com&gt; 1735689600 +0000
committer Jane Developer &lt;jane@example.com&gt; 1735689600 +0000

Add user authentication feature</code></code></pre><p>The parent reference creates the edge in our DAG. This is why Git&#8217;s history is immutable &#8212; you can&#8217;t change a commit&#8217;s parent without changing its hash, which would require updating all descendant commits.</p><h2>References, Branches, and HEAD</h2><p>References are human-readable names that point to commits. The most common reference is a branch, which is simply a <strong>movable pointer</strong> to a commit. When you create a branch with <code>git branch feature-x</code>, Git creates a file at <code>.git/refs/heads/feature-x</code> containing the SHA-1 hash of the current commit.</p><p>HEAD is a special reference that points to the branch you&#8217;re currently working on. When you make a new commit, Git updates the branch HEAD points to, moving the branch reference forward. This design explains why branching in Git is instant and cheap &#8212; creating a branch just means writing a 41-byte file containing a commit hash.</p><p>Other important references include:</p><ul><li><p>Remote-tracking branches (<code>refs/remotes/origin/main</code>) track remote branches</p></li><li><p>Tags point to specific commits and don&#8217;t move</p></li><li><p>The reflog records every position HEAD has occupied</p></li><li><p>Stashes are commits pointed to by special refs</p></li></ul><p>When you delete a branch with <code>git branch -d</code>, Git checks whether the branch&#8217;s commits are reachable from another reference. If not, it warns you. These commits still exist in the object database until garbage collection, but they&#8217;re no longer accessible through normal references.</p><h2>Visualizing the Graph</h2><p>The most basic visualization is <code>git log --graph --oneline --all</code>, which shows commits as nodes with lines connecting them:</p><pre><code><code>* a1b2c3d (HEAD -&gt; main) Fix critical bug
| * e5f6g7h (feature-x) Add new feature
|/
* d4e5f6g Initial commit</code></code></pre><p>When reading the graph, look for important patterns:</p><ul><li><p><strong>Linear chains</strong> represent sequential development</p></li><li><p><strong>Merge commits</strong> have multiple parents (two lines converging)</p></li><li><p><strong>Branch points</strong> occur where divergent commits share a parent</p></li><li><p><strong>Orphaned commits</strong> with no path from HEAD will be garbage collected</p></li></ul><p>More sophisticated tools like <code>gitk</code>, <code>tig</code>, or GitKraken make these patterns even more explicit.</p><h2>Safe vs. Dangerous Operations</h2><p>The key to working confidently with Git is understanding which operations rewrite history and which only add new commits.</p><h3>Safe Operations (Add New Commits)</h3><p>Any operation that adds new commits without changing existing ones is safe, even for shared history. <strong>Merging</strong> is the canonical example:</p><pre><code><code>git checkout main
git merge feature-branch</code></code></pre><p>This creates a new commit with two parents. No existing commits are modified, so this is safe even if others have based work on main.</p><p><strong>Cherry-picking</strong> also creates new commits rather than modifying existing ones:</p><pre><code><code>git cherry-pick abc1234</code></code></pre><p>Git creates a new commit containing the same changes but with a different hash (different parent, different timestamp). The original commit remains untouched.</p><h3>Dangerous Operations (Rewrite History)</h3><p>Rebasing rewrites history by creating new commits and abandoning old ones. This is powerful but dangerous for shared history:</p><pre><code><code>git checkout feature-branch
git rebase main</code></code></pre><p>This finds the merge base between feature-branch and main, then recreates each commit with main as the new parent. Anyone who built work on the original commits will need to reconcile &#8212; the old commits still exist in the reflog, but they&#8217;re no longer referenced by a branch name.</p><p>Interactive rebasing gives you surgical control:</p><pre><code><code>git rebase -i HEAD~5</code></code></pre><p>This opens an editor showing the last five commits, letting you reorder, squash, edit, or drop them. Each action rewrites history.</p><h2>Recovery: The Reflog Is Your Safety Net</h2><p>When things go wrong, the reflog records every position HEAD has occupied &#8212; including commits you&#8217;ve &#8220;lost&#8221; through rebasing or branch deletion:</p><pre><code><code># Show everywhere HEAD has been
git reflog

# Restore to a previous state
git reset --hard HEAD@{5}</code></code></pre><p>Git garbage collection won&#8217;t remove commits still in the reflog, giving you a window to recover mistakes. Before panicking about lost work, always check the reflog first.</p><p><strong>Detached HEAD state</strong> occurs when HEAD points directly to a commit rather than a branch reference. Any commits you create in this state aren&#8217;t referenced by a branch name and can be lost if you switch away. To preserve them:</p><pre><code><code>git checkout -b rescue-branch</code></code></pre><h2>Key Takeaways</h2><ul><li><p><strong>Git stores history as a DAG</strong> where commits are nodes and parent references are directed edges. This structure provides integrity, enables efficient operations, and makes parallel development explicit.</p></li><li><p><strong>References are movable pointers to commits.</strong> Branches are simply references, and HEAD is a special reference pointing to your current location. Understanding this clarifies branch operations and detached HEAD.</p></li><li><p><strong>Safe operations add new commits without modifying existing ones.</strong> Merging, cherry-picking, and branching are safe even for shared history because they only extend the graph.</p></li><li><p><strong>Rebasing rewrites history by creating new commits.</strong> Use rebase on local feature branches; avoid it on public branches others have based work on.</p></li><li><p><strong>The reflog provides recovery from mistakes.</strong> Every position HEAD occupies is recorded. Before panicking about lost work, check the reflog.</p></li></ul><p>Git&#8217;s DAG structure isn&#8217;t just an implementation detail &#8212; it&#8217;s the foundation for everything Git does. Next time you encounter a merge conflict, a confusing rebase, or an unexpected Git state, visualize the graph. Ask yourself: what does the graph look like right now? What references point where? What operation will produce the structure I want? This mental model will serve you through every Git challenge you face.</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><item><title><![CDATA[Start sharing my journey]]></title><description><![CDATA[Learn & Build & Share & Learn & Build & Share]]></description><link>https://news.tienng21.com/p/coming-soon</link><guid isPermaLink="false">https://news.tienng21.com/p/coming-soon</guid><pubDate>Fri, 01 Nov 2024 03:20: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>I&#8217;ve been built many tools, products but it&#8217;s still in silent and I have no chance to sharing it so from now, I will sharing about my journey as a fullstack developer.</p><p></p><p>Love technology, building helpful tool, so firstly I&#8217;m creating tool to support my own demand. Currently is for learning English and Japanese, some other tool for productivity so I'll share about it as well.</p><p></p><p><a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">Let&#8217;s start!!</a></p><p></p>]]></content:encoded></item></channel></rss>