HomeWorkAboutContact

Case · Mobile · Payments

DuitNow Payments App

A DuitNow payments client built like money matters: real bank-QR parsing, integer-cent money, and biometrics before every debit.

Role
Sole engineer
Year
2026
Status
Demo · complete
Stack
Flutter · Dart · Riverpod
Problem

Payments are unforgiving: a mis-parsed QR, a rounding error, or a debit without consent is a real-world failure, not a bug ticket.

My role

Sole Flutter engineer

Result

A working client in one night — a real CIMB production QR parsed and checksum-verified, integer-cent money end to end, biometrics before every debit, 71 tests.

01The problem

DuitNow QR and DuitNow Transfer are Malaysia's instant-payment rails, and they punish guesswork: a QR payload has to satisfy EMVCo's tag-length-value structure and a CRC checksum, money has to reconcile to the cent, and no debit should ever happen without the payer's consent. I built a working Flutter client in a single overnight push to hold exactly that bar — not to mock a happy path, but to treat money and identity the way a real wallet has to.

02A look at the app

03What I built

  • Scan a DuitNow QR, parse its EMVCo tag-length-value payload, recompute the CRC16-CCITT-FALSE checksum, and refuse any code whose checksum doesn't match — verified against a real CIMB OCTO production QR, not a synthetic fixture — then show an explicit confirmation before any debit.
  • Compose DuitNow transfers three ways — bank account (validated against per-bank account-length rules across six banks), phone number (E.164), or national ID (MyKad, checked against the 12-digit format and state-code table) — each on its own validation path before the flow proceeds.
  • Gate every money-moving action behind device biometrics — Face ID, fingerprint, or PIN — through local_auth.
  • Carry the payment as typed state through a Riverpod provider, never through route parameters, so amount and recipient live in typed app state instead of a tamperable URL.
  • Keep money as integer cents from parse to display; floating-point currency never enters the system.

04Key decisions

Validate at the boundary, not the button

QR payloads, MyKad numbers, phone numbers, and per-bank account formats are all checked before the user reaches a confirmation screen. By the time money is on screen, the inputs are already known-good.

Make failure a type, not a surprise

Every fallible path returns a sealed Result / Failure (Freezed) instead of throwing. There is no exception-driven control flow and no untyped `as` casts in the hand-written code (generated Freezed and JSON files excepted), so the compiler forces every error to be handled.

Money is integer cents, end to end

Amounts are integer cents from the moment a value is parsed to the moment it is displayed. Floating-point never touches currency, so totals can't drift by a rounding error.

Consent and least disclosure on every sensitive screen

Biometrics gate every debit, the payment intent travels through a provider rather than route params, and user-facing errors stay deliberately non-revealing — the precise failure reason lives in typed failures and tests, never leaked to the UI.

05Checks and tests

  • 40 parser tests across 7 groups cover EMVCo TLV parsing and CRC16-CCITT-FALSE checksum verification.
  • Checksum fixtures were derived by hand — the EMVCo reference ("123456789" → 0x29B1) plus hand-computed payloads — so the parser is pinned to known-good values, not to its own output.
  • Separate suites cover currency math, payment execution, and widget behaviour; validators cover MyKad, E.164 phone numbers, and per-bank account formats.
  • 71 tests in total — 40 parser, 21 currency, 4 payment-execution, 6 widget — so a regression names exactly which layer broke.

06Code sample

dartCRC16-CCITT-FALSE, hand-rolled — every scanned QR's checksum is recomputed and a mismatch refuses to parse
/// Every EMVCo QR payload ends with a CRC16-CCITT-FALSE checksum
/// over the preceding bytes, including the "6304" tag and length.
/// We recompute it and refuse to parse a code whose checksum is wrong.
int crc16(List<int> data) {
  var crc = 0xFFFF;                 // CCITT-FALSE initial value
  for (final byte in data) {
    crc ^= byte << 8;               // fold next byte into the high byte
    for (var i = 0; i < 8; i++) {   // then process it one bit at a time
      crc = (crc & 0x8000) != 0     // is the top bit set?
          ? (crc << 1) ^ 0x1021     //   yes: shift out, then XOR the polynomial
          : crc << 1;               //   no:  just shift
      crc &= 0xFFFF;                // stay 16-bit (Dart ints are 64-bit)
    }
  }
  return crc;
}

07Trade-offs

  • This is a demo on a mock backend, by design. Going live on DuitNow needs a PayNet partnership agreement and sandbox access — a commercial gate, not a coding one — so I recorded that as an ADR instead of faking live rails.
  • The mock transfer backend declines roughly one in ten attempts on an injectable random, so the failure path is exercised honestly rather than always showing success.
  • Payment-screen errors are intentionally non-revealing — a deliberate trade of user-facing diagnostics for not leaking why a payment failed.

08Result

A test-backed DuitNow client built to be checked, not trusted: a real CIMB production QR parsed and checksum-verified, integer-cent math end to end, typed failures on every fallible path, and biometrics before any debit. Built in one overnight push — honest about what's mocked, rigorous about what isn't.

09What I would improve next

  • Finish the native screenshot block — the Dart guard is wired and called, but the Android/iOS platform side is still stubbed, so it does not yet block on a real device.
  • Add mocked bank adapters so more transfer outcomes — limits, rejections, timeouts — can be driven end to end.
  • Add receipt and transaction-history screens without widening the sensitive surface area.