taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

Transactions.kt (24797B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2020 Taler Systems S.A.
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 package net.taler.wallet.transactions
     18 
     19 import android.net.Uri
     20 import android.util.Log
     21 import androidx.annotation.DrawableRes
     22 import androidx.annotation.StringRes
     23 import androidx.compose.runtime.Composable
     24 import androidx.compose.ui.res.stringResource
     25 import androidx.core.net.toUri
     26 import kotlinx.serialization.KSerializer
     27 import kotlinx.serialization.SerialName
     28 import kotlinx.serialization.Serializable
     29 import kotlinx.serialization.SerializationException
     30 import kotlinx.serialization.Transient
     31 import kotlinx.serialization.builtins.ListSerializer
     32 import kotlinx.serialization.builtins.MapSerializer
     33 import kotlinx.serialization.builtins.serializer
     34 import kotlinx.serialization.descriptors.SerialDescriptor
     35 import kotlinx.serialization.encoding.Decoder
     36 import kotlinx.serialization.encoding.Encoder
     37 import kotlinx.serialization.json.JsonElement
     38 import net.taler.common.Amount
     39 import net.taler.common.Bech32
     40 import net.taler.common.ContractProduct
     41 import net.taler.common.ContractTerms
     42 import net.taler.common.Timestamp
     43 import net.taler.wallet.R
     44 import net.taler.wallet.main.TAG
     45 import net.taler.wallet.backend.TalerErrorCode
     46 import net.taler.wallet.backend.TalerErrorInfo
     47 import net.taler.common.CurrencySpecification
     48 import net.taler.common.Merchant
     49 import net.taler.common.RelativeTime
     50 import net.taler.wallet.WalletDestination
     51 import net.taler.wallet.accounts.PaytoUriCyclos
     52 import net.taler.wallet.accounts.PaytoUriIban
     53 import net.taler.wallet.accounts.PaytoUriTalerBank
     54 import net.taler.wallet.balances.ScopeInfo
     55 import net.taler.wallet.refund.RefundPaymentInfo
     56 import net.taler.wallet.transactions.TransactionMajorState.Done
     57 import net.taler.wallet.transactions.TransactionMajorState.None
     58 import net.taler.wallet.transactions.TransactionMajorState.Pending
     59 import net.taler.wallet.transactions.WithdrawalDetails.ManualTransfer
     60 import net.taler.wallet.transactions.WithdrawalDetails.TalerBankIntegrationApi
     61 import net.taler.wallet.withdraw.TransferData
     62 import java.util.UUID
     63 
     64 @Serializable
     65 data class Transactions(
     66     @Serializable(with = TransactionListSerializer::class)
     67     val transactions: List<Transaction>,
     68 )
     69 
     70 class TransactionListSerializer : KSerializer<List<Transaction>> {
     71     private val serializer = ListSerializer(TransactionSerializer())
     72     override val descriptor: SerialDescriptor = serializer.descriptor
     73 
     74     override fun deserialize(decoder: Decoder): List<Transaction> {
     75         return decoder.decodeSerializableValue(serializer)
     76     }
     77 
     78     override fun serialize(encoder: Encoder, value: List<Transaction>) {
     79         throw NotImplementedError()
     80     }
     81 }
     82 
     83 class TransactionSerializer : KSerializer<Transaction> {
     84 
     85     private val serializer = Transaction.serializer()
     86     override val descriptor: SerialDescriptor = serializer.descriptor
     87     private val jsonSerializer = MapSerializer(String.serializer(), JsonElement.serializer())
     88 
     89     override fun deserialize(decoder: Decoder): Transaction {
     90         return try {
     91             decoder.decodeSerializableValue(serializer)
     92         } catch (e: SerializationException) {
     93             Log.e(TAG, "Error deserializing transaction.", e)
     94             DummyTransaction(
     95                 transactionId = UUID.randomUUID().toString(),
     96                 timestamp = Timestamp.now(),
     97                 error = TalerErrorInfo(
     98                     code = TalerErrorCode.UNKNOWN,
     99                     message = e.message,
    100                     extra = decoder.decodeSerializableValue(jsonSerializer)
    101                 ),
    102             )
    103         }
    104     }
    105 
    106     override fun serialize(encoder: Encoder, value: Transaction) {
    107         throw NotImplementedError()
    108     }
    109 }
    110 
    111 @Serializable
    112 sealed class Transaction {
    113     abstract val transactionId: String
    114     abstract val timestamp: Timestamp
    115     abstract val txState: TransactionState
    116     abstract val txActions: List<TransactionAction>
    117     abstract val error: TalerErrorInfo?
    118     abstract val amountRaw: Amount
    119     abstract val amountEffective: Amount
    120     abstract val scopes: List<ScopeInfo>
    121 
    122     @get:DrawableRes
    123     abstract val icon: Int
    124 
    125     abstract val detailPageNav: WalletDestination
    126 
    127     abstract val amountType: AmountType
    128 
    129     @Composable
    130     abstract fun getTitle(): String
    131 
    132     @get:StringRes
    133     abstract val generalTitleRes: Int
    134 }
    135 
    136 @Serializable
    137 enum class TransactionAction {
    138     // Common States
    139     @SerialName("delete")
    140     Delete,
    141 
    142     @SerialName("suspend")
    143     Suspend,
    144 
    145     @SerialName("resume")
    146     Resume,
    147 
    148     @SerialName("abort")
    149     Abort,
    150 
    151     @SerialName("fail")
    152     Fail,
    153 
    154     @SerialName("retry")
    155     Retry,
    156 }
    157 
    158 sealed class AmountType {
    159     object Positive : AmountType()
    160     object Negative : AmountType()
    161     object Neutral : AmountType()
    162 }
    163 
    164 @Serializable
    165 @SerialName("withdrawal")
    166 class TransactionWithdrawal(
    167     override val transactionId: String,
    168     override val timestamp: Timestamp,
    169     override val txState: TransactionState,
    170     override val txActions: List<TransactionAction>,
    171     val kycUrl: String? = null,
    172     val exchangeBaseUrl: String? = null,
    173     val withdrawalDetails: WithdrawalDetails,
    174     override val error: TalerErrorInfo? = null,
    175     override val amountRaw: Amount,
    176     override val amountEffective: Amount,
    177     override val scopes: List<ScopeInfo>,
    178 ) : Transaction() {
    179     override val icon = R.drawable.transaction_withdrawal
    180 
    181     override val detailPageNav = WalletDestination.TransactionWithdrawal
    182 
    183     @Transient
    184     override val amountType = AmountType.Positive
    185     @Composable
    186     override fun getTitle() = stringResource(R.string.withdraw_title)
    187     override val generalTitleRes = R.string.withdraw_title
    188     val confirmed: Boolean
    189         get() = txState.major != Pending && (
    190                 (withdrawalDetails is TalerBankIntegrationApi && withdrawalDetails.confirmed) ||
    191                         withdrawalDetails is ManualTransfer
    192                 )
    193 }
    194 
    195 @Serializable
    196 sealed class WithdrawalDetails {
    197     @Serializable
    198     @SerialName("manual-transfer")
    199     class ManualTransfer(
    200         val exchangeCreditAccountDetails: List<WithdrawalExchangeAccountDetails>? = null,
    201         val reserveClosingDelay: RelativeTime,
    202     ) : WithdrawalDetails()
    203 
    204     @Serializable
    205     @SerialName("taler-bank-integration-api")
    206     class TalerBankIntegrationApi(
    207         /**
    208          * Set to true if the bank has confirmed the withdrawal, false if not.
    209          * An unconfirmed withdrawal usually requires user-input
    210          * and should be highlighted in the UI.
    211          * See also bankConfirmationUrl below.
    212          */
    213         val confirmed: Boolean,
    214 
    215         /**
    216          * If the withdrawal is unconfirmed, this can include a URL for user-initiated confirmation.
    217          */
    218         val bankConfirmationUrl: String? = null,
    219     ) : WithdrawalDetails()
    220 }
    221 
    222 @Serializable
    223 data class WithdrawalExchangeAccountDetails (
    224     /**
    225      * Payto URI to credit the exchange.
    226      *
    227      * Depending on whether the (manual!) withdrawal is accepted or just
    228      * being checked, this already includes the subject with the
    229      * reserve public key.
    230      */
    231     val paytoUri: String,
    232 
    233     /**
    234      * Status that indicates whether the account can be used
    235      * by the user to send funds for a withdrawal.
    236      *
    237      * ok: account should be shown to the user
    238      * error: account should not be shown to the user, UIs might render the error (in conversionError),
    239      *   especially in dev mode.
    240      */
    241     val status: Status,
    242 
    243     /**
    244      * Transfer amount. Might be in a different currency than the requested
    245      * amount for withdrawal.
    246      *
    247      * Redundant with the amount in paytoUri, just included to avoid parsing.
    248      */
    249     val transferAmount: Amount? = null,
    250 
    251     /**
    252      * Currency specification for the external currency.
    253      *
    254      * Only included if this account requires a currency conversion.
    255      */
    256     val currencySpecification: CurrencySpecification? = null,
    257 
    258     /**
    259      * Further restrictions for sending money to the
    260      * exchange.
    261      */
    262     val creditRestrictions: List<AccountRestriction>? = null,
    263 
    264     /**
    265      * Label given to the account or the account's bank by the exchange.
    266      */
    267     val bankLabel: String? = null,
    268 
    269     val priority: Int? = null,
    270 ) {
    271     @Serializable
    272     enum class Status {
    273         @SerialName("ok")
    274         Ok,
    275 
    276         @SerialName("error")
    277         Error;
    278     }
    279 
    280     fun getTransferDetails(
    281         amountRaw: Amount,
    282         amountEffective: Amount,
    283     ): TransferData? {
    284         val uri = paytoUri.trim().toUri()
    285         val transferAmount = (transferAmount
    286             ?: uri.getQueryParameter("amount")
    287                 ?.let { Amount.fromJSONString(it) }
    288             ?: amountEffective).withSpec(currencySpecification)
    289         return if ("bitcoin".equals(uri.authority, true)) {
    290             // FIXME: use parsing logic from PaytoUriBitcoin.fromString()
    291             val msg = uri.getQueryParameter("message").orEmpty()
    292             val reg = "\\b([A-Z0-9]{52})\\b".toRegex().find(msg)
    293             val reserve = reg?.value
    294                 ?: uri.getQueryParameter("subject")
    295                 ?: return null
    296             val segwitAddresses =
    297                 Bech32.generateFakeSegwitAddress(reserve, uri.pathSegments.first())
    298             TransferData.Bitcoin(
    299                 account = uri.lastPathSegment ?: return null,
    300                 segwitAddresses = segwitAddresses,
    301                 subject = reserve,
    302                 amountRaw = amountRaw,
    303                 amountEffective = amountEffective,
    304                 transferAmount = transferAmount,
    305                 withdrawalAccount = copy(paytoUri = uri.toString()),
    306             )
    307         } else if (uri.authority.equals("x-taler-bank", true)) {
    308             PaytoUriTalerBank.fromString(uri)?.let { data ->
    309                 TransferData.Taler(
    310                     account = data.account,
    311                     receiverName = data.receiverName,
    312                     subject = uri.getQueryParameter("message") ?: return@let null,
    313                     amountRaw = amountRaw,
    314                     amountEffective = amountEffective,
    315                     exchangeBaseUrl = data.host,
    316                     transferAmount = transferAmount,
    317                     withdrawalAccount = copy(paytoUri = uri.toString())
    318                 )
    319             }
    320         } else if (uri.authority.equals("iban", true)) {
    321             PaytoUriIban.fromString(uri)?.let { data ->
    322                 TransferData.IBAN(
    323                     iban = data.iban,
    324                     receiverName = data.receiverName,
    325                     receiverTown = data.receiverTown,
    326                     receiverPostalCode = data.receiverPostalCode,
    327                     subject = uri.getQueryParameter("message") ?: return@let null,
    328                     amountRaw = amountRaw,
    329                     amountEffective = amountEffective,
    330                     transferAmount = transferAmount,
    331                     withdrawalAccount = copy(paytoUri = uri.toString()),
    332                 )
    333             }
    334         } else if (uri.authority.equals("cyclos", true)) {
    335             PaytoUriCyclos.fromString(uri)?.let { data ->
    336                 TransferData.Cyclos(
    337                     account = data.account,
    338                     receiverName = data.receiverName,
    339                     host = data.host,
    340                     subject = uri.getQueryParameter("message") ?: return@let null,
    341                     amountRaw = amountRaw,
    342                     amountEffective = amountEffective,
    343                     transferAmount = transferAmount
    344                         .withSpec(currencySpecification),
    345                     withdrawalAccount = copy(paytoUri = uri.toString()),
    346                 )
    347             }
    348         } else null
    349     }
    350 }
    351 
    352 @Serializable
    353 sealed class AccountRestriction {
    354     @Serializable
    355     @SerialName("deny")
    356     data object DenyAllAccount: AccountRestriction()
    357 
    358     @Serializable
    359     @SerialName("regex")
    360     data class RegexAccount(
    361         // Regular expression that the payto://-URI of the
    362         // partner account must follow.  The regular expression
    363         // should follow posix-egrep, but without support for character
    364         // classes, GNU extensions, back-references or intervals. See
    365         // https://www.gnu.org/software/findutils/manual/html_node/find_html/posix_002degrep-regular-expression-syntax.html
    366         // for a description of the posix-egrep syntax. Applications
    367         // may support regexes with additional features, but exchanges
    368         // must not use such regexes.
    369         @SerialName("payto_regex")
    370         val paytoRegex: String,
    371 
    372         // Hint for a human to understand the restriction
    373         // (that is hopefully easier to comprehend than the regex itself).
    374         @SerialName("human_hint")
    375         val humanHint: String,
    376 
    377         // Map from IETF BCP 47 language tags to localized
    378         // human hints.
    379         @SerialName("human_hint_i18n")
    380         val humanHintI18n: Map<String, String>? = null,
    381     ): AccountRestriction()
    382 }
    383 
    384 @Serializable
    385 @SerialName("payment")
    386 class TransactionPayment(
    387     override val transactionId: String,
    388     override val timestamp: Timestamp,
    389     override val txState: TransactionState,
    390     override val txActions: List<TransactionAction>,
    391     val info: TransactionInfo? = null,
    392     override val error: TalerErrorInfo? = null,
    393     override val amountRaw: Amount,
    394     override val amountEffective: Amount,
    395     override val scopes: List<ScopeInfo>,
    396     val posConfirmation: String? = null,
    397 ) : Transaction() {
    398     override val icon = R.drawable.transaction_payment
    399     override val detailPageNav = WalletDestination.TransactionPayment
    400 
    401     @Transient
    402     override val amountType = AmountType.Negative
    403     @Composable
    404     override fun getTitle() = info?.merchant?.name
    405         ?: stringResource(R.string.payment_title)
    406     override val generalTitleRes = R.string.payment_title
    407 }
    408 
    409 @Serializable
    410 class TransactionInfo(
    411     val orderId: String,
    412     val merchant: Merchant,
    413     val summary: String,
    414     @SerialName("summary_i18n")
    415     val summaryI18n: Map<String, String>? = null,
    416     val products: List<ContractProduct> = emptyList(),
    417     val fulfillmentUrl: String? = null,
    418     /**
    419      * Message shown to the user after the payment is complete.
    420      */
    421     val fulfillmentMessage: String? = null,
    422     /**
    423      * Map from IETF BCP 47 language tags to localized fulfillment messages
    424      */
    425     val fulfillmentMessage_i18n: Map<String, String>? = null,
    426 )
    427 
    428 @Serializable
    429 @SerialName("refund")
    430 class TransactionRefund(
    431     override val transactionId: String,
    432     override val timestamp: Timestamp,
    433     override val txState: TransactionState,
    434     override val txActions: List<TransactionAction>,
    435     val refundedTransactionId: String,
    436     val paymentInfo: RefundPaymentInfo? = null,
    437     override val error: TalerErrorInfo? = null,
    438     override val amountRaw: Amount,
    439     override val amountEffective: Amount,
    440     override val scopes: List<ScopeInfo>,
    441 ) : Transaction() {
    442     override val icon = R.drawable.transaction_refund
    443     override val detailPageNav = WalletDestination.TransactionRefund
    444 
    445     @Transient
    446     override val amountType = AmountType.Positive
    447     @Composable
    448     override fun getTitle() = paymentInfo?.merchant?.name ?: stringResource(R.string.transaction_refund)
    449 
    450     override val generalTitleRes = R.string.refund_title
    451 }
    452 
    453 @Serializable
    454 @SerialName("refresh")
    455 class TransactionRefresh(
    456     override val transactionId: String,
    457     override val timestamp: Timestamp,
    458     override val txState: TransactionState,
    459     override val txActions: List<TransactionAction>,
    460     override val error: TalerErrorInfo? = null,
    461     override val amountRaw: Amount,
    462     override val amountEffective: Amount,
    463     override val scopes: List<ScopeInfo>,
    464 ) : Transaction() {
    465     override val icon = R.drawable.transaction_refresh
    466     override val detailPageNav = WalletDestination.TransactionRefresh
    467 
    468     @Transient
    469     override val amountType = AmountType.Negative
    470     @Composable
    471     override fun getTitle(): String {
    472         return stringResource(R.string.transaction_refresh)
    473     }
    474 
    475     override val generalTitleRes = R.string.transaction_refresh
    476 }
    477 
    478 @Serializable
    479 @SerialName("deposit")
    480 class TransactionDeposit(
    481     override val transactionId: String,
    482     override val timestamp: Timestamp,
    483     override val txState: TransactionState,
    484     override val txActions: List<TransactionAction>,
    485     val kycUrl: String? = null,
    486     val kycAuthTransferInfo: KycAuthTransferInfo? = null,
    487     override val error: TalerErrorInfo? = null,
    488     override val amountRaw: Amount,
    489     override val amountEffective: Amount,
    490     override val scopes: List<ScopeInfo>,
    491     val targetPaytoUri: String,
    492     val depositGroupId: String,
    493 ) : Transaction() {
    494     override val icon = R.drawable.transaction_deposit
    495     override val detailPageNav = WalletDestination.TransactionDeposit
    496 
    497     @Transient
    498     override val amountType = AmountType.Negative
    499     @Composable
    500     override fun getTitle(): String {
    501         val uri = Uri.parse(targetPaytoUri)
    502         return uri.getQueryParameter("receiver-name")?.let { receiverName ->
    503             stringResource(R.string.transaction_deposit_to, receiverName)
    504         } ?: stringResource(R.string.transaction_deposit)
    505     }
    506 
    507     override val generalTitleRes = R.string.transaction_deposit
    508 }
    509 
    510 @Serializable
    511 data class KycAuthTransferInfo(
    512     val debitPaytoUri: String,
    513     val accountPub: String,
    514     val creditPaytoUris: List<String>,
    515 )
    516 
    517 @Serializable
    518 data class PeerInfoShort(
    519     val expiration: Timestamp? = null,
    520     val summary: String? = null,
    521 )
    522 
    523 /**
    524  * Debit because we paid someone's invoice.
    525  */
    526 @Serializable
    527 @SerialName("peer-pull-debit")
    528 class TransactionPeerPullDebit(
    529     override val transactionId: String,
    530     override val timestamp: Timestamp,
    531     override val txState: TransactionState,
    532     override val txActions: List<TransactionAction>,
    533     val exchangeBaseUrl: String,
    534     override val error: TalerErrorInfo? = null,
    535     override val amountRaw: Amount,
    536     override val amountEffective: Amount,
    537     override val scopes: List<ScopeInfo>,
    538     val info: PeerInfoShort,
    539 ) : Transaction() {
    540     override val icon = R.drawable.transaction_p2p_outgoing
    541     override val detailPageNav = WalletDestination.TransactionPeer
    542 
    543     @Transient
    544     override val amountType = AmountType.Negative
    545     @Composable
    546     override fun getTitle(): String {
    547         return if (txState.major == Done) {
    548             stringResource(R.string.transaction_peer_pull_debit)
    549         } else {
    550             stringResource(R.string.transaction_peer_pull_debit_pending)
    551         }
    552     }
    553 
    554     override val generalTitleRes = R.string.transaction_peer_pull_debit
    555 }
    556 
    557 /**
    558  * Credit because someone paid for an invoice we created.
    559  */
    560 @Serializable
    561 @SerialName("peer-pull-credit")
    562 class TransactionPeerPullCredit(
    563     override val transactionId: String,
    564     override val timestamp: Timestamp,
    565     override val txState: TransactionState,
    566     override val txActions: List<TransactionAction>,
    567     val kycUrl: String? = null,
    568     val exchangeBaseUrl: String,
    569     override val error: TalerErrorInfo? = null,
    570     override val amountRaw: Amount,
    571     override val amountEffective: Amount,
    572     override val scopes: List<ScopeInfo>,
    573     val info: PeerInfoShort,
    574     val talerUri: String,
    575     // val completed: Boolean, maybe
    576 ) : Transaction() {
    577     override val icon = R.drawable.transaction_p2p_incoming
    578     override val detailPageNav = WalletDestination.TransactionPeer
    579 
    580     override val amountType get() = AmountType.Positive
    581     @Composable
    582     override fun getTitle(): String {
    583         return stringResource(R.string.transaction_peer_pull_credit)
    584     }
    585 
    586     override val generalTitleRes = R.string.transaction_peer_pull_credit
    587 }
    588 
    589 /**
    590  * Debit because we sent money to someone.
    591  */
    592 @Serializable
    593 @SerialName("peer-push-debit")
    594 class TransactionPeerPushDebit(
    595     override val transactionId: String,
    596     override val timestamp: Timestamp,
    597     override val txState: TransactionState,
    598     override val txActions: List<TransactionAction>,
    599     val exchangeBaseUrl: String,
    600     override val error: TalerErrorInfo? = null,
    601     override val amountRaw: Amount,
    602     override val amountEffective: Amount,
    603     override val scopes: List<ScopeInfo>,
    604     val info: PeerInfoShort,
    605     val talerUri: String? = null,
    606     // val completed: Boolean, definitely
    607 ) : Transaction() {
    608     override val icon = R.drawable.transaction_p2p_outgoing
    609     override val detailPageNav = WalletDestination.TransactionPeer
    610 
    611     @Transient
    612     override val amountType = AmountType.Negative
    613     @Composable
    614     override fun getTitle(): String {
    615         return if (txState.major == Done) {
    616             stringResource(R.string.transaction_peer_push_debit)
    617         } else {
    618             stringResource(R.string.transaction_peer_push_debit_pending)
    619         }
    620     }
    621 
    622     override val generalTitleRes = R.string.payment_title
    623 }
    624 
    625 /**
    626  * We received money via a peer payment.
    627  */
    628 @Serializable
    629 @SerialName("peer-push-credit")
    630 class TransactionPeerPushCredit(
    631     override val transactionId: String,
    632     override val timestamp: Timestamp,
    633     override val txState: TransactionState,
    634     override val txActions: List<TransactionAction>,
    635     val kycUrl: String? = null,
    636     val exchangeBaseUrl: String,
    637     override val error: TalerErrorInfo? = null,
    638     override val amountRaw: Amount,
    639     override val amountEffective: Amount,
    640     override val scopes: List<ScopeInfo>,
    641     val info: PeerInfoShort,
    642 ) : Transaction() {
    643     override val icon = R.drawable.transaction_p2p_incoming
    644     override val detailPageNav = WalletDestination.TransactionPeer
    645 
    646     @Transient
    647     override val amountType = AmountType.Positive
    648     @Composable
    649     override fun getTitle(): String {
    650         return if (txState.major == Done) {
    651             stringResource(R.string.transaction_peer_push_credit)
    652         } else {
    653             stringResource(R.string.transaction_peer_push_credit_pending)
    654         }
    655     }
    656 
    657     override val generalTitleRes = R.string.transaction_peer_push_credit
    658 }
    659 
    660 /**
    661  * A transaction to indicate financial loss due to denominations
    662  * that became unusable for deposits.
    663  */
    664 @Serializable
    665 @SerialName("denom-loss")
    666 class TransactionDenomLoss(
    667     override val transactionId: String,
    668     override val timestamp: Timestamp,
    669     override val txState: TransactionState,
    670     override val txActions: List<TransactionAction>,
    671     override val error: TalerErrorInfo? = null,
    672     override val amountRaw: Amount,
    673     override val amountEffective: Amount,
    674     override val scopes: List<ScopeInfo>,
    675     val lossEventType: LossEventType,
    676 ): Transaction() {
    677     override val icon: Int = R.drawable.transaction_loss
    678     override val detailPageNav = WalletDestination.TransactionLoss
    679 
    680     @Transient
    681     override val amountType: AmountType = AmountType.Negative
    682 
    683     @Composable
    684     override fun getTitle(): String {
    685         return stringResource(R.string.transaction_denom_loss)
    686     }
    687 
    688     override val generalTitleRes: Int = R.string.transaction_denom_loss
    689 }
    690 
    691 @Serializable
    692 enum class LossEventType {
    693     @SerialName("denom-expired")
    694     DenomExpired,
    695 
    696     @SerialName("denom-vanished")
    697     DenomVanished,
    698 
    699     @SerialName("denom-unoffered")
    700     DenomUnoffered
    701 }
    702 
    703 /**
    704  * This represents a transaction that we can not parse for some reason.
    705  */
    706 class DummyTransaction(
    707     override val transactionId: String,
    708     override val timestamp: Timestamp,
    709     override val error: TalerErrorInfo,
    710 ) : Transaction() {
    711     override val txState: TransactionState = TransactionState(None)
    712     override val txActions: List<TransactionAction> = emptyList()
    713     override val amountRaw: Amount = Amount.zero("TESTKUDOS")
    714     override val amountEffective: Amount = Amount.zero("TESTKUDOS")
    715     override val icon: Int = R.drawable.transaction_dummy
    716     override val detailPageNav: WalletDestination = WalletDestination.TransactionDummy
    717     override val amountType: AmountType = AmountType.Neutral
    718     override val generalTitleRes: Int = R.string.transaction_dummy_title
    719     override val scopes: List<ScopeInfo> = listOf(ScopeInfo.Exchange(
    720         currency = "TESTKUDOS",
    721         url = "exchange.test.taler.net",
    722     ))
    723     @Composable
    724     override fun getTitle(): String {
    725         return stringResource(R.string.transaction_dummy_title)
    726     }
    727 }