Skip to content

Out-of-System Payments: a Marker, Not a Module

Adityo Guni Waluyo

An externally verified payment modeled as two nullable columns: required *bool contract, a dedicated 409, and a two-arm union SQL.

TL;DR

The system never handles payments, so the fix adds just two nullable columns, payment_marked and payment_marked_at, to track when treasury verifies fees externally. A guarded endpoint uses *bool decoding so missing fields fail loudly instead of silently clearing the marker. Public API hides the columns, admin API exposes them, and submission status stays untouched.

The administration screen asks one question: has this rental fee been paid? In reality, the money is verified outside the application, by the treasury through the office's payment channel. The first instinct usually goes two ways: install a payment gateway, or add a new value to the status column. Both overshoot. The process has no payment lifecycle inside the system, and a new status value means touching a state machine whose flow is already stable and audited.

The honest model is the smallest one: two nullable columns on the existing submissions table. payment_marked, a boolean, states that the out-of-system payment has been verified; payment_marked_at records when. Migration 000074 adds both; the down migration simply drops the two columns. No gateway, no new status value, no new table.

A Request Contract That Refuses Silence

The change point is one payment-marker endpoint behind a write-permission guard. The request body carries one field named marked, and it must be present as a boolean. That requirement is enforced by decoding into a *bool: the encoding/json library sets the pointer to nil when the JSON contains null, and unmarshals into the pointed value otherwise [8]. A missing field and an explicit false therefore read differently, and both are rejected as invalid input. Without the pointer, an empty body would silently mean "clear the marker", a destructive command issued by a request that was actually broken.

After decoding, the service layer re-checks the domain. The marker only makes sense for rental-type submissions; other types answer 409 through a dedicated error, separate from the stale-status message, so the interface knows exactly what conflicts. Scope checks are copied in order from the status-update flow: unknown actors and rows outside the unit's scope are rejected first as not found. Only then does the mutation run, idempotently: set means boolean one plus the current time, clear means zero plus NULL, and the rental-type predicate is attached once more inside the UPDATE as a second line of defense at the database level.

What Each Side of the Contract Sees

The two columns have two audiences with different rights. Toward the public API, the struct fields carry json:"-": the public handler uses a star SELECT and the sqlx mapper, outside unsafe mode, rejects columns with no destination, so the columns must exist on the struct yet never serialize. Toward the admin contract, the list response adds both. The admin list is built from a UNION of two tables, and every arm must return the same column shape; the complaints arm contributes 0 and NULL constants. DATE columns in the merged arm stay wrapped in DATE_FORMAT with a YYYY-MM-DD pattern so they scan back as strings, not driver time objects [2].

The interface changes in one important way: the GET contract now carries the latest marker values, so the admin panel toggle reads the server state before drawing itself instead of blind-toggling an assumption. The audit entry is best-effort after a successful mutation with the action payment_mark; a failed audit log does not undo a legitimate change.

The end result is directly testable. Setting the marker on a rental submission shows its state on the right admin row and NULL on complaint rows; the public response never exposes the columns; a PUT without a body is rejected 400; marking a complaint is rejected 409. The submission status is never touched. The system keeps admitting it does not manage payments; it only records the fact that someone else verified them.

Sources

[2] MySQL 8.4 Reference Manual, Date and Time Functions (Wayback snapshot), accessed 13 October 2026.
[8] golang/go, src/encoding/json/decode.go, accessed 13 October 2026.

Related articles