diff --git a/README.md b/README.md
index 044be2c..822b09c 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,17 @@ sdk.dir=/path/to/Android/Sdk
- **決済手数料率 / 与信限度 / チャージ可能額(余力)**
- 加盟店コード・名称・状態・ID・登録日時
+### POS 連動モード
+
+テンキーによるスタンドアロン操作に加え、**POS レジと LAN 連動**するモードがあります。
+上部の「🖥 POS連動」から切り替えます。切り替えると端末は「待受中」画面になり、同一LAN上のPOSからのコマンド(支払 / チャージ / 残高照会 / 返金)を待ち受けます。
+
+- 端末の検索は **UDP ブロードキャスト**、コマンドおよび状態取得は **TCP + JSON**で行います。
+- カードのタッチが必要な業務では、コマンドを受けて端末が「カードをかざしてください」表示になり、その結果をPOSが受け取ります。
+- 端末側・POS側のどちらからでも処理をキャンセルできます。
+
+プロトコルの詳細は **[docs/POS-Protocol.md](docs/POS-Protocol.md)** を参照してください。
+
---
## セキュリティ
@@ -131,13 +142,18 @@ app/src/main/java/jp/unknowntech/melonterminal/
net/
Dto.kt API の JSON DTO(kotlinx.serialization)
MelonClient.kt melon-server クライアント(OkHttp)
+ pos/
+ PosProtocol.kt POS 連動プロトコルの DTO / エンベロープ / ハンドラ IF
+ PosServer.kt LAN サーバ(UDP 端末検索 + TCP コマンド)
core/
- Settings.kt サーバ URL / API キーの保存
+ Settings.kt サーバ URL / API キーの保存 / POS連動用の端末名称・ID保存
Models.kt Op / エラー分類 / 表示用エラー
CardFlow.kt 相互認証の中継フロー
ui/
- TerminalViewModel.kt 画面状態と操作の実行
+ TerminalViewModel.kt 画面状態と操作の実行(POS 連動の取引状態管理を含む)
TerminalScreen.kt 操作画面・テンキー・各種シート
+ PosScreen.kt POS 連動モードの画面(待受中 / 決済画面)
+ PosModels.kt POS モードの UI 状態モデル
SettingsScreen.kt サーバ URL / API キー設定
Format.kt 金額 / ID / 日付の整形
```
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0a11348..11c9693 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,10 @@
+
+
+
POS), distinguished by `type` -----
+
+/** Reply to a UDP `discover` broadcast (sent unicast to the sender). */
+@Serializable
+data class AnnounceMsg(
+ val type: String = "announce",
+ val terminal_id: String,
+ val name: String,
+ val ip: String? = null,
+ val tcp_port: Int = PosProtocol.COMMAND_PORT,
+ val app_version: String,
+ val state: String,
+)
+
+/** Reply to an `info` command — static terminal capabilities. */
+@Serializable
+data class InfoMsg(
+ val type: String = "info",
+ val protocol: Int = PosProtocol.VERSION,
+ val terminal_id: String,
+ val name: String,
+ val app_version: String,
+ val jobs: List = listOf("payment", "topup", "balance", "refund_query", "refund_execute"),
+)
+
+/**
+ * The transaction snapshot — the reply to `payment` / `balance` / `refund_query` /
+ * `refund_execute` / `status` / `cancel`. POS polls by re-sending `status` until
+ * [state] is terminal (`success` / `failed` / `cancelled`).
+ */
+@Serializable
+data class StatusMsg(
+ val type: String = "status",
+ val transaction_id: String? = null,
+ val request_id: String? = null,
+ /** `payment` | `topup` | `balance` | `refund_query` | `refund` | null when idle. */
+ val job: String? = null,
+ /** `idle` | `pending` | `waiting_card` | `processing` | `success` | `failed` |
+ * `cancelled`. */
+ val state: String,
+ val status_text: String,
+ val amount: Long? = null,
+ /** The refundable transactions returned by a completed `refund_query`. */
+ val refundable: List? = null,
+ val updated_at: Long? = null,
+ /** The final outcome; null until the transaction reaches a terminal state. */
+ val result: ResultMsg? = null,
+)
+
+@Serializable
+data class RefundableMsg(
+ val payment_id: String,
+ val amount: Long,
+ val fee: Long = 0,
+ val refunded: Long = 0,
+ val refundable: Long,
+ val occurred_at: String,
+)
+
+/**
+ * The union of every completed-operation payload. Fields not relevant to a given
+ * job are omitted (`explicitNulls = false`). On failure only the error trio is set.
+ */
+@Serializable
+data class ResultMsg(
+ val ok: Boolean,
+ // failure
+ val code: String? = null,
+ val title: String? = null,
+ val detail: String? = null,
+ // shared
+ val account_id: String? = null,
+ val amount: Long? = null,
+ // payment
+ val fee: Long? = null,
+ val balance: Long? = null,
+ // balance
+ val total: Long? = null,
+ val buckets: List? = null,
+ val expires_at: String? = null,
+)
+
+@Serializable
+data class BucketMsg(
+ val bucket_id: String,
+ val remaining: Long,
+ val expires_at: String,
+)
+
+/** A refused or malformed command. */
+@Serializable
+data class ErrorMsg(
+ val type: String = "error",
+ val code: String,
+ val message: String,
+)
+
+// ----- reply union -----
+
+/** What a [PosCommandHandler] hands back to the server for a single request. */
+sealed interface PosReply {
+ data class Status(val msg: StatusMsg) : PosReply
+ data class Info(val msg: InfoMsg) : PosReply
+ data class Announce(val msg: AnnounceMsg) : PosReply
+ data class Error(val msg: ErrorMsg) : PosReply
+}
+
+/** Stable error codes returned in [ErrorMsg.code]. */
+object PosError {
+ const val BAD_REQUEST = "BAD_REQUEST"
+ const val UNKNOWN_COMMAND = "UNKNOWN_COMMAND"
+ const val UNSUPPORTED_VERSION = "UNSUPPORTED_VERSION"
+ const val UNSUPPORTED_ALG = "UNSUPPORTED_ALG"
+ const val NOT_CONFIGURED = "NOT_CONFIGURED"
+ const val BUSY = "BUSY"
+}
+
+/**
+ * The terminal-side command sink. Every method is called from a [PosServer] network
+ * thread and must be thread-safe; each returns the reply to serialize back. Starting
+ * a card operation only *arms* the terminal — the actual card tap completes later and
+ * is observed by POS through subsequent `status` polls.
+ */
+interface PosCommandHandler {
+ fun handlePayment(requestId: String?, amount: Long, note: String?): PosReply
+ fun handleTopup(requestId: String?, amount: Long): PosReply
+ fun handleBalance(requestId: String?): PosReply
+ fun handleRefundQuery(requestId: String?): PosReply
+ fun handleRefundExecute(requestId: String?, paymentId: String, amount: Long?): PosReply
+ fun handleStatus(): PosReply
+ fun handleCancel(requestId: String?): PosReply
+ fun handleInfo(): PosReply
+ /** Build the discovery announcement; [localIp] is the address the reply goes out on. */
+ fun announce(localIp: String?): AnnounceMsg
+}
diff --git a/app/src/main/java/jp/unknowntech/melonterminal/pos/PosServer.kt b/app/src/main/java/jp/unknowntech/melonterminal/pos/PosServer.kt
new file mode 100644
index 0000000..5db0a92
--- /dev/null
+++ b/app/src/main/java/jp/unknowntech/melonterminal/pos/PosServer.kt
@@ -0,0 +1,256 @@
+package jp.unknowntech.melonterminal.pos
+
+import android.content.Context
+import android.net.wifi.WifiManager
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.longOrNull
+import java.io.BufferedReader
+import java.io.IOException
+import java.io.InputStreamReader
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import java.net.Inet4Address
+import java.net.InetSocketAddress
+import java.net.NetworkInterface
+import java.net.ServerSocket
+import java.net.Socket
+import java.nio.charset.StandardCharsets
+
+/**
+ * The POS-facing LAN endpoint: a UDP responder for discovery and a TCP server for
+ * JSON commands. Both run on [Dispatchers.IO] coroutines; [stop] closes the sockets
+ * (unblocking the accept/receive loops) and cancels the scope. Command handling is
+ * delegated to a [PosCommandHandler] — this class only frames, parses, and routes.
+ *
+ * A [WifiManager.MulticastLock] is held while running so broadcast discovery packets
+ * are delivered on devices that would otherwise filter them.
+ */
+class PosServer(
+ context: Context,
+ private val handler: PosCommandHandler,
+) {
+ private val appContext = context.applicationContext
+ private val multicastLock =
+ (appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager)
+ ?.createMulticastLock("melon-pos")
+ ?.apply { setReferenceCounted(false) }
+
+ private var scope: CoroutineScope? = null
+ private var udp: DatagramSocket? = null
+ private var tcp: ServerSocket? = null
+
+ /** "ip:port" the command server is reachable at, for display; null until started. */
+ @Volatile
+ var address: String? = null
+ private set
+
+ val isRunning: Boolean get() = scope != null
+
+ fun start() {
+ if (scope != null) return
+ val s = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ scope = s
+ runCatching { multicastLock?.acquire() }
+ val ip = localIpv4()
+ address = "${ip ?: "?"}:${PosProtocol.COMMAND_PORT}"
+ s.launch { runDiscovery() }
+ s.launch { runCommands() }
+ }
+
+ fun stop() {
+ scope?.cancel()
+ scope = null
+ runCatching { udp?.close() }
+ runCatching { tcp?.close() }
+ udp = null
+ tcp = null
+ address = null
+ runCatching { if (multicastLock?.isHeld == true) multicastLock.release() }
+ }
+
+ // ----- UDP discovery -----
+
+ private fun runDiscovery() {
+ val socket = try {
+ DatagramSocket(null).apply {
+ reuseAddress = true
+ broadcast = true
+ bind(InetSocketAddress(PosProtocol.DISCOVERY_PORT))
+ }
+ } catch (e: IOException) {
+ Log.w(TAG, "discovery bind failed", e)
+ return
+ }
+ udp = socket
+ val buf = ByteArray(2048)
+ while (scope?.isActive == true) {
+ val packet = DatagramPacket(buf, buf.size)
+ try {
+ socket.receive(packet)
+ } catch (_: IOException) {
+ break // socket closed by stop()
+ }
+ val line = String(packet.data, packet.offset, packet.length, StandardCharsets.UTF_8)
+ val msg = parseEnvelope(line) ?: continue
+ if (msg["command"]?.jsonPrimitive?.contentOrNull != "discover") continue
+ val reply = handler.announce(localIpv4())
+ val out = wrap(PosProtocol.json.encodeToString(AnnounceMsg.serializer(), reply))
+ runCatching {
+ socket.send(DatagramPacket(out, out.size, packet.address, packet.port))
+ }
+ }
+ }
+
+ // ----- TCP commands -----
+
+ private fun runCommands() {
+ val server = try {
+ ServerSocket().apply {
+ reuseAddress = true
+ bind(InetSocketAddress(PosProtocol.COMMAND_PORT))
+ }
+ } catch (e: IOException) {
+ Log.w(TAG, "command bind failed", e)
+ return
+ }
+ tcp = server
+ while (scope?.isActive == true) {
+ val client = try {
+ server.accept()
+ } catch (_: IOException) {
+ break // socket closed by stop()
+ }
+ scope?.launch { serveClient(client) }
+ }
+ }
+
+ /** One connection: read newline-delimited request lines, reply to each in turn. */
+ private fun serveClient(socket: Socket) {
+ socket.use {
+ val reader = BufferedReader(InputStreamReader(it.getInputStream(), StandardCharsets.UTF_8))
+ val output = it.getOutputStream()
+ while (scope?.isActive == true) {
+ val line = try {
+ reader.readLine() ?: break
+ } catch (_: IOException) {
+ break
+ }
+ if (line.isBlank()) continue
+ val reply = dispatch(line)
+ try {
+ output.write(encodeReply(reply))
+ output.flush()
+ } catch (_: IOException) {
+ break
+ }
+ }
+ }
+ }
+
+ /** Parse one request line and route it to the handler. */
+ private fun dispatch(line: String): PosReply {
+ val envelope = runCatching {
+ PosProtocol.json.decodeFromString(Envelope.serializer(), line)
+ }.getOrNull()
+ ?: return PosReply.Error(ErrorMsg(code = PosError.BAD_REQUEST, message = "invalid JSON envelope"))
+
+ if (envelope.melon_pos != PosProtocol.VERSION) {
+ return PosReply.Error(
+ ErrorMsg(
+ code = PosError.UNSUPPORTED_VERSION,
+ message = "protocol ${envelope.melon_pos} not supported"
+ )
+ )
+ }
+ if (envelope.alg != PosProtocol.ALG_NONE) {
+ return PosReply.Error(
+ ErrorMsg(
+ code = PosError.UNSUPPORTED_ALG,
+ message = "alg '${envelope.alg}' not supported"
+ )
+ )
+ }
+
+ val msg = envelope.msg
+ val command = msg["command"]?.jsonPrimitive?.contentOrNull
+ ?: return PosReply.Error(ErrorMsg(code = PosError.BAD_REQUEST, message = "missing command"))
+ val requestId = msg.str("request_id")
+
+ return when (command) {
+ "info" -> handler.handleInfo()
+ "status" -> handler.handleStatus()
+ "cancel" -> handler.handleCancel(requestId)
+ "payment" -> {
+ val amount = msg["amount"]?.jsonPrimitive?.longOrNull
+ ?: return badRequest("payment requires a numeric amount")
+ handler.handlePayment(requestId, amount, msg.str("note"))
+ }
+
+ "topup" -> {
+ val amount = msg["amount"]?.jsonPrimitive?.longOrNull
+ ?: return badRequest("topup requires a numeric amount")
+ handler.handleTopup(requestId, amount)
+ }
+
+ "balance" -> handler.handleBalance(requestId)
+ "refund_query" -> handler.handleRefundQuery(requestId)
+ "refund_execute" -> {
+ val paymentId = msg.str("payment_id")
+ ?: return badRequest("refund_execute requires payment_id")
+ handler.handleRefundExecute(requestId, paymentId, msg["amount"]?.jsonPrimitive?.longOrNull)
+ }
+
+ else -> PosReply.Error(ErrorMsg(code = PosError.UNKNOWN_COMMAND, message = "unknown command '$command'"))
+ }
+ }
+
+ // ----- framing helpers -----
+
+ private fun encodeReply(reply: PosReply): ByteArray {
+ val body = when (reply) {
+ is PosReply.Status -> PosProtocol.json.encodeToString(StatusMsg.serializer(), reply.msg)
+ is PosReply.Info -> PosProtocol.json.encodeToString(InfoMsg.serializer(), reply.msg)
+ is PosReply.Announce -> PosProtocol.json.encodeToString(AnnounceMsg.serializer(), reply.msg)
+ is PosReply.Error -> PosProtocol.json.encodeToString(ErrorMsg.serializer(), reply.msg)
+ }
+ return wrap(body)
+ }
+
+ /** Wrap a serialized message object into an envelope line (newline-terminated). */
+ private fun wrap(bodyJson: String): ByteArray {
+ val line = "{\"melon_pos\":${PosProtocol.VERSION},\"alg\":\"${PosProtocol.ALG_NONE}\",\"msg\":$bodyJson}\n"
+ return line.toByteArray(StandardCharsets.UTF_8)
+ }
+
+ private fun parseEnvelope(line: String): JsonObject? =
+ runCatching { PosProtocol.json.decodeFromString(Envelope.serializer(), line).msg }.getOrNull()
+
+ private fun badRequest(message: String): PosReply =
+ PosReply.Error(ErrorMsg(code = PosError.BAD_REQUEST, message = message))
+
+ private fun JsonObject.str(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull
+
+ companion object {
+ private const val TAG = "PosServer"
+
+ /** First site-local IPv4 on an up, non-loopback interface (the Wi-Fi/LAN address). */
+ fun localIpv4(): String? =
+ runCatching {
+ NetworkInterface.getNetworkInterfaces().asSequence()
+ .filter { it.isUp && !it.isLoopback }
+ .flatMap { it.inetAddresses.asSequence() }
+ .filterIsInstance()
+ .firstOrNull { it.isSiteLocalAddress }
+ ?.hostAddress
+ }.getOrNull()
+ }
+}
diff --git a/app/src/main/java/jp/unknowntech/melonterminal/ui/PosModels.kt b/app/src/main/java/jp/unknowntech/melonterminal/ui/PosModels.kt
new file mode 100644
index 0000000..a3246fc
--- /dev/null
+++ b/app/src/main/java/jp/unknowntech/melonterminal/ui/PosModels.kt
@@ -0,0 +1,81 @@
+package jp.unknowntech.melonterminal.ui
+
+import jp.unknowntech.melonterminal.pos.RefundableMsg
+import jp.unknowntech.melonterminal.pos.ResultMsg
+
+/** Which screen the terminal presents: the standalone keypad, or the POS bridge. */
+enum class AppMode { STANDALONE, POS }
+
+/**
+ * The POS business types, with their on-screen label. [needsCard] jobs read a card (and
+ * show the status rectangles); [REFUND] talks to the server only and shows no rectangles.
+ * The refund lookup ([REFUND_QUERY]) and the refund itself ([REFUND]) are fully separate:
+ * the refund can run standalone with a payment id supplied directly by POS.
+ */
+enum class PosJob(val wire: String, val label: String, val needsCard: Boolean) {
+ PAYMENT("payment", "支払", true),
+ TOPUP("topup", "チャージ", true),
+ BALANCE("balance", "残高照会", true),
+ REFUND_QUERY("refund_query", "返金照会", true),
+ REFUND("refund", "返金", false);
+
+ /** Only card-driven jobs light the touch rectangles. */
+ val showRects: Boolean get() = needsCard
+}
+
+/**
+ * Lifecycle of a single POS transaction. Only [SUCCESS], [FAILED] and [CANCELLED] are
+ * terminal.
+ */
+enum class PosTxnState(val wire: String) {
+ PENDING("pending"),
+ WAITING_CARD("waiting_card"),
+ PROCESSING("processing"),
+ SUCCESS("success"),
+ FAILED("failed"),
+ CANCELLED("cancelled");
+
+ val isTerminal: Boolean get() = this == SUCCESS || this == FAILED || this == CANCELLED
+ val isActive: Boolean get() = !isTerminal
+
+ /**
+ * Cancellable only before the card tap / server operation. Once [PROCESSING], a
+ * cancel is refused: the server may already have settled the transaction, so
+ * marking it cancelled on the terminal alone would desync the two sides.
+ */
+ val isCancellable: Boolean get() = this == PENDING || this == WAITING_CARD
+}
+
+/**
+ * One POS-driven transaction. [statusText] is the operator-facing status string (an
+ * existing terminal string) and is also sent to POS. [result] is set once the
+ * transaction completes; [refundable] holds the choices while awaiting a refund pick.
+ */
+data class PosTxn(
+ val id: String,
+ val requestId: String?,
+ val job: PosJob,
+ val amount: Long,
+ val state: PosTxnState,
+ val statusText: String,
+ val note: String? = null,
+ val accountId: String? = null,
+ val refundable: List = emptyList(),
+ val result: ResultMsg? = null,
+ val updatedAt: Long = System.currentTimeMillis(),
+)
+
+/**
+ * POS-mode UI state. [txn] is the current or most recently finished transaction (null =
+ * idle, showing 待受中). [displayCleared] flips true 5 s after a transaction finishes so
+ * the screen returns to 待受中 while the final [txn] stays queryable by polling.
+ */
+data class PosUiState(
+ val serverRunning: Boolean = false,
+ val address: String? = null,
+ val txn: PosTxn? = null,
+ val displayCleared: Boolean = false,
+) {
+ /** True when the screen should show the plain 待受中 splash. */
+ val idle: Boolean get() = txn == null || displayCleared
+}
diff --git a/app/src/main/java/jp/unknowntech/melonterminal/ui/PosScreen.kt b/app/src/main/java/jp/unknowntech/melonterminal/ui/PosScreen.kt
new file mode 100644
index 0000000..be25f64
--- /dev/null
+++ b/app/src/main/java/jp/unknowntech/melonterminal/ui/PosScreen.kt
@@ -0,0 +1,202 @@
+package jp.unknowntech.melonterminal.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.delay
+
+// A fixed white kiosk face, independent of the app theme.
+private val BG = Color.White
+private val FG = Color.Black
+private val OFF = Color.White
+private val CYAN = Color(0xFF0074FF)
+private val RED = Color(0xFFFF0000)
+
+// Status rectangle, 1:1.5 (height:width).
+private val RECT_H = 30.dp
+private val RECT_W = 45.dp
+
+/** Blink half-period: 0.5s on, 0.5s off. */
+private const val BLINK_HALF_MS = 500L
+
+@Composable
+fun PosScreen(vm: TerminalViewModel) {
+ val pos by vm.pos.collectAsState()
+
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(BG)
+ ) {
+ val txn = pos.txn
+ if (pos.idle || txn == null) {
+ IdleFace()
+ } else {
+ PaymentFace(txn = txn, onCancel = vm::posCancel)
+ }
+
+ // Minimal exit affordance — returns to the standalone keypad.
+ TextButton(
+ onClick = vm::exitPosMode,
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .safeDrawingPadding(),
+ ) {
+ Text("← スタンドアロン", color = Color(0xFF9E9E9E), fontSize = 13.sp)
+ }
+ }
+}
+
+/** 待受中 splash: centered black text on white. */
+@Composable
+private fun IdleFace() {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("待受中", color = FG, fontSize = 34.sp, fontWeight = FontWeight.Bold)
+ }
+}
+
+/** Transaction face: status rectangles, business type, amount, status line. */
+@Composable
+private fun PaymentFace(txn: PosTxn, onCancel: () -> Unit) {
+ val blinking = txn.state == PosTxnState.WAITING_CARD || txn.state == PosTxnState.PROCESSING
+ var blinkOn by remember { mutableStateOf(true) }
+ LaunchedEffect(blinking) {
+ if (!blinking) {
+ blinkOn = true
+ } else {
+ while (true) {
+ blinkOn = true
+ delay(BLINK_HALF_MS)
+ blinkOn = false
+ delay(BLINK_HALF_MS)
+ }
+ }
+ }
+ val rectColor = when (txn.state) {
+ PosTxnState.WAITING_CARD, PosTxnState.PROCESSING -> if (blinkOn) CYAN else OFF
+ PosTxnState.SUCCESS -> CYAN
+ PosTxnState.FAILED -> RED
+ else -> OFF // pending / cancelled
+ }
+
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ val quarter = maxHeight * 0.25f
+
+ // Card-free jobs (refund execution) show no rectangles.
+ if (txn.job.showRects) {
+ StatusRect(rectColor, Modifier.align(Alignment.TopStart))
+ StatusRect(rectColor, Modifier.align(Alignment.TopEnd))
+ StatusRect(rectColor, Modifier.align(Alignment.TopStart).offset(y = quarter - RECT_H / 2))
+ StatusRect(rectColor, Modifier.align(Alignment.TopEnd).offset(y = quarter - RECT_H / 2))
+ }
+
+ // Center-ish column: title slightly above the middle.
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(horizontal = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Spacer(Modifier.height(RECT_H))
+ Text(
+ "Melon ${txn.job.label}",
+ color = FG,
+ fontSize = 30.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ )
+ // Prominent value per job; a refund lookup returns only a list.
+ val bigAmount: Long? = when (txn.job) {
+ PosJob.PAYMENT, PosJob.TOPUP -> txn.amount
+ PosJob.BALANCE -> txn.result?.total
+ PosJob.REFUND -> txn.result?.amount
+ PosJob.REFUND_QUERY -> null
+ }
+ if (bigAmount != null) {
+ Spacer(Modifier.height(16.dp))
+ Text(
+ yen(bigAmount),
+ color = FG,
+ fontSize = 56.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ // Balance success shows the total alone — no status line.
+ val showStatus = !(txn.job == PosJob.BALANCE && txn.state == PosTxnState.SUCCESS)
+ if (showStatus) {
+ Spacer(Modifier.height(20.dp))
+ Text(
+ txn.statusText,
+ color = FG,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ )
+ }
+ if (txn.job == PosJob.PAYMENT && txn.state == PosTxnState.SUCCESS) {
+ txn.result?.balance?.let { balance ->
+ Spacer(Modifier.height(12.dp))
+ Text(
+ "支払後残高 ${yen(balance)}",
+ color = FG,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ }
+
+ // Cancel only before the card tap / server op (see PosTxnState.isCancellable).
+ if (txn.state.isCancellable) {
+ Button(
+ onClick = onCancel,
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFECECEC), contentColor = FG),
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .safeDrawingPadding()
+ .padding(bottom = 28.dp)
+ .height(52.dp),
+ ) {
+ Text("キャンセル", fontSize = 16.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatusRect(color: Color, modifier: Modifier = Modifier) {
+ Box(
+ modifier
+ .size(width = RECT_W, height = RECT_H)
+ .background(color)
+ )
+}
diff --git a/app/src/main/java/jp/unknowntech/melonterminal/ui/TerminalScreen.kt b/app/src/main/java/jp/unknowntech/melonterminal/ui/TerminalScreen.kt
index ac47027..57b7700 100644
--- a/app/src/main/java/jp/unknowntech/melonterminal/ui/TerminalScreen.kt
+++ b/app/src/main/java/jp/unknowntech/melonterminal/ui/TerminalScreen.kt
@@ -68,13 +68,22 @@ fun TerminalScreen(vm: TerminalViewModel) {
MerchantScreen(vm, onClose = { showMerchant = false })
return
}
+ // POS-linked mode takes over the whole screen with its own kiosk face.
+ if (state.mode == AppMode.POS) {
+ PosScreen(vm)
+ return
+ }
Column(
Modifier
.fillMaxSize()
.safeDrawingPadding()
) {
- Header(onMerchant = { showMerchant = true }, onSettings = { showSettings = true })
+ Header(
+ onPos = vm::enterPosMode,
+ onMerchant = { showMerchant = true },
+ onSettings = { showSettings = true },
+ )
if (state.nfc != NfcState.ENABLED) NfcBanner(state.nfc)
OpTabs(state.op) { vm.setOp(it) }
AmountArea(state, Modifier.weight(1f))
@@ -159,7 +168,7 @@ private fun ArmedSheet(op: Op, amount: Long, onCancel: () -> Unit) {
}
@Composable
-private fun Header(onMerchant: () -> Unit, onSettings: () -> Unit) {
+private fun Header(onPos: () -> Unit, onMerchant: () -> Unit, onSettings: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
@@ -177,6 +186,7 @@ private fun Header(onMerchant: () -> Unit, onSettings: () -> Unit) {
Text("Melon 端末", fontWeight = FontWeight.Bold, fontSize = 18.sp)
}
Row(verticalAlignment = Alignment.CenterVertically) {
+ TextButton(onClick = onPos) { Text("🖥 POS連動") }
TextButton(onClick = onMerchant) { Text("🏬 加盟店") }
TextButton(onClick = onSettings) { Text("⚙ 設定") }
}
diff --git a/app/src/main/java/jp/unknowntech/melonterminal/ui/TerminalViewModel.kt b/app/src/main/java/jp/unknowntech/melonterminal/ui/TerminalViewModel.kt
index cdc4d74..872d6f7 100644
--- a/app/src/main/java/jp/unknowntech/melonterminal/ui/TerminalViewModel.kt
+++ b/app/src/main/java/jp/unknowntech/melonterminal/ui/TerminalViewModel.kt
@@ -18,12 +18,25 @@ import jp.unknowntech.melonterminal.net.PayResp
import jp.unknowntech.melonterminal.net.RefundableView
import jp.unknowntech.melonterminal.net.TopupResp
import jp.unknowntech.melonterminal.nfc.CardSession
+import jp.unknowntech.melonterminal.pos.AnnounceMsg
+import jp.unknowntech.melonterminal.pos.BucketMsg
+import jp.unknowntech.melonterminal.pos.ErrorMsg
+import jp.unknowntech.melonterminal.pos.InfoMsg
+import jp.unknowntech.melonterminal.pos.PosCommandHandler
+import jp.unknowntech.melonterminal.pos.PosError
+import jp.unknowntech.melonterminal.pos.PosReply
+import jp.unknowntech.melonterminal.pos.PosServer
+import jp.unknowntech.melonterminal.pos.RefundableMsg
+import jp.unknowntech.melonterminal.pos.ResultMsg
+import jp.unknowntech.melonterminal.pos.StatusMsg
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
enum class NfcState { ENABLED, DISABLED, UNSUPPORTED }
@@ -59,6 +72,8 @@ data class UiState(
val configured: Boolean = false,
val nfc: NfcState = NfcState.ENABLED,
val serverUrl: String = Settings.DEFAULT_SERVER,
+ /** Standalone keypad, or the POS-linked bridge. */
+ val mode: AppMode = AppMode.STANDALONE,
val sheet: Sheet = Sheet.None,
val busy: Boolean = false,
/** For pay/top-up only: the operator pressed 支払う/チャージする, so a tap is now
@@ -73,7 +88,7 @@ data class UiState(
private const val MAX_AMOUNT_DIGITS = 7 // ¥9,999,999
private const val MAX_MEMO_LEN = 200
-class TerminalViewModel(app: Application) : AndroidViewModel(app) {
+class TerminalViewModel(app: Application) : AndroidViewModel(app), PosCommandHandler {
private val settings = Settings(app)
private val _state = MutableStateFlow(
@@ -81,6 +96,16 @@ class TerminalViewModel(app: Application) : AndroidViewModel(app) {
)
val state: StateFlow = _state.asStateFlow()
+ private val _pos = MutableStateFlow(PosUiState())
+ val pos: StateFlow = _pos.asStateFlow()
+
+ private val appVersion: String = runCatching {
+ app.packageManager.getPackageInfo(app.packageName, 0).versionName
+ }.getOrNull() ?: "?"
+
+ @Volatile
+ private var posServer: PosServer? = null
+
@Volatile
private var systemCodesCache: List? = null
@@ -176,6 +201,10 @@ class TerminalViewModel(app: Application) : AndroidViewModel(app) {
* coroutine that drives the online relay + operation, and is closed at the end.
*/
fun onTag(tag: Tag) {
+ if (_state.value.mode == AppMode.POS) onTagPos(tag) else onTagStandalone(tag)
+ }
+
+ private fun onTagStandalone(tag: Tag) {
val s = _state.value
// Accept a tap only when idle — no modal is showing. This is what stops a card
// left on the reader from being charged again and again: after an operation the
@@ -289,4 +318,455 @@ class TerminalViewModel(app: Application) : AndroidViewModel(app) {
_state.update { it.copy(busy = false, sheet = sheet) }
}
}
+
+ // ===================== POS-linked mode =====================
+ //
+ // The terminal answers commands from a POS register over the LAN (see PosServer and
+ // docs/POS-Protocol.md). A single transaction is tracked in [_pos]; the network
+ // handler methods below run on PosServer threads and mutate it through the
+ // thread-safe MutableStateFlow. A card-driven job (payment/balance/refund-query) is
+ // only *armed* here — the actual tap is delivered later to [onTagPos].
+
+ /** Enter POS mode and bring the LAN server up. */
+ fun enterPosMode() {
+ if (_state.value.mode == AppMode.POS) return
+ _state.update { it.copy(mode = AppMode.POS) }
+ startPosServer()
+ }
+
+ /** Leave POS mode: cancel any live transaction and shut the server down. */
+ fun exitPosMode() {
+ if (_state.value.mode == AppMode.STANDALONE) return
+ handleCancel(null)
+ stopPosServer()
+ _pos.value = PosUiState()
+ _state.update { it.copy(mode = AppMode.STANDALONE) }
+ }
+
+ /** Terminal-side cancel button — same path as a POS `cancel`. */
+ fun posCancel() {
+ handleCancel(null)
+ }
+
+ private fun startPosServer() {
+ if (posServer != null) return
+ val server = PosServer(getApplication(), this)
+ server.start()
+ posServer = server
+ _pos.update { it.copy(serverRunning = true, address = server.address) }
+ }
+
+ private fun stopPosServer() {
+ posServer?.stop()
+ posServer = null
+ _pos.update { it.copy(serverRunning = false, address = null) }
+ }
+
+ // ----- PosCommandHandler (called on PosServer network threads) -----
+
+ override fun handlePayment(requestId: String?, amount: Long, note: String?): PosReply {
+ if (client() == null) return notConfigured()
+ if (amount <= 0) return badRequest("amount must be positive")
+ activeConflict(requestId, PosJob.PAYMENT)?.let { return it }
+ val txn = PosTxn(
+ id = newTxnId(),
+ requestId = requestId,
+ job = PosJob.PAYMENT,
+ amount = amount,
+ state = PosTxnState.PENDING,
+ statusText = PENDING_TEXT,
+ note = note?.trim()?.ifBlank { null },
+ )
+ setTxn(txn)
+ prepareForCard(txn.id)
+ return statusReply(txn)
+ }
+
+ override fun handleTopup(requestId: String?, amount: Long): PosReply {
+ if (client() == null) return notConfigured()
+ if (amount <= 0) return badRequest("amount must be positive")
+ activeConflict(requestId, PosJob.TOPUP)?.let { return it }
+ val txn = PosTxn(
+ id = newTxnId(),
+ requestId = requestId,
+ job = PosJob.TOPUP,
+ amount = amount,
+ state = PosTxnState.PENDING,
+ statusText = PENDING_TEXT,
+ )
+ setTxn(txn)
+ prepareForCard(txn.id)
+ return statusReply(txn)
+ }
+
+ override fun handleBalance(requestId: String?): PosReply {
+ if (client() == null) return notConfigured()
+ activeConflict(requestId, PosJob.BALANCE)?.let { return it }
+ val txn = PosTxn(
+ id = newTxnId(),
+ requestId = requestId,
+ job = PosJob.BALANCE,
+ amount = 0,
+ state = PosTxnState.PENDING,
+ statusText = PENDING_TEXT,
+ )
+ setTxn(txn)
+ prepareForCard(txn.id)
+ return statusReply(txn)
+ }
+
+ override fun handleRefundQuery(requestId: String?): PosReply {
+ if (client() == null) return notConfigured()
+ activeConflict(requestId, PosJob.REFUND_QUERY)?.let { return it }
+ val txn = PosTxn(
+ id = newTxnId(),
+ requestId = requestId,
+ job = PosJob.REFUND_QUERY,
+ amount = 0,
+ state = PosTxnState.PENDING,
+ statusText = PENDING_TEXT,
+ )
+ setTxn(txn)
+ prepareForCard(txn.id)
+ return statusReply(txn)
+ }
+
+ /**
+ * Execute a refund. This is fully independent of [handleRefundQuery]: POS may pass a
+ * `payment_id` it already knows (e.g. recorded at payment time) without any prior
+ * query. No card is involved — the terminal only talks to the server. The server
+ * validates the payment_id and amount and reports any error (REFUND_EXCEEDS_PAYMENT,
+ * NOT_FOUND, …).
+ */
+ override fun handleRefundExecute(requestId: String?, paymentId: String, amount: Long?): PosReply {
+ if (client() == null) return notConfigured()
+ if (paymentId.isBlank()) return badRequest("refund_execute requires payment_id")
+ if (amount != null && amount < 1) return badRequest("amount must be positive")
+ activeConflict(requestId, PosJob.REFUND)?.let { return it }
+ val txn = PosTxn(
+ id = newTxnId(),
+ requestId = requestId,
+ job = PosJob.REFUND,
+ amount = amount ?: 0,
+ state = PosTxnState.PROCESSING,
+ statusText = PROCESSING_TEXT,
+ )
+ setTxn(txn)
+ executeRefundPos(txn.id, paymentId, amount)
+ return statusReply(txn)
+ }
+
+ override fun handleStatus(): PosReply = statusReply(_pos.value.txn)
+
+ override fun handleCancel(requestId: String?): PosReply {
+ var cancelledId: String? = null
+ _pos.update { st ->
+ val cur = st.txn
+ // Ignore a cancel once processing has begun — refusing it keeps the terminal
+ // in sync with the server (see PosTxnState.isCancellable). The current status
+ // is returned unchanged so POS sees the transaction still in progress.
+ if (cur != null && cur.state.isCancellable) {
+ cancelledId = cur.id
+ st.copy(
+ txn = cur.copy(
+ state = PosTxnState.CANCELLED,
+ statusText = CANCELLED_TEXT,
+ result = ResultMsg(ok = false, code = "CANCELLED", title = CANCELLED_TEXT),
+ updatedAt = now(),
+ )
+ )
+ } else st
+ }
+ cancelledId?.let { scheduleClear(it) }
+ return statusReply(_pos.value.txn)
+ }
+
+ override fun handleInfo(): PosReply = PosReply.Info(
+ InfoMsg(
+ terminal_id = settings.terminalId,
+ name = settings.terminalName,
+ app_version = appVersion,
+ )
+ )
+
+ override fun announce(localIp: String?): AnnounceMsg = AnnounceMsg(
+ terminal_id = settings.terminalId,
+ name = settings.terminalName,
+ ip = localIp,
+ app_version = appVersion,
+ state = _pos.value.let { if (it.idle) "idle" else it.txn!!.state.wire },
+ )
+
+ // ----- POS tap flow -----
+
+ private fun onTagPos(tag: Tag) {
+ val txn = _pos.value.txn ?: return
+ if (txn.state != PosTxnState.WAITING_CARD) return
+ if (!handling.compareAndSet(false, true)) return
+ val client = client()
+ if (client == null) {
+ handling.set(false)
+ completeFail(txn.id, ResultMsg(ok = false, code = PosError.NOT_CONFIGURED, title = "端末が未設定です"))
+ return
+ }
+ val nfcF = try {
+ CardSession.connect(tag)
+ } catch (e: Exception) {
+ handling.set(false)
+ completeFail(txn.id, errorResult(e))
+ return
+ }
+ updateTxn(txn.id) { it.copy(state = PosTxnState.PROCESSING, statusText = PROCESSING_TEXT, updatedAt = now()) }
+ val id = txn.id
+ val job = txn.job
+ val amount = txn.amount
+ val note = txn.note
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ runCardPhase(id, job, amount, note, client, nfcF)
+ } finally {
+ runCatching { nfcF.close() }
+ handling.set(false)
+ }
+ }
+ }
+
+ private suspend fun runCardPhase(
+ id: String,
+ job: PosJob,
+ amount: Long,
+ note: String?,
+ client: MelonClient,
+ nfcF: android.nfc.tech.NfcF,
+ ) {
+ val auth = runCatching {
+ val codes = systemCodes(client)
+ CardFlow.authenticate(nfcF, client, codes)
+ }.getOrElse { completeFail(id, errorResult(it)); return }
+
+ updateTxn(id) { it.copy(accountId = auth.accountId) }
+
+ when (job) {
+ PosJob.PAYMENT -> {
+ val r = runCatching { client.pay(auth.sessionId, amount, note) }
+ .getOrElse { completeFail(id, errorResult(it)); return }
+ completeSuccess(
+ id, "支払完了",
+ ResultMsg(
+ ok = true,
+ account_id = auth.accountId,
+ amount = r.amount,
+ fee = r.fee,
+ balance = r.balance,
+ )
+ )
+ }
+
+ PosJob.TOPUP -> {
+ val r = runCatching { client.topup(auth.sessionId, amount) }
+ .getOrElse { completeFail(id, errorResult(it)); return }
+ completeSuccess(
+ id, "チャージ完了",
+ ResultMsg(
+ ok = true,
+ account_id = auth.accountId,
+ amount = r.amount,
+ balance = r.balance,
+ expires_at = r.expires_at,
+ )
+ )
+ }
+
+ PosJob.BALANCE -> {
+ val r = runCatching { client.balance(auth.sessionId) }
+ .getOrElse { completeFail(id, errorResult(it)); return }
+ completeSuccess(
+ id, BALANCE_DONE_TEXT,
+ ResultMsg(
+ ok = true,
+ account_id = r.account_id,
+ total = r.total,
+ buckets = r.buckets.map { BucketMsg(it.bucket_id, it.remaining, it.expires_at) },
+ )
+ )
+ }
+
+ PosJob.REFUND_QUERY -> {
+ val list = runCatching { client.refundable(auth.accountId) }
+ .getOrElse { completeFail(id, errorResult(it)); return }
+ val refundable = list.map {
+ RefundableMsg(it.id, it.amount, it.fee, it.refunded, it.refundable, it.occurred_at)
+ }
+ // A lookup is complete once the list is fetched — it is its own terminal
+ // transaction (like a balance inquiry). The choices go to POS in the
+ // status; POS later runs an independent refund_execute.
+ var done = false
+ _pos.update { st ->
+ val cur = st.txn
+ if (cur != null && cur.id == id && cur.state == PosTxnState.PROCESSING) {
+ done = true
+ st.copy(
+ txn = cur.copy(
+ state = PosTxnState.SUCCESS,
+ statusText = REFUND_QUERY_DONE_TEXT,
+ accountId = auth.accountId,
+ refundable = refundable,
+ result = ResultMsg(ok = true, account_id = auth.accountId),
+ updatedAt = now(),
+ )
+ )
+ } else st
+ }
+ if (done) scheduleClear(id)
+ }
+
+ // Refund execution is card-free (handled in executeRefundPos) and never
+ // reaches this card phase; present only for exhaustiveness.
+ PosJob.REFUND -> Unit
+ }
+ }
+
+ private fun executeRefundPos(id: String, paymentId: String, amount: Long?) {
+ val client = client()
+ if (client == null) {
+ completeFail(id, ResultMsg(ok = false, code = PosError.NOT_CONFIGURED, title = "端末が未設定です"))
+ return
+ }
+ viewModelScope.launch(Dispatchers.IO) {
+ val result = runCatching {
+ val r = client.refund(paymentId, amount)
+ ResultMsg(ok = true, account_id = _pos.value.txn?.accountId, amount = r.amount, balance = r.balance)
+ }.getOrElse { errorResult(it) }
+ if (result.ok) completeSuccess(id, REFUND_DONE_TEXT, result) else completeFail(id, result)
+ }
+ }
+
+ // ----- POS card preparation & completion -----
+
+ /** Prime the system-code cache (the "connecting to Melon" phase), then wait for a tap. */
+ private fun prepareForCard(id: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val client = client()
+ if (client == null) {
+ completeFail(id, ResultMsg(ok = false, code = PosError.NOT_CONFIGURED, title = "端末が未設定です"))
+ return@launch
+ }
+ val err = runCatching { systemCodes(client) }.exceptionOrNull()
+ if (err != null) {
+ completeFail(id, errorResult(err))
+ return@launch
+ }
+ updateTxn(id) { t ->
+ if (t.state == PosTxnState.PENDING) {
+ t.copy(state = PosTxnState.WAITING_CARD, statusText = WAITING_CARD_TEXT, updatedAt = now())
+ } else t
+ }
+ }
+ }
+
+ private fun completeSuccess(id: String, statusText: String, result: ResultMsg) {
+ var done = false
+ _pos.update { st ->
+ val cur = st.txn
+ if (cur != null && cur.id == id && cur.state.isActive) {
+ done = true
+ st.copy(txn = cur.copy(state = PosTxnState.SUCCESS, statusText = statusText, result = result, updatedAt = now()))
+ } else st
+ }
+ if (done) scheduleClear(id)
+ }
+
+ private fun completeFail(id: String, result: ResultMsg) {
+ var done = false
+ _pos.update { st ->
+ val cur = st.txn
+ if (cur != null && cur.id == id && cur.state.isActive) {
+ done = true
+ st.copy(txn = cur.copy(state = PosTxnState.FAILED, statusText = result.title ?: "エラー", result = result, updatedAt = now()))
+ } else st
+ }
+ if (done) scheduleClear(id)
+ }
+
+ /** After the 5 s result display, return the screen to 待受中 (the record stays
+ * queryable by polling until a new command replaces it). */
+ private fun scheduleClear(id: String) {
+ viewModelScope.launch {
+ delay(RESULT_DISPLAY_MS)
+ _pos.update { st ->
+ if (st.txn?.id == id && st.txn.state.isTerminal) st.copy(displayCleared = true) else st
+ }
+ }
+ }
+
+ // ----- POS helpers -----
+
+ private fun setTxn(txn: PosTxn) = _pos.update { it.copy(txn = txn, displayCleared = false) }
+
+ private fun updateTxn(id: String, transform: (PosTxn) -> PosTxn) = _pos.update { st ->
+ val cur = st.txn
+ if (cur != null && cur.id == id) st.copy(txn = transform(cur)) else st
+ }
+
+ /**
+ * BUSY when a transaction is already live. A retry is treated as idempotent (and the
+ * live status returned) only when BOTH the request_id AND the job match — otherwise a
+ * reused request_id on a different command must not silently ride the wrong
+ * transaction (e.g. a `topup` returning a still-running `balance`).
+ */
+ private fun activeConflict(requestId: String?, job: PosJob): PosReply? {
+ val active = _pos.value.txn?.takeIf { it.state.isActive } ?: return null
+ return if (requestId != null && active.requestId == requestId && active.job == job) statusReply(active)
+ else PosReply.Error(ErrorMsg(code = PosError.BUSY, message = "a transaction is already in progress"))
+ }
+
+ private fun statusReply(txn: PosTxn?): PosReply.Status = PosReply.Status(txn?.toStatusMsg() ?: idleStatus())
+
+ private fun PosTxn.toStatusMsg(): StatusMsg = StatusMsg(
+ transaction_id = id,
+ request_id = requestId,
+ job = job.wire,
+ state = state.wire,
+ status_text = statusText,
+ amount = amount.takeIf { it > 0 },
+ refundable = refundable.takeIf { it.isNotEmpty() },
+ updated_at = updatedAt,
+ result = result,
+ )
+
+ private fun idleStatus(): StatusMsg =
+ StatusMsg(state = "idle", status_text = IDLE_TEXT, updated_at = now())
+
+ private fun errorResult(e: Throwable): ResultMsg {
+ val code = (e as? ApiException)?.code
+ val ui = classify(e)
+ return ResultMsg(ok = false, code = code, title = ui.title, detail = ui.detail)
+ }
+
+ private fun notConfigured(): PosReply =
+ PosReply.Error(ErrorMsg(code = PosError.NOT_CONFIGURED, message = "terminal has no API key configured"))
+
+ private fun badRequest(message: String): PosReply =
+ PosReply.Error(ErrorMsg(code = PosError.BAD_REQUEST, message = message))
+
+ private fun newTxnId(): String = UUID.randomUUID().toString()
+
+ private fun now(): Long = System.currentTimeMillis()
+
+ override fun onCleared() {
+ super.onCleared()
+ stopPosServer()
+ }
+
+ private companion object {
+ const val RESULT_DISPLAY_MS = 5_000L
+ const val IDLE_TEXT = "待受中"
+ const val PENDING_TEXT = "お待ちください"
+ const val WAITING_CARD_TEXT = "カードをかざしてください"
+ const val PROCESSING_TEXT = "処理中…"
+ const val BALANCE_DONE_TEXT = "残高照会完了"
+ const val REFUND_QUERY_DONE_TEXT = "照会完了しました"
+ const val REFUND_DONE_TEXT = "返金完了"
+ const val CANCELLED_TEXT = "キャンセルしました"
+ }
}
diff --git a/docs/POS-Protocol.md b/docs/POS-Protocol.md
new file mode 100644
index 0000000..27227cc
--- /dev/null
+++ b/docs/POS-Protocol.md
@@ -0,0 +1,229 @@
+# Melon Terminal ⇄ POS 連動プロトコル
+
+POSレジ等の上位機器が、同一LAN上の Melon決済端末へコマンドを送り、
+支払 / チャージ / 残高照会 / 返金を実行させるための通信仕様です。
+
+## モデル
+
+- **端末検索** … UDP ブロードキャストで端末の IP と TCP ポートを取得。
+- **コマンド** … TCP + 改行区切りJSON。
+- **状態取得** … Polling形式。取引開始後は `status` を繰り返し送り、終了状態になるまで待つ。
+- 端末は同時に1取引だけを扱う。取引の全状態は `status` レスポンスに集約される。
+
+
+## トランスポート
+
+| 項目 | 値 |
+|---|---|
+| 端末検索 UDP ポート | `65024` |
+| コマンド TCP ポート | `65025` |
+| 文字コード | UTF-8 |
+| フレーミング | `\n` 終端のJSON |
+| プロトコルバージョン | `1`(フレームの `melon_pos`値) |
+
+## メッセージ形式
+
+UDP・TCP とも、全パケットを次のフレームで包む。リクエストは `msg.command`、レスポンスは
+`msg.type` を持つ。キー順は不定。
+
+```json
+{ "melon_pos": 1, "alg": "none", "sig": null, "msg": { ... } }
+```
+
+| フィールド | 型 | 説明 |
+|---|---|---|
+| `melon_pos` | int | プロトコルバージョン 兼 マジック。`1` 以外は拒否。 |
+| `alg` | string | RFU。現状 `"none"` 固定(他値は `UNSUPPORTED_ALG`)。 |
+| `sig` | string \| null | RFU。現状 `null`。 |
+| `msg` | object | 実体オブジェクト。 |
+
+応答フレームでは値が `null` のフィールド(`sig` など)は省略される。
+
+## 端末検索
+
+POS がブロードキャストへ `discover` を送り、端末がユニキャストで `announce` を返す。
+
+```json
+// → 255.255.255.255:65024
+{ "melon_pos": 1, "alg": "none", "msg": { "command": "discover" } }
+
+// ← 端末
+{ "melon_pos": 1, "alg": "none", "msg": {
+ "type": "announce", "terminal_id": "3f2b…", "name": "Melon 端末",
+ "ip": "192.168.0.12", "tcp_port": 65025, "app_version": "0.1.5", "state": "idle"
+} }
+```
+
+`ip` / `tcp_port` で TCP 接続を張る。`terminal_id` は端末ごとに一意。
+
+## コマンド
+
+`amount` は円単位の整数。`request_id` は任意だが、付与を推奨(同一
+`request_id` の再送は冪等になる)。`request_id` はPOSが決める任意文字列。
+
+| command | 業務 | カード | パラメータ |
+|---|---|---|---|
+| `info` | — | — | なし |
+| `payment` | 支払 | 要 | `amount`(必須), `note`, `request_id` |
+| `topup` | チャージ | 要 | `amount`(必須), `request_id` |
+| `balance` | 残高照会 | 要 | `request_id` |
+| `refund_query` | 返金照会 | 要 | `request_id` |
+| `refund_execute` | 返金 | 不要 | `payment_id`(必須), `amount`, `request_id` |
+| `status` | 状態取得 | — | なし |
+| `cancel` | キャンセル | — | `request_id` |
+
+```json
+{ "melon_pos":1, "alg":"none", "msg":{ "command":"payment", "amount":1234, "note":"お弁当", "request_id":"r-001" } }
+{ "melon_pos":1, "alg":"none", "msg":{ "command":"refund_execute", "payment_id":"019f7893-b275-7961-9c23-5f2bf4e74d28", "amount":500, "request_id":"r-004" } }
+{ "melon_pos":1, "alg":"none", "msg":{ "command":"status" } }
+```
+
+## status レスポンス
+
+`payment` / `topup` / `balance` / `refund_query` / `refund_execute` / `status` / `cancel` は
+すべて同じ `status` を返す。POSはこれをPollingする。
+
+```json
+{
+ "type": "status",
+ "transaction_id": "98765ab…",
+ "request_id": "12345ab…",
+ "job": "payment",
+ "state": "waiting_card",
+ "status_text": "カードをかざしてください",
+ "amount": 1234,
+ "refundable": null,
+ "updated_at": 1752480000000,
+ "result": null
+}
+```
+
+| フィールド | 説明 |
+|---|---|
+| `transaction_id` | 取引 ID(`idle` のとき null)。 |
+| `request_id` | 取引を開始したコマンドの `request_id`。 |
+| `job` | `payment` / `topup` / `balance` / `refund_query` / `refund` / null。 |
+| `state` | 下表。 |
+| `status_text` | 端末表示中の日本語ステータス。 |
+| `amount` | 金額(該当業務のみ)。 |
+| `refundable` | `refund_query` 成功時のみ、返金可能な取引の配列。 |
+| `updated_at` | 端末側の更新時刻(Unix ミリ秒)。 |
+| `result` | 終了状態でのみ設定(下記)。 |
+
+### state
+
+| state | 意味 | 端末画面 |
+|---|---|---|
+| `idle` | 取引なし | 待受中 |
+| `pending` | 準備中(サーバ接続など) | 「お待ちください」 |
+| `waiting_card` | カード待受 | 「カードをかざしてください」・青色点滅 |
+| `processing` | 処理中 | 「処理中…」・青色点滅(カード必要業務のみ) |
+| `success` | 成功(終了) | 5 秒 青色 |
+| `failed` | 失敗(終了) | 5 秒 赤色 |
+| `cancelled` | キャンセル(終了) | 「キャンセルしました」 |
+
+`success` / `failed` / `cancelled` が終了状態。POS はこの 3 状態になるまで Polling する。
+端末の結果表示は 5 秒続くが、`result` は完了と同時に返されるので待つ必要はない。
+
+## result(終了状態のみ)
+
+
+```json
+// 支払
+{ "ok": true, "account_id": "67995d5d-…", "amount": 1234, "fee": 12, "balance": 5000 }
+// チャージ(expires_at は無ければ省略)
+{ "ok": true, "account_id": "67995d5d-…", "amount": 3000, "balance": 8000, "expires_at": "2027-03-31" }
+// 残高照会
+{ "ok": true, "account_id": "67995d5d-…", "total": 5000,
+ "buckets": [ { "bucket_id": "d0721d5c…", "remaining": 5000, "expires_at": "2027-03-31" } ] }
+// 返金照会(一覧は status.refundable)
+{ "ok": true, "account_id": "67995d5d-…" }
+// 返金(カード非経由のため account_id は返さない)
+{ "ok": true, "amount": 500, "balance": 4500 }
+// 失敗(全業務共通)
+{ "ok": false, "code": "INSUFFICIENT_FUNDS", "title": "残高が不足しています", "detail": "…" }
+```
+
+## 返金
+`refund_query` と `refund_execute` は独立している。
+
+- **`refund_query`**
+ カードを読み、返金可能な取引の一覧を `status.refundable` で返す(カード業務、成功で終了)。
+ 各要素:`{ "payment_id", "amount", "fee", "refunded", "refundable", "occurred_at" }`
+- **`refund_execute`**
+ `payment_id`を指定して返金処理を実行する。`amount` 省略で全額。
+
+## キャンセル
+
+`cancel` コマンド、または端末の「キャンセル」ボタン。取り消せるのは `pending` /
+`waiting_card` のみで、成功すると `cancelled`(終了状態)になる。
+
+`processing` 以降の `cancel` は無視される(POSとの取引アンマッチ防止のため)。この場合は取引を変えず、現在の `status` をそのまま返す。端末側もこの間はキャンセルボタンを表示しない。
+
+## エラー
+
+受理できないコマンドは `status` ではなく `error` を返す。
+
+```json
+{ "melon_pos": 1, "alg": "none", "msg": { "type": "error", "code": "BUSY", "message": "…" } }
+```
+
+| code | 意味 |
+|---|---|
+| `BAD_REQUEST` | JSON 不正・必須不足・値が範囲外 |
+| `UNKNOWN_COMMAND` | 未知の `command` |
+| `UNSUPPORTED_VERSION` | `melon_pos` が非対応 |
+| `UNSUPPORTED_ALG` | `alg` が非対応 |
+| `NOT_CONFIGURED` | 端末に API キー未設定 |
+| `BUSY` | 別の取引が進行中 |
+
+同一 `request_id` かつ同一 `job` で進行中の取引に再送した場合は、`BUSY` ではなく現在の`status` を返す(冪等性を持たせるため)。
+
+## info
+
+```json
+// → { "command": "info" }
+// ←
+{ "type": "info", "protocol": 1, "terminal_id": "3f2b…", "name": "Melon 端末",
+ "app_version": "0.1.5", "jobs": ["payment","topup","balance","refund_query","refund_execute"] }
+```
+
+`app_version` は端末アプリの実バージョン。プロトコルバージョンは `protocol`(= フレームの `melon_pos`)。
+
+## 通信例
+### 状態取得(Polling)
+
+```
+send {"melon_pos":1,"alg":"none","sig":null,"msg":{"command":"status"}}
+recv {"melon_pos":1,"alg":"none","msg":{"type":"status","transaction_id":"64f6b140-8709-4688-818b-457ad12d9230","request_id":"270622be-269e-480e-bdea-b2cd4843f3ba","job":"refund_query","state":"processing","status_text":"処理中…","updated_at":1784434535886}}
+```
+
+### 返金照会の完了(refundable つき)
+
+```
+recv {"melon_pos":1,"alg":"none","msg":{"type":"status","transaction_id":"64f6b140-8709-4688-818b-457ad12d9230","request_id":"270622be-269e-480e-bdea-b2cd4843f3ba","job":"refund_query","state":"success","status_text":"照会完了しました","refundable":[{"payment_id":"019f7893-b275-7961-9c23-5f2bf4e74d28","amount":1,"fee":0,"refunded":0,"refundable":1,"occurred_at":"2026-07-19T04:12:53.237087Z"},{"payment_id":"019f70eb-35d5-7760-b81a-0965974c2e36","amount":1,"fee":0,"refunded":0,"refundable":1,"occurred_at":"2026-07-17T16:31:30.772797Z"}],"updated_at":1784434537503,"result":{"ok":true,"account_id":"67995d5d-fa5f-4915-b399-d3ca02751142"}}}
+```
+
+### 支払の完了
+
+```
+recv {"melon_pos":1,"alg":"none","msg":{"type":"status","transaction_id":"da11215a-4f75-402e-874a-6db59e95b902","request_id":"5b4636d1-79b5-4c54-aa29-80f4392d4548","job":"payment","state":"success","status_text":"支払完了","amount":1,"updated_at":1784434374039,"result":{"ok":true,"account_id":"67995d5d-fa5f-4915-b399-d3ca02751142","amount":1,"fee":0,"balance":1}}}
+```
+
+### チャージの完了
+
+```
+recv {"melon_pos":1,"alg":"none","msg":{"type":"status","transaction_id":"4ee68c3d-352f-42aa-9961-caa22cb5e27f","request_id":"8fcf8993-4163-49ba-8349-6cc8c3ab7799","job":"topup","state":"success","status_text":"チャージ完了","amount":1,"updated_at":1784436180053,"result":{"ok":true,"account_id":"67995d5d-fa5f-4915-b399-d3ca02751142","amount":1,"balance":3,"expires_at":"2027-01-18T15:00:00Z"}}}
+```
+
+### 残高照会の完了
+
+```
+recv {"melon_pos":1,"alg":"none","msg":{"type":"status","transaction_id":"8835d511-b944-47f0-973a-8c266052ce9d","request_id":"d6c6f161-55f8-4f9a-93b6-93022504775d","job":"balance","state":"success","status_text":"残高照会完了","updated_at":1784436090463,"result":{"ok":true,"account_id":"67995d5d-fa5f-4915-b399-d3ca02751142","total":1,"buckets":[{"bucket_id":"019f613a-54e5-7993-802f-d672646680ce","remaining":1,"expires_at":"2027-01-14T15:00:00Z"}]}}}
+```
+
+### 返金の完了
+
+```
+recv {"melon_pos":1,"alg":"none","msg":{"type":"status","transaction_id":"54e10948-8150-4b98-a176-a37aae612887","request_id":"bf8ea8b5-0588-4d60-a6aa-4165e6bc0697","job":"refund","state":"success","status_text":"返金完了","updated_at":1784436127412,"result":{"ok":true,"amount":1,"balance":2}}}
+```