Engineering Journal · ENGINEERING · November 8, 2023 · 17 min read
The Payment Cleared. The Month Still Wouldn't Close.
A charge can succeed and still leave finance unable to trust the books. The hard part of payments is not capturing money. It is explaining the same money in three systems.
By Golam Sorwar, Tech Lead and Full Stack Engineer in Dublin.
There is a particular kind of incident that does not look like an incident. The customer has a receipt. The provider dashboard shows success. Your own payment row says paid. And finance still cannot close the period because the invoice, the payout, and the ledger do not tell the same story.
I used to treat that as a follow-up task. Get the money in first. Reconcile later. That attitude is how you build a payment feature that passes review and still burns a week of operations every month. The product is technically correct in the narrow sense that money moved. It is operationally wrong in the sense that nobody can defend the movement without opening three tabs and making a judgement call.
This is not a webhook tutorial. I have written about provider events and idempotency elsewhere, and those things matter. This is about the quieter failure: the integration did what engineering asked, and the organisation still could not run its month.
Context
The systems I care about here sit between a product people pay through and a finance function that has to produce invoices, refunds, and a record that survives audit. In a college, that money is tuition, deposits, extras, sometimes partial payments, sometimes a transfer that arrives days after the student thinks they are done. In earlier work it was marketplace and travel money, with local methods that did not behave like a card charge.
The technical shape is familiar. Laravel owns the customer-facing payment. A provider — Stripe in some flows, a bank or specialist transfer product in others — owns the movement. QuickBooks, or something like it, owns the books. Each of these systems is good at its job. None of them agreed, out of the box, on what a "payment" is.
The users of the failure were not only developers. Admissions wanted to know if someone was clear to proceed. Finance wanted to know if the invoice was settled, in which currency, against which item, and whether a fee had been taken out before the money arrived. Support wanted to know what to say when all three screens disagreed. Engineering wanted the ticket to leave the board.
The real problem
The apparent problem was mapping. Map a successful charge to an invoice. Sync the invoice to accounting. Done. That is the diagram that gets approved.
The deeper problem was that success is not one event. There is authorisation, capture, payout, settlement, refund, partial refund, chargeback, a transfer that is pending at the bank, a payment that succeeded in the provider and failed to attach to the right person in your database, a fee that exists only on the provider statement. If your domain model has a boolean called paid, you have already lost a large part of the month-end conversation.
There is a second deeper problem, and it is organisational. Engineering tends to optimise for the customer path: can they complete checkout. Finance optimises for the period: can we explain cash. Those are different success conditions. A checkout that writes paid at the moment the provider says okay will make the product feel snappy. It will also create rows that finance cannot journal because the money is not yet theirs, or is theirs minus a fee nobody modelled, or is theirs but against the wrong invoice because the student paid a balance that spanned two items.
I have watched technically neat integrations create operational work because they stored a provider status and assumed the rest of the company would learn to speak provider. They will not. They should not have to. If your admin screen is a thin mirror of Stripe or a bank portal, you have not integrated payments. You have outsourced the explanation.
Constraints
We could not pause collections while we redesigned money. People were paying. Offers had dates. That alone kills any plan that starts with "stop taking payments for a month and do it properly."
Provider behaviour was not ours to tidy. Settlement timing differs. Local transfer products post in ways card APIs do not. Webhooks arrive twice, late, or in an order you did not write a test for. QuickBooks has its own objects, rate limits, and opinions about customers and line items. If your design requires those systems to become simple, the design is a wish.
Historical data already existed. Old payments had been recorded with whatever honesty the previous code could afford. Some were notes. Some were imported. Some were correct in one system and absent in another. A new model that only worked on clean future rows would split the universe into "payments we understand" and "payments we smile about."
The team was small, which meant the same people who shipped checkout were the people finance would ping when a row looked wrong. There was no payments platform team in another building. If the explanation was bad, we would be the ones in the spreadsheet.
And we could not invent precision we did not have. I will not pretend we had a measured reconciliation rate or a clean before-and-after. What we had was a monthly texture: too many conversations that started with "it's paid on their side."
Options considered
One option was to keep the product boolean and let finance reconcile in the provider dashboards. Advantage: engineering stays fast, checkout stays simple. Disadvantage: month-end becomes a human integration layer. Risk: the human layer does not scale with volume or with holidays. Maintenance looks cheap in the repository and expensive in the calendar. Business impact: you will eventually get a version of this product that nobody in finance trusts, even when it is right.
A second option was to make the provider the system of record and sync everything else from it. Advantage: one upstream truth for money movement. Disadvantage: your product rules — who the payer is, which invoice, which academic or commercial item — do not live at the provider. Risk: you become fluent in someone else's object model and clumsy in your own. Complexity shifts into mapping tables and repair jobs. When the provider is down or merely late, your whole explanation stack is late.
A third option was to make accounting the system of record. Advantage: finance will like the sentence. Disadvantage: accounting systems are poor places to run product workflow. They are slower, stricter, and less interested in your edge cases. Risk: you start making product decisions in a ledger tool, then invent a second unofficial ledger in the application anyway.
The fourth option was to keep an internal money language — intent, captured, settled, allocated, refunded — and treat every external system as a witness. Advantage: support and finance can be taught one story. Disadvantage: you now have to write that story, persist it, and accept that witnesses disagree in time. Risk: if the internal states are sloppy, you have only added vocabulary. Complexity is real: allocations, partials, fees. Maintenance is higher in code and lower in operations if you finish the job.
Decision
We kept an internal ledger-shaped history, even though we were not a bank and did not need to pretend to be one. Provider events updated that history. Accounting sync read from it. The product UI was allowed to say "paid" only when the meaning was one we could defend to finance, not merely when a charge API had returned 200.
I chose that because the other options all exported the hard part to a person. I had already seen what that person does: they keep a side spreadsheet, and then the spreadsheet becomes the real system. At that point your integration is decoration.
The decision was also a refusal. We refused to mark an invoice settled solely because a webhook arrived. We refused to create accounting objects as a side effect of a controller that was also trying to render a success page. Money that matters to a month-end has to pass through a place that can be replayed.
Why not the provider as source of truth? Because our product knew which person, which invoice, and which promise the money was for. The provider knew that a payment method moved value. Those are different facts. Collapsing them is how you get a successful charge against the wrong life.
Implementation / Process
The useful model was small. A payment attempt. A set of events that happened to it. An allocation onto an invoice or balance. A link to an external id we could search when someone forwarded a screenshot from a portal. Fees as their own lines when we knew them, and an honest unknown when we did not.
Request flow for checkout stayed ordinary: create the attempt, send the user to the provider or collect the instrument, wait. The important work moved off the request. A job consumed the provider event, wrote the internal event if we had not seen it, and only then considered allocation. Another job, slower and more cautious, proposed accounting writes. I do not mean we automated judgement we did not have. I mean we stopped mixing "talk to Stripe" and "talk to QuickBooks" in the same nervous function.
Idempotency sat on external event ids and on our own allocation keys. That is dull to say and expensive to skip. Partial success toward accounting is how you get duplicate invoices and a finance team that stops believing your sync.
We also changed the admin language. Screens showed the internal state first, then the provider state as evidence. That sounds cosmetic. It trains everyone, including us, not to treat the provider as the product.
Monitoring was less about throughput and more about stuckness: attempts that aged in a pending state, events we could not parse, accounting writes that failed after money had already moved. Those are the queues that become month-end.
Problems and failures
We assumed settlement language would be teachable in a single meeting. It was not. People had been trained by the old boolean. For a while, the richer screen created more tickets, not fewer, because it told the truth and the truth was uglier than "paid."
Partial payments were harder than refunds. A refund is at least a story people know. A student or customer who pays part of an invoice, then another part by a different method, then gets a discount applied by staff, produces a history that will humiliate a naive unique constraint. We found that only when a real person did it, which is when they always do it.
Fees embarrassed us. Some providers make the fee obvious. Others bury it in a payout. If you allocate the gross to an invoice and the bank receives the net, finance will ask where the rest went. If you allocate the net, the customer receipt looks wrong. We had to admit the fee as a first-class thing instead of hoping it would wash in a batch.
The accounting sync lagged in ways that looked like bugs and were actually honesty. We waited until we had an allocation we trusted. Finance, reasonably, wanted the invoice in QuickBooks earlier. That tension never fully disappears. If you sync too early, you repair. If you sync too late, you are accused of not syncing.
There was also a communication failure I still dislike. Engineering talked about states. Finance talked about periods and accounts. For too long we used the same English words for different objects. A shared glossary would have been worth more than another retry policy.
Trade-offs
We made checkout slightly less triumphant. The user-facing success could still be warm. Internally we accepted a window where money was received but not yet allocated. That is worse for a dashboard that wants all green. It is better for a month that has to close.
We spent engineering time on a model that does not impress in a sprint review. You cannot screenshot an allocation table and get the same reaction as a new payment method logo. I still think that trade is correct if the organisation has a finance function that is not optional.
We did not get perfect consistency across systems. We got a defined delay and a defined owner. Anyone who promises instant agreement between a bank, a card platform, and an accounting API is selling a diagram.
Result
The conversations changed shape. They did not vanish. Instead of "the provider says paid, why doesn't the invoice?", we could usually point to a missing allocation, a pending settlement, or a fee line that had not been booked. That is a worse sentence for engineering pride and a better sentence for operations.
Finance still used provider portals. They used them as evidence, not as the only map. Support had a screen that was closer to a story and further from a raw JSON dump. I count that as the integration starting to exist.
I will not invent a closing-time improvement. What I can say is that the month stopped depending as heavily on one person who "knew how to fix the file."
What I would do differently today
I would have brought finance into the state machine before the first provider went live, not after the first ugly month. We designed a good customer path and then tried to hang accounting on it. That is backwards if the organisation cannot operate without the books.
I would have treated fees and partials as day-one cases, even if the first method was a simple card charge. The first method teaches the model. If the model cannot hold a partial, the second method will arrive and break the month.
I would also have refused any admin UI that displayed provider enums as the primary status. It trains the whole company to become junior operators of someone else's product. That is not leverage. It is a slow headcount tax.
Broader lesson
A payment integration can be technically successful and still be an operational failure. The test is not "did the API return success." The test is "can a person who does not write code explain the same money in the product, at the provider, and in the books, without inventing a fourth story in a spreadsheet."
If you cannot pass that test, you do not have a payments platform. You have a checkout.
Closing thought
Money is not moved when the charge succeeds. Money is moved when the organisation can defend what happened. If your integration cannot survive that sentence, the next month will keep finding you, no matter how clean the webhook handler looks.