Every BNPL pitch deck I have ever seen leads with the credit model. The approval rate, the bureau-thin scoring, the fancy gradient boosting on alternative data. I have built that model, and I will say the quiet thing out loud: it is the easy part. The product that broke me, the one that kept me on calls at midnight with a banking partner’s treasury team, was not underwriting. It was getting the money out the door correctly and getting it back.
I ran the BNPL product inside a fintech business that needed bank partners to fund the loans. We did the front end, the checkout integration, the scoring, the servicing. The bank held the balance sheet and the license. That arrangement is the whole reason the unglamorous parts matter so much. When the money on the line is yours and a partner’s, every cent has to be accounted for, on a schedule a regulator will one day ask to see.
A loan is a state machine, not a row in a table
The first instinct, and the one I had to talk three different engineers out of, is to model a loan as a record with a status column you update in place. Approved, then disbursed, then repaid. Looks clean. It is a trap.
A loan is a long-lived state machine that moves money at several of its transitions, and money movement is asynchronous, fallible, and externally owned. You ask the bank’s disbursement rail to send funds. It says “accepted.” That is not “sent.” Hours later a webhook tells you it settled, or it bounced because the merchant’s account details were stale, or you hear nothing at all and have to go poll. Each of those is a different transition, and each one has to be idempotent, because the rail will absolutely deliver the same webhook twice and your retry job will absolutely fire while the first attempt is still in flight.
So the loan is a graph of explicit states with an append-only ledger underneath, never a mutable status field.
The states across the top are what the customer feels. The ledger across the bottom is what survives an audit. Every arrow that moves money writes a double-entry pair before it tells anyone the money moved.
The states across the top are cheap. The thing that makes this real is the ledger. Every transition that touches money writes a balanced double-entry pair (debit one account, credit another, in the same atomic write) before the loan is allowed to advance. The state of the loan is then a derived view over the ledger, not a separate source of truth you have to keep in sync. When the inevitable argument with the bank’s reconciliation team happens, and it happens monthly, you do not defend your status column. You replay the ledger.
Disbursement is where the optimism dies
Here is the part nobody tells you. Disbursement reads like a single step in the deck. In production it is a small distributed-systems problem with a regulator watching.
You have an instruction to send funds to a merchant. The bank’s rail is the system of record for whether that happened, and it is eventually consistent at best. So you cannot fire-and-forget, and you cannot block the customer’s checkout on a settlement that takes hours. The pattern that held up was an outbox: write the disbursement intent into your own database in the same transaction that advances the loan, then let a separate worker drain that outbox against the rail, with the loan reference as an idempotency key the bank honors.
def disburse(loan_id):
loan = ledger.load(loan_id)
if loan.state != "APPROVED":
return # idempotent: someone already moved this one along
# the idempotency key is the loan id, NOT a fresh uuid per attempt.
# a fresh uuid means a retry double-pays the merchant, and you will
# find out a week later from a furious finance email, not a stack trace.
intent = DisbursementIntent(
loan_id=loan_id,
amount=loan.principal,
idempotency_key=f"disb-{loan_id}",
)
with ledger.tx() as tx:
tx.post(debit="funding_clearing", credit="merchant_payable", amount=loan.principal)
tx.advance(loan_id, to="DISBURSING")
tx.enqueue_outbox(intent)
# the worker that drains the outbox is the only thing that talks to the rail.
# checkout returns now. settlement confirms later, by webhook.
The detail that saved us was making the idempotency key a function of the loan, not a fresh UUID per attempt. A fresh UUID per retry is the single most expensive bug in this whole product, because it pays the merchant twice and you find out from an angry email, not an alert. (We learned that on a smaller scale before it could hurt. Not everyone is so lucky.)
The failure mode that kills the unit economics
Now the real argument. Every BNPL team I have watched optimizes approval rate, because approval rate is visible, it is in the dashboard the CEO looks at, and it is the number the growth team is paid on. Almost nobody staffs collections with the same seriousness until the book is already sour.
This is backwards. Your gross profit on a BNPL loan is a thin spread. One default eats the margin on a stack of good loans. Which means the marginal value of approving a borderline applicant is small and the marginal cost of collecting badly is enormous, and yet the org pours its talent into the approval side because that is the side that feels like growth.
Collections is a workflow engine, not a call center bolted on at the end. When a payment fails (and the common case is a soft failure: insufficient funds today, fine on payday), what you do in the next seventy-two hours decides whether that account cures or rolls into a writeoff. A dunning schedule with the right retry timing, the right channel (a quiet nudge before a hard notice), and a clean handoff to a human exactly when self-cure stops being likely. All of that is engineering. All of it writes to the same ledger, because a partial repayment, a fee waiver, a hardship reschedule, every one of those is a money movement the bank will reconcile against you.
def on_repayment_failed(loan_id, reason):
loan = ledger.load(loan_id)
# soft failures self-cure most of the time. do NOT fire a hard
# collections notice on day one. you train good customers to hate you
# and you spend goodwill you will want later for the real defaulters.
if reason == "INSUFFICIENT_FUNDS" and loan.consecutive_fails < 3:
schedule_retry(loan_id, after=days_until_likely_payday(loan))
nudge(loan_id, channel="soft") # reminder, not a threat
else:
ledger.advance(loan_id, to="DELINQUENT")
open_collections_case(loan_id) # human judgment starts here
The companies that blew up in this space mostly did not blow up on bad models. They blew up because they treated approval as the product and collections as an afterthought, then watched their loss rate cross their spread.
Responsible lending is an architecture constraint
One more thing, and it is not a footnote. We were lending to people, and some of them should not have been borrowing. The regulator’s framing, the one I came to actually agree with, is affordability: not “will this person probably pay,” which is the underwriter’s question, but “can this person afford this without harm,” which is a different question with a different answer. The two diverge most exactly where the growth incentive is strongest, on the thin-file, high-intent borrower who will click yes to anything.
So affordability checks live in the underwriting path as a hard gate, before the score, not as a compliance overlay sprayed on after launch. A customer who clears the credit model but fails affordability does not get a smaller limit quietly buried in the terms. They get declined, and the decline reason is logged in a form an auditor can read, because “explain why you approved this loan” is a question you will be asked, and “the model said so” is not an answer that ends the conversation.
The credit model is the easy part. It is the part with the conference talks and the leaderboard. The product is the ledger, the outbox, the dunning state machine, and the affordability gate, and none of those have ever once made it onto a pitch deck. Build the boring parts first. The model can wait. The reconciliation call with your bank partner cannot.