Skip to content
BUI CHI DUNG
  • AboutSections
  • ExpertiseSections
  • ProjectsSections
  • PrinciplesSections
  • AI EngineeringSections
  • ExperienceSections
  • ContactSections
  • E-Budget case studyCase studies
  • ESG System case studyCase studies
  • AI Sales Forecasting case studyCase studies
  • Digisales case studyCase studies
  • Salesportal case studyCase studies
  • Download CVLinks
  • Send an emailLinks
  • LinkedInLinks

15 results

Back to projects

Case study

E-Budget

Enterprise Budgeting Platform

An internal enterprise budgeting platform that replaces manual Excel workflows with centralized budget planning, Excel import/export, approval workflows and consolidated reporting.

Excel Technologies · In progress since February 2026

Project scale · counted from the repository, 2026-10-13

Domain modules
65
Route handlers
488
62 controllers
Entities
130
131 entity files
Migrations
431
Hand-written; no ORM synchronize
Lines of TypeScript source
~190k
~101k lines of test code
Test blocks
4,288
346 spec and e2e files; static count, not run
Domain error codes
180
Template handlers
35

Problem and goals

Budgeting ran on manual Excel workflows. E-Budget replaces them with one internal platform.

  • Centralized budget planning

    Budget planning happens in one platform instead of in manual spreadsheet workflows.

  • Excel import/export

    Spreadsheets remain a supported way to move data in and out, through templates and validated imports and exports.

  • Approval workflows

    Multi-level approval routes are resolved from the submitter's organizational scope, with email notifications.

  • Consolidated reporting

    Reports consolidate budget data in one place.

My contribution

Stated by me. The rest of this page describes the system, not who built each part. E-Budget development began in February 2026; the current source repository's first commit dates to June 2026, so its history does not cover the full project timeline.

  • Backend engineering: implemented business logic and backend modules across budgeting workflows, master data, templates, input norms and reporting.
  • Architecture and data consistency: contributed to technical design decisions around modular backend architecture, transaction boundaries and reliable multi-table writes.
  • Access control and data security: implemented and refined authorization and data-scoping mechanisms, alongside controls for sensitive salary data.
  • Data processing and automation: worked on Excel import/export workflows, budget recalculation and background job processing.
  • Engineering quality: contributed to validation, error handling and data-integrity safeguards for complex enterprise workflows.

System architecture

Request path

  1. Client

    Frontend consuming the API and the domain error codes.

  2. Global guard chain

    Applied to every request, in this order.

    1. JWT
    2. Per-user throttling
    3. Feature permission
  3. Controllers

    Thin entry points; none touches a repository directly.

  4. Services

    Own the transaction boundaries.

  5. Repositories

    Receive the EntityManager so multi-table writes stay atomic.

  6. PostgreSQL

    Migrations, partial unique indexes and numeric money columns.

Supporting services

  • Microsoft Entra SSO

    MSAL authorization-code sign-in; short-lived JWTs and rotating refresh tokens.

  • Excel engine

    Template population, validated imports and streamed exports.

  • Job workers

    Database-polling workers claiming rows with FOR UPDATE SKIP LOCKED.

  • Calculation engine

    Derives suggested figures from approved data and configurable norms.

  • Approval workflow

    Up to three levels, with email notifications sent after commit.

  • Sensitive-data protection

    Vault transit encryption, a whitelist and a short-lived reveal token.

  • Email queue

    BullMQ on Redis, used only for outgoing email.

  • Error-code catalog

    180 domain error codes, returned in API error responses.

Domain modules 65 in total, including master-data catalogs, templates, input norms, reports and accounts.

Simplified from the source. It shows layers and supporting capabilities, not a deployment topology.

Engineering deep dives

Modular backend and transaction design

Where atomicity is decided, and how the layers divide the work.

