Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/Web Development/KO

Bank, Card and Investment Aggregation App

Bank, Card and Investment Aggregation App

Certificate scraping that gathers banks, cards, and investments into one screen

Four apps for one balance

This was a personal finance management (PFM) service. The core was scraping everything in a single certificate login and putting banks, cards, loans, and investments on one screen. The same view had to render inside partner apps as well as the service's own app. In 2020 personalized loan comparison was added: compare products from several lenders in one place, analyze the member's financial data to return a firm limit and rate rather than a provisional estimate, and finish the application without a branch visit.

System boundary and external dependencies
System boundary and external dependencies

Certificates native, screens on web

The entire viewing surface was built as a WebView instead of native screens, and the build output shipped to S3

A partner's app cannot be submitted to the store. If those screens had been native, changing a single line of copy would have meant waiting on the partner's app review schedule. .github/workflows/production.yml took a push to production and copied straight to s3://broccoli-inapp, which put the release cadence under direct control Certificate handling and the scraping engine live only in native code, so the web side could only invoke them through the bridge

The loan service was stood up on Apollo Server (GraphQL), with only progress state pushed over Subscriptions

A loan scan means the user taps a button and then waits tens of seconds for several lenders to answer. Instead of the screen hitting GET /status once a second, pushing over WebSocket only when the Context document changes seemed better for both the server and the battery MongoDB change streams require a replica set even for a single node, so production ran with ?replicaSet=rs pinned into the connection URL

Lender integrations were split into an Agent abstract class plus three per-company files: parser, maps, transforms

Every lender had a completely different message spec. Some used AES CBC, some ECB, some required fetching an OAuth token first. Letting per-company if statements bleed into service logic would clearly become unmanageable by the fifth lender The partner list kept growing. Three lenders at launch, with ten more already under contract

Every outbound call to a lender was routed behind an internal proxy (proxyUrl)

Financial institutions generally whitelist the source IP. Asking each lender to register a new IP every time a service instance is added turns into a deployment blocker. Pinning the egress IP to one proxy decouples scale-out from lender negotiations

Loan product terms were editable from an admin tool rather than in code

The limit, rate, early repayment fee, and disclaimers on a product detail page must be the exact wording that cleared compliance review. Keeping those as frontend constants meant a deploy for every line of copy The admin was internal-only, so it stayed minimal: one Express + Mongoose CRUD app deployed to Heroku

Deployment and infrastructure
Deployment and infrastructure

One request, fanned out and gathered

Three pieces mesh together.

The first is asset viewing. The native app opens a WebView, and the web asks for a token with window.broccoliView.getToken(). Native hands it back by calling window.setToken(token), and from that point the web calls asset APIs like /asset/main and /expense/main directly. The web cannot scrape, so it asks with requestAllScraping(type), and native reports progress back through window.onLoading('on') and window.setCompaniesStatus(json). Data comes from the server; commands and status travel over the bridge.

The second is loan comparison. When the user enters employment, income, and vehicle details and taps scan, a scan mutation fires, and LoanManager creates one Agent per active lender and throws them all at once. At the same time the last two months of transactions are pulled and sent to a scoring API, and that score is attached to the scan request so users with a higher approval probability go first.

The third is state. Each user has exactly one Context document holding where they are among idle → entering → scraping → scanned → presenting → applying → accepted, and every time that document changes, the change stream pushes it to the screen over a Subscription. Kill the app and reopen it, and entering at / lets ContextStatusMapper read the state and put the user back on the screen they left.

Core data model
Core data model

Fixing a screen inside someone else's app without waiting for app review

The first idea that comes to mind is loading a URL in the WebView and passing the token in the query string. It does connect. But the token then sits in browser history and server access logs, and more importantly the web has no way to ask native to do anything. Certificate linking, scraping retries, closing the screen: all of that ends up rebuilt as native screens, and at that moment you are tied to the partner's app review again.

The two-way bridge was pinned down as a contract. When the web calls native, Android goes through window.broccoliView.* and iOS through window.webkit.messageHandlers.broccoliView.postMessage({ method, param }). When native calls the web, four functions are installed by the web at mount time: window.setToken, window.onRefresh, window.onLoading, window.setCompaniesStatus. A fake bridge was also kept for local development. With REACT_APP_ENV=development, window.broccoliWebview and window.webkit are constructed in the web itself, a token is handed over after three seconds, close() routes to /_native, and startScraping() routes to /scan/status.

Since every screen is web, copy edits and layout changes end at an S3 upload. The partner app never goes back through review. With the bridge surface fixed at roughly twenty function names, the contract on the native side was unambiguous, and the fake bridge allowed development in a browser without a simulator.

When five lenders are asked at once, when do you say the scan is done?

Collecting every lender response with Promise.all and rendering them together looks like the natural move. But it breaks in two places. One slow lender ties the whole thing to its own speed, and one dead lender takes the four perfectly good results down with it. On top of that, some lenders do not answer the request directly, they fire the result back later to a callback URL on the service's server. That kind of response never enters a single Promise chain to begin with.

Instead of being awaited, responses were received as events. LoanManager.scan() writes the active lender count into ScanRequest.requestCount up front, then throws each Agent without awaiting. Lenders that answer synchronously parse their own response, lenders that answer by callback do it at the moment POST /api/v1/loans/event arrives, and both emit the same loans/manager/scan event. Every arriving result appends one ScannedCompany row, and when that count reaches requestCount, only then does Context.status move to scanned. In parallel, scan() arms a 30 second SCAN_TIMEOUT timer, so the state moves forward even when some lender never answers at all.

A fast lender's result is not held hostage by a slow one, and a failed lender counts as a single row with status: failed, so it does not block the completion check. The completion condition is not just "everything arrived" but "count reached or timed out", so either way the screen never stalls. Once the status reaches scanned, the change stream pushes it to the screen over the Subscription, and if the user already hid the screen, the same code path sends a push instead. setScanned quietly returns when status !== 'scanning', so overlapping count and timeout signals never process twice.

What a user can do

Open the asset screen for the first time inside a partner bank app and connect institutions with a certificate
Open the asset screen for the first time inside a partner bank app and connect institutions with a certificate

Look at account, card bill, and loan balances and tap one to open its detail
Look at account, card bill, and loan balances and tap one to open its detail

Tap refresh to re-scrape every connected institution
Tap refresh to re-scrape every connected institution

Check the valuation of stock and fund holdings
Check the valuation of stock and fund holdings

See the month's spending and drill into it by category
See the month's spending and drill into it by category

Write in a cash expense the cards never picked up
Write in a cash expense the cards never picked up

Tidy up connected institutions and reconnect the ones that errored
Tidy up connected institutions and reconnect the ones that errored

Check spending progress and benefits on my cards, and register a new one
Check spending progress and benefits on my cards, and register a new one

Start loan comparison for the first time and agree to the terms of service
Start loan comparison for the first time and agree to the terms of service

Enter employment, income, and vehicle details, then start the scan
Enter employment, income, and vehicle details, then start the scan

Wait for firm rates and limits to arrive from several lenders
Wait for firm rates and limits to arrive from several lenders

Compare the scan results, pick one product, and apply
Compare the scan results, pick one product, and apply

Check the review result and look back at past approvals
Check the review result and look back at past approvals

An operator edits loan product terms and pulls a preview and a compliance image
An operator edits loan product terms and pulls a preview and a compliance image

1 / 1

A year carried by logs

Honestly, there is not a single line of test code. Lender specs kept shifting mid-negotiation, sandbox responses differed from production responses, so what it leaned on was not tests but logs. Requests and responses were written into AgentLog, masking resident registration numbers, names, CI values, and phone numbers with '*' before storing. That surfaced the root cause of most incidents, but there was never a safety net that catches a spec change before deploy. If I built it again I would start with contract tests for each lender's parser. All it needed was response fixtures, and I put it off.

Second, counting completion by the number of ScannedCompany rows goes wrong if the same lender sends its result twice. Today one row is appended per arrival and counted by scanId, so a duplicate flips the status to scanned earlier than it should. There should have been a unique index on scanId + companyName.

Third, because a single Context carries scraping, scan, and application state all at once, a user can only have one loan application in flight. That matched the product spec at the time, so it was the right call, but moving to applying for several products in parallel would need a redesign that promotes Application to the owner of state.

Read next

Boiler Replacement Application and Share Entry Campaign Site

Navien — 2019