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.

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

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.

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
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.













