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