Documented design

  • 65 domain modules, 62 controllers, 93 services and 56 repositories. Controllers are thin: none injects a repository or the data source.
  • Services open transactions with DataSource.transaction and pass the EntityManager to repository methods, so multi-table writes commit or roll back together (about 100 transaction call sites across 67 services).
  • Repositories only build queries: reads use the shared manager, writes use the transaction's manager, and domain errors are raised in services.
  • Row locks (findForUpdate) guard read-modify-write steps, and sequential codes are generated inside the transaction so a failed insert rolls the counter back.
  • Connection-level statement and idle-in-transaction timeouts are set in the shared data-source configuration.

Design rationale

  • Putting the boundary in the service lets one use case map to one transaction, however many repositories it touches.
Transaction patternIllustrative pattern, not project code. Names are generic.
await dataSource.transaction(async (manager) => {  await repositoryA.write(manager, changeA);  await repositoryB.write(manager, changeB);}); // both writes commit or roll back together

Authentication, RBAC and row-level data scoping

Who can sign in, which actions they may perform, and which rows they may touch.

Documented design

  • Sign-in uses Microsoft Entra SSO through the MSAL authorization-code flow, with a random state value in a short-lived cookie guarding the callback.
  • Access tokens are short-lived JWTs (15 minutes by default) carrying only a subject and an email; permissions are not in the token.
  • Refresh tokens are random values stored only as SHA-256 hashes and rotated in a transaction with a row lock. Presenting a token that was already used counts as reuse and revokes all of that user's sessions.
  • Global guards run in this order: JWT authentication, per-user throttling, then a feature-permission guard.
  • Permissions are keyed by (module, resource, action): 3 modules, 65 resources and 10 actions. They are resolved from the database on every request, as the union of the user's roles reached through positions.
  • Row-level scoping: a user's scope is a set of rows, each with up to five optional organizational dimensions. A record is in scope when it matches any row (OR of rows), and a row matches only when every dimension it specifies matches (AND of dimensions). A dimension left empty adds no constraint.
  • Scoping is applied to reads (list and export queries, and a single out-of-scope record reads as not found), to writes (referenced parents must be in scope) and to imports (parent codes resolve within scope).

Design rationale

  • Keeping permissions out of the token means a role change takes effect on the next request, at the cost of a lookup.
  • Answering "not found" for an out-of-scope record avoids confirming that it exists.
Row-level scopingIllustrative example with abstract dimensions, not real project data. Records (A=a1, B=b1) and (A=a2, B=anything) are in scope; (A=a1, B=b9) is not.
  1. Scope row 1

    • Dimension A = a1
    • ANDDimension B = b1
  2. OR

    Scope row 2

    • Dimension A = a2

A record is in scope when it matches any scope row (OR of rows). A row matches only when every dimension it specifies matches (AND of dimensions); a dimension a row leaves empty adds no constraint.

Excel import/export and large-file handling

Moving spreadsheet data in and out of the platform without trusting the file.

Documented design

  • 35 template handlers. Most templates are real spreadsheets authored by analysts, which the engine fills with reference data and dropdown lists using ExcelJS; master-data templates are generated from scratch.
  • Dropdowns are data-validation lists pointing at a reference sheet; number and percentage rules are enforced at import time, not inside the workbook.
  • Template import validates every file and milestone first and reports all errors, each with a row, a code and a reason (codes E01 to E15). The write is one transaction, so an import is all-or-nothing.
  • Input-norm uploads have a preview endpoint that validates without writing; the other imports have no preview step.
  • Above 50,000 rows or 1,000,000 cells an export switches to a streaming writer that writes to a temporary file. The decision comes from a count query before any data is loaded.
  • Hard caps: 1,000,000 rows per sheet and 2,000,000 rows per file. The writer yields to the event loop every 2,000 rows, and at most 2 large exports run at once per process (configurable).
  • Uploads are limited to 5 MiB (10 MiB for input norms) and checked by declared type. A cap of 100 MB on uncompressed worksheet XML is read from the zip directory before the file is loaded, and 2 uploads run at once per process.
  • Rows are read with eachRow rather than up to the declared row count, after a single styled cell near the last row exhausted memory.

Design rationale

  • Checking declared sizes before loading a file rejects oversized workbooks cheaply.
Import path
  1. Template

    Reference data and dropdowns

  2. Upload

    Size, type and XML-size checks

  3. Validation

    All files; row, code and reason

  4. One transaction

    All-or-nothing write

Transactional outbox and async jobs

Doing slow work in the background without a message broker.

Documented design

  • Recalculation jobs use a transactional outbox: the job row is inserted with the EntityManager of the business operation (applying an input norm, approving a template or importing base prices), so it commits or rolls back with that operation. The default mode is asynchronous.
  • Export jobs are a database-backed queue: the job row is inserted in its own short transaction (duplicate check, per-user and disk caps), not in a business transaction, so they are not an outbox.
  • Workers for both tables poll on a timer and claim rows with FOR UPDATE SKIP LOCKED inside a transaction; no message broker is involved.
  • A heartbeat marks live jobs and stale ones are reclaimed (recalculation after 15 minutes, exports after 5), up to 3 attempts. Recalculation retries transient database errors; exports have no automatic retry.
  • State writes are conditional on the job id, status and attempt number, so a superseded run cannot overwrite a newer one. Export files are written per attempt and renamed into place.
  • Exports offer status polling, regeneration (permissions are rechecked) and a download restricted to the job's owner. Files expire after a configurable time (24 hours by default) and a scheduled job purges them.
  • BullMQ with Redis is used only for the email queue.

Design rationale

  • Writing the job row in the business transaction means the job exists only if the business change committed.
  • SKIP LOCKED makes a worker pass over rows that another worker has locked, so concurrent workers do not claim the same row at the same time.
  • Delivery is at-least-once: a reclaim, a retry or a crash after side effects can run a job more than once. The outbox does not by itself provide exactly-once execution; fenced writes and per-attempt files limit the damage of a repeat.
Illustrative recalculation flowSimplified sequence for an asynchronous recalculation; it is not a trace of the real system.
  1. 1Client to API service: Approve a template

  2. 2API service to PostgreSQL: One transaction: the approval and a recalculation job row

  3. 3API service to Client: Approval returns

  4. 4Worker to PostgreSQL: Claim the job with FOR UPDATE SKIP LOCKED

  5. 5Worker to PostgreSQL: Recalculate in one transaction; update the status

Claiming a jobIllustrative pattern, not the project's actual query. Table and column names are generic.
SELECT id FROM jobsWHERE status = 'pending'ORDER BY created_atLIMIT 1FOR UPDATE SKIP LOCKED;

Financial calculation correctness

Suggested figures that never rest on a guessed choice between norms.

Documented design

  • Suggested figures are derived for people costs (a unit cost per headcount group and month across 23 cost fields), non-people costs and selling prices. Inputs are approved source data and configurable norms held in the input-norm tables.
  • When several norm rows are equally specific for a case, the calculation does not pick one: it collects the conflicts, lists up to 20, writes nothing and fails the whole recalculation, keeping the previous suggested data.
  • A nested transaction (a SAVEPOINT) lets only the recalculation roll back: the action that triggered it, such as approving a norm edit, still succeeds and a failed job is recorded.
  • Reports aggregate money with exact integer arithmetic at a scale of one millionth, and reject invalid numbers instead of treating them as zero.

Design rationale

  • Failing explicitly surfaces the ambiguity to a person who can resolve it, instead of letting a guessed number enter a budget.
  • A SAVEPOINT scopes a failure to the recalculation, so one bad calculation does not undo the user's original action.
Decision for each case
Approved source data + configurable norms

Do several norm rows tie as the most specific match?

  • Yes

    The recalculation fails

    Conflicts are listed; nothing is written

  • No

    The most specific norm is applied

    Suggested figures are written

Salary data protection and data integrity

Keeping sensitive compensation data out of plain sight, and keeping the data consistent.

Documented design

  • Sensitive amounts (the new-hire cost norm and the suggested salary and bonus costs) are encrypted through HashiCorp Vault's transit engine and stored as ciphertext. An encryption failure raises an error instead of falling back to plaintext.
  • Reading them requires being on a whitelist of permitted users and presenting a reveal token. A per-person access code is emailed once and stored only as an argon2 hash. The token lasts 15 minutes, is bound to a user and a scope, and can be revoked for everyone.
  • Every rejected unlock returns the same error, and a dummy argon2 verification evens out the work done on each path; the audit log keeps the real reason. Sensitive export files need the same re-authentication to download.
  • 431 hand-written migrations, because the migration generator cannot express partial-index predicates; schema synchronization is disabled and a test asserts it.
  • Partial unique indexes apply only to rows not yet soft-deleted, so unique keys stay reusable. NULLS NOT DISTINCT is used where optional columns are part of a key, and bulk upserts use ON CONFLICT.
  • Money columns are PostgreSQL numeric; no float column types are used.
  • 180 domain error codes carry a stable code in every error response. The catalog is a convention with the frontend, not a machine-readable export.

Design rationale

  • A uniform error and equalized work remove two signals an attacker could use to learn who is on the whitelist.
  • Partial unique indexes let a soft-deleted row coexist with an active row that has the same business key.
Documented controls
  1. Vault transit encryption

    Amounts are stored as ciphertext

  2. Whitelist and access code

    Permitted users; code stored hashed

  3. Reveal token

    15 minutes, bound to user and scope

  4. Uniform denial

    Same error; equalized work

Approval workflow and notifications

Multi-level approval routed by organizational scope.

Documented design

  • Up to three approval levels. Approvers are resolved from the submitter's data-scope rows, and rows with the same approver chain form one route.
  • A level opens only when the lower levels of its route are approved; any rejection rejects the record, and the record is approved when every step of every route is approved.
  • Approving and rejecting are limited to the step's approver, and a missing or inactive approver configuration is reported as an error.
  • Notification email is sent after the transaction commits, through the BullMQ email queue; a failure is logged and the decision stays committed.

Design rationale

  • Sending email after commit means a notification never describes a decision that was rolled back.
Approval route
  1. Submitted

    Route resolved from scope rows

  2. Level 1

    Optional

  3. Level 2

    Opens after level 1

  4. Level 3

    Optional

  5. Approved

    When every step is approved

Measured performance

Figures come from the project's own performance report and code comments and were not re-measured for this page. Export figures were measured on a development machine and read-path figures on UAT. There are no production measurements, so none of these figures should be read as production performance.

Streaming Excel export

Development machine

Problem

Building a workbook in memory costs about 20 KB per row, so exports break down around 500k rows, while a single valid template can reach 642k rows.

What changed

  • Switch to a streaming writer above the thresholds described in the Excel deep dive.
  • De-duplicate identical data across forms: 642k rows became 321k (−50%).
Export time by sizeBars are scaled to the longest run.
  • 321k rows5.9 s
  • 1M rows~30 s
  • 2M rows~58 s
Peak memory
321k rows
~0.86 GB
1M rows
~1.2 GB
2M rows
~1.7 GB
Output file size
321k rows
35.7 MB
2M rows
215 MB

Reading data from the database

UAT

Problem

The SQL itself ran in 1.9 s, but about 61 MB had to travel to the application and the query hit statement_timeout. The main table is only 22 MB; the rest was category names repeated on every row.

What changed

  • Select only the 17 columns that are needed.
  • Look category names up from a map loaded once (about 2,350 cost centers) instead of repeating joins on every row.
  • Read with keyset pagination on (row_no, id), 10k rows per page, inside a REPEATABLE READ transaction for a consistent snapshot.
  • A dedicated reader replaces find({ relations }); no index was added to the 4.5M-row table, so no write locking was introduced.
Measured on UAT
Faster data reads
~2.5×
Per form (~4.7×)
14 s → 3 s
Per page
37–114 ms
Correctness checkThe new reader's output was compared with the old one using isDeepStrictEqual on UAT data and was identical.
Old vs new result
Identical

Pagination in SQL

Not a production measurement

Problem

Viewing 100 rows meant reading and decrypting an entire 107k-row form.

What changed

  • Push pagination down into SQL so only the rows of the requested page are read and decrypted.
Per pageMeasured on a 107k-row form.
Per page
0.16–0.63 s
Pages identical to the old path (first, middle, last)
6 of 6

Engineering trade-offs

  • Services own the transaction

    Why it matters
    One use case maps to one atomic unit of work, regardless of how many repositories it uses.
    What it costs
    Every repository call must be handed the EntityManager, and atomicity depends on each service following the convention.
  • A database-backed outbox and queue instead of a message broker for jobs

    Why it matters
    A recalculation job row is written in the business transaction, and no extra infrastructure is needed.
    What it costs
    Job claiming shares the database with business traffic, and delivery is at-least-once, so exactly-once execution is not a claim.
  • Streaming large exports with hard caps

    Why it matters
    Memory stays bounded while rows are produced, instead of scaling with the workbook model.
    What it costs
    The finished file is still buffered, and the caps and concurrency limit turn some requests away.
  • Failing the whole recalculation on a norm conflict

    Why it matters
    A visible error is safer than a plausible but wrong budget figure, and the previous data is kept.
    What it costs
    Users must resolve the conflict before any suggestion is updated, and a case with no matching norm still yields zero.
  • Resolving permissions from the database on every request

    Why it matters
    A role change applies immediately and the token stays small.
    What it costs
    Every guarded request pays for the lookups.
  • Row-level scoping as OR of rows and AND of dimensions

    Why it matters
    One small model expresses several organizational scopes, and the same rule serves reads, writes and imports.
    What it costs
    The rule has to be applied per module, so coverage must be checked module by module.
  • Hand-written migrations

    Why it matters
    They can express partial-index predicates that the generator cannot, and schema synchronization stays off.
    What it costs
    Manual authoring and review effort for each change, and no automatic drift check.

Lessons learned

  1. Decide atomicity at the use-case level

    Transaction boundaries in services keep the question of what must commit together next to the business logic.

  2. Treat background work as part of the transaction

    Storing the job row alongside the change makes a job's existence depend on the change having committed.

  3. Measure before optimizing

    The read path looked like a slow query, but the SQL took 1.9 s; the cost was moving about 61 MB of repeated data.

  4. Fail loudly, then cover the quiet cases too

    Failing on a tie between norms is a deliberate alternative to guessing; the next case to make explicit is a lookup that silently matches nothing.

  5. Bound resource use for file processing

    One styled cell could create about 20 million objects, so size checks, concurrency limits and streaming matter together.

  6. Back conventions with checks

    Layering, transaction ownership and the error catalog are conventions here; a lint rule, an architecture test or a machine-readable export would keep them honest.

Known limitations

  • Layering and transaction ownership are conventions: some services use repositories or query builders directly, and no lint rule or architecture test enforces them.
  • Transaction rollback is covered by mock-based unit tests; no test applies migrations or checks a multi-table rollback against a real database.
  • Money is stored exactly, but the compute step uses floating-point numbers and no test asserts rounding at compute time.
  • A recalculation case with no matching norm yields a cost of zero instead of an error.
  • Job delivery is at-least-once, recalculation has no idempotency key, and in asynchronous mode a recalculation failure does not roll back the business operation.
  • A streamed export is read back into memory before it is returned, and its thresholds and caps have no dedicated tests; concurrency limits are per process.
  • Notification email is best-effort, with no retry.
  • The domain error catalog is shared with the frontend by convention only, and a few raw exceptions remain.

Tech stack

  • Runtime and language

    • Node.js 20
    • TypeScript (strict)
  • Framework

    • NestJS 11
  • Data

    • PostgreSQL
    • TypeORM
  • Files

    • ExcelJS
  • Authentication

    • Microsoft Entra SSO (MSAL)
    • JWT
  • Security

    • HashiCorp Vault
    • argon2
  • Messaging

    • BullMQ
    • Redis
    • nodemailer
  • Testing

    • Jest
    • Supertest
  • API documentation

    • Swagger/OpenAPI
  • Tooling

    • pnpm