cashless2ecash

cashless2ecash: pay with cards for digital cash (experimental)
Log | Files | Refs | README

commit 7dbdc36af26aeb940ceb9badce3c6b4e9b287019
parent 48288ad5329c5ff7e77888832a5dc65f0633969b
Author: Joel-Haeberli <haebu@rubigen.ch>
Date:   Thu, 30 May 2024 17:30:21 +0200

save commit

Diffstat:
Mc2ec/amount_test.go | 5+++++
Mc2ec/api-bank-integration.go | 31++++++++++++++++++++++---------
Mc2ec/api-terminals.go | 22++++++++++++----------
Mc2ec/c2ec-config.conf | 4++++
Mc2ec/c2ec-config.yaml | 1+
Mc2ec/config.go | 7+++++++
Mc2ec/main.go | 8++++++++
Mc2ec/wallee-client.go | 2+-
Msimulation/amount.go | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Msimulation/c2ec-simulation | 0
Msimulation/config.yaml | 1+
Msimulation/main.go | 3++-
Msimulation/sim-terminal.go | 2+-
Msimulation/sim-wallet.go | 4++++
Mwallee-c2ec/app/src/main/java/ch/bfh/habej2/wallee_c2ec/client/taler/TerminalClientImplementation.kt | 1-
Mwallee-c2ec/app/src/main/java/ch/bfh/habej2/wallee_c2ec/client/taler/model/TerminalsApiModel.kt | 9+++++----
Mwallee-c2ec/app/src/main/java/ch/bfh/habej2/wallee_c2ec/withdrawal/ExchangeSelectionScreen.kt | 30+++++++++++++++++++-----------
Mwallee-c2ec/app/src/main/java/ch/bfh/habej2/wallee_c2ec/withdrawal/WithdrawalViewModel.kt | 43+++++++++++++++++++++++++++++++++++++------
18 files changed, 197 insertions(+), 65 deletions(-)

diff --git a/c2ec/amount_test.go b/c2ec/amount_test.go @@ -80,6 +80,10 @@ func TestParseValid(t *testing.T) { "CHF:23.99", "CHF:50.35", "USD:109992332", + "CHF:0.0", + "EUR:00.0", + "USD:0.00", + "CHF:00.00", } for _, a := range amnts { @@ -100,6 +104,7 @@ func TestParseInvalid(t *testing.T) { "EUR:452:001", "USD:1099928583593859583332", "CHF:4564:005", + "CHF:.40", } for _, a := range amnts { diff --git a/c2ec/api-bank-integration.go b/c2ec/api-bank-integration.go @@ -54,9 +54,13 @@ type BankWithdrawalOperationPostResponse struct { type BankWithdrawalOperationStatus struct { Status WithdrawalOperationStatus `json:"status"` Amount string `json:"amount"` + CardFees string `json:"card_fees"` SenderWire string `json:"sender_wire"` WireTypes []string `json:"wire_types"` ReservePubKey EddsaPublicKey `json:"selected_reserve_pub"` + Aborted bool `json:"aborted"` + SelectionDone bool `json:"selection_done"` + TransferDone bool `json:"transfer_done"` } func bankIntegrationConfig(res http.ResponseWriter, req *http.Request) { @@ -370,17 +374,26 @@ func formatWithdrawalOrErrorStatus(w *Withdrawal) ([]byte, int) { LogError("bank-integration-api", err) return nil, HTTP_INTERNAL_SERVER_ERROR } else { - withdrawalStatusBytes, err := NewJsonCodec[BankWithdrawalOperationStatus]().EncodeToBytes(&BankWithdrawalOperationStatus{ - Status: w.WithdrawalStatus, - Amount: FormatAmount(amount, CONFIG.Server.CurrencyFractionDigits), - SenderWire: fmt.Sprintf("payto://%s/%d", operator.PaytoTargetType, w.ProviderTransactionId), - WireTypes: []string{operator.PaytoTargetType}, - ReservePubKey: EddsaPublicKey((encodeCrock(w.ReservePubKey))), - }) - if err != nil { + if fees, err := ToAmount(w.TerminalFees); err != nil { LogError("bank-integration-api", err) return nil, HTTP_INTERNAL_SERVER_ERROR + } else { + withdrawalStatusBytes, err := NewJsonCodec[BankWithdrawalOperationStatus]().EncodeToBytes(&BankWithdrawalOperationStatus{ + Status: w.WithdrawalStatus, + Amount: FormatAmount(amount, CONFIG.Server.CurrencyFractionDigits), + CardFees: FormatAmount(fees, CONFIG.Server.CurrencyFractionDigits), + SenderWire: fmt.Sprintf("payto://%s/%d", operator.PaytoTargetType, w.ProviderTransactionId), + WireTypes: []string{operator.PaytoTargetType}, + ReservePubKey: EddsaPublicKey((encodeCrock(w.ReservePubKey))), + Aborted: w.WithdrawalStatus == ABORTED, + SelectionDone: w.WithdrawalStatus == SELECTED, + TransferDone: w.WithdrawalStatus == CONFIRMED, + }) + if err != nil { + LogError("bank-integration-api", err) + return nil, HTTP_INTERNAL_SERVER_ERROR + } + return withdrawalStatusBytes, HTTP_OK } - return withdrawalStatusBytes, HTTP_OK } } diff --git a/c2ec/api-terminals.go b/c2ec/api-terminals.go @@ -13,11 +13,12 @@ const TERMINAL_API_CHECK_WITHDRAWAL = "/withdrawals/{wopid}/check" const TERMINAL_API_ABORT_WITHDRAWAL = "/withdrawals/{wopid}/abort" type TerminalConfig struct { - Name string `json:"name"` - Version string `json:"version"` - ProviderName string `json:"provider_name"` - Currency string `json:"currency"` - WireType string `json:"wire_type"` + Name string `json:"name"` + Version string `json:"version"` + ProviderName string `json:"provider_name"` + Currency string `json:"currency"` + WithdrawalFees string `json:"withdrawal_fees"` + WireType string `json:"wire_type"` } type TerminalWithdrawalSetup struct { @@ -59,11 +60,12 @@ func handleTerminalConfig(res http.ResponseWriter, req *http.Request) { encoder := NewJsonCodec[TerminalConfig]() cfg, err := encoder.EncodeToBytes(&TerminalConfig{ - Name: "taler-terminal", - Version: "0:0:0", - ProviderName: p.Name, - Currency: CONFIG.Server.Currency, - WireType: p.PaytoTargetType, + Name: "taler-terminal", + Version: "0:0:0", + ProviderName: p.Name, + Currency: CONFIG.Server.Currency, + WithdrawalFees: CONFIG.Server.WithdrawalFees, + WireType: p.PaytoTargetType, }) if err != nil { LogError("terminals-api", err) diff --git a/c2ec/c2ec-config.conf b/c2ec/c2ec-config.conf @@ -41,6 +41,10 @@ CURRENCY = CHF # and 0 for JPY. CURRENCY_FRACTION_DIGITS = 2 +# Fees which are to be added to each withdrawal of the +# payment service providers. Default: none. +WITHDRAWAL_FEES = CHF:0.0 + # How many retries shall be triggered, when the confirmation # of a transaction fails (when negative, the process tries forever) MAX_RETRIES = -1 diff --git a/c2ec/c2ec-config.yaml b/c2ec/c2ec-config.yaml @@ -9,6 +9,7 @@ c2ec: credit-account: "payto://IBAN/CH50030202099498" # this account must be specified at the providers backends as well currency: "CHF" currency-fraction-digits: 2 + withdrawal-fees: "CHF:0.00" max-retries: 100 retry-delay-ms: 1000 wire-gateway: diff --git a/c2ec/config.go b/c2ec/config.go @@ -26,6 +26,7 @@ type C2ECServerConfig struct { CreditAccount string `yaml:"credit-account"` Currency string `yaml:"currency"` CurrencyFractionDigits int `yaml:"currency-fraction-digits"` + WithdrawalFees string `yaml:"withdrawal-fees"` MaxRetries int32 `yaml:"max-retries"` RetryDelayMs int `yaml:"retry-delay-ms"` WireGateway C2ECWireGatewayConfig `yaml:"wire-gateway"` @@ -189,6 +190,12 @@ func ParseIni(content []byte) (*C2ECConfig, error) { } cfg.Server.CurrencyFractionDigits = num + value, err = s.GetKey("WITHDRAWAL_FEES") + if err != nil { + return nil, err + } + cfg.Server.WithdrawalFees = value.String() + value, err = s.GetKey("MAX_RETRIES") if err != nil { return nil, err diff --git a/c2ec/main.go b/c2ec/main.go @@ -8,6 +8,7 @@ import ( http "net/http" "os" "os/signal" + "strings" "syscall" "time" ) @@ -74,6 +75,13 @@ func main() { if cfg == nil { panic("config is nil") } + a, err := ParseAmount(cfg.Server.WithdrawalFees, cfg.Server.CurrencyFractionDigits) + if err != nil { + panic("invalid withdrawal fees amount") + } + if !strings.EqualFold(a.Currency, cfg.Server.Currency) { + panic("withdrawal fees currency must be same as the specified currency") + } CONFIG = *cfg DB, err = setupDatabase(&CONFIG.Database) diff --git a/c2ec/wallee-client.go b/c2ec/wallee-client.go @@ -261,7 +261,7 @@ func parseCredentials(raw string, cfg *C2ECProviderConfig) (*WalleeCredentials, } if !ValidPassword(cfg.Key, creds.ApplicationUserKey) { - return nil, errors.New("invalid application user key") + return nil, errors.New("invalid application user key in wallee client configuration") } // correct application user key. diff --git a/simulation/amount.go b/simulation/amount.go @@ -1,5 +1,6 @@ // This file is part of taler-go, the Taler Go implementation. // Copyright (C) 2022 Martin Schanzenbach +// Copyright (C) 2024 Joel Häberli // // Taler Go is free software: you can redistribute it and/or modify it // under the terms of the GNU Affero General Public License as published @@ -22,7 +23,6 @@ import ( "errors" "fmt" "math" - "regexp" "strconv" "strings" ) @@ -40,13 +40,22 @@ type Amount struct { Fraction uint64 `json:"fraction"` } -func FormatAmount(amount *Amount) string { +func FormatAmount(amount *Amount, fractionalDigits int) string { if amount == nil { return "" } - return fmt.Sprintf("%s:%d.%d", amount.Currency, amount.Value, amount.Fraction) + if amount.Currency == "" && amount.Value == 0 && amount.Fraction == 0 { + return "" + } + + if amount.Fraction <= 0 { + return fmt.Sprintf("%s:%d", amount.Currency, amount.Value) + } + + fractionStr := toFractionStr(int(amount.Fraction), fractionalDigits) + return fmt.Sprintf("%s:%d.%s", amount.Currency, amount.Value, fractionStr) } // The maximim length of a fraction (in digits) @@ -67,6 +76,31 @@ func NewAmount(currency string, value uint64, fraction uint64) Amount { } } +func toFractionStr(frac int, fractionalDigits int) string { + + if fractionalDigits > 8 { + return "" + } + + leadingZerosStr := "" + strLengthTens := int(math.Pow10(fractionalDigits - 1)) + strLength := int(math.Log10(float64(strLengthTens))) + leadingZeros := 0 + if strLengthTens > frac { + for i := 0; i < strLength; i++ { + if strLengthTens > frac { + leadingZeros++ + strLengthTens = strLengthTens / 10 + } + } + for i := 0; i < leadingZeros; i++ { + leadingZerosStr += "0" + } + } + + return leadingZerosStr + strconv.Itoa(frac) +} + // Subtract the amount b from a and return the result. // a and b must be of the same currency and a >= b func (a *Amount) Sub(b Amount) (*Amount, error) { @@ -116,31 +150,44 @@ func (a *Amount) Add(b Amount) (*Amount, error) { } // Parses an amount string in the format <currency>:<value>[.<fraction>] -func ParseAmount(s string) (*Amount, error) { - re, err := regexp.Compile(`^\s*([-_*A-Za-z0-9]+):([0-9]+)\.?([0-9]+)?\s*$`) - parsed := re.FindStringSubmatch(s) +func ParseAmount(s string, fractionDigits int) (*Amount, error) { + + if s == "" { + return &Amount{"", 0, 0}, nil + } - if nil != err { + if !strings.Contains(s, ":") { return nil, fmt.Errorf("invalid amount: %s", s) } - tail := "0.0" - if len(parsed) >= 4 { - tail = "0." + parsed[3] + + currencyAndAmount := strings.Split(s, ":") + if len(currencyAndAmount) != 2 { + return nil, fmt.Errorf("invalid amount: %s", s) } - if len(tail) > FractionalLength+1 { - return nil, errors.New("fraction too long") + + currency := currencyAndAmount[0] + valueAndFraction := strings.Split(currencyAndAmount[1], ".") + if len(valueAndFraction) < 1 && len(valueAndFraction) > 2 { + return nil, fmt.Errorf("invalid value and fraction part in amount %s", s) } - value, err := strconv.ParseUint(parsed[2], 10, 64) - if nil != err { - return nil, fmt.Errorf("unable to parse value %s", parsed[2]) + value, err := strconv.Atoi(valueAndFraction[0]) + if err != nil { + return nil, fmt.Errorf("invalid value in amount %s", s) } - fractionF, err := strconv.ParseFloat(tail, 64) - if nil != err { - return nil, fmt.Errorf("unable to parse fraction %s", tail) + + fraction := 0 + if len(valueAndFraction) == 2 { + if len(valueAndFraction[1]) > fractionDigits { + return nil, fmt.Errorf("invalid amount: %s expected at max %d fractional digits", s, fractionDigits) + } + fractionInt, err := strconv.Atoi(valueAndFraction[1]) + if err != nil { + return nil, fmt.Errorf("invalid fraction in amount %s", s) + } + fraction = fractionInt } - fraction := uint64(math.Round(fractionF * FractionalBase)) - currency := parsed[1] - a := NewAmount(currency, value, fraction) + + a := NewAmount(currency, uint64(value), uint64(fraction)) return &a, nil } diff --git a/simulation/c2ec-simulation b/simulation/c2ec-simulation Binary files differ. diff --git a/simulation/config.yaml b/simulation/config.yaml @@ -3,6 +3,7 @@ c2ec-base-url: "http://localhost:8080" parallel-withdrawals: 1 provider-backend-payment-delay: 1000 amount: "CHF:10.05" +fees: "CHF:0.05" terminal-accept-card-delay: 4000 terminal-provider: "Simulation" terminal-id: "1" diff --git a/simulation/main.go b/simulation/main.go @@ -93,7 +93,8 @@ type SimulationConfig struct { // simulates the terminal talking to its backend system and executing the payment. ProviderBackendPaymentDelay int `yaml:"provider-backend-payment-delay"` // simulates the user presenting his card to the terminal - Amount string `yaml:"amount"` + Amount string `yaml:"amount"` + Fees string `yaml:"fees"` TerminalAcceptCardDelay int `yaml:"terminal-accept-card-delay"` TerminalProvider string `yaml:"terminal-provider"` TerminalId string `yaml:"terminal-id"` diff --git a/simulation/sim-terminal.go b/simulation/sim-terminal.go @@ -53,7 +53,7 @@ func Terminal(in chan *SimulatedPhysicalInteraction, out chan *SimulatedPhysical Amount: CONFIG.Amount, SuggestedAmount: "", ProviderTransactionId: "", - TerminalFees: "", + TerminalFees: CONFIG.Fees, RequestUid: uuid.String(), UserUuid: "", Lock: "", diff --git a/simulation/sim-wallet.go b/simulation/sim-wallet.go @@ -170,7 +170,11 @@ type BankWithdrawalOperationPostResponse struct { type BankWithdrawalOperationStatus struct { Status WithdrawalOperationStatus `json:"status"` Amount string `json:"amount"` + CardFees string `json:"card_fees"` SenderWire string `json:"sender_wire"` WireTypes []string `json:"wire_types"` ReservePubKey EddsaPublicKey `json:"selected_reserve_pub"` + Aborted bool `json:"aborted"` + SelectionDone bool `json:"selection_done"` + TransferDone bool `json:"transfer_done"` } diff --git a/wallee-c2ec/app/src/main/java/ch/bfh/habej2/wallee_c2ec/client/taler/TerminalClientImplementation.kt b/wallee-c2ec/app/src/main/java/ch/bfh/habej2/wallee_c2ec/client/taler/TerminalClientImplementation.kt @@ -17,7 +17,6 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response -import okio.IOException import java.util.Optional import java.util.concurrent.Executors diff --git a/wallee-c2ec/app/src/main/java/ch/bfh/habej2/wallee_c2ec/client/taler/model/TerminalsApiModel.kt b/wallee-c2ec/app/src/main/java/ch/bfh/habej2/wallee_c2ec/client/taler/model/TerminalsApiModel.kt @@ -8,6 +8,7 @@ data class TerminalApiConfig( @Json(name = "version") val version: String, @Json(name = "provider_name") val providerName: String, @Json(name = "currency") val currency: String, + @Json(name = "withdrawal_fees") val exchangeFees: String, @Json(name = "wire_type") val wireType: String ) @@ -43,7 +44,7 @@ data class BankWithdrawalOperationStatus( @Json(name = "amount") val amount: String, // @Json(name = "suggested_amount") val suggestedAmount: String, // @Json(name = "max_amount") val maxAmount: String, -// @Json(name = "card_fees") val cardFees: String, + @Json(name = "card_fees") val cardFees: String, @Json(name = "sender_wire") val senderWire: String, // @Json(name = "suggested_exchange") val suggestedExchange: String, // @Json(name = "required_exchange") val requiredExchange: String, @@ -51,7 +52,7 @@ data class BankWithdrawalOperationStatus( @Json(name = "wire_types") val wireTypes: Array<String>, @Json(name = "selected_reserve_pub") val selectedReservePub: String, // @Json(name = "selected_exchange_acount") val selectedExchangeAccount: String, -// @Json(name = "aborted") val aborted: Boolean, -// @Json(name = "selection_done") val selectionDone: Boolean, -// @Json(name = "transfer_done") val transferDone: Boolean + @Json(name = "aborted") val aborted: Boolean, + @Json(name = "selection_done") val selectionDone: Boolean, + @Json(name = "transfer_done") val transferDone: Boolean ) diff --git a/wallee-c2ec/app/src/main/java/ch/bfh/habej2/wallee_c2ec/withdrawal/ExchangeSelectionScreen.kt b/wallee-c2ec/app/src/main/java/ch/bfh/habej2/wallee_c2ec/withdrawal/ExchangeSelectionScreen.kt @@ -17,27 +17,35 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import ch.bfh.habej2.wallee_c2ec.client.taler.config.TalerTerminalConfig -val exchanges = listOf( +var exchanges = listOf( + TalerTerminalConfig( - "KUDOS Exchange (BFH)", - "http://taler-c2ec.ti.bfh.ch", + "BFH Taler (CHF)", + "http://exchange.chf.taler.net/terminals", "Wallee-3", "YHrpzeHUyybGT5gOY9VAiZ7QAV/icOXtHPRKZTXv2n8=" ), TalerTerminalConfig( - "CHF Exchange (PostFinance)", - "https://taler-c2ec.ti.bfh.ch", - "Wallee-2", - "1A92pgloFR8WIgr0LDA+s9hbkO4EgyJlHj+3dQ9IJ9U=" + "BFH Taler (CHF, https)", + "https://exchange.chf.taler.net/terminals", + "Wallee-3", + "YHrpzeHUyybGT5gOY9VAiZ7QAV/icOXtHPRKZTXv2n8=" ), TalerTerminalConfig( - "EUR Exchange (UBS)", + "Test System Joel (CHF)", "http://taler-c2ec.ti.bfh.ch", - "Wallee-2", - "1A92pgloFR8WIgr0LDA+s9hbkO4EgyJlHj+3dQ9IJ9U=" - ) + "Wallee-3", + "YHrpzeHUyybGT5gOY9VAiZ7QAV/icOXtHPRKZTXv2n8=" + ), + + TalerTerminalConfig( + "Test System Joel (CHF, https)", + "https://taler-c2ec.ti.bfh.ch", + "Wallee-3", + "YHrpzeHUyybGT5gOY9VAiZ7QAV/icOXtHPRKZTXv2n8=" + ), ) @Composable diff --git a/wallee-c2ec/app/src/main/java/ch/bfh/habej2/wallee_c2ec/withdrawal/WithdrawalViewModel.kt b/wallee-c2ec/app/src/main/java/ch/bfh/habej2/wallee_c2ec/withdrawal/WithdrawalViewModel.kt @@ -56,7 +56,7 @@ object TalerConstants { const val TALER_INTEGRATION_WITHDRAWAL_OPERATION = "/withdrawal-operation" fun formatTalerUri(terminalsApiBasePath: String, encodedWopid: String) = - "taler://withdraw/$terminalsApiBasePath$TALER_INTEGRATION_WITHDRAWAL_OPERATION/$encodedWopid" + "taler://withdraw/$terminalsApiBasePath$TALER_INTEGRATION$TALER_INTEGRATION_WITHDRAWAL_OPERATION/$encodedWopid" } @Stable @@ -68,6 +68,7 @@ interface WithdrawalOperationState { val amountStr: String val amountError: String val currency: String + val withdrawalFees: Amount val transactionState: TransactionState val transaction: TransactionResponse? val transactionCompletion: TransactionCompletionResponse? @@ -92,6 +93,7 @@ private class MutableWithdrawalOperationState : WithdrawalOperationState { override var amountStr: String by mutableStateOf("") override var amountError: String by mutableStateOf("") override var currency: String by mutableStateOf("") + override var withdrawalFees: Amount by mutableStateOf(Amount(0,0,"")) override var transactionState: TransactionState by mutableStateOf(TransactionState.AUTHORIZATION_PENDING) override var transaction: TransactionResponse? by mutableStateOf(null) override var transactionCompletion: TransactionCompletionResponse? by mutableStateOf(null) @@ -137,6 +139,9 @@ class WithdrawalViewModel( _uiState.value.terminalsApiBasePath = "${cfg.terminalApiBaseUrl}${TalerConstants.TALER_INTEGRATION}" this@WithdrawalViewModel.updateCurrency(it.get().currency) + if (!this@WithdrawalViewModel.updateWithdrawalFees(it.get().exchangeFees)) { + activity.finish() + } } } exchangeSelected = true @@ -186,6 +191,18 @@ class WithdrawalViewModel( _uiState.value.currency = currency } + private fun updateWithdrawalFees(exchangeFees: String): Boolean { + + val optRes = this.parseAmount(exchangeFees) + if (!optRes.isPresent) { + return false + } + + _uiState.value.withdrawalFees = optRes.get() + + return true + } + fun setAuthorizing() { _uiState.value.transactionState = TransactionState.AUTHORIZATION_STARTED } @@ -321,18 +338,32 @@ class WithdrawalViewModel( fun validAmount(inp: String) = Regex("\\d+(\\.\\d+)?").matches(inp) /** - * Format expected X[.X], X an integer + * Format expected [CURRENCY:]X[.X], X an integer */ private fun parseAmount(inp: String): Optional<Amount> { - val points = inp.count { it == '.' } + var currency = "" + var valueFraction = "" + if (inp.contains(":")) { + val splitted = inp.split(":") + currency = splitted[0] + if (currency != uiState.value.currency) { + println("illegal currency $currency. expected ${uiState.value.currency}") + return Optional.empty() + } + valueFraction = splitted[1] + } else { + valueFraction = inp + } + + val points = valueFraction.count { it == '.' } if (points > 1) { return Optional.empty() } if (points == 1) { - val valueStr = inp.split(".")[0] - val fracStr = inp.split(".")[1] + val valueStr = valueFraction.split(".")[0] + val fracStr = valueFraction.split(".")[1] return try { val value = valueStr.toInt() var frac = 0 @@ -347,7 +378,7 @@ class WithdrawalViewModel( } return try { - val value = inp.toInt() + val value = valueFraction.toInt() Optional.of(Amount(value, 0, uiState.value.currency)) } catch (ex: NumberFormatException) { println(ex.message)