A separate service in Docker with two interfaces: gRPC for the application and HTTP for the outside world. Integrated into a live product without rewriting it
0 lines
Of payment code in the client's application
How we measured: the entire payment layer was moved into a separate service; the application calls it through a contract and contains neither the acquirer's keys nor any payment processing logic
The application was working and bringing in money, but that money was being collected outside of it. Payment acceptance needed to be built into a product that was already written, already in use, and couldn't be taken down for the work.
The first temptation in a situation like this is to write payments directly into the application's code. That's a bad idea for several reasons, and all of them surface later than the decision gets made. Payment logic is the most volatile part of a system: acquirer terms change, payment methods get added, receipt requirements appear. Every such change would mean editing and redeploying the entire application.
The application was already written, already in use, and couldn't be stopped for the work. The question wasn't how to write payments, it was how to add them without breaking anything.
The second reason is more serious. Payments bring secrets with them: the acquirer's access keys. Inside shared code, those become accessible to the whole application and to everyone who works with that code. For a layer that money flows through, that's a poor starting point.
And third: a transaction doesn't end with the charge. You need to hold the amount until confirmation, handle refunds, process failed attempts, and reflect all of it in accounting. The client kept their books in 1C, and matching incoming payments to orders was manual work that grows linearly with the number of payments.
A separate service in Python, packaged in Docker and running alongside the client's application. It handles only payments: it accepts a payment request, carries the transaction through all its states, talks to the acquirer, and returns the result.
The boundary was drawn deliberately hard. The application doesn't know how payment acceptance works and doesn't store the acquirer's keys, it simply asks to accept payment for a given order and gets a response back. Everything behind that lives in the service and can change independently: adding a new payment method or changing acquiring terms can now be done without touching the product.
The application doesn't know how payment acceptance works and doesn't store the acquirer's keys. It asks to accept payment for an order and gets a response back.
Payment acceptance is implemented through CloudPayments, with a full transaction cycle. An amount can be held until confirmation and captured once the obligation is fulfilled, instead of the scheme of taking the money right away and figuring things out later. Refunds and failed attempts are transaction states just like any other, not exceptions that a person has to handle.
Docker here isn't a trend to follow: the payment service gets updated more often than the rest and restarts independently. The container is what turns shipping a new version of payments into an action that doesn't touch the application.
The service responds over two protocols at once, and that's not redundancy, it's a response to the fact that it has two different counterparts.
gRPC faces inward. The client's application calls the service through a strict contract: the set of operations and the data structure are described explicitly, and a mismatch is caught at build time, not at the moment of payment. For a call exchanged between two of our systems within the same layer, that's the right choice: a fast binary protocol with a verifiable contract.
The service has two counterparts: the application within the same layer, and the payment system outside it. Forcing them to speak the same protocol would be inconvenient for both.
HTTP faces outward. It's where notifications from the acquirer about a payment's fate arrive, since a payment system won't adapt to our protocol, it only knows webhooks. Everything else connects through it too: support tools, debugging, any system that doesn't have gRPC.
The distinct value of this decision goes to the client, not to us. The service didn't lock their application into a single way of communicating: if payments are needed by another part of the product or by a third party contractor tomorrow, they can connect using whichever protocol they have. A contractor forcing their own approach as a condition of the work is a common fear, and here it's removed by the design of the solution.
In payments, a mistake costs more than almost anywhere else: here you can not just lose data but charge money twice or fail to deliver what was paid for. That's why transaction states are described explicitly, and the transition between them is an operation that can't be carried out halfway.
Acquirer notifications are processed with protection against duplicates. A payment system retries delivery if it didn't receive confirmation, and a service that doesn't account for that will sooner or later process one payment as two. A repeated notification about the same operation is recognized and doesn't create a second transaction.
Failed and abandoned payments don't stay in limbo: each one has a defined status, visible to both the application and support. That's what distinguishes a working payment layer from a demo: in a demo, every payment succeeds.
1C remained the client's accounting system, and the service doesn't replace it. An exchange runs between them: information on completed payments and refunds lands in the books as data, not as a reason to sit down and reconcile two spreadsheets.
This removes work that was growing along with the business. When there are a dozen payments a month, matching incoming payments to orders takes half an hour; at hundreds of payments, it becomes a person's ongoing job, and one where a mistake means a discrepancy in money.
The exchange runs as background tasks: the accounting system can be unavailable or busy, and a payment at that moment shouldn't be lost or block the user. A task that fails on the first attempt is retried.
The application gained payment acceptance without being rewritten. That's the main result: a working product with users stayed the same product, with a new capability added to it.
The payment layer became independently updatable. Changing acquiring terms, adding a new payment method, refining refund logic: all of that is now an edit in a single service that doesn't require redeploying the whole application or putting it at risk.
The acquirer's access keys live inside the payment layer, not in the shared codebase. That doesn't make the system invulnerable, but it narrows what needs to be protected down to a single service.
And manual reconciliation of payments against accounting is gone from the daily work: data reaches 1C through an exchange, not by carrying an export into a spreadsheet.
The first conversation commits you to nothing: we will look at your situation and tell you whether you need a project like this at all.