Checkout.kt
/*
* This file is part of GNU Taler
* (C) 2026 Taler Systems S.A.
*
* GNU Taler is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3, or (at your option) any later version.
*
* GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
package net.taler.merchantpos.checkout
import net.taler.common.Amount
import net.taler.common.ExternalPayment
import net.taler.common.Order
import net.taler.common.RelativeTime
import net.taler.merchantlib.MinimalInventoryProduct
import net.taler.merchantlib.PostOrderRequest
import kotlinx.serialization.Serializable
const val METHOD_CASH = "cash"
/**
* Collecting accepts external payments; AwaitingTaler waits for the wallet.
* Completed is settled. Abandoned retains payments that still need reconciliation.
*/
@Serializable
enum class CheckoutStage
{
Collecting,
AwaitingTaler,
Completed,
Abandoned,
}
/**
* Confirmed external payment. The ID also identifies its backend payment entry.
* For cash, amount excludes change; tendered and change are stored only when needed.
*/
@Serializable
data class RecordedPayment(
val id: String,
val method: String,
val amount: Amount,
val tendered: Amount? = null,
val change: Amount? = null,
val transactionCode: String? = null,
val recordedAt: Long,
)
/** Refund already issued for an external payment. */
@Serializable
data class RecordedRefund(
val id: String,
val paymentId: String,
val amount: Amount,
val reason: String,
val recordedAt: Long,
)
/** Provider charge awaiting a confirmed outcome; retained for recovery after a restart. */
@Serializable
data class PendingCharge(
val reference: String,
val method: String,
val amount: Amount,
val startedAt: Long,
)
/**
* Checkout state for the full sale, including external payments and refunds.
* Updates return copies; CheckoutManager persists them and handles backend requests.
*
* The backend order requests only the remaining Taler amount. Its ID stays stable
* within each attempt so creation can be retried. Refunds do not reopen the sale.
*/
@Serializable
data class Checkout(
val id: String,
val createdAt: Long,
val session: String,
val posOrderId: Int,
val contractTerms: Order,
val inventoryProducts: List<MinimalInventoryProduct>? = null,
val payments: List<RecordedPayment> = emptyList(),
val refunds: List<RecordedRefund> = emptyList(),
val stage: CheckoutStage = CheckoutStage.Collecting,
val backendOrderId: String? = null,
val attempt: Int = 0,
val merchantId: String? = null,
val creationPending: Boolean = false,
val pendingCharge: PendingCharge? = null,
)
{
val currency: String get() = contractTerms.amount.currency
val total: Amount get() = contractTerms.amount
val paid: Amount get() = payments.fold(Amount.zero(currency)) { sum, payment -> sum + payment.amount }
val refunded: Amount
get() = refunds.fold(Amount.zero(currency)) { sum, refund -> sum + refund.amount }
val open: Amount get() = total - paid
val isFullyPaidExternally: Boolean get() = open.isZero()
val backendOrderIdToCreate: String
get() = if (attempt == 0) "pos-$id" else "pos-$id-$attempt"
val canTakeExternalPayments: Boolean
get() = stage == CheckoutStage.Collecting && !creationPending && pendingCharge == null && !open.isZero()
fun refundableOf(payment: RecordedPayment): Amount =
payment.amount -
refunds
.filter { it.paymentId == payment.id }
.fold(Amount.zero(currency)) { sum, refund -> sum + refund.amount }
fun withRefund(
refundId: String,
paymentId: String,
amount: Amount,
reason: String,
now: Long,
): Checkout
{
val payment = payments.firstOrNull { it.id == paymentId }
requireNotNull(payment) { "Unknown payment" }
require(amount.currency == currency) { "Refund currency does not match the order" }
require(amount > Amount.zero(currency)) { "A refund must be positive" }
require(amount <= refundableOf(payment)) { "More than is left of this payment" }
return copy(
refunds = refunds + RecordedRefund(refundId, paymentId, amount, reason, now),
)
}
fun withCash(
tendered: Amount,
paymentId: String,
now: Long,
): CashOutcome
{
requirePayable(tendered)
val applied = if (tendered > open) open else tendered
val change = tendered - applied
val payment =
RecordedPayment(
id = paymentId,
method = METHOD_CASH,
amount = applied,
tendered = tendered.takeIf { it != applied },
change = change.takeUnless { it.isZero() },
recordedAt = now,
)
return CashOutcome(copy(payments = payments + payment), applied, change)
}
fun withExternalPayment(
method: String,
amount: Amount,
paymentId: String,
transactionCode: String?,
now: Long,
): Checkout
{
requirePayable(amount)
require(amount <= open) { "An external payment cannot exceed the open amount" }
require(method != "taler") { "Taler is paid through the backend order, not recorded" }
val payment =
RecordedPayment(
id = paymentId,
method = method,
amount = amount,
transactionCode = transactionCode,
recordedAt = now,
)
return copy(payments = payments + payment)
}
private fun requirePayable(amount: Amount)
{
check(canTakeExternalPayments) { "No further external payments can be taken" }
require(amount.currency == currency) { "Payment currency does not match the order" }
require(amount > Amount.zero(currency)) { "A payment must be positive" }
}
fun toOrderRequest(refundDelay: RelativeTime?): PostOrderRequest =
PostOrderRequest(
contractTerms =
contractTerms.copy(
orderId = backendOrderIdToCreate,
amount = open,
amountExternal =
payments
.map { payment ->
ExternalPayment(
method = payment.method,
id = payment.id,
amount = payment.amount,
transactionCode = payment.transactionCode,
)
}.ifEmpty { null },
),
refundDelay = refundDelay,
inventoryProducts = inventoryProducts,
)
}
data class CashOutcome(
val checkout: Checkout,
val applied: Amount,
val change: Amount,
)