From 4725dde9faac5755abea62bdf592bf180f0cd181 Mon Sep 17 00:00:00 2001 From: MCCL Date: Sun, 19 Jul 2026 17:49:09 +0900 Subject: [PATCH] add POS register linkage over LAN --- README.md | 20 +- app/src/main/AndroidManifest.xml | 4 + .../unknowntech/melonterminal/MainActivity.kt | 27 + .../melonterminal/core/Settings.kt | 19 + .../melonterminal/pos/PosProtocol.kt | 182 +++++++ .../melonterminal/pos/PosServer.kt | 256 ++++++++++ .../unknowntech/melonterminal/ui/PosModels.kt | 81 +++ .../unknowntech/melonterminal/ui/PosScreen.kt | 202 ++++++++ .../melonterminal/ui/TerminalScreen.kt | 14 +- .../melonterminal/ui/TerminalViewModel.kt | 482 +++++++++++++++++- docs/POS-Protocol.md | 229 +++++++++ 11 files changed, 1511 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/jp/unknowntech/melonterminal/pos/PosProtocol.kt create mode 100644 app/src/main/java/jp/unknowntech/melonterminal/pos/PosServer.kt create mode 100644 app/src/main/java/jp/unknowntech/melonterminal/ui/PosModels.kt create mode 100644 app/src/main/java/jp/unknowntech/melonterminal/ui/PosScreen.kt create mode 100644 docs/POS-Protocol.md 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}}} +```