payments

Unknown is not a decline

Unknown is not a decline

A club's reception runs the 1club Point of Sale in a browser. Staff ring up a sale, pick the card terminal on the counter, the amount appears on it, the customer taps, and the Payment settles in 1club with nothing typed twice. That already works for terminals that live in someone's cloud: Stripe Terminal and myPOS take a charge over an API, and our card-present rail treats them as two providers behind one adapter.

A lot of Bulgarian clubs do not have those terminals. They have a Datecs BlueCash, an Android terminal with a card reader built in, or a BluePad pinpad cabled to the reception computer. Next to it sits a fiscal printer, because a card sale in Bulgaria needs a fiscal receipt registered with the NRA no matter how the card was charged. None of these devices has a cloud API. They take commands only from software running on them or next to them, through a vendor SDK or a byte protocol.

So, as with the access-control bridge a month ago, something has to run on site. This post is the design for that something, written before the code. Nothing below has touched a real terminal yet.

The shape

1club server
  → Payment Bridge contract     vendor-neutral OpenAPI
    → bridge app                Android on the terminal, or desktop at reception
      → integration             one per vendor and role
        → vendor SDK / protocol → card reader, fiscal module

It ships as two apps over one core. The first is an Android app installed on the BlueCash itself, where the card reader and, on the BlueCash-50, the fiscal module are built in. The second, later, is a desktop app on the reception computer for an external BluePad and an X-series fiscal printer over USB, serial or LAN.

What makes one core possible is that the drivers never open a device. Each app opens it - through the terminal's own system services on Android, through a serial port or a socket on the desktop - and hands the driver a Connection:

interface Connection : AutoCloseable {
    val input: InputStream
    val output: OutputStream
}

interface Integration {
    val id: String
    fun open(connection: Connection): Device
}

The Datecs card protocol and the Datecs fiscal protocol are both byte protocols, so a driver written against two streams does not care whether those streams come from an Android system service or a USB cable. The card driver is written once for the BlueCash and the BluePad.

The scope is two jobs: take a card payment and print the fiscal receipt for it. No refunds, voids, tips, pre-authorisations or X/Z reports from 1club. Voids and refunds are done on the terminal, as they are today. An operation gets added when 1club needs it, not because the device supports it.

This time the bridge dials out

The access-control bridge left the "who dials whom" question open: the cloud calls it, and that costs an inbound firewall rule at every site. For payments I am not leaving it open. A terminal on a club's Wi-Fi behind a consumer router is not getting a port forward.

The bridge only makes outbound HTTPS calls. It long-polls 1club for commands ("charge 30.00 on device X"), runs them, and reports results. A WebSocket was the other candidate, and the deployment ruled it out: the 1club server runs on Cloud Run with several instances, a WebSocket pins the bridge to one of them, and the POS request that queues a charge lands on another. A Postgres command table plus a long-poll needs no sticky sessions, survives a deploy, and needs nothing from the club's network.

The poll carries a version. The server holds the request until its answer's version differs from the one the bridge sent, or 25 seconds pass:

GET /v1/payment-bridge/commands?wait=25&after=<version>

Without the version, a command the bridge is already running would come back on every poll and the poll would return at once, in a loop. The server re-reads the queue once a second while a request waits, so a new charge reaches the terminal within about a second. LISTEN/NOTIFY can replace the re-read if the number of bridges ever makes it expensive.

No database on the terminal

I considered a small SQLite journal of started charges and dropped it. Every question that journal would answer already has an owner that knows better:

  • Which commands are open, and how far each got: 1club, which resends them with their state.
  • What happened to a charge: the terminal's transaction log.
  • Whether a receipt was printed: the fiscal module's last sale number.
  • When end of day last ran, and the per-device settings: 1club.

A journal that disagrees with the terminal is a second source of truth to reconcile, and a bridge with a database is a bridge whose reinstall needs a migration. What stays on disk is a settings file: the bridge id, the API key, the desktop app's device list, and a cached copy of the last settings answer so the app can start offline. A restart, a reinstall or a wiped terminal loses nothing, because 1club resends whatever is still open and the bridge asks the device before it acts.

That only works if 1club keeps its side of it. A command stays in the poll response until 1club has a final outcome. Each command carries its current state (queued, accepted, approved, declined, unknown), so a bridge that just restarted knows how far the previous run got. Results are idempotent: one that would move a command backwards is answered 200 and ignored, so a duplicate or late delivery changes nothing.

The money rules

Most of the design is a short list of rules about when the bridge may touch a card and what it may claim afterwards. Each one comes from a way of charging a customer twice or losing a charge.

Ambiguity is unknown

A timeout, a dropped link, a crash in the middle of an authorisation, a device answer the driver cannot classify - all of these are unknown, which 1club holds as pending. None of them is ever declined. A decline voids the sale in 1club and invites staff to try again, and if the first attempt went through, the second one charges the customer twice. Only an explicit decline from the terminal or the bank is a decline.

The driver interface makes this the default:

sealed interface ChargeOutcome {
    data class Approved(val refs: ChargeRefs) : ChargeOutcome
    data class Declined(val code: String, val message: String) : ChargeOutcome
    data class Unknown(val reason: String) : ChargeOutcome
}

Every wait on a device or on the network has a timeout, and every timeout path ends in Unknown. The bridge then resolves it itself: it polls the terminal's status and looks the charge up in the terminal's log, for up to five minutes, before it reports unknown and leaves the command for 1club to resend.

The card waits for the gate

The race to worry about is cancel against pay. Staff press "Pay", change their mind and cancel in the POS, while the bridge is already picking the command up.

So before the amount goes on the screen, the bridge reports accepted and waits for 1club's 200. The server flips the command from queued to accepted with a conditional update, and the POS's cancel is the same conditional update the other way round. Whichever lands first wins. A cancel after accepted is refused; an accepted after a cancel gets 409 and the bridge drops the command. If the network is down, no answer means no charge: the bridge keeps retrying accepted and the sale waits with "waiting for the terminal" in the POS.

This is the one report the bridge cannot fire and forget. Everything else is reported with retries until acknowledged, while the bridge moves on.

rejected means never accepted

The bridge can refuse a command it can see is wrong before touching the card: the terminal is offline, the currency is not the terminal's, the receipt lines do not add up to the charge. That is rejected, and it is allowed only while the command is still queued.

For a command that was accepted before, an unreachable terminal is unknown, because the previous run may have reached the card. 1club enforces this rather than trusting the bridge to remember: a rejected that arrives after an accepted is refused. That is the case that would turn a charged card into a failed payment and a retry.

Ask the terminal before charging

Before every charge the bridge reads the terminal's status. If the command was accepted before, it also looks the reference up in the terminal's transaction log. A charge that already happened is reported, not repeated.

That lookup is a walk backwards over a serial link, record by record, and a busy day holds hundreds of records. Two things keep it cheap. It only runs for commands 1club has seen accepted or unknown, because a command that never passed the gate cannot have reached the card. And every command carries its createdAt, so the walk stops at the first record older than the command.

Both depend on the log being intact and on nobody running transactions from the terminal's own menu, which is why the plan is to hide that menu.

End of day without losing the log

Card terminals need a daily end of day, and without one the terminal eventually refuses payments. The bridge runs it once a day at an hour set per club in 1club's settings, in the club's time zone.

End of day clears the transaction log, and the log is what lookup reads. So the bridge closes the day only when 1club reports no open charges for that terminal, which every poll includes. Before closing, it reads every record in the log and sends them to 1club as the batch report, and closes only after the 200. That report is what 1club reconciles against the acquirer's settlement, and after the close it does not exist anywhere else.

The receipt is a second, separate outcome

Fiscalisation is decided per charge. Every charge command carries an explicit fiscalize: true or false, with no default and no separate receipt command. With false, staff keep printing the receipt by hand on the club's existing fiscal device, which is what they do today. With true, the command also carries the receipt lines and a unique sale number, and the bridge prints it after the charge is approved.

If a fiscalized charge cannot be fiscalized - no fiscal device, or the device is offline - the bridge rejects the charge before touching the card. A payment that should have a receipt is never taken without the means to print one.

Once the card is charged, a failed receipt never changes the Payment. The money is taken. The command carries two outcomes that become final separately: the charge at approved, declined or rejected, the receipt at printed, or waived when an admin records that it was handled outside the bridge. The command stays open until both are final and 1club keeps resending it; on each resend the log lookup finds the approved charge, so the card is not charged again, and only the receipt is retried.

Deduplicating the receipt uses the fiscal module's own memory. Before printing, the bridge reads the last sale number the device completed. If it equals this receipt's sale number, the receipt exists and is reported as printed. That check is only sound if the device's last receipt is always the one in question, so 1club sends a fiscalized charge only to a terminal whose fiscal device has no unresolved receipt, and the bridge runs receipts one at a time per fiscal device. With that rule, "not the last receipt" means "never printed", and a reprint is safe.

It also decides what a broken printer blocks. The terminal takes its next charge as soon as the previous charge is final, so card payments with fiscalize: false keep working. Fiscalized sales stop until the printer is fixed or the receipt is waived, which the law would stop anyway.

Kotlin, because the terminal is Android

The language follows from the BlueCash. The bridge runs on an Android terminal next to a vendor SDK that ships as a Java jar, so it is Kotlin, calling the jar directly, with coroutines for the long-poll and the device waits, Ktor for HTTP and kotlinx.serialization for the contract.

The same modules have to run on the desktop, so the split is strict. The driver interface, the core and the integrations are plain Kotlin/JVM libraries emitting Java 17 bytecode, using only APIs Android has: no java.net.http, nothing desktop-only. Everything Android-specific - the system-service connections, the foreground service, the pairing screen - lives in the Android app. Serial ports and installers live in the desktop app.

Both build on JDK 25. Running a build on it sets a floor I checked before writing it down: Gradle 9.1 is the first release that runs on Java 25, Android Gradle Plugin 9.0 needs Gradle 9.1, and Kotlin 2.3 is the first compiler with full Java 25 support. On the terminal none of that matters; the app runs on ART, and the Android version decides which Java APIs exist. The desktop app gets the same JDK 25 bundled by jpackage, so nobody at reception installs a runtime.

The Android app is a foreground service started on boot, so a terminal that was switched off overnight comes back without anyone touching it. Its type is specialUse rather than dataSync, because from Android 15 a dataSync service is capped at six hours a day, and a long-poll that stops after lunch is not a payment terminal. The API key is sealed with an Android Keystore key.

Pairing is a one-time code. An admin adds the device in 1club, gets the code, types it on the terminal's screen, and the app exchanges it for its API key and announces its devices. A 401 on any later call means the bridge was unpaired in 1club, and the app goes back to the pairing screen.

Card data never reaches 1club. The terminal authorises the card with BORICA over its own channel, and the bridge reports only receipt-grade references: masked PAN, scheme, RRN, authorisation code, STAN, terminal ID.

What can still stop it

Two questions are outside the code, and either one can change the plan.

The first is whether we can install our own app on a BlueCash at all, and through which channel - the vendor's store, or signing by Datecs or the bank. The whole BlueCash-first order depends on the answer. If it is no, the desktop app with a BluePad becomes the first app instead of the second.

The second is СУПТО. Once 1club sends a unique sale number and receipt lines to a fiscal device, it may count as sales-management software under Наредба Н-18, Appendix 29, which comes with its own requirements. That needs a tax advisor before any fiscalized sale goes out. Until then every charge is sent with fiscalize: false, and staff keep printing receipts by hand.

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

© 2026 Deyan Peev