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,
)