CheckoutManager.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 androidx.annotation.UiThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import net.taler.common.Amount
import net.taler.common.Version
import net.taler.merchantlib.ExternalRefundRequest
import net.taler.merchantlib.MerchantApi
import net.taler.merchantpos.config.ConfigManager
import net.taler.merchantpos.order.Order
import net.taler.merchantpos.order.OrderManager
import net.taler.merchantpos.payment.POS_REFUND_DELAY
import net.taler.merchantpos.payment.toOrderParts
import net.taler.merchantpos.paymentoptions.ChargeResult
import net.taler.merchantpos.paymentoptions.ExternalCharge
import net.taler.merchantpos.paymentoptions.PaymentOption
import net.taler.merchantpos.paymentoptions.PaymentOptionsManager
import net.taler.merchantpos.paymentoptions.RefundOutcome
import net.taler.merchantpos.paymentoptions.TALER_OPTION_ID
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
// DD 96: external payments and merchant collection.
const val MIXED_PAYMENTS_PROTOCOL = 42
private val KEEP_COMPLETED_MS = TimeUnit.DAYS.toMillis(30)
sealed interface CheckoutResult
{
data object Completed : CheckoutResult
data class AwaitingWallet(
val checkout: Checkout,
) : CheckoutResult
data class Failed(
val message: String,
) : CheckoutResult
}
data class ChargeEvent(
val method: String,
val amount: Amount,
val result: ChargeResult,
)
/** NotRecorded means the refund succeeded but its backend record still needs updating. */
sealed interface ExternalRefundResult
{
data object Done : ExternalRefundResult
data class NotRecorded(
val message: String,
) : ExternalRefundResult
data class Failed(
val message: String,
) : ExternalRefundResult
}
sealed interface RevertResult
{
data object Reverted : RevertResult
data object AlreadyPaid : RevertResult
data class Failed(
val message: String,
) : RevertResult
}
sealed interface AbandonResult
{
data object Discarded : AbandonResult
data class KeptForSettlement(
val checkout: Checkout,
) : AbandonResult
}
/**
* Persists checkout state and coordinates external payments with backend orders.
* Pending charges must be verified before resolving them; manual refunds must
* already have been returned to the customer before they are recorded here.
*/
class CheckoutManager internal constructor(
private val store: CheckoutStore,
private val backendForCurrentMerchant: () -> CheckoutBackend?,
private val merchantIdentity: LiveData<String?>,
private val backendVersion: () -> String?,
private val externalOptionsEnabled: () -> Boolean,
private val optionFor: (method: String) -> PaymentOption?,
private val onOrderSettled: (posOrderId: Int) -> Unit,
private val now: () -> Long,
private val newId: () -> String,
)
{
constructor(
store: CheckoutStore,
api: MerchantApi,
configManager: ConfigManager,
paymentOptions: PaymentOptionsManager,
orderManager: OrderManager,
) : this(
store = store,
backendForCurrentMerchant = {
configManager.merchantConfig?.let { MerchantCheckoutBackend(api, it) }
},
merchantIdentity = configManager.merchantIdentity,
backendVersion = { configManager.backendVersion },
externalOptionsEnabled = {
paymentOptions.options.any { it.id != TALER_OPTION_ID && paymentOptions.isEnabled(it) }
},
optionFor = { method -> paymentOptions.options.firstOrNull { it.id == method } },
onOrderSettled = orderManager::onOrderPaid,
now = System::currentTimeMillis,
newId = { UUID.randomUUID().toString() },
)
private val session = newId()
private var allCheckouts = dropOldCompleted(store.load())
private val creationMutex = Mutex()
private val mCheckouts = MediatorLiveData<List<Checkout>>()
private val mCurrentId = MutableLiveData<String?>(null)
private val mChargeEvent = MutableLiveData<ChargeEvent?>(null)
val checkouts: LiveData<List<Checkout>> = mCheckouts
val currentId: LiveData<String?> = mCurrentId
val chargeEvent: LiveData<ChargeEvent?> = mChargeEvent
val current: Checkout? get() = mCurrentId.value?.let(::find)
init
{
mCheckouts.value = visibleCheckouts()
mCheckouts.addSource(merchantIdentity) {
if (mCurrentId.value?.let(::find) == null) mCurrentId.value = null
mChargeEvent.value = null
mCheckouts.value = visibleCheckouts()
}
}
private fun belongsToCurrentMerchant(checkout: Checkout): Boolean =
checkout.merchantId != null && checkout.merchantId == merchantIdentity.value
private fun visibleCheckouts() = allCheckouts.filter(::belongsToCurrentMerchant)
private fun findStored(id: String) = allCheckouts.firstOrNull { it.id == id }
fun find(id: String): Checkout? = findStored(id)?.takeIf(::belongsToCurrentMerchant)
fun unfinished(): List<Checkout> = visibleCheckouts().filter { it.stage != CheckoutStage.Completed }
fun backendSupportsMixedPayments(): Boolean
{
val current = backendVersion()?.let(Version::parse)?.current ?: return false
return current >= MIXED_PAYMENTS_PROTOCOL
}
fun shouldUseCheckout(): Boolean = backendSupportsMixedPayments() && externalOptionsEnabled()
@UiThread
fun start(
order: Order,
includeProducts: Boolean,
): Checkout
{
val parts = order.toOrderParts(includeProducts)
val checkout =
Checkout(
id = newId(),
merchantId = checkNotNull(merchantIdentity.value) { "Merchant is not configured" },
createdAt = now(),
session = session,
posOrderId = order.id,
contractTerms = parts.contractTerms,
inventoryProducts = parts.inventoryProducts,
)
save(allCheckouts + checkout)
mCurrentId.value = checkout.id
return checkout
}
@UiThread
fun resume(checkoutId: String): Boolean
{
val checkout = find(checkoutId) ?: return false
if (checkout.stage == CheckoutStage.Completed ||
checkout.stage == CheckoutStage.Abandoned)
{
return false
}
mCurrentId.value = checkoutId
return true
}
fun newPaymentId(): String = newId()
@UiThread
fun recordCash(tendered: Amount): CashOutcome
{
val outcome = requireCurrent().withCash(tendered, newId(), now())
replace(outcome.checkout)
return outcome
}
@UiThread
fun recordExternalPayment(
method: String,
amount: Amount,
paymentId: String,
transactionCode: String?,
): Checkout = recordExternal(requireCurrent(), method, amount, paymentId, transactionCode)
private fun recordExternal(
checkout: Checkout,
method: String,
amount: Amount,
paymentId: String,
transactionCode: String?,
): Checkout
{
if (checkout.payments.any { it.id == paymentId }) return checkout
val updated = checkout.withExternalPayment(method, amount, paymentId, transactionCode, now())
replace(updated)
return updated
}
@UiThread
fun consumeChargeEvent()
{
mChargeEvent.value = null
}
@UiThread
fun chargeWith(
option: PaymentOption,
amount: Amount,
title: String,
): Boolean
{
val checkout = requireCurrent()
if (!checkout.canTakeExternalPayments ||
amount.currency != checkout.currency ||
amount <= Amount.zero(checkout.currency) ||
amount > checkout.open)
{
return false
}
val pending = PendingCharge(newId(), option.id, amount, now())
// A failed disk write must prevent the terminal from being launched.
replace(checkout.copy(pendingCharge = pending))
val started =
try
{
option.startCharge(ExternalCharge(pending.reference, amount, title)) { result ->
receiveChargeResult(checkout.id, pending.reference, result)
}
}
catch (e: Exception)
{
// Launch may already have reached the provider. Keep the unresolved attempt.
receiveChargeResult(checkout.id, pending.reference, ChargeResult.Unknown(e.message))
true
}
if (!started) receiveChargeResult(checkout.id, pending.reference, ChargeResult.Cancelled)
return started
}
private fun receiveChargeResult(
checkoutId: String,
reference: String,
result: ChargeResult,
)
{
val checkout = findStored(checkoutId) ?: return
val pending = checkout.pendingCharge?.takeIf { it.reference == reference } ?: return
val reported =
try
{
when (result)
{
is ChargeResult.Success ->
{
replace(
checkout
.copy(pendingCharge = null)
.withExternalPayment(
pending.method,
pending.amount,
reference,
result.transactionCode,
now(),
),
)
}
ChargeResult.Cancelled, is ChargeResult.Failed ->
{
replace(checkout.copy(pendingCharge = null))
}
is ChargeResult.Unknown ->
{
Unit
}
}
result
}
catch (e: Exception)
{
ChargeResult.Unknown(e.message)
}
if (belongsToCurrentMerchant(checkout))
{
mChargeEvent.value = ChargeEvent(pending.method, pending.amount, reported)
}
}
@UiThread
fun resolvePendingCharge(
reference: String,
paid: Boolean,
transactionCode: String? = null,
)
{
val checkout = requireCurrent()
require(checkout.pendingCharge?.reference == reference) { "Payment is no longer pending" }
require(!paid || !transactionCode.isNullOrBlank()) { "Enter the confirmed transaction reference" }
receiveChargeResult(
checkout.id,
reference,
if (paid) ChargeResult.Success(transactionCode!!.trim()) else ChargeResult.Cancelled,
)
}
suspend fun finishWithTaler(): CheckoutResult
{
val checkout = current ?: return CheckoutResult.Failed("No checkout in progress")
val backend = backendForCurrentMerchant() ?: return CheckoutResult.Failed("Merchant is not configured")
return when (val order = ensureBackendOrder(checkout, backend))
{
is BackendResult.Success ->
{
if (belongsToCurrentMerchant(checkout))
{
CheckoutResult.AwaitingWallet(findStored(checkout.id)!!)
}
else
{
CheckoutResult.Failed("Merchant account changed")
}
}
is BackendResult.Failure ->
{
CheckoutResult.Failed(order.message)
}
}
}
suspend fun collectByMerchant(): CheckoutResult
{
val checkout = current ?: return CheckoutResult.Failed("No checkout in progress")
if (!checkout.isFullyPaidExternally)
{
return CheckoutResult.Failed("Part of this order still has to be paid with Taler")
}
val backend = backendForCurrentMerchant() ?: return CheckoutResult.Failed("Merchant is not configured")
val orderId =
when (val order = ensureBackendOrder(checkout, backend))
{
is BackendResult.Success -> order.value
is BackendResult.Failure -> return CheckoutResult.Failed(order.message)
}
if (!belongsToCurrentMerchant(checkout)) return CheckoutResult.Failed("Merchant account changed")
return when (val collected = backend.collect(orderId))
{
is BackendResult.Success ->
{
complete(orderId, checkout.merchantId)
if (belongsToCurrentMerchant(checkout))
{
CheckoutResult.Completed
}
else
{
CheckoutResult.Failed("Merchant account changed")
}
}
// The customer may have scanned the free order in the meantime.
is BackendResult.Failure ->
{
if (backend.isPaid(orderId) == true)
{
complete(orderId, checkout.merchantId)
if (belongsToCurrentMerchant(checkout))
{
CheckoutResult.Completed
}
else
{
CheckoutResult.Failed("Merchant account changed")
}
}
else
{
CheckoutResult.Failed(collected.message)
}
}
}
}
@UiThread
fun onBackendOrderPaid(orderId: String) = complete(orderId)
suspend fun refundExternalPayment(
checkoutId: String,
paymentId: String,
amount: Amount,
reason: String,
): ExternalRefundResult
{
val checkout = find(checkoutId) ?: return ExternalRefundResult.Failed("Unknown payment")
val backend = backendForCurrentMerchant() ?: return ExternalRefundResult.Failed("Merchant is not configured")
val payment =
checkout.payments.firstOrNull { it.id == paymentId }
?: return ExternalRefundResult.Failed("Unknown payment")
if (amount.currency != checkout.currency ||
amount <= Amount.zero(checkout.currency) ||
amount > checkout.refundableOf(payment))
{
return ExternalRefundResult.Failed("More than is left of this payment")
}
val option = optionFor(payment.method)
if (option == null ||
!option.canRefund)
{
return ExternalRefundResult.Failed("This payment has to be refunded in its own app")
}
when (val outcome = option.refund(payment.transactionCode, amount, reason))
{
RefundOutcome.Success ->
{
Unit
}
RefundOutcome.NotSupported ->
{
return ExternalRefundResult.Failed("This payment has to be refunded in its own app")
}
is RefundOutcome.Failed ->
{
return ExternalRefundResult.Failed(outcome.message)
}
}
val refundId = newId()
replace(checkout.withRefund(refundId, paymentId, amount, reason, now()))
val orderId = checkout.backendOrderId ?: return ExternalRefundResult.Done
val request =
ExternalRefundRequest(
method = payment.method,
id = refundId,
paymentId = paymentId,
amount = amount,
reason = reason,
)
return when (val recorded = backend.recordExternalRefund(orderId, request))
{
is BackendResult.Success -> ExternalRefundResult.Done
is BackendResult.Failure -> ExternalRefundResult.NotRecorded(recorded.message)
}
}
suspend fun recordManualRefund(
checkoutId: String,
paymentId: String,
amount: Amount,
reason: String,
): ExternalRefundResult
{
val checkout = find(checkoutId) ?: return ExternalRefundResult.Failed("Unknown payment")
val backend = backendForCurrentMerchant() ?: return ExternalRefundResult.Failed("Merchant is not configured")
val payment =
checkout.payments.firstOrNull { it.id == paymentId }
?: return ExternalRefundResult.Failed("Unknown payment")
if (amount.currency != checkout.currency ||
amount <= Amount.zero(checkout.currency) ||
amount > checkout.refundableOf(payment))
{
return ExternalRefundResult.Failed("More than is left of this payment")
}
val refundId = newId()
// Persist before contacting the backend. A retry must never return this money twice.
replace(checkout.withRefund(refundId, paymentId, amount, reason, now()))
val orderId = checkout.backendOrderId ?: return ExternalRefundResult.Done
return when (
val recorded =
backend.recordExternalRefund(
orderId,
ExternalRefundRequest(payment.method, refundId, paymentId, amount, reason),
)
)
{
is BackendResult.Success -> ExternalRefundResult.Done
is BackendResult.Failure -> ExternalRefundResult.NotRecorded(recorded.message)
}
}
suspend fun revertTalerStep(): RevertResult
{
val checkout = current ?: return RevertResult.Failed("No checkout in progress")
val backend = backendForCurrentMerchant() ?: return RevertResult.Failed("Merchant is not configured")
if (checkout.creationPending) return RevertResult.Failed("Retry order creation before changing payments")
val orderId = checkout.backendOrderId ?: return RevertResult.Reverted
return when (backend.isPaid(orderId))
{
true ->
{
complete(orderId, checkout.merchantId)
RevertResult.AlreadyPaid
}
null ->
{
RevertResult.Failed("The merchant backend could not be reached")
}
false ->
{
when (val deleted = backend.deleteOrder(orderId, checkout.payments.isNotEmpty()))
{
is BackendResult.Success ->
{
replace(
checkout.copy(
stage = CheckoutStage.Collecting,
backendOrderId = null,
attempt = checkout.attempt + 1,
),
)
RevertResult.Reverted
}
is BackendResult.Failure ->
{
RevertResult.Failed(deleted.message)
}
}
}
}
}
suspend fun abandon(): AbandonResult?
{
val checkout = current ?: return null
mCurrentId.value = null
// Do not discard or abandon a record that still needs reconciliation.
if (checkout.pendingCharge != null ||
checkout.creationPending)
{
return AbandonResult.KeptForSettlement(checkout)
}
val backend = backendForCurrentMerchant() ?: return AbandonResult.KeptForSettlement(checkout)
if (checkout.payments.isEmpty())
{
checkout.backendOrderId?.let { orderId ->
// A failed delete may hide a paid order or a lost response. Keep the
// checkout recoverable until the backend confirms it is gone.
if (backend.deleteOrder(orderId) is BackendResult.Failure)
{
return AbandonResult.KeptForSettlement(checkout)
}
}
save(allCheckouts.filterNot { it.id == checkout.id })
return AbandonResult.Discarded
}
val kept = checkout.copy(stage = CheckoutStage.Abandoned)
replace(kept)
return AbandonResult.KeptForSettlement(kept)
}
@UiThread
fun onBackendOrderDeleted(orderId: String)
{
val checkout = visibleCheckouts().find { it.backendOrderId == orderId } ?: return
replace(checkout.copy(stage = CheckoutStage.Abandoned, backendOrderId = null))
if (mCurrentId.value == checkout.id) mCurrentId.value = null
}
@UiThread
fun dismiss(checkoutId: String)
{
val checkout = find(checkoutId) ?: return
if (checkout.pendingCharge != null ||
checkout.creationPending)
{
return
}
if (checkout.stage != CheckoutStage.Completed &&
checkout.stage != CheckoutStage.Abandoned)
{
return
}
save(allCheckouts.filterNot { it.id == checkoutId })
}
@UiThread
fun close()
{
mCurrentId.value = null
}
private suspend fun ensureBackendOrder(
original: Checkout,
backend: CheckoutBackend,
): BackendResult<String> =
creationMutex.withLock {
val checkout = find(original.id) ?: return@withLock BackendResult.Failure("Merchant account changed")
if (checkout.pendingCharge !=
null)
{
return@withLock BackendResult.Failure("Resolve the pending card payment first")
}
if (checkout.stage ==
CheckoutStage.Abandoned)
{
return@withLock BackendResult.Failure("Checkout was abandoned")
}
checkout.backendOrderId?.let { return@withLock BackendResult.Success(it) }
val frozen = checkout.copy(creationPending = true)
replace(frozen)
val orderId =
when (val created = backend.createOrder(frozen.toOrderRequest(POS_REFUND_DELAY)))
{
is BackendResult.Success ->
{
created.value
}
is BackendResult.Failure ->
{
// Creation may have succeeded before its response was lost.
val existing = frozen.backendOrderIdToCreate
if (backend.isPaid(existing) == null) return@withLock created
existing
}
}
replace(frozen.copy(stage = CheckoutStage.AwaitingTaler, backendOrderId = orderId, creationPending = false))
BackendResult.Success(orderId)
}
private fun complete(
orderId: String,
merchantId: String? = merchantIdentity.value,
)
{
val checkout =
allCheckouts.firstOrNull {
it.backendOrderId == orderId && it.merchantId == merchantId && merchantId != null
}
?: return
if (checkout.stage == CheckoutStage.Completed) return
replace(checkout.copy(stage = CheckoutStage.Completed))
if (mCurrentId.value == checkout.id) mCurrentId.value = null
// After a restart the order list is new; its ids no longer mean that sale.
if (checkout.session == session &&
belongsToCurrentMerchant(checkout))
{
onOrderSettled(checkout.posOrderId)
}
}
private fun requireCurrent(): Checkout = checkNotNull(current) { "No checkout in progress" }
private fun replace(checkout: Checkout)
{
save(allCheckouts.map { if (it.id == checkout.id) checkout else it })
}
private fun save(checkouts: List<Checkout>)
{
store.save(checkouts)
allCheckouts = checkouts
mCheckouts.value = visibleCheckouts()
}
private fun dropOldCompleted(checkouts: List<Checkout>): List<Checkout>
{
val cutoff = now() - KEEP_COMPLETED_MS
return checkouts.filterNot { it.stage == CheckoutStage.Completed && it.createdAt < cutoff }
}
}