PaymentResponses.kt

/*
 * This file is part of GNU Taler
 * (C) 2020 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.wallet.payment

import androidx.annotation.StringRes
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonClassDiscriminator
import net.taler.common.Amount
import net.taler.common.ContractTerms
import net.taler.wallet.R
import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.donau.DonauInfo
import net.taler.wallet.transactions.TransactionState
import net.taler.wallet.payment.InsufficientBalanceHint.AgeRestricted
import net.taler.wallet.payment.InsufficientBalanceHint.ExchangeMissingGlobalFees
import net.taler.wallet.payment.InsufficientBalanceHint.FeesNotCovered
import net.taler.wallet.payment.InsufficientBalanceHint.MerchantAcceptInsufficient
import net.taler.wallet.payment.InsufficientBalanceHint.MerchantDepositInsufficient
import net.taler.wallet.payment.InsufficientBalanceHint.Unknown
import net.taler.wallet.payment.InsufficientBalanceHint.WalletBalanceAvailableInsufficient
import net.taler.wallet.payment.InsufficientBalanceHint.WalletBalanceMaterialInsufficient

@OptIn(ExperimentalSerializationApi::class)
@Serializable
@JsonClassDiscriminator("status")
sealed class PreparePayResponse {

    @Suppress("unused")
    @Serializable
    @SerialName("payment-possible")
    data class PaymentPossibleResponse(
        val transactionId: String,
        val contractTerms: ContractTerms,
    ) : PreparePayResponse()

    @Suppress("unused")
    @Serializable
    @SerialName("insufficient-balance")
    data class InsufficientBalanceResponse(
        val transactionId: String,
        val amountRaw: Amount,
        val contractTerms: ContractTerms,
        val balanceDetails: PaymentInsufficientBalanceDetails,
    ) : PreparePayResponse()

    @Suppress("unused")
    @Serializable
    @SerialName("already-confirmed")
    data class AlreadyConfirmedResponse(
        val transactionId: String,
        /**
         * Did the payment succeed?
         */
        val paid: Boolean,
        val amountRaw: Amount,
        val amountEffective: Amount? = null,
        val contractTerms: ContractTerms,
    ) : PreparePayResponse()

    @Suppress("unused")
    @Serializable
    @SerialName("choice-selection")
    data class ChoiceSelection(
        val transactionId: String,
        val contractTerms: ContractTerms,
    ) : PreparePayResponse()
}

@Serializable
data class PreparePayV2Response (
    val transactionId: String,
)

@Serializable
data class GetChoicesForPaymentResponse(
    val choices: List<ChoiceSelectionDetail>,
    val contractTerms: ContractTerms,
    val defaultChoiceIndex: Int? = null,
    val automaticExecution: Boolean? = null,
    val automaticExecutableIndex: Int? = null,
) {
    @Serializable
    @OptIn(ExperimentalSerializationApi::class)
    @JsonClassDiscriminator("status")
    sealed class ChoiceSelectionDetail {
        abstract val amountRaw: Amount
        abstract val tokenDetails: PaymentTokenAvailabilityDetails?
        abstract val description: String?
        abstract val descriptionI18n: Map<String, String>?

        @Serializable
        @SerialName("payment-possible")
        data class PaymentPossible(
            override val amountRaw: Amount,
            val amountEffective: Amount,
            override val tokenDetails: PaymentTokenAvailabilityDetails? = null,
            override val description: String? = null,
            override val descriptionI18n: Map<String, String>? = null,
        ) : ChoiceSelectionDetail()

        @Serializable
        @SerialName("insufficient-balance")
        data class InsufficientBalance(
            override val amountRaw: Amount,
            val balanceDetails: PaymentInsufficientBalanceDetails? = null,
            override val tokenDetails: PaymentTokenAvailabilityDetails? = null,
            override val description: String? = null,
            override val descriptionI18n: Map<String, String>? = null,
        ) : ChoiceSelectionDetail()
    }
}

@Serializable
enum class InsufficientBalanceHint {
    Unknown,

    /**
     *  Merchant doesn't accept money from exchange(s) that the wallet supports.
     */
    @SerialName("merchant-accept-insufficient")
    MerchantAcceptInsufficient,

    /**
     * Merchant accepts funds from a matching exchange, but the funds can't be
     * deposited with the wire method.
     */
    @SerialName("merchant-deposit-insufficient")
    MerchantDepositInsufficient,

    /**
     * While in principle the balance is sufficient,
     * the age restriction on coins causes the spendable
     * balance to be insufficient.
     */
    @SerialName("age-restricted")
    AgeRestricted,

    /**
     * Wallet has enough available funds,
     * but the material funds are insufficient. Usually because there is a
     * pending refresh operation.
     */
    @SerialName("wallet-balance-material-insufficient")
    WalletBalanceMaterialInsufficient,

    /**
     * The wallet simply doesn't have enough available funds.
     * This is the "obvious" case of insufficient balance.
     */
    @SerialName("wallet-balance-available-insufficient")
    WalletBalanceAvailableInsufficient,

    /**
     * Exchange is missing the global fee configuration, thus fees are unknown
     * and funds from this exchange can't be used for p2p payments.
     */
    @SerialName("exchange-missing-global-fees")
    ExchangeMissingGlobalFees,

    /**
     * Even though the balance looks sufficient for the instructed amount,
     * the fees can be covered by neither the merchant nor the remaining wallet
     * balance.
     */
    @SerialName("fees-not-covered")
    FeesNotCovered;
}

@StringRes
fun InsufficientBalanceHint.stringResId(): Int? = when(this) {
    Unknown -> null
    MerchantAcceptInsufficient -> R.string.payment_balance_insufficient_hint_merchant_accept_insufficient
    MerchantDepositInsufficient -> R.string.payment_balance_insufficient_hint_merchant_deposit_insufficient
    AgeRestricted -> R.string.payment_balance_insufficient_hint_age_restricted
    WalletBalanceMaterialInsufficient -> R.string.payment_balance_insufficient_hint_wallet_balance_material_insufficient
    WalletBalanceAvailableInsufficient -> null // "normal case"
    ExchangeMissingGlobalFees -> R.string.payment_balance_insufficient_hint_exchange_missing_global_fees
    FeesNotCovered -> R.string.payment_balance_insufficient_hint_fees_not_covered
}

@Serializable
data class PaymentInsufficientBalanceDetails(
    /**
     * Amount requested by the merchant.
     */
    val amountRequested: Amount,

    /**
     * Wire method for the requested payment, only applicable
     * for merchant payments.
     */
    val wireMethod: String? = null,

    /**
     * Hint as to why the balance is insufficient.
     *
     * If this hint is not provided, the balance hints of
     * the individual exchanges should be shown, as the overall
     * reason might be a combination of the reasons for different exchanges.
     */
    val causeHint: InsufficientBalanceHint? = null,

    /**
     * Balance of type "available" (see balance.ts for definition).
     */
    val balanceAvailable: Amount,

    /**
     * Balance of type "material" (see balance.ts for definition).
     */
    val balanceMaterial: Amount,

    /**
     * Balance of type "age-acceptable" (see balance.ts for definition).
     */
    val balanceAgeAcceptable: Amount,

    /**
     * Balance of type "merchant-acceptable" (see balance.ts for definition).
     */
    val balanceReceiverAcceptable: Amount,

    /**
     * Balance of type "merchant-depositable" (see balance.ts for definition).
     */
    val balanceReceiverDepositable: Amount,

    val balanceExchangeDepositable: Amount,

    /**
     * Maximum effective amount that the wallet can spend,
     * when all fees are paid by the wallet.
     */
    val maxEffectiveSpendAmount: Amount,

    val perExchange: Map<String, PerExchange>,
) {

    @Serializable
    data class PerExchange(
        val balanceAvailable: Amount,
        val balanceMaterial: Amount,
        val balanceExchangeDepositable: Amount,
        val balanceAgeAcceptable: Amount,
        val balanceReceiverAcceptable: Amount,
        val balanceReceiverDepositable: Amount,
        val maxEffectiveSpendAmount: Amount,

        /**
         * Hint that UIs should show to explain the insufficient
         * balance.
         */
        val causeHint: InsufficientBalanceHint? = null,
    )
}

@Serializable
data class PaymentTokenAvailabilityDetails(
    /**
     * Number of tokens requested by the merchant.
     */
    val tokensRequested: Int,

    /**
     * Number of tokens for which the merchant is unexpected.
     *
     * Can be used to pay (i.e. with forced selection),
     * but a warning should be displayed to the user.
     */
    val tokensAvailable: Int,

    /**
     * Number of tokens for which the merchant is untrusted.
     *
     * Cannot be used to pay, so an error should be displayed.
     */
    val tokensUnexpected: Int,

    /**
     * Number of tokens with a malformed domain.
     *
     * Cannot be used to pay, so an error should be displayed.
     */
    val tokensUntrusted: Int,

    // {[slug: String]: PerTokenFamily}
    val perTokenFamily: Map<String, PerTokenFamily>,
) {
    @Serializable
    data class PerTokenFamily(
        val causeHint: TokenAvailabilityHint? = null,
        val requested: Int,
        val available: Int,
        val unexpected: Int,
        val untrusted: Int,
    )
}

@Serializable
enum class TokenAvailabilityHint {
    @SerialName("wallet-tokens-available-insufficient")
    WalletTokensAvailableInsufficient,

    @SerialName("merchant-unexpected")
    MerchantUnexpected,

    @SerialName("merchant-untrusted")
    MerchantUntrusted,
}

sealed class DonauStatus {
    data object Unavailable: DonauStatus()
    data object Available: DonauStatus()
    data class Unset(val donauUrls: List<String>): DonauStatus()
    data class Mismatch(
        val donauInfo: DonauInfo,
        val donauUrls: List<String>
    ): DonauStatus()
}

@Serializable
sealed class ConfirmPayResult {
    @Serializable
    @SerialName("done")
    data class Done(
        val transactionId: String,
        val contractTerms: ContractTerms,
    ) : ConfirmPayResult()

    @Serializable
    @SerialName("pending")
    data class Pending(
        val transactionId: String,
        val lastError: TalerErrorInfo? = null,
    ) : ConfirmPayResult()
}

@Serializable
data class WaitTransactionStateResponse(
    val matched: String? = null,
    val txState: TransactionState? = null,
)