cashless2ecash

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

commit 2a29c5bbed11f65ee1f3cffac4846bb4111c9d05
parent 877873e9385c39e9365e2c98ba65e74037527e37
Author: Joel-Haeberli <haebu@rubigen.ch>
Date:   Fri, 18 Oct 2024 16:59:06 +0200

restuctured source code (feedback kesim özgür)

Diffstat:
M.gitignore | 3+++
Ac2ec/Makefile | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mc2ec/README | 46++++++++++++++++++++++++++++++++++++++++++++++
Dc2ec/amount.go | 256-------------------------------------------------------------------------------
Dc2ec/amount_test.go | 423-------------------------------------------------------------------------------
Dc2ec/api-auth.go | 266-------------------------------------------------------------------------------
Dc2ec/api-auth_test.go | 65-----------------------------------------------------------------
Dc2ec/api-bank-integration.go | 439-------------------------------------------------------------------------------
Dc2ec/api-terminals.go | 405-------------------------------------------------------------------------------
Dc2ec/api-wire-gateway.go | 561-------------------------------------------------------------------------------
Dc2ec/c2ec-config.conf | 93-------------------------------------------------------------------------------
Dc2ec/c2ec-config.yaml | 30------------------------------
Dc2ec/codec.go | 75---------------------------------------------------------------------------
Dc2ec/codec_test.go | 99-------------------------------------------------------------------------------
Dc2ec/config.go | 302------------------------------------------------------------------------------
Ac2ec/configs/c2ec-config.conf | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/configs/c2ec-config.yaml | 31+++++++++++++++++++++++++++++++
Dc2ec/db-postgres.go | 959-------------------------------------------------------------------------------
Dc2ec/db.go | 272-------------------------------------------------------------------------------
Dc2ec/encoding.go | 156-------------------------------------------------------------------------------
Dc2ec/encoding_test.go | 152-------------------------------------------------------------------------------
Dc2ec/exponential-backoff.go | 95-------------------------------------------------------------------------------
Dc2ec/exponential-backoff_test.go | 80-------------------------------------------------------------------------------
Mc2ec/go.mod | 24++++++++++++++++++++++++
Dc2ec/http-util.go | 292-------------------------------------------------------------------------------
Dc2ec/http-util_test.go | 90-------------------------------------------------------------------------------
Ac2ec/internal/api/api-agpl.go | 12++++++++++++
Ac2ec/internal/api/api-auth.go | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/api/api-auth_test.go | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/api/api-bank-integration.go | 435+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/api/api-consts.go | 3+++
Ac2ec/internal/api/api-terminals.go | 402+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/api/api-wire-gateway.go | 556+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/c2ec.go | 342+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/db/db.go | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/db/postgres/db-postgres.go | 953+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/proc/proc-attestor.go | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/proc/proc-listener.go | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/proc/proc-retrier.go | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/proc/proc-transfer.go | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/provider/simulation/simulation-client.go | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/provider/wallee/wallee-client.go | 408+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/provider/wallee/wallee-client_test.go | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/provider/wallee/wallee-models.go | 437+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/utils/amount.go | 263+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/utils/amount_test.go | 424+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/utils/codec.go | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/utils/codec_test.go | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/utils/encoding.go | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/utils/encoding_test.go | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/utils/exponential-backoff.go | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/utils/exponential-backoff_test.go | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/utils/http-util.go | 292+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/utils/http-util_test.go | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/utils/logger.go | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/utils/payto.go | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/internal/utils/utils.go | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dc2ec/logger.go | 115-------------------------------------------------------------------------------
Mc2ec/main.go | 310+++----------------------------------------------------------------------------
Dc2ec/payto.go | 106-------------------------------------------------------------------------------
Ac2ec/pkg/config/config.go | 396+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/pkg/db/db.go | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/pkg/provider/provider.go | 40++++++++++++++++++++++++++++++++++++++++
Dc2ec/proc-attestor.go | 205-------------------------------------------------------------------------------
Dc2ec/proc-listener.go | 65-----------------------------------------------------------------
Dc2ec/proc-retrier.go | 126-------------------------------------------------------------------------------
Dc2ec/proc-transfer.go | 248-------------------------------------------------------------------------------
Dc2ec/provider.go | 33---------------------------------
Dc2ec/simulation-client.go | 92-------------------------------------------------------------------------------
Dc2ec/utils.go | 69---------------------------------------------------------------------
Dc2ec/wallee-client.go | 404-------------------------------------------------------------------------------
Dc2ec/wallee-client_test.go | 178-------------------------------------------------------------------------------
Dc2ec/wallee-models.go | 436-------------------------------------------------------------------------------
Dsimulation/c2ec-simulation | 0
74 files changed, 7813 insertions(+), 7489 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,3 +1,5 @@ +film +Erklaerung.pdf schemaspy/*.jar schemaspy/Makefile infra/ @@ -6,6 +8,7 @@ c2ec/c2ec c2ec/.vscode c2ec/c2ec-log.txt cli/c2ec-cli +simulation/c2ec-simulation setup-local setup-local-2 diff --git a/c2ec/Makefile b/c2ec/Makefile @@ -0,0 +1,50 @@ +C2EC_HOME ?= ${HOME} +C2EC_USER ?= $(shell whoami) +C2EC_POSTGRES_USER ?= postgres +C2EC_POSTGRES_PASSWORD ?= postgres +C2EC_DB_NAME ?= postgres +C2EC_DB_ADMIN_PW ?= secret +C2EC_DB_OPERATOR_PW ?= secret +C2EC_DB_API_PW ?= secret +C2EC_FILE_PERMISSIONS ?= 660 + +dependencies-check: + go version + psql --version + ls ${C2EC_HOME} + +build: dependencies-check + go build -C ./ -o ${C2EC_HOME} + +stop: + kill $(pgrep c2ec) + +start: stop + (cd ${C2EC_HOME}; ./ &) + +migrate: build + (cd ./db; ./migrate.sh c2ec_admin ${C2EC_DB_ADMIN_PW} ${C2EC_DB_NAME}) + +install: dependencies-check + cp ./c2ec-config.yaml ${C2EC_HOME}/c2ec-config.yaml + chmod ${C2EC_FILE_PERMISSIONS} ${C2EC_HOME}/c2ec-config.yaml + chown ${C2EC_USER} ${C2EC_HOME}/c2ec-config.yaml + cp ./c2ec-config.conf ${C2EC_HOME}/c2ec-config.conf + chmod ${C2EC_FILE_PERMISSIONS} ${C2EC_HOME}/c2ec-config.conf + chown ${C2EC_USER} ${C2EC_HOME}/c2ec-config.conf + touch ${C2EC_HOME}/c2ec-log.txt + chmod ${C2EC_FILE_PERMISSIONS} ${C2EC_HOME}/c2ec-log.txt + chown ${C2EC_USER} ${C2EC_HOME}/c2ec-log.txt + cp ./simulation/config.yaml ${C2EC_HOME}/sim-config.yaml + (cd ./db; ./migrate.sh ${C2EC_POSTGRES_USER} ${C2EC_POSTGRES_PASSWORD} ${C2EC_DB_NAME} ${C2EC_DB_ADMIN_PW} ${C2EC_DB_OPERATOR_PW} ${C2EC_DB_API_PW}) + echo "you may want to alter the current configuration of your installation at ${C2EC_HOME}/c2ec-config.conf" + +cli: dependencies-check + go build -C ./cli/ -o ${C2EC_HOME} + +simulation: dependencies-check + go build -C ./simulation/ -o ${C2EC_HOME} + +# ONLY DO THIS WHEN YOU NOW WHAT YOU ARE DOING +#wipe: +# (cd ./install; ./wipe_db.sh ${C2EC_POSTGRES_USER} ${C2EC_POSTGRES_PASSWORD} ${C2EC_DB_NAME}) diff --git a/c2ec/README b/c2ec/README @@ -68,3 +68,48 @@ run `rt`. It will ask you for two things: 2. the name of the provider, which the device belongs to. In the simulation case this will MUST be `Simulation`. Be aware that a terminal can only be added, after the provdier it belongs to was added. + +## Database setup for development + +You can install Postgres on your machine or use an existing installation. + +Or you could use docker like this: + +```docker +docker run -d \\ + --name c2ec-db-dev \\ + -p 5432:5432 \\ + -e POSTGRES_PASSWORD=[PW] \\ + -e POSTGRES_USER=[USERNAME] \\ + postgres:latest +``` + +Access running Postgres-Container using: + +```docker +docker exec -it c2ec-db-dev /bin/bash + +# to access your database using psql first change the user +su postgres + +# run psql and define the user from the docker run command to login +psql -U local +``` + +Then create a database using: + +```sql +CREATE DATABASE {DB_NAME} [WITH OWNER {USERNAME}]; +``` + +Make sure to setup the database using the `migrate.sh` script in the `db` subdir: + +``` +./migrate.sh {DB_USERNAME} {DB_PASSWORD} {DB_NAME} {ADMIN_PW} {OPERATOR_PW} {API_PW} +``` + +Later when manually manipulating or plumbing the database by hand make sure to also define the database when using psql + +```bash +psql -U {username} -d {database} +``` +\ No newline at end of file diff --git a/c2ec/amount.go b/c2ec/amount.go @@ -1,256 +0,0 @@ -// 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 -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// Taler Go 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "errors" - "fmt" - "math" - "strconv" - "strings" -) - -// The GNU Taler Amount object -type Amount struct { - - // The type of currency, e.g. EUR - Currency string `json:"currency"` - - // The value (before the ".") - Value uint64 `json:"value"` - - // The fraction (after the ".", optional) - Fraction uint64 `json:"fraction"` -} - -func ToAmount(amount *TalerAmountCurrency) (*Amount, error) { - - if amount == nil { - return &Amount{ - Currency: "", - Value: 0, - Fraction: 0, - }, nil - } - a := new(Amount) - a.Currency = amount.Curr - a.Value = uint64(amount.Val) - a.Fraction = uint64(amount.Frac) - return a, nil -} - -func FormatAmount(amount *Amount, fractionalDigits int) string { - - if amount == nil { - return "" - } - - 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) -const FractionalLength = 8 - -// The base of the fraction. -const FractionalBase = 1e8 - -// The maximum value -var MaxAmountValue = uint64(math.Pow(2, 52)) - -// Create a new amount from value and fraction in a currency -func NewAmount(currency string, value uint64, fraction uint64) Amount { - return Amount{ - Currency: currency, - Value: value, - Fraction: fraction, - } -} - -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) -} - -// checks if a < b -// returns error if the currencies do not match. -func (a *Amount) IsSmallerThan(b Amount) (bool, error) { - - if !strings.EqualFold(a.Currency, b.Currency) { - return false, errors.New("unable tos compare different currencies") - } - - if a.Value < b.Value { - return true, nil - } - - if a.Value == b.Value && a.Fraction < b.Fraction { - return true, nil - } - - return false, nil -} - -// checks if a = b -// returns error if the currencies do not match. -func (a *Amount) IsEqualTo(b Amount) (bool, error) { - - if !strings.EqualFold(a.Currency, b.Currency) { - return false, errors.New("unable tos compare different currencies") - } - - return a.Value == b.Value && a.Fraction == b.Fraction, nil -} - -// 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, fractionalDigits int) (*Amount, error) { - if a.Currency != b.Currency { - return nil, errors.New("currency mismatch") - } - v := a.Value - f := a.Fraction - if a.Fraction < b.Fraction { - v -= 1 - f += uint64(math.Pow10(fractionalDigits)) - } - f -= b.Fraction - if v < b.Value { - return nil, errors.New("amount overflow") - } - v -= b.Value - r := Amount{ - Currency: a.Currency, - Value: v, - Fraction: f, - } - return &r, nil -} - -// Add b to a and return the result. -// Returns an error if the currencies do not match or the addition would -// cause an overflow of the value -func (a *Amount) Add(b Amount, fractionalDigits int) (*Amount, error) { - if a.Currency != b.Currency { - return nil, errors.New("currency mismatch") - } - v := a.Value + - b.Value + - uint64(math.Floor((float64(a.Fraction)+float64(b.Fraction))/FractionalBase)) - - if v >= MaxAmountValue { - return nil, fmt.Errorf("amount overflow (%d > %d)", v, MaxAmountValue) - } - f := uint64((a.Fraction + b.Fraction) % uint64(math.Pow10(fractionalDigits))) - r := Amount{ - Currency: a.Currency, - Value: v, - Fraction: f, - } - return &r, nil -} - -// Parses an amount string in the format <currency>:<value>[.<fraction>] -func ParseAmount(s string, fractionDigits int) (*Amount, error) { - - if s == "" { - return &Amount{CONFIG.Server.Currency, 0, 0}, nil - } - - if !strings.Contains(s, ":") { - return nil, fmt.Errorf("invalid amount: %s", s) - } - - currencyAndAmount := strings.Split(s, ":") - if len(currencyAndAmount) != 2 { - return nil, fmt.Errorf("invalid amount: %s", s) - } - - 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.Atoi(valueAndFraction[0]) - if err != nil { - LogError("amount", err) - return nil, fmt.Errorf("invalid value in amount %s", s) - } - - 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) - } - k := 0 - if len(valueAndFraction[1]) < fractionDigits { - k = fractionDigits - len(valueAndFraction[1]) - } - fractionInt, err := strconv.Atoi(valueAndFraction[1]) - if err != nil { - LogError("amount", err) - return nil, fmt.Errorf("invalid fraction in amount %s", s) - } - fraction = fractionInt * int(math.Pow10(k)) - } - - a := NewAmount(currency, uint64(value), uint64(fraction)) - return &a, nil -} - -// Check if this amount is zero -func (a *Amount) IsZero() bool { - return (a.Value == 0) && (a.Fraction == 0) -} - -// Returns the string representation of the amount: <currency>:<value>[.<fraction>] -// Omits trailing zeroes. -func (a *Amount) String(fractionalDigits int) string { - - return FormatAmount(a, fractionalDigits) -} diff --git a/c2ec/amount_test.go b/c2ec/amount_test.go @@ -1,423 +0,0 @@ -// 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 -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// Taler Go 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "fmt" - "strconv" - "testing" -) - -var a = Amount{ - Currency: "EUR", - Value: 1, - Fraction: 50000000, -} -var b = Amount{ - Currency: "EUR", - Value: 23, - Fraction: 70007000, -} -var c = Amount{ - Currency: "EUR", - Value: 25, - Fraction: 20007000, -} - -func TestAmountAdd(t *testing.T) { - d, err := a.Add(b, 8) - if err != nil { - t.Errorf("Failed adding amount") - } - if c.String(8) != d.String(8) { - t.Errorf("Failed to add to correct amount") - } -} - -func TestAmountSub(t *testing.T) { - d, err := c.Sub(b, 8) - if err != nil { - t.Errorf("Failed substracting amount") - } - if a.String(8) != d.String(8) { - t.Errorf("Failed to substract to correct amount") - } -} - -func TestAmountLarge(t *testing.T) { - x, err := ParseAmount("EUR:50", 2) - if err != nil { - fmt.Println(err) - t.Errorf("Failed") - } - _, err = x.Add(a, 2) - if err != nil { - fmt.Println(err) - t.Errorf("Failed") - } -} - -func TestAmountSub2(t *testing.T) { - - amnts := []string{ - "CHF:30", - "EUR:20.34", - "CHF:23.99", - "CHF:50.35", - "USD:109992332", - "CHF:0.0", - "EUR:00.0", - "USD:0.00", - "CHF:00.00", - } - - for _, a := range amnts { - am, err := ParseAmount(a, 2) - if err != nil { - fmt.Println("parsing failed!", a, err) - t.FailNow() - } - fmt.Println("subtracting", am.String(2)) - a2, err := am.Sub(*am, 2) - if err != nil { - fmt.Println("subtracting failed!", a, err) - t.FailNow() - } - fmt.Println("subtraction result", a2.String(2)) - if !a2.IsZero() { - fmt.Println("subtracting failure... expected zero amount but was", a2.String(2)) - } - } -} - -func TestAmountSub3(t *testing.T) { - - amnts := []string{ - "CHF:30.0004", - "CHF:30.004", - "CHF:30.04", - "CHF:30.4", - "CHF:30", - } - - for _, a := range amnts { - am, err := ParseAmount(a, 4) - if err != nil { - fmt.Println("parsing failed!", a, err) - t.FailNow() - } - fmt.Println("subtracting", am.String(4)) - a2, err := am.Sub(*am, 4) - if err != nil { - fmt.Println("subtracting failed!", a, err) - t.FailNow() - } - fmt.Println("subtraction result", a2.String(4)) - if !a2.IsZero() && a2.String(4) != am.Currency+":0" { - fmt.Println("subtracting failure... expected zero amount but was", a2.String(4)) - } - } -} - -func TestParseValid(t *testing.T) { - - amnts := []string{ - "CHF:30", - "EUR:20.34", - "CHF:23.99", - "CHF:50.35", - "USD:109992332", - "CHF:0.0", - "EUR:00.0", - "USD:0.00", - "CHF:00.00", - } - - for _, a := range amnts { - _, err := ParseAmount(a, 2) - if err != nil { - fmt.Println("failed!", a) - t.FailNow() - } - } -} - -func TestParseInvalid(t *testing.T) { - - amnts := []string{ - "CHF", - "EUR:.34", - "CHF:23.", - "EUR:452:001", - "USD:1099928583593859583332", - "CHF:4564:005", - "CHF:.40", - } - - for _, a := range amnts { - _, err := ParseAmount(a, 2) - if err == nil { - fmt.Println("failed! (expected error)", a) - t.FailNow() - } - } -} - -func TestFormatAmountValid(t *testing.T) { - - amnts := []string{ - "CHF:30", - "EUR:20.34", - "CHF:23.99", - "USD:109992332", - "CHF:20.05", - "USD:109992332.01", - "CHF:10.00", - "", - } - amntsParsed := make([]Amount, 0) - for _, a := range amnts { - a, err := ParseAmount(a, 2) - if err != nil { - fmt.Println("failed!", err) - t.FailNow() - } - amntsParsed = append(amntsParsed, *a) - } - - amntsFormatted := make([]string, 0) - for _, a := range amntsParsed { - amntsFormatted = append(amntsFormatted, FormatAmount(&a, 2)) - } - - for i, frmtd := range amntsFormatted { - fmt.Println(frmtd) - expectation, err1 := ParseAmount(amnts[i], 2) - reality, err2 := ParseAmount(frmtd, 2) - if err1 != nil || err2 != nil { - fmt.Println("failed!", err1, err2) - t.FailNow() - } - - if expectation.Currency != reality.Currency || - expectation.Value != reality.Value || - expectation.Fraction != reality.Fraction { - - fmt.Println("failed!", amnts[i], frmtd) - t.FailNow() - } - - fmt.Println("success!", amnts[i], frmtd) - } -} - -func TestFormatAmountInvalid(t *testing.T) { - - amnts := []string{ - "CHF:30", - "EUR:20.34", - "CHF:23.99", - "USD:109992332", - "USD:30.30", - } - amntsParsed := make([]Amount, 0) - for _, a := range amnts { - a, err := ParseAmount(a, 2) - if err != nil { - fmt.Println("failed!", err) - t.FailNow() - } - amntsParsed = append(amntsParsed, *a) - } - - amntsFormatted := make([]string, 0) - for _, a := range amntsParsed { - amntsFormatted = append(amntsFormatted, FormatAmount(&a, 2)) - } - - for i, frmtd := range amntsFormatted { - fmt.Println(frmtd) - expectation, err1 := ParseAmount(amnts[i], 2) - reality, err2 := ParseAmount(frmtd, 2) - if err1 != nil || err2 != nil { - fmt.Println("failed!", err1, err2) - t.FailNow() - } - - if expectation.Currency != reality.Currency || - expectation.Value != reality.Value || - expectation.Fraction != reality.Fraction { - - fmt.Println("failed!", amnts[i], frmtd) - t.FailNow() - } - } -} - -func TestFeesSub(t *testing.T) { - - amountWithFeesStr := fmt.Sprintf("%s:%s", "CHF", "5.00") - amountWithFees, err := ParseAmount(amountWithFeesStr, 3) - if err != nil { - fmt.Println("failed!", err) - t.FailNow() - } - - fees, err := ParseAmount("CHF:0.005", 3) - if err != nil { - fmt.Println("failed!", err) - t.FailNow() - } - - refundAmount, err := amountWithFees.Sub(*fees, 3) - if err != nil { - fmt.Println("failed!", err) - t.FailNow() - } - - if amnt := refundAmount.String(3); amnt != "CHF:4.995" { - fmt.Println("expected the refund amount to be CHF:4.995, but it was", amnt) - } else { - fmt.Println("refundable amount:", amnt) - } -} - -func TestFloat64ToAmount(t *testing.T) { - - type tuple struct { - a string - b int - } - - floats := map[float64]tuple{ - 2.345: {"2.345", 3}, - 4.204: {"4.204", 3}, - 1293.2: {"1293.2", 1}, - 1294.2: {"1294.20", 2}, - 1295.02: {"1295.02", 2}, - 2424.003: {"2424.003", 3}, - } - - for k, v := range floats { - - str := strconv.FormatFloat(k, 'f', v.b, 64) - if str != v.a { - fmt.Println("failed! expected", v.a, "got", str) - t.FailNow() - } - } -} - -func TestIsSmallerThan(t *testing.T) { - amnts := []string{ - "CHF:0", - "CHF:0.01", - "CHF:0.1", - "CHF:10", - "CHF:20", - "CHF:20.01", - "CHF:20.02", - "CHF:20.023", - } - amntsParsed := make([]Amount, 0) - for _, a := range amnts { - a, err := ParseAmount(a, 3) - if err != nil { - fmt.Println("failed!", err) - t.FailNow() - } - amntsParsed = append(amntsParsed, *a) - } - - for i, current := range amntsParsed { - if i == 0 { - continue - } - - last := amntsParsed[i-1] - fmt.Printf("checking: %s < %s\n", last.String(3), current.String(3)) - if smaller, err := last.IsSmallerThan(current); !smaller || err != nil { - fmt.Println("failed!", err) - t.FailNow() - } - } -} - -func TestIsSmallerThanNegative(t *testing.T) { - amnts := []string{ - "EUR:20.05", - "EUR:0.05", - "EUR:0.05", - } - amntsParsed := make([]Amount, 0) - for _, a := range amnts { - a, err := ParseAmount(a, 2) - if err != nil { - fmt.Println("failed!", err) - t.FailNow() - } - amntsParsed = append(amntsParsed, *a) - } - - for i, current := range amntsParsed { - if i == 0 { - continue - } - - last := amntsParsed[i-1] - fmt.Printf("checking (negative): %s < %s\n", last.String(2), current.String(2)) - if smaller, err := last.IsSmallerThan(current); smaller || err != nil { - fmt.Println("failed!", err) - t.FailNow() - } - } -} - -func TestIsEqualTo(t *testing.T) { - amnts := []string{ - "CHF:10", - "CHF:10.00", - "CHF:10.1", - "CHF:10.10", - "CHF:10.01", - "CHF:10.01", - } - amntsParsed := make([]Amount, 0) - for _, a := range amnts { - a, err := ParseAmount(a, 2) - if err != nil { - fmt.Println("failed!", err) - t.FailNow() - } - amntsParsed = append(amntsParsed, *a) - } - - doubleJump := 1 - for doubleJump <= len(amntsParsed) { - current := amntsParsed[doubleJump] - last := amntsParsed[doubleJump-1] - fmt.Printf("checking: %s = %s\n", last.String(2), current.String(2)) - if equal, err := last.IsEqualTo(current); !equal || err != nil { - fmt.Println("failed!", err) - t.FailNow() - } - doubleJump += 2 - } -} diff --git a/c2ec/api-auth.go b/c2ec/api-auth.go @@ -1,266 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "encoding/base64" - "errors" - "fmt" - "net/http" - "strconv" - "strings" - - "golang.org/x/crypto/argon2" -) - -const AUTHORIZATION_HEADER = "Authorization" -const BASIC_AUTH_PREFIX = "Basic " - -// Authenticates the Exchange against C2EC -// returns true if authentication was successful, otherwise false -// when not successful, the api shall return immediately -// The exchange is specified to use basic auth -func AuthenticateExchange(req *http.Request) bool { - - auth := req.Header.Get(AUTHORIZATION_HEADER) - if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found { - - ba := fmt.Sprintf("%s:%s", CONFIG.Server.WireGateway.Username, CONFIG.Server.WireGateway.Password) - encoded := base64.StdEncoding.EncodeToString([]byte(ba)) - return encoded == basicAuth - } - return false -} - -// Authenticates a terminal against C2EC -// returns true if authentication was successful, otherwise false -// when not successful, the api shall return immediately -// -// Terminals are authenticated using basic auth. -// The basic authorization header MUST be base64 encoded. -// The username part is the name of the provider (case sensitive) a '-' sign, followed -// by the id of the terminal, which is a number. -func AuthenticateTerminal(req *http.Request) bool { - - auth := req.Header.Get(AUTHORIZATION_HEADER) - if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found { - - decoded, err := base64.StdEncoding.DecodeString(basicAuth) - if err != nil { - LogWarn("auth", "failed decoding basic auth header from base64") - return false - } - - username, password, err := parseBasicAuth(string(decoded)) - if err != nil { - LogWarn("auth", "failed parsing username password from basic auth") - return false - } - - provider, terminalId, err := parseTerminalUser(username) - if err != nil { - LogWarn("auth", "failed parsing terminal from username in basic auth") - return false - } - LogInfo("auth", fmt.Sprintf("req=%s by terminal with id=%d, provider=%s", req.RequestURI, terminalId, provider)) - - terminal, err := DB.GetTerminalById(terminalId) - if err != nil { - return false - } - - if !terminal.Active { - LogWarn("auth", fmt.Sprintf("request from inactive terminal. id=%d", terminalId)) - return false - } - - prvdr, err := DB.GetTerminalProviderByName(provider) - if err != nil { - LogWarn("auth", fmt.Sprintf("failed requesting provider by name %s", err.Error())) - return false - } - - if terminal.ProviderId != prvdr.ProviderId { - LogWarn("auth", "terminal's provider id did not match provider id of supplied provider") - return false - } - - return ValidPassword(password, terminal.AccessToken) - } - LogWarn("auth", "basic auth prefix did not match") - return false -} - -func AuthenticateWirewatcher(req *http.Request) bool { - - auth := req.Header.Get(AUTHORIZATION_HEADER) - if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found { - - decoded, err := base64.StdEncoding.DecodeString(basicAuth) - if err != nil { - LogWarn("auth", "failed decoding basic auth header from base64") - return false - } - - username, password, err := parseBasicAuth(string(decoded)) - if err != nil { - LogWarn("auth", "failed parsing username password from basic auth") - return false - } - - if strings.EqualFold(username, CONFIG.Server.WireGateway.Username) && - strings.EqualFold(password, CONFIG.Server.WireGateway.Password) { - - return true - } - } else { - LogWarn("auth", "expecting exact 'Basic' prefix!") - } - LogWarn("auth", "basic auth prefix did not match") - return false -} - -func parseBasicAuth(basicAuth string) (string, string, error) { - - parts := strings.Split(basicAuth, ":") - if len(parts) != 2 { - return "", "", errors.New("malformed basic auth") - } - return parts[0], parts[1], nil -} - -// parses the username of the basic auth param of the terminal. -// the username has following format: -// -// [PROVIDER_NAME]-[TERMINAL_ID] -func parseTerminalUser(username string) (string, int, error) { - - parts := strings.Split(username, "-") - if len(parts) != 2 { - return "", -1, errors.New("malformed basic auth username") - } - - providerName := parts[0] - terminalId, err := strconv.Atoi(parts[1]) - if err != nil { - return "", -1, errors.New("malformed basic auth username") - } - - return providerName, terminalId, nil -} - -// Parses the terminal id from the token. -// This function is used to determine the terminal -// which orchestrates the withdrawal. -func parseTerminalId(req *http.Request) int { - auth := req.Header.Get(AUTHORIZATION_HEADER) - if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found { - - decoded, err := base64.StdEncoding.DecodeString(basicAuth) - if err != nil { - return -1 - } - - username, _, err := parseBasicAuth(string(decoded)) - if err != nil { - return -1 - } - - _, terminalId, err := parseTerminalUser(username) - if err != nil { - return -1 - } - - return terminalId - } - - return -1 -} - -func parseProvider(req *http.Request) (*Provider, error) { - - auth := req.Header.Get(AUTHORIZATION_HEADER) - if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found { - - decoded, err := base64.StdEncoding.DecodeString(basicAuth) - if err != nil { - return nil, err - } - - username, _, err := parseBasicAuth(string(decoded)) - if err != nil { - return nil, err - } - - providerName, _, err := parseTerminalUser(username) - if err != nil { - return nil, err - } - - p, err := DB.GetTerminalProviderByName(providerName) - if err != nil { - return nil, err - } - - return p, nil - } - - return nil, errors.New("authorization header did not match expectations") -} - -// takes a password and a base64 encoded password hash, including salt and checks -// the password supplied against it. -// the format of the password hash is expected to be the following: -// -// [32 BYTES HASH][16 BYTES SALT] = Bytes array with length of 48 bytes. -// -// returns true if password matches the password hash. Otherwise false. -func ValidPassword(pw string, base64EncodedHashAndSalt string) bool { - - hashedBytes := make([]byte, 48) - decodedLen, err := base64.StdEncoding.Decode(hashedBytes, []byte(base64EncodedHashAndSalt)) - if err != nil { - return false - } - - if decodedLen != 48 { - // malformed credentials - return false - } - - salt := hashedBytes[32:48] - rfcTime := 3 - rfcMemory := 32 * 1024 - key := argon2.Key([]byte(pw), salt, uint32(rfcTime), uint32(rfcMemory), 4, 32) - - if len(key) != 32 { - // length mismatch - return false - } - - for i := range key { - if key[i] != hashedBytes[i] { - // wrong password (application user key) - return false - } - } - - // password correct. - return true -} diff --git a/c2ec/api-auth_test.go b/c2ec/api-auth_test.go @@ -1,65 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - "testing" - - "golang.org/x/crypto/argon2" -) - -func TestValidPassword(t *testing.T) { - - pw := "verygoodpassword" - hashedEncodedPw, err := pbkdf(pw) - if err != nil { - fmt.Println("pbkdf failed") - t.FailNow() - } - - if !ValidPassword(pw, hashedEncodedPw) { - fmt.Println("password check failed") - t.FailNow() - } -} - -// copied from the cli tool. this function is used to obtain a base64 encoded password hash. -func pbkdf(pw string) (string, error) { - - rfcTime := 3 - rfcMemory := 32 * 1024 - salt := make([]byte, 16) - _, err := rand.Read(salt) - if err != nil { - return "", err - } - key := argon2.Key([]byte(pw), salt, uint32(rfcTime), uint32(rfcMemory), 4, 32) - - keyAndSalt := make([]byte, 0, 48) - keyAndSalt = append(keyAndSalt, key...) - keyAndSalt = append(keyAndSalt, salt...) - if len(keyAndSalt) != 48 { - return "", errors.New("invalid password hash and salt") - } - return base64.StdEncoding.EncodeToString(keyAndSalt), nil -} diff --git a/c2ec/api-bank-integration.go b/c2ec/api-bank-integration.go @@ -1,439 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "context" - "encoding/base64" - "errors" - "fmt" - http "net/http" - "strconv" - "time" -) - -const WITHDRAWAL_OPERATION = "/withdrawal-operation" - -const WOPID_PARAMETER = "wopid" -const BANK_INTEGRATION_CONFIG_PATTERN = "/config" -const WITHDRAWAL_OPERATION_PATTERN = WITHDRAWAL_OPERATION -const WITHDRAWAL_OPERATION_BY_WOPID_PATTERN = WITHDRAWAL_OPERATION + "/{" + WOPID_PARAMETER + "}" -const WITHDRAWAL_OPERATION_ABORTION_PATTERN = WITHDRAWAL_OPERATION_BY_WOPID_PATTERN + "/abort" - -const DEFAULT_LONG_POLL_MS = 1000 -const DEFAULT_OLD_STATE = PENDING - -// https://docs.taler.net/core/api-exchange.html#tsref-type-CurrencySpecification -type CurrencySpecification struct { - Name string `json:"name"` - Currency string `json:"currency"` - NumFractionalInputDigits int `json:"num_fractional_input_digits"` - NumFractionalNormalDigits int `json:"num_fractional_normal_digits"` - NumFractionalTrailingZeroDigits int `json:"num_fractional_trailing_zero_digits"` - AltUnitNames map[string]string `json:"alt_unit_names"` -} - -// https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankIntegrationConfig -type BankIntegrationConfig struct { - Name string `json:"name"` - Version string `json:"version"` - Implementation string `json:"implementation"` - Currency string `json:"currency"` - CurrencySpecification CurrencySpecification `json:"currency_specification"` -} - -type BankWithdrawalOperationPostRequest struct { - ReservePubKey EddsaPublicKey `json:"reserve_pub"` - SelectedExchange string `json:"selected_exchange"` - Amount *Amount `json:"amount"` -} - -type BankWithdrawalOperationPostResponse struct { - Status WithdrawalOperationStatus `json:"status"` - ConfirmTransferUrl string `json:"confirm_transfer_url"` - TransferDone bool `json:"transfer_done"` -} - -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"` - SuggestedExchange string `json:"suggested_exchange"` - RequiredExchange string `json:"required_exchange"` - Aborted bool `json:"aborted"` - SelectionDone bool `json:"selection_done"` - TransferDone bool `json:"transfer_done"` -} - -func bankIntegrationConfig(res http.ResponseWriter, req *http.Request) { - - LogInfo("bank-integration-api", "reading config") - cfg := BankIntegrationConfig{ - Name: "taler-bank-integration", - Version: "4:8:2", - Currency: CONFIG.Server.Currency, - CurrencySpecification: CurrencySpecification{ - Name: CONFIG.Server.Currency, - Currency: CONFIG.Server.Currency, - NumFractionalInputDigits: CONFIG.Server.CurrencyFractionDigits, - NumFractionalNormalDigits: CONFIG.Server.CurrencyFractionDigits, - NumFractionalTrailingZeroDigits: 0, - AltUnitNames: map[string]string{ - "0": CONFIG.Server.Currency, - }, - }, - } - - encoder := NewJsonCodec[BankIntegrationConfig]() - serializedCfg, err := encoder.EncodeToBytes(&cfg) - if err != nil { - LogInfo("bank-integration-api", fmt.Sprintf("failed serializing config: %s", err.Error())) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - res.Header().Add(CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) - setLastResponseCodeForLogger(HTTP_OK) - res.WriteHeader(HTTP_OK) - res.Write(serializedCfg) -} - -func handleParameterRegistration(res http.ResponseWriter, req *http.Request) { - - jsonCodec := NewJsonCodec[BankWithdrawalOperationPostRequest]() - registration, err := ReadStructFromBody(req, jsonCodec) - if err != nil { - LogWarn("bank-integration-api", fmt.Sprintf("invalid body for withdrawal registration error=%s", err.Error())) - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - // read and validate the wopid path parameter - wopid := req.PathValue(WOPID_PARAMETER) - wpd, err := ParseWopid(wopid) - if err != nil { - LogWarn("bank-integration-api", "wopid "+wopid+" not valid") - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - if w, err := DB.GetWithdrawalByWopid(wpd); err != nil { - LogError("bank-integration-api", err) - setLastResponseCodeForLogger(HTTP_NOT_FOUND) - res.WriteHeader(HTTP_NOT_FOUND) - return - } else { - if w.ReservePubKey != nil || len(w.ReservePubKey) > 0 { - LogWarn("bank-integration-api", "tried registering a withdrawal-operation with already existing wopid") - setLastResponseCodeForLogger(HTTP_CONFLICT) - res.WriteHeader(HTTP_CONFLICT) - return - } - } - - if err = DB.RegisterWithdrawalParameters( - wpd, - registration.ReservePubKey, - ); err != nil { - LogError("bank-integration-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - withdrawal, err := DB.GetWithdrawalByWopid(wpd) - if err != nil { - LogError("bank-integration-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - } - - resbody := &BankWithdrawalOperationPostResponse{ - Status: withdrawal.WithdrawalStatus, - ConfirmTransferUrl: "", // not used in our case - TransferDone: withdrawal.WithdrawalStatus == CONFIRMED, - } - - encoder := NewJsonCodec[BankWithdrawalOperationPostResponse]() - resbyts, err := encoder.EncodeToBytes(resbody) - if err != nil { - LogError("bank-integration-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - } - - res.Header().Add(CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) - res.Write(resbyts) -} - -// Get status of withdrawal associated with the given WOPID -// -// Parameters: -// - long_poll_ms (optional): -// milliseconds to wait for state to change -// given old_state until responding -// - old_state (optional): -// Default is 'pending' -func handleWithdrawalStatus(res http.ResponseWriter, req *http.Request) { - - // read and validate request query parameters - shouldStartLongPoll := true - longPollMilli := DEFAULT_LONG_POLL_MS - oldState := DEFAULT_OLD_STATE - if longPollMilliPtr, accepted := AcceptOptionalParamOrWriteResponse( - "long_poll_ms", strconv.Atoi, req, res, - ); accepted { - if longPollMilliPtr != nil { - longPollMilli = *longPollMilliPtr - if oldStatePtr, accepted := AcceptOptionalParamOrWriteResponse( - "old_state", ToWithdrawalOperationStatus, req, res, - ); accepted { - if oldStatePtr != nil { - oldState = *oldStatePtr - } - } - } else { - // this means parameter was not given. - // no long polling (simple get) - LogInfo("bank-integration-api", "will not start long-polling") - shouldStartLongPoll = false - } - } else { - LogInfo("bank-integration-api", "will not start long-polling") - shouldStartLongPoll = false - } - - // read and validate the wopid path parameter - wopid := req.PathValue(WOPID_PARAMETER) - wpd, err := ParseWopid(wopid) - if err != nil { - LogWarn("bank-integration-api", "wopid "+wopid+" not valid") - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - var timeoutCtx context.Context - notifications := make(chan *Notification) - w := make(chan []byte) - errStat := make(chan int) - if shouldStartLongPoll { - - go func() { - // when the current state differs from the old_state - // of the request, return immediately. This goroutine - // does this check and sends the withdrawal to through - // the specified channel, if the withdrawal was already - // changed. - withdrawal, err := DB.GetWithdrawalByWopid(wpd) - if err != nil { - LogError("bank-integration-api", err) - } - if withdrawal == nil { - // do nothing because other goroutine might deliver result - return - } - if withdrawal.WithdrawalStatus != oldState { - byts, status := formatWithdrawalOrErrorStatus(withdrawal) - if status != HTTP_OK { - errStat <- status - } else { - w <- byts - } - } - }() - - var cancelFunc context.CancelFunc - timeoutCtx, cancelFunc = context.WithTimeout( - req.Context(), - time.Duration(longPollMilli)*time.Millisecond, - ) - defer cancelFunc() - - channel := "w_" + base64.StdEncoding.EncodeToString(wpd) - - listenFunc, err := DB.NewListener( - channel, - notifications, - ) - - if err != nil { - LogError("bank-integration-api", err) - errStat <- HTTP_INTERNAL_SERVER_ERROR - } else { - go listenFunc(timeoutCtx) - } - } else { - wthdrl, stat := getWithdrawalOrError(wpd) - LogInfo("bank-integration-api", "loaded withdrawal") - if stat != HTTP_OK { - LogWarn("bank-integration-api", "tried loading withdrawal but got error") - //errStat <- stat - setLastResponseCodeForLogger(stat) - res.WriteHeader(stat) - return - } else { - //w <- wthdrl - res.Header().Add(CONTENT_TYPE_HEADER, "application/json") - res.Write(wthdrl) - return - } - } - - for wait := true; wait; { - select { - case <-timeoutCtx.Done(): - LogInfo("bank-integration-api", "long poll time exceeded") - setLastResponseCodeForLogger(HTTP_NO_CONTENT) - res.WriteHeader(HTTP_NO_CONTENT) - wait = false - case <-notifications: - wthdrl, stat := getWithdrawalOrError(wpd) - if stat != 200 { - setLastResponseCodeForLogger(stat) - res.WriteHeader(stat) - } else { - res.Header().Add(CONTENT_TYPE_HEADER, "application/json") - res.Write(wthdrl) - } - wait = false - case wthdrl := <-w: - res.Header().Add(CONTENT_TYPE_HEADER, "application/json") - res.Write(wthdrl) - wait = false - case status := <-errStat: - LogInfo("bank-integration-api", "got unsucessful state for withdrawal operation request") - setLastResponseCodeForLogger(status) - res.WriteHeader(status) - wait = false - } - } - LogInfo("bank-integration-api", "withdrawal operation status request finished") -} - -func handleWithdrawalAbort(res http.ResponseWriter, req *http.Request) { - - // read and validate the wopid path parameter - wopid := req.PathValue(WOPID_PARAMETER) - wpd, err := ParseWopid(wopid) - if err != nil { - LogWarn("bank-integration-api", "wopid "+wopid+" not valid") - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - withdrawal, err := DB.GetWithdrawalByWopid(wpd) - if err != nil { - LogError("bank-integration-api", err) - setLastResponseCodeForLogger(HTTP_NOT_FOUND) - res.WriteHeader(HTTP_NOT_FOUND) - return - } - - if withdrawal.WithdrawalStatus == CONFIRMED { - setLastResponseCodeForLogger(HTTP_CONFLICT) - res.WriteHeader(HTTP_CONFLICT) - return - } - - err = DB.FinaliseWithdrawal(int(withdrawal.WithdrawalRowId), ABORTED, make([]byte, 0)) - if err != nil { - LogError("bank-integration-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - setLastResponseCodeForLogger(HTTP_NO_CONTENT) - res.WriteHeader(HTTP_NO_CONTENT) -} - -// Tries to load a WithdrawalOperationStatus from the database. If no -// entry could been found, it will write the correct error to the response. -func getWithdrawalOrError(wopid []byte) ([]byte, int) { - // read the withdrawal from the database - withdrawal, err := DB.GetWithdrawalByWopid(wopid) - if err != nil { - LogError("bank-integration-api", err) - return nil, HTTP_NOT_FOUND - } - - if withdrawal == nil { - // not found -> 404 - return nil, HTTP_NOT_FOUND - } - - // return the C2ECWithdrawalStatus - return formatWithdrawalOrErrorStatus(withdrawal) -} - -func formatWithdrawalOrErrorStatus(w *Withdrawal) ([]byte, int) { - - if w == nil { - return nil, HTTP_INTERNAL_SERVER_ERROR - } - - operator, err := DB.GetProviderByTerminal(w.TerminalId) - if err != nil { - LogError("bank-integration-api", err) - return nil, HTTP_INTERNAL_SERVER_ERROR - } - - client := PROVIDER_CLIENTS[operator.Name] - if client == nil { - LogError("bank-integration-api", errors.New("no provider client registered for provider "+operator.Name)) - return nil, HTTP_INTERNAL_SERVER_ERROR - } - - if amount, err := ToAmount(w.Amount); err != nil { - LogError("bank-integration-api", err) - return nil, HTTP_INTERNAL_SERVER_ERROR - } else { - 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: client.FormatPayto(w), - WireTypes: []string{operator.PaytoTargetType, "iban"}, - ReservePubKey: EddsaPublicKey((encodeCrock(w.ReservePubKey))), - SuggestedExchange: CONFIG.Server.ExchangeBaseUrl, - RequiredExchange: CONFIG.Server.ExchangeBaseUrl, - 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 - } - } -} diff --git a/c2ec/api-terminals.go b/c2ec/api-terminals.go @@ -1,405 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "crypto/rand" - "errors" - "fmt" - "net/http" -) - -const TERMINAL_API_CONFIG = "/config" -const TERMINAL_API_REGISTER_WITHDRAWAL = "/withdrawals" -const TERMINAL_API_WITHDRAWAL_STATUS = "/withdrawals/{wopid}" -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"` - WithdrawalFees string `json:"withdrawal_fees"` - WireType string `json:"wire_type"` -} - -type TerminalWithdrawalSetup struct { - Amount string `json:"amount"` - SuggestedAmount string `json:"suggested_amount"` - ProviderTransactionId string `json:"provider_transaction_id"` - TerminalFees string `json:"terminal_fees"` - RequestUid string `json:"request_uid"` - UserUuid string `json:"user_uuid"` - Lock string `json:"lock"` -} - -type TerminalWithdrawalSetupResponse struct { - Wopid string `json:"withdrawal_id"` -} - -type TerminalWithdrawalConfirmationRequest struct { - ProviderTransactionId string `json:"provider_transaction_id"` - TerminalFees string `json:"terminal_fees"` - UserUuid string `json:"user_uuid"` - Lock string `json:"lock"` -} - -func handleTerminalConfig(res http.ResponseWriter, req *http.Request) { - - p, auth, err := authAndParseProvider(req) - if !auth { - setLastResponseCodeForLogger(HTTP_UNAUTHORIZED) - res.WriteHeader(HTTP_UNAUTHORIZED) - return - } - - if err != nil || p == nil { - LogError("terminals-api", err) - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - encoder := NewJsonCodec[TerminalConfig]() - cfg, err := encoder.EncodeToBytes(&TerminalConfig{ - 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) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - res.Header().Add(CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) - setLastResponseCodeForLogger(HTTP_OK) - res.WriteHeader(HTTP_OK) - res.Write(cfg) -} - -func handleWithdrawalSetup(res http.ResponseWriter, req *http.Request) { - - p, auth, err := authAndParseProvider(req) - if !auth { - setLastResponseCodeForLogger(HTTP_UNAUTHORIZED) - res.WriteHeader(HTTP_UNAUTHORIZED) - return - } - if err != nil || p == nil { - LogError("terminals-api", err) - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - jsonCodec := NewJsonCodec[TerminalWithdrawalSetup]() - setup, err := ReadStructFromBody(req, jsonCodec) - if err != nil { - LogWarn("terminals-api", fmt.Sprintf("invalid body for withdrawal registration error=%s", err.Error())) - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - if hasConflict(setup) { - setLastResponseCodeForLogger(HTTP_CONFLICT) - res.WriteHeader(HTTP_CONFLICT) - return - } - - terminalId := parseTerminalId(req) - if terminalId == -1 { - LogWarn("terminals-api", "terminal id could not be read from authorization header") - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - // generate wopid - generatedWopid := make([]byte, 32) - _, err = rand.Read(generatedWopid) - if err != nil { - LogWarn("terminals-api", "unable to generate correct wopid") - LogError("terminals-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - } - - suggstdAmnt, err := parseAmount(setup.SuggestedAmount) - if err != nil { - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - amnt, err := parseAmount(setup.Amount) - if err != nil { - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - fees, err := parseAmount(setup.TerminalFees) - if err != nil { - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - err = DB.SetupWithdrawal( - generatedWopid, - suggstdAmnt, - amnt, - terminalId, - setup.ProviderTransactionId, - fees, - setup.RequestUid, - ) - - if err != nil { - LogError("terminals-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - encoder := NewJsonCodec[TerminalWithdrawalSetupResponse]() - encodedBody, err := encoder.EncodeToBytes( - &TerminalWithdrawalSetupResponse{ - Wopid: talerBinaryEncode(generatedWopid), - }, - ) - if err != nil { - LogError("terminal-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - res.Header().Add(CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) - res.Write(encodedBody) -} - -func handleWithdrawalCheck(res http.ResponseWriter, req *http.Request) { - - p, auth, err := authAndParseProvider(req) - if !auth { - setLastResponseCodeForLogger(HTTP_UNAUTHORIZED) - res.WriteHeader(HTTP_UNAUTHORIZED) - return - } - - if err != nil || p == nil { - LogError("terminals-api", err) - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - wopid := req.PathValue(WOPID_PARAMETER) - wpd, err := ParseWopid(wopid) - if err != nil { - LogWarn("terminals-api", "wopid "+wopid+" not valid") - if wopid == "" { - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - } - - jsonCodec := NewJsonCodec[TerminalWithdrawalConfirmationRequest]() - paymentNotification, err := ReadStructFromBody(req, jsonCodec) - if err != nil { - LogError("terminals-api", err) - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - LogInfo("terminals-api", "received payment notification") - - terminalId := parseTerminalId(req) - if terminalId == -1 { - LogWarn("terminals-api", "terminal id could not be read from authorization header") - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - trmlFees, err := ParseAmount(paymentNotification.TerminalFees, CONFIG.Server.CurrencyFractionDigits) - if err != nil { - LogError("terminals-api", err) - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - exchangeFees, err := parseAmount(CONFIG.Server.WithdrawalFees) - if err != nil { - LogError("terminals-api", errors.New("unable to parse withdrawal fees - FATAL SHOULD NEVER HAPPEN")) - LogError("terminals-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - // Fees are optional here and since the Exchange can specify - // zero fees, the value can be zero as well. The case that the - // the terminal sends no fees and the exchange does not charge - // fees needs to be covered as compliant request, currently done - // by the trmlFees < exchangeFees check. - // Check that fees are at least as high as the configured withdrawal fees. - // a higher value would indicate that the payment service provider does - // also charge fees. - // incoming fees >= specified fees - if smaller, err := trmlFees.IsSmallerThan(exchangeFees); smaller || err != nil { - if err != nil { - LogError("terminals-api", err) - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - if smaller { - LogError("terminals-api", errors.New("terminal did specify uncorrect fees")) - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - } - - LogInfo("terminals-api", "received valid check request for provider_transaction_id="+paymentNotification.ProviderTransactionId) - err = DB.NotifyPayment( - wpd, - paymentNotification.ProviderTransactionId, - terminalId, - preventNilAmount(trmlFees), - ) - if err != nil { - LogError("terminals-api", err) - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - setLastResponseCodeForLogger(HTTP_NO_CONTENT) - res.WriteHeader(HTTP_NO_CONTENT) -} - -func handleWithdrawalStatusTerminal(res http.ResponseWriter, req *http.Request) { - - _, auth, err := authAndParseProvider(req) - if err != nil || !auth { - setLastResponseCodeForLogger(HTTP_UNAUTHORIZED) - res.WriteHeader(HTTP_UNAUTHORIZED) - return - } - - handleWithdrawalStatus(res, req) -} - -func handleWithdrawalAbortTerminal(res http.ResponseWriter, req *http.Request) { - - _, auth, err := authAndParseProvider(req) - if err != nil || !auth { - setLastResponseCodeForLogger(HTTP_UNAUTHORIZED) - res.WriteHeader(HTTP_UNAUTHORIZED) - return - } - - handleWithdrawalAbort(res, req) -} - -func parseAmount(amountStr string) (Amount, error) { - - a, err := ParseAmount(amountStr, CONFIG.Server.CurrencyFractionDigits) - if err != nil { - return Amount{"", 0, 0}, err - } - return preventNilAmount(a), nil -} - -func preventNilAmount(exchangeFees *Amount) Amount { - - if exchangeFees == nil { - return Amount{"", 0, 0} - } - - return *exchangeFees -} - -func hasConflict(t *TerminalWithdrawalSetup) bool { - - w, err := DB.GetWithdrawalByRequestUid(t.RequestUid) - if err != nil { - LogError("terminals-api", err) - return true - } - - if w == nil { - return false // no request with this uid - } - - suggstdAmnt, err := parseAmount(t.SuggestedAmount) - if err != nil { - LogError("terminals-api", err) - return true - } - amnt, err := parseAmount(t.Amount) - if err != nil { - LogError("terminals-api", err) - return true - } - fees, err := parseAmount(t.TerminalFees) - if err != nil { - LogError("terminals-api", err) - return true - } - - isEqual := w.Amount.Curr == amnt.Currency && - w.Amount.Val == int64(amnt.Value) && - w.Amount.Frac == int32(amnt.Fraction) && - w.TerminalFees.Curr == fees.Currency && - uint64(w.TerminalFees.Val) == fees.Value && - uint64(w.TerminalFees.Frac) == fees.Fraction && - w.SuggestedAmount.Curr == suggstdAmnt.Currency && - uint64(w.SuggestedAmount.Val) == suggstdAmnt.Value && - uint64(w.SuggestedAmount.Frac) == suggstdAmnt.Fraction && - w.ProviderTransactionId == &t.ProviderTransactionId && - w.RequestUid == t.RequestUid - - return !isEqual -} - -func authAndParseProvider(req *http.Request) (*Provider, bool, error) { - - if authenticated := AuthenticateTerminal(req); !authenticated { - return nil, false, nil - } - - p, err := parseProvider(req) - if err != nil { - return nil, true, err - } - - return p, true, nil -} diff --git a/c2ec/api-wire-gateway.go b/c2ec/api-wire-gateway.go @@ -1,561 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "bytes" - "errors" - "fmt" - "log" - "net/http" - "strconv" - "time" -) - -const WIRE_GATEWAY_CONFIG_ENDPOINT = "/config" -const WIRE_GATEWAY_HISTORY_ENDPOINT = "/history" - -const WIRE_GATEWAY_CONFIG_PATTERN = WIRE_GATEWAY_CONFIG_ENDPOINT -const WIRE_TRANSFER_PATTERN = "/transfer" -const WIRE_HISTORY_INCOMING_PATTERN = WIRE_GATEWAY_HISTORY_ENDPOINT + "/incoming" -const WIRE_HISTORY_OUTGOING_PATTERN = WIRE_GATEWAY_HISTORY_ENDPOINT + "/outgoing" -const WIRE_ADMIN_ADD_INCOMING_PATTERN = "/admin/add-incoming" - -const INCOMING_RESERVE_TRANSACTION_TYPE = "RESERVE" - -// https://docs.taler.net/core/api-bank-wire.html#tsref-type-WireConfig -type WireConfig struct { - Name string `json:"name"` - Version string `json:"version"` - Currency string `json:"currency"` - Implementation string `json:"implementation"` -} - -// https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferRequest -type TransferRequest struct { - RequestUid string `json:"request_uid"` - Amount string `json:"amount"` - ExchangeBaseUrl string `json:"exchange_base_url"` - Wtid string `json:"wtid"` - CreditAccount string `json:"credit_account"` -} - -// https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferResponse -type TransferResponse struct { - Timestamp Timestamp `json:"timestamp"` - RowId int `json:"row_id"` -} - -// https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingHistory -type IncomingHistory struct { - IncomingTransactions []IncomingReserveTransaction `json:"incoming_transactions"` - CreditAccount string `json:"credit_account"` -} - -// type RESERVE | https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingReserveTransaction -type IncomingReserveTransaction struct { - Type string `json:"type"` - RowId int `json:"row_id"` - Date Timestamp `json:"date"` - Amount string `json:"amount"` - DebitAccount string `json:"debit_account"` - ReservePub EddsaPublicKey `json:"reserve_pub"` -} - -type OutgoingHistory struct { - OutgoingTransactions []*OutgoingBankTransaction `json:"outgoing_transactions"` - DebitAccount string `json:"debit_account"` -} - -type OutgoingBankTransaction struct { - RowId uint64 `json:"row_id"` - Date Timestamp `json:"date"` - Amount string `json:"amount"` - CreditAccount string `json:"credit_account"` - Wtid ShortHashCode `json:"wtid"` - ExchangeBaseUrl string `json:"exchange_base_url"` -} - -func NewIncomingReserveTransaction(w *Withdrawal) *IncomingReserveTransaction { - - if w == nil { - LogWarn("wire-gateway", "the withdrawal was nil") - return nil - } - - provider, err := DB.GetProviderByTerminal(w.TerminalId) - if err != nil { - LogError("wire-gateway", err) - return nil - } - - client := PROVIDER_CLIENTS[provider.Name] - if client == nil { - LogError("wire-gateway", errors.New("no provider client with name="+provider.Name)) - return nil - } - - t := new(IncomingReserveTransaction) - a, err := ToAmount(w.Amount) - if err != nil { - LogError("wire-gateway", err) - return nil - } - t.Amount = FormatAmount(a, CONFIG.Server.CurrencyFractionDigits) - t.Date = Timestamp{ - Ts: int(w.RegistrationTs), - } - t.DebitAccount = client.FormatPayto(w) - t.ReservePub = FormatEddsaPubKey(w.ReservePubKey) - if w.ConfirmedRowId == nil { - LogError("wire-gateway", fmt.Errorf("expected non-nil confirmed_row_id for withdrawal_row_id=%d", w.WithdrawalRowId)) - return nil - } - t.RowId = int(*w.ConfirmedRowId) - t.Type = INCOMING_RESERVE_TRANSACTION_TYPE - return t -} - -func NewOutgoingBankTransaction(tr *Transfer) *OutgoingBankTransaction { - t := new(OutgoingBankTransaction) - a, err := ToAmount(tr.Amount) - if err != nil { - LogError("wire-gateway", err) - return nil - } - t.Amount = FormatAmount(a, CONFIG.Server.CurrencyFractionDigits) - t.Date = Timestamp{ - Ts: int(tr.TransferTs), - } - t.CreditAccount = tr.CreditAccount - t.ExchangeBaseUrl = tr.ExchangeBaseUrl - if tr.TransferredRowId == nil { - LogError("wire-gateway", fmt.Errorf("expected non-nil transferred_row_id for row_id=%d", tr.RowId)) - return nil - } - t.RowId = uint64(*tr.TransferredRowId) - t.Wtid = ShortHashCode(tr.Wtid) - return t -} - -func wireGatewayConfig(res http.ResponseWriter, req *http.Request) { - - cfg := WireConfig{ - Name: "taler-wire-gateway", - Currency: CONFIG.Server.Currency, - Version: "0:0:1", - Implementation: "", - } - - serializedCfg, err := NewJsonCodec[WireConfig]().EncodeToBytes(&cfg) - if err != nil { - log.Default().Printf("failed serializing config: %s", err.Error()) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - setLastResponseCodeForLogger(HTTP_OK) - res.WriteHeader(HTTP_OK) - res.Write(serializedCfg) -} - -func transfer(res http.ResponseWriter, req *http.Request) { - - auth := AuthenticateWirewatcher(req) - if !auth { - setLastResponseCodeForLogger(HTTP_UNAUTHORIZED) - res.WriteHeader(HTTP_UNAUTHORIZED) - return - } - - jsonCodec := NewJsonCodec[TransferRequest]() - transfer, err := ReadStructFromBody(req, jsonCodec) - if err != nil { - LogError("wire-gateway-api", err) - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - if transfer.Amount == "" || transfer.CreditAccount == "" || transfer.RequestUid == "" { - LogError("wire-gateway-api", errors.New("invalid request")) - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - paytoTargetType, tid, err := ParsePaytoUri(transfer.CreditAccount) - LogInfo("wire-gateway-api", fmt.Sprintf("parsed payto-target-type='%s'", paytoTargetType)) - if err != nil { - LogError("wire-gateway-api", err) - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return - } - - p, err := DB.GetTerminalProviderByPaytoTargetType(paytoTargetType) - if err != nil { - LogWarn("wire-gateway-api", "unable to find provider for provider-target-type="+paytoTargetType) - LogError("wire-gateway-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - decodedRequestUid := bytes.NewBufferString(transfer.RequestUid).Bytes() - t, err := DB.GetTransferById(decodedRequestUid) - if err != nil { - LogWarn("wire-gateway-api", "failed retrieving transfer for requestUid="+transfer.RequestUid) - LogError("wire-gateway-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - if t == nil { - - // limitation: currently only full refunds are implemented. - // this means that we also check that no other transaction - // to the same recipient with this credit_account is present. - transfers, err := DB.GetTransfersByCreditAccount(transfer.CreditAccount) - if err != nil { - LogWarn("wire-gateway-api", "looking for transfers with the credit account failed") - LogError("wire-gateway-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - if len(transfers) > 0 { - // when the withdrawal was already refunded we act like everything is - // ok, because the transfer was registered earlier and the customer - // will get their money back (or already have). The Exchange will - // not loose money on the other hand because the refund is done twice. - LogWarn("wire-gateway-api", "full refunds only limitation") - LogError("wire-gateway-api", fmt.Errorf("currently only full refunds are supported. Withdrawal %s already refunded", transfer.CreditAccount)) - setLastResponseCodeForLogger(HTTP_OK) - res.WriteHeader(HTTP_OK) - return - } - - // no transfer for this request_id -> generate new - amount, err := ParseAmount(transfer.Amount, CONFIG.Server.CurrencyFractionDigits) - if err != nil { - LogWarn("wire-gateway-api", "failed parsing amount") - LogError("wire-gateway-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - err = DB.AddTransfer( - decodedRequestUid, - amount, - transfer.ExchangeBaseUrl, - string(transfer.Wtid), - transfer.CreditAccount, - time.Now(), - ) - if err != nil { - LogWarn("wire-gateway-api", "failed adding new transfer entry to database") - LogError("wire-gateway-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - } else { - - // check that the wanted provider is configured. - refundClient := PROVIDER_CLIENTS[p.Name] - if refundClient == nil { - LogError("wire-gateway-api", errors.New("client for provider "+p.Name+" not initialized")) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - // the transfer is only processed if the body matches. - ta, err := ToAmount(t.Amount) - if err != nil { - LogError("wire-gateway-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - if transfer.Amount != FormatAmount(ta, CONFIG.Server.CurrencyFractionDigits) || - transfer.ExchangeBaseUrl != t.ExchangeBaseUrl || - transfer.Wtid != t.Wtid || - transfer.CreditAccount != t.CreditAccount { - - LogWarn("wire-gateway-api", "idempotency violation") - setLastResponseCodeForLogger(HTTP_CONFLICT) - res.WriteHeader(HTTP_CONFLICT) - return - } - - w, err := DB.GetWithdrawalByProviderTransactionId(tid) - if err != nil || w == nil { - LogWarn("wire-gateway-api", "unable to find withdrawal with given provider transaction id") - LogError("wire-gateway-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - } - setLastResponseCodeForLogger(HTTP_OK) -} - -// :query start: *Optional.* -// -// Row identifier to explicitly set the *starting point* of the query. -// -// :query delta: -// -// The *delta* value that determines the range of the query. -// -// :query long_poll_ms: *Optional.* -// -// If this parameter is specified and the result of the query would be empty, -// the bank will wait up to ``long_poll_ms`` milliseconds for new transactions -// that match the query to arrive and only then send the HTTP response. -// A client must never rely on this behavior, as the bank may return a response -// immediately or after waiting only a fraction of ``long_poll_ms``. -func historyIncoming(res http.ResponseWriter, req *http.Request) { - - auth := AuthenticateWirewatcher(req) - if !auth { - setLastResponseCodeForLogger(HTTP_UNAUTHORIZED) - res.WriteHeader(HTTP_UNAUTHORIZED) - return - } - - // read and validate request query parameters - timeOfReq := time.Now() - shouldStartLongPoll := true - var longPollMilli int - if longPollMilliPtr, accepted := AcceptOptionalParamOrWriteResponse( - "long_poll_ms", strconv.Atoi, req, res, - ); accepted { - if longPollMilliPtr != nil { - longPollMilli = *longPollMilliPtr - } else { - // this means parameter was not given. - // no long polling (simple get) - shouldStartLongPoll = false - } - } - - var start = 0 // read most recent entries by default - if startPtr, accepted := AcceptOptionalParamOrWriteResponse( - "start", strconv.Atoi, req, res, - ); accepted { - if startPtr != nil { - start = *startPtr - } - } else { - res.Header().Add(CONTENT_TYPE_HEADER, "application/json") - LogWarn("wire-gateway-api", "invalid parameter") - return - } - - var delta = 0 - if deltaPtr, accepted := AcceptOptionalParamOrWriteResponse( - "delta", strconv.Atoi, req, res, - ); accepted { - if deltaPtr != nil { - delta = *deltaPtr - } - } else { - res.Header().Add(CONTENT_TYPE_HEADER, "application/json") - LogWarn("wire-gateway-api", "invalid parameter") - return - } - - if delta == 0 { - delta = 10 - } - - if shouldStartLongPoll { - - // this will just wait / block until the milliseconds are exceeded. - time.Sleep(time.Duration(longPollMilli) * time.Millisecond) - } - - withdrawals, err := DB.GetConfirmedWithdrawals(start, delta, timeOfReq) - - if err != nil { - LogError("wire-gateway-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - if len(withdrawals) < 1 { - setLastResponseCodeForLogger(HTTP_NO_CONTENT) - res.WriteHeader(HTTP_NO_CONTENT) - return - } - - transactions := make([]IncomingReserveTransaction, 0) - for _, w := range withdrawals { - if w.Amount.Val == 0 && w.Amount.Frac == 0 { - LogInfo("wire-gateway-api", "ignoring zero amount withdrawal") - continue - } - if w.ReservePubKey == nil || len(w.ReservePubKey) == 0 { - LogWarn("wire-gateway-api", "ignoring confirmed withdrawal with no reserve public key (probably a test transaction)") - continue - } - transaction := NewIncomingReserveTransaction(w) - if transaction != nil { - transactions = append(transactions, *transaction) - } - } - - hist := IncomingHistory{ - IncomingTransactions: transactions, - CreditAccount: CONFIG.Server.CreditAccount, - } - - encoder := NewJsonCodec[IncomingHistory]() - enc, err := encoder.EncodeToBytes(&hist) - if err != nil { - LogError("wire-gateway-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - res.Header().Add(CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) - setLastResponseCodeForLogger(HTTP_OK) - res.WriteHeader(HTTP_OK) - res.Write(enc) -} - -func historyOutgoing(res http.ResponseWriter, req *http.Request) { - - auth := AuthenticateWirewatcher(req) - if !auth { - setLastResponseCodeForLogger(HTTP_UNAUTHORIZED) - res.WriteHeader(HTTP_UNAUTHORIZED) - return - } - - // read and validate request query parameters - timeOfReq := time.Now() - shouldStartLongPoll := true - var longPollMilli int - if longPollMilliPtr, accepted := AcceptOptionalParamOrWriteResponse( - "long_poll_ms", strconv.Atoi, req, res, - ); accepted { - } else { - if longPollMilliPtr != nil { - longPollMilli = *longPollMilliPtr - } else { - // this means parameter was not given. - // no long polling (simple get) - shouldStartLongPoll = false - } - } - - var start int - if startPtr, accepted := AcceptOptionalParamOrWriteResponse( - "start", strconv.Atoi, req, res, - ); accepted { - } else { - if startPtr != nil { - start = *startPtr - } - } - - var delta int - if deltaPtr, accepted := AcceptOptionalParamOrWriteResponse( - "delta", strconv.Atoi, req, res, - ); accepted { - } else { - if deltaPtr != nil { - delta = *deltaPtr - } - } - - if delta == 0 { - delta = 10 - } - - if shouldStartLongPoll { - - // this will just wait / block until the milliseconds are exceeded. - time.Sleep(time.Duration(longPollMilli) * time.Millisecond) - } - - transfers, err := DB.GetTransfers(start, delta, timeOfReq) - - if err != nil { - LogError("wire-gateway-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - filtered := make([]*Transfer, 0) - for _, t := range transfers { - if t.Status == 0 { - // only consider transfer which were successful - filtered = append(filtered, t) - } - } - - if len(filtered) < 1 { - setLastResponseCodeForLogger(HTTP_NO_CONTENT) - res.WriteHeader(HTTP_NO_CONTENT) - return - } - - transactions := make([]*OutgoingBankTransaction, len(filtered)) - for _, t := range filtered { - transactions = append(transactions, NewOutgoingBankTransaction(t)) - } - transactions = removeNulls(transactions) - - outgoingHistory := OutgoingHistory{ - OutgoingTransactions: transactions, - DebitAccount: CONFIG.Server.CreditAccount, - } - encoder := NewJsonCodec[OutgoingHistory]() - enc, err := encoder.EncodeToBytes(&outgoingHistory) - if err != nil { - LogError("wire-gateway-api", err) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return - } - - res.Header().Add(CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) - setLastResponseCodeForLogger(HTTP_OK) - res.WriteHeader(HTTP_OK) - res.Write(enc) -} - -// This method is currently dead and implemented for API conformance -func adminAddIncoming(res http.ResponseWriter, req *http.Request) { - - // not implemented, because not used - setLastResponseCodeForLogger(HTTP_NOT_IMPLEMENTED) - res.WriteHeader(HTTP_NOT_IMPLEMENTED) -} diff --git a/c2ec/c2ec-config.conf b/c2ec/c2ec-config.conf @@ -1,93 +0,0 @@ -[c2ec] - -# Will force production specific configuration -# for example the simulation terminal cannot be -# used in production -PROD = false - -# tcp or unix -SERVE = tcp - -# only effective when SERVE = tcp -HOST = localhost - -# only effective when SERVE = tcp -PORT = 8082 - -# only effective when SERVE = unix -UNIXPATH = c2ec.sock - -# only effective when SERVE = unix -UNIXPATH_MODE = 660 - -# how shall the application behave if -# an attestor is not configured? -# forced when PROD = true -FAIL_ON_MISSING_ATTESTORS = false - -# This exchange will be sent to the wallet in order -# to allow the withdrawal through it. -# MAKE SURE TO DEFINE THE URL IN CANONICAL FORM (ending with /) -# withdrawals will not work otherwise -EXCHANGE_BASE_URL = "http://exchange.test.net/" - -# The account where the exchange receives payments -# of the providers. Must be the same, in the providers -# backend. -EXCHANGE_ACCOUNT = payto://iban/CH50030202099498 - -# The currency supported by this C2EC instance -# The terminals must accept payments in this currency -# and the Exchange creating the reserve must create -# reserves with the specified currency. -CURRENCY = CHF - -# How many digits does the currency use by default on displays. -# Hint provided to wallets. Should be 2 for EUR/USD/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 - -# How long shall the confirmations retry be delayed in milliseconds at max? -# When the delay backoff algorithm results in a higher value, this value -# is set as delay before retrying. -RETRY_DELAY_MS = 1000 - -[wire-gateway] - -USERNAME = wire - -PASSWORD = secret - -[database] - -CONFIG = postgres:///c2ec - -# each provider gets its own provider section. -# the section name should be like 'provider-[provider_name]' -[provider-wallee] - -# the name of the provider must match the name of the provider in -# the database. -NAME = Wallee - -# The secret must be capable of accessing the credentials, stored -# during the registration of the provider using the cli. -KEY = secret - -[provider-simulation] - -NAME = Simulation -KEY = secret - -# [provider-xyz] -# -# NAME = xyz -# KEY = secret diff --git a/c2ec/c2ec-config.yaml b/c2ec/c2ec-config.yaml @@ -1,30 +0,0 @@ -c2ec: - prod: false - host: "localhost" - port: 8082 - unix-domain-socket: false - unix-socket-path: "c2ec.sock" - unix-path-mode: 660 - fail-on-missing-attestors: false # forced if prod=true - exchange-base-url: "http://exchange.test.net/" # MAKE SURE TO DEFINE THE URL IN CANONICAL FORM (ending with /) - 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.05" - max-retries: 100 - retry-delay-ms: 1000 - wire-gateway: - username: "wire" - password: "secret" -db: - connstr: "" - host: "localhost" - port: 5432 - username: "local" - password: "local" - database: "postgres" -providers: - - name: "Simulation" - key: "secret" - # - name: "Wallee" - # key: "secret" diff --git a/c2ec/codec.go b/c2ec/codec.go @@ -1,75 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "bytes" - "encoding/json" - "io" -) - -type Codec[T any] interface { - HttpApplicationContentHeader() string - Encode(*T) (io.Reader, error) - EncodeToBytes(body *T) ([]byte, error) - Decode(io.Reader) (*T, error) -} - -type JsonCodec[T any] struct { - Codec[T] -} - -func NewJsonCodec[T any]() Codec[T] { - - return new(JsonCodec[T]) -} - -func (*JsonCodec[T]) HttpApplicationContentHeader() string { - return "application/json" -} - -func (*JsonCodec[T]) Encode(body *T) (io.Reader, error) { - - encodedBytes, err := json.Marshal(body) - if err != nil { - return nil, err - } - - return bytes.NewReader(encodedBytes), err -} - -func (c *JsonCodec[T]) EncodeToBytes(body *T) ([]byte, error) { - - reader, err := c.Encode(body) - if err != nil { - return make([]byte, 0), err - } - buf, err := io.ReadAll(reader) - if err != nil { - return make([]byte, 0), err - } - return buf, nil -} - -func (*JsonCodec[T]) Decode(reader io.Reader) (*T, error) { - - body := new(T) - err := json.NewDecoder(reader).Decode(body) - return body, err -} diff --git a/c2ec/codec_test.go b/c2ec/codec_test.go @@ -1,99 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "bytes" - "fmt" - - "testing" - - "gotest.tools/v3/assert" -) - -func TestJsonCodecRoundTrip(t *testing.T) { - - type TestStruct struct { - A string - B int - C []string - D byte - E []byte - F *TestStruct - } - - testObj := TestStruct{ - "TestA", - 1, - []string{"first", "second"}, - 'A', - []byte{0xdf, 0x01, 0x34}, - &TestStruct{ - "TestAA", - 2, - []string{"third", "fourth", "fifth"}, - 'B', - []byte{0xdf, 0x01, 0x34}, - nil, - }, - } - - jsonCodec := new(JsonCodec[TestStruct]) - - encodedTestObj, err := jsonCodec.Encode(&testObj) - if err != nil { - fmt.Println("error happened while encoding test obj", err.Error()) - t.FailNow() - } - - encodedTestObjBytes := make([]byte, 200) - _, err = encodedTestObj.Read(encodedTestObjBytes) - if err != nil { - fmt.Println("error happened while encoding test obj to byte array", err.Error()) - t.FailNow() - } - - encodedTestObjReader := bytes.NewReader(encodedTestObjBytes) - decodedTestObj, err := jsonCodec.Decode(encodedTestObjReader) - if err != nil { - fmt.Println("error happened while encoding test obj to byte array", err.Error()) - t.FailNow() - } - - assert.DeepEqual(t, &testObj, decodedTestObj) -} - -func TestTransferRequest(t *testing.T) { - - reqStr := "{\"request_uid\":\"test-1\",\"amount\":\"CHF:4.95\",\"exchange_base_url\":\"https://exchange.chf.taler.net\",\"wtid\":\"\",\"credit_account\":\"payto://wallee-transaction/R361ZT45TZ026EQ0S909C88F0E2YJY11HXV0VQTCHKR2VHA7DQCG\"}" - - fmt.Println("request string:", reqStr) - - codec := NewJsonCodec[TransferRequest]() - - rdr := bytes.NewReader([]byte(reqStr)) - - req, err := codec.Decode(rdr) - if err != nil { - fmt.Println("error:", err) - t.FailNow() - } - - fmt.Println(req) -} diff --git a/c2ec/config.go b/c2ec/config.go @@ -1,302 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "errors" - "os" - "strings" - - "gopkg.in/ini.v1" - "gopkg.in/yaml.v3" -) - -type C2ECConfig struct { - Server C2ECServerConfig `yaml:"c2ec"` - Database C2ECDatabseConfig `yaml:"db"` - Providers []C2ECProviderConfig `yaml:"providers"` -} - -type C2ECServerConfig struct { - IsProd bool `yaml:"prod"` - Host string `yaml:"host"` - Port int `yaml:"port"` - UseUnixDomainSocket bool `yaml:"unix-domain-socket"` - UnixSocketPath string `yaml:"unix-socket-path"` - UnixPathMode int `yaml:"unix-path-mode"` - StrictAttestors bool `yaml:"fail-on-missing-attestors"` - ExchangeBaseUrl string `yaml:"exchange-base-url"` - 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"` -} - -type C2ECWireGatewayConfig struct { - Username string `yaml:"username"` - Password string `yaml:"password"` -} - -type C2ECDatabseConfig struct { - ConnectionString string `yaml:"connstr"` - Host string `yaml:"host"` - Port int `yaml:"port"` - Username string `yaml:"username"` - Password string `yaml:"password"` - Database string `yaml:"database"` -} - -type C2ECProviderConfig struct { - Name string `yaml:"name"` - Key string `yaml:"key"` -} - -func Parse(path string) (*C2ECConfig, error) { - - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - - stat, err := f.Stat() - if err != nil { - return nil, err - } - - content := make([]byte, stat.Size()) - _, err = f.Read(content) - if err != nil { - return nil, err - } - - if strings.HasSuffix(path, ".yml") || strings.HasSuffix(path, ".yaml") { - cfg := new(C2ECConfig) - err = yaml.Unmarshal(content, cfg) - if err != nil { - return nil, err - } - return cfg, nil - } - - cfg, err := ParseIni(content) - if err != nil { - return nil, err - } - - 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") - } - - return cfg, nil -} - -func ConfigForProvider(name string) (*C2ECProviderConfig, error) { - - for _, provider := range CONFIG.Providers { - - if provider.Name == name { - return &provider, nil - } - } - return nil, errors.New("no such provider") -} - -func ParseIni(content []byte) (*C2ECConfig, error) { - - ini, err := ini.Load(content) - if err != nil { - return nil, err - } - - cfg := new(C2ECConfig) - for _, s := range ini.Sections() { - - if s.Name() == "c2ec" { - - value, err := s.GetKey("PROD") - if err != nil { - return nil, err - } - - cfg.Server.IsProd, err = value.Bool() - if err != nil { - return nil, err - } - - value, err = s.GetKey("SERVE") - if err != nil { - return nil, err - } - - str := value.String() - cfg.Server.UseUnixDomainSocket = str == "unix" - - value, err = s.GetKey("HOST") - if err != nil { - return nil, err - } - - cfg.Server.Host = value.String() - - value, err = s.GetKey("PORT") - if err != nil { - return nil, err - } - - cfg.Server.Port, err = value.Int() - if err != nil { - return nil, err - } - - value, err = s.GetKey("UNIXPATH") - if err != nil { - return nil, err - } - - cfg.Server.UnixSocketPath = value.String() - - value, err = s.GetKey("UNIXPATH_MODE") - if err != nil { - return nil, err - } - - cfg.Server.UnixSocketPath = value.String() - - value, err = s.GetKey("FAIL_ON_MISSING_ATTESTORS") - if err != nil { - return nil, err - } - cfg.Server.StrictAttestors, err = value.Bool() - if err != nil { - return nil, err - } - - value, err = s.GetKey("EXCHANGE_BASE_URL") - if err != nil { - return nil, err - } - cfg.Server.ExchangeBaseUrl = value.String() - - value, err = s.GetKey("EXCHANGE_ACCOUNT") - if err != nil { - return nil, err - } - cfg.Server.CreditAccount = value.String() - - value, err = s.GetKey("CURRENCY") - if err != nil { - return nil, err - } - cfg.Server.Currency = value.String() - - value, err = s.GetKey("CURRENCY_FRACTION_DIGITS") - if err != nil { - return nil, err - } - num, err := value.Int() - if err != nil { - return nil, err - } - 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 - } - - num, err = value.Int() - if err != nil { - return nil, err - } - cfg.Server.MaxRetries = int32(num) - - value, err = s.GetKey("RETRY_DELAY_MS") - if err != nil { - return nil, err - } - - cfg.Server.RetryDelayMs, err = value.Int() - if err != nil { - return nil, err - } - - } - - if s.Name() == "wire-gateway" { - - value, err := s.GetKey("USERNAME") - if err != nil { - return nil, err - } - cfg.Server.WireGateway.Username = value.String() - - value, err = s.GetKey("PASSWORD") - if err != nil { - return nil, err - } - cfg.Server.WireGateway.Password = value.String() - } - - if s.Name() == "database" { - - value, err := s.GetKey("CONFIG") - if err != nil { - return nil, err - } - - connstr := value.String() - - cfg.Database.ConnectionString = connstr - } - - if strings.HasPrefix(s.Name(), "provider-") { - - provider := C2ECProviderConfig{} - - value, err := s.GetKey("NAME") - if err != nil { - return nil, err - } - provider.Name = value.String() - - value, err = s.GetKey("KEY") - if err != nil { - return nil, err - } - provider.Key = value.String() - - cfg.Providers = append(cfg.Providers, provider) - } - } - return cfg, nil -} diff --git a/c2ec/configs/c2ec-config.conf b/c2ec/configs/c2ec-config.conf @@ -0,0 +1,96 @@ +[c2ec] + +# Used as redirect to sourcecode location on /agpl +SOURCE = https://git.taler.net/cashless2ecash.git/tree/c2ec + +# Will force production specific configuration +# for example the simulation terminal cannot be +# used in production +PROD = false + +# tcp or unix +SERVE = tcp + +# only effective when SERVE = tcp +HOST = localhost + +# only effective when SERVE = tcp +PORT = 8082 + +# only effective when SERVE = unix +UNIXPATH = c2ec.sock + +# only effective when SERVE = unix +UNIXPATH_MODE = 660 + +# how shall the application behave if +# an attestor is not configured? +# forced when PROD = true +FAIL_ON_MISSING_ATTESTORS = false + +# This exchange will be sent to the wallet in order +# to allow the withdrawal through it. +# MAKE SURE TO DEFINE THE URL IN CANONICAL FORM (ending with /) +# withdrawals will not work otherwise +EXCHANGE_BASE_URL = "http://exchange.test.net/" + +# The account where the exchange receives payments +# of the providers. Must be the same, in the providers +# backend. +EXCHANGE_ACCOUNT = payto://iban/CH50030202099498 + +# The currency supported by this C2EC instance +# The terminals must accept payments in this currency +# and the Exchange creating the reserve must create +# reserves with the specified currency. +CURRENCY = CHF + +# How many digits does the currency use by default on displays. +# Hint provided to wallets. Should be 2 for EUR/USD/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 + +# How long shall the confirmations retry be delayed in milliseconds at max? +# When the delay backoff algorithm results in a higher value, this value +# is set as delay before retrying. +RETRY_DELAY_MS = 1000 + +[wire-gateway] + +USERNAME = wire + +PASSWORD = secret + +[database] + +CONFIG = postgres:///c2ec + +# each provider gets its own provider section. +# the section name should be like 'provider-[provider_name]' +[provider-wallee] + +# the name of the provider must match the name of the provider in +# the database. +NAME = Wallee + +# The secret must be capable of accessing the credentials, stored +# during the registration of the provider using the cli. +KEY = secret + +[provider-simulation] + +NAME = Simulation +KEY = secret + +# [provider-xyz] +# +# NAME = xyz +# KEY = secret diff --git a/c2ec/configs/c2ec-config.yaml b/c2ec/configs/c2ec-config.yaml @@ -0,0 +1,31 @@ +c2ec: + source: "https://git.taler.net/cashless2ecash.git/tree/c2ec" + prod: false + host: "localhost" + port: 8082 + unix-domain-socket: false + unix-socket-path: "c2ec.sock" + unix-path-mode: 660 + fail-on-missing-attestors: false # forced if prod=true + exchange-base-url: "http://exchange.test.net/" # MAKE SURE TO DEFINE THE URL IN CANONICAL FORM (ending with /) + 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.05" + max-retries: 100 + retry-delay-ms: 1000 + wire-gateway: + username: "wire" + password: "secret" +db: + connstr: "" + host: "localhost" + port: 5432 + username: "local" + password: "local" + database: "c2ec" +providers: + - name: "Simulation" + key: "secret" + # - name: "Wallee" + # key: "secret" diff --git a/c2ec/db-postgres.go b/c2ec/db-postgres.go @@ -1,959 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "context" - "errors" - "fmt" - "math" - "os" - "strconv" - "strings" - "time" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/jackc/pgxlisten" -) - -const PS_INSERT_WITHDRAWAL = "INSERT INTO " + WITHDRAWAL_TABLE_NAME + " (" + - WITHDRAWAL_FIELD_NAME_WOPID + ", " + WITHDRAWAL_FIELD_NAME_RUID + ", " + - WITHDRAWAL_FIELD_NAME_SUGGESTED_AMOUNT + ", " + WITHDRAWAL_FIELD_NAME_AMOUNT + ", " + - WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + ", " + WITHDRAWAL_FIELD_NAME_FEES + ", " + - WITHDRAWAL_FIELD_NAME_TS + ", " + WITHDRAWAL_FIELD_NAME_TERMINAL_ID + - ") VALUES ($1,$2,($3,$4,$5),($6,$7,$8),$9,($10,$11,$12),$13,$14)" - -const PS_REGISTER_WITHDRAWAL_PARAMS = "UPDATE " + WITHDRAWAL_TABLE_NAME + " SET (" + - WITHDRAWAL_FIELD_NAME_RESPUBKEY + "," + - WITHDRAWAL_FIELD_NAME_STATUS + "," + - WITHDRAWAL_FIELD_NAME_TS + ")" + - " = ($1,$2,$3)" + - " WHERE " + WITHDRAWAL_FIELD_NAME_WOPID + "=$4" - -const PS_GET_UNCONFIRMED_WITHDRAWALS = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME + - " WHERE " + WITHDRAWAL_FIELD_NAME_STATUS + " = '" + string(SELECTED) + "'" - -const PS_PAYMENT_NOTIFICATION = "UPDATE " + WITHDRAWAL_TABLE_NAME + " SET (" + - WITHDRAWAL_FIELD_NAME_FEES + "," + WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + "," + - WITHDRAWAL_FIELD_NAME_TERMINAL_ID + ")" + - " = (($1,$2,$3),$4,$5)" + - " WHERE " + WITHDRAWAL_FIELD_NAME_WOPID + "=$6" - -const PS_FINALISE_PAYMENT = "UPDATE " + WITHDRAWAL_TABLE_NAME + " SET (" + - WITHDRAWAL_FIELD_NAME_STATUS + "," + - WITHDRAWAL_FIELD_NAME_COMPLETION_PROOF + "," + - WITHDRAWAL_FIELD_NAME_CONFIRMED_ROW_ID + ")" + - " = ($1, $2, (SELECT ((SELECT MAX(" + WITHDRAWAL_FIELD_NAME_CONFIRMED_ROW_ID + ") FROM " + WITHDRAWAL_TABLE_NAME + " WHERE " + WITHDRAWAL_FIELD_NAME_STATUS + "='" + string(CONFIRMED) + "')+1)))" + - " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$3" - -const PS_SET_LAST_RETRY = "UPDATE " + WITHDRAWAL_TABLE_NAME + - " SET " + WITHDRAWAL_FIELD_NAME_LAST_RETRY + "=$1" + - " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$2" - -const PS_SET_RETRY_COUNTER = "UPDATE " + WITHDRAWAL_TABLE_NAME + - " SET " + WITHDRAWAL_FIELD_NAME_RETRY_COUNTER + "=$1" + - " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$2" - -const PS_GET_WITHDRAWAL_BY_RUID = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME + - " WHERE " + WITHDRAWAL_FIELD_NAME_RUID + "=$1" - -const PS_GET_WITHDRAWAL_BY_ID = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME + - " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$1" - -const PS_GET_WITHDRAWAL_BY_WOPID = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME + - " WHERE " + WITHDRAWAL_FIELD_NAME_WOPID + "=$1" - -const PS_GET_WITHDRAWAL_BY_PTID = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME + - " WHERE " + WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + "=$1" - -const PS_GET_PROVIDER_BY_TERMINAL = "SELECT * FROM " + PROVIDER_TABLE_NAME + - " WHERE " + PROVIDER_FIELD_NAME_ID + - " = (SELECT " + TERMINAL_FIELD_NAME_PROVIDER_ID + " FROM " + TERMINAL_TABLE_NAME + - " WHERE " + TERMINAL_FIELD_NAME_ID + "=$1)" - -const PS_GET_PROVIDER_BY_NAME = "SELECT * FROM " + PROVIDER_TABLE_NAME + - " WHERE " + PROVIDER_FIELD_NAME_NAME + "=$1" - -const PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE = "SELECT * FROM " + PROVIDER_TABLE_NAME + - " WHERE " + PROVIDER_FIELD_NAME_PAYTO_TARGET_TYPE + "=$1" - -const PS_GET_TERMINAL_BY_ID = "SELECT * FROM " + TERMINAL_TABLE_NAME + - " WHERE " + TERMINAL_FIELD_NAME_ID + "=$1" - -const PS_GET_TRANSFER_BY_ID = "SELECT * FROM " + TRANSFER_TABLE_NAME + - " WHERE " + TRANSFER_FIELD_NAME_ID + "=$1" - -const PS_GET_TRANSFER_BY_CREDIT_ACCOUNT = "SELECT * FROM " + TRANSFER_TABLE_NAME + - " WHERE " + TRANSFER_FIELD_NAME_CREDIT_ACCOUNT + "=$1" - -const PS_ADD_TRANSFER = "INSERT INTO " + TRANSFER_TABLE_NAME + - " (" + TRANSFER_FIELD_NAME_ID + ", " + TRANSFER_FIELD_NAME_AMOUNT + ", " + - TRANSFER_FIELD_NAME_EXCHANGE_BASE_URL + ", " + TRANSFER_FIELD_NAME_WTID + ", " + - TRANSFER_FIELD_NAME_CREDIT_ACCOUNT + ", " + TRANSFER_FIELD_NAME_TS + - ") VALUES ($1,$2,$3,$4,$5,$6)" - -const PS_UPDATE_TRANSFER = "UPDATE " + TRANSFER_TABLE_NAME + " SET (" + - TRANSFER_FIELD_NAME_TS + ", " + TRANSFER_FIELD_NAME_STATUS + ", " + - TRANSFER_FIELD_NAME_RETRIES + ", " + TRANSFER_FIELD_NAME_TRANSFERRED_ROW_ID + ") = ($1,$2,$3," + - "(SELECT ((SELECT MAX(" + TRANSFER_FIELD_NAME_TRANSFERRED_ROW_ID + ") FROM " + TRANSFER_TABLE_NAME + " WHERE " + TRANSFER_FIELD_NAME_STATUS + "=0)+1))" + - ") WHERE " + TRANSFER_FIELD_NAME_ID + "=$4" - -const PS_CONFIRMED_TRANSACTIONS_ASC = "SELECT * FROM c2ec.withdrawal WHERE confirmed_row_id > $1 ORDER BY confirmed_row_id ASC LIMIT $2" - -const PS_CONFIRMED_TRANSACTIONS_DESC = "SELECT * FROM c2ec.withdrawal WHERE confirmed_row_id < $1 ORDER BY confirmed_row_id DESC LIMIT $2" - -const PS_CONFIRMED_TRANSACTIONS_ASC_MAX = "SELECT * FROM c2ec.withdrawal WHERE confirmed_row_id > $1 ORDER BY confirmed_row_id ASC LIMIT $2" - -const PS_CONFIRMED_TRANSACTIONS_DESC_MAX = "SELECT * FROM c2ec.withdrawal WHERE confirmed_row_id < (SELECT MAX(confirmed_row_id) FROM c2ec.withdrawal) ORDER BY confirmed_row_id DESC LIMIT $1" - -const PS_GET_TRANSFERS_ASC = "SELECT * FROM c2ec.transfer WHERE transferred_row_id > $1 ORDER BY transferred_row_id ASC LIMIT $2" - -const PS_GET_TRANSFERS_DESC = "SELECT * FROM c2ec.transfer WHERE transferred_row_id < $1 ORDER BY transferred_row_id DESC LIMIT $2" - -const PS_GET_TRANSFERS_ASC_MAX = "SELECT * FROM c2ec.transfer WHERE transferred_row_id > $1 ORDER BY transferred_row_id ASC LIMIT $2" - -const PS_GET_TRANSFERS_DESC_MAX = "SELECT * FROM c2ec.transfer WHERE transferred_row_id < (SELECT MAX(transferred_row_id) FROM c2ec.transfer) ORDER BY transferred_row_id DESC LIMIT $1" - -const PS_GET_TRANSFERS_BY_STATUS = "SELECT * FROM " + TRANSFER_TABLE_NAME + - " WHERE " + TRANSFER_FIELD_NAME_STATUS + "=$1" - -// Postgres implementation of the C2ECDatabase -type C2ECPostgres struct { - C2ECDatabase - - ctx context.Context - pool *pgxpool.Pool -} - -func PostgresConnectionString(cfg *C2ECDatabseConfig) string { - - if cfg.ConnectionString != "" { - return cfg.ConnectionString - } - - pgHost := os.Getenv("PGHOST") - if pgHost != "" { - LogInfo("postgres", "pghost was set") - } else { - pgHost = cfg.Host - } - - pgPort := os.Getenv("PGPORT") - if pgPort != "" { - LogInfo("postgres", "pgport was set") - } else { - pgPort = strconv.Itoa(cfg.Port) - } - - pgUsername := os.Getenv("PGUSER") - if pgUsername != "" { - LogInfo("postgres", "pghost was set") - } else { - pgUsername = cfg.Username - } - - pgPassword := os.Getenv("PGPASSWORD") - if pgPassword != "" { - LogInfo("postgres", "pghost was set") - } else { - pgPassword = cfg.Password - } - - pgDb := os.Getenv("PGDATABASE") - if pgDb != "" { - LogInfo("postgres", "pghost was set") - } else { - pgDb = cfg.Database - } - - return fmt.Sprintf( - "postgres://%s:%s@%s:%s/%s", - pgUsername, - pgPassword, - pgHost, - pgPort, - pgDb, - ) -} - -func NewC2ECPostgres(cfg *C2ECDatabseConfig) (*C2ECPostgres, error) { - - ctx := context.Background() - db := new(C2ECPostgres) - - connectionString := PostgresConnectionString(cfg) - - dbConnCfg, err := pgxpool.ParseConfig(connectionString) - if err != nil { - panic(err.Error()) - } - dbConnCfg.AfterConnect = db.registerCustomTypesHook - db.pool, err = pgxpool.NewWithConfig(context.Background(), dbConnCfg) - if err != nil { - panic(err.Error()) - } - - db.ctx = ctx - - return db, nil -} - -func (db *C2ECPostgres) registerCustomTypesHook(ctx context.Context, conn *pgx.Conn) error { - - t, err := conn.LoadType(ctx, "c2ec.taler_amount_currency") - if err != nil { - return err - } - - conn.TypeMap().RegisterType(t) - return nil -} - -func (db *C2ECPostgres) SetupWithdrawal( - wopid []byte, - suggestedAmount Amount, - amount Amount, - terminalId int, - providerTransactionId string, - terminalFees Amount, - requestUid string, -) error { - - ts := time.Now() - res, err := db.pool.Exec( - db.ctx, - PS_INSERT_WITHDRAWAL, - wopid, - requestUid, - suggestedAmount.Value, - suggestedAmount.Fraction, - suggestedAmount.Currency, - amount.Value, - amount.Fraction, - amount.Currency, - providerTransactionId, - terminalFees.Value, - terminalFees.Fraction, - terminalFees.Currency, - ts.Unix(), - terminalId, - ) - if err != nil { - LogError("postgres", err) - return err - } - LogInfo("postgres", "query="+PS_INSERT_WITHDRAWAL) - LogInfo("postgres", "setup withdrawal successfully. affected rows="+strconv.Itoa(int(res.RowsAffected()))) - return nil -} - -func (db *C2ECPostgres) RegisterWithdrawalParameters( - wopid []byte, - resPubKey EddsaPublicKey, -) error { - - resPubKeyBytes, err := ParseEddsaPubKey(resPubKey) - if err != nil { - return err - } - - ts := time.Now() - res, err := db.pool.Exec( - db.ctx, - PS_REGISTER_WITHDRAWAL_PARAMS, - resPubKeyBytes, - SELECTED, - ts.Unix(), - wopid, - ) - if err != nil { - LogError("postgres", err) - return err - } - LogInfo("postgres", "query="+PS_REGISTER_WITHDRAWAL_PARAMS) - LogInfo("postgres", "registered withdrawal successfully. affected rows="+strconv.Itoa(int(res.RowsAffected()))) - return nil -} - -func (db *C2ECPostgres) GetWithdrawalByRequestUid(requestUid string) (*Withdrawal, error) { - - if row, err := db.pool.Query( - db.ctx, - PS_GET_WITHDRAWAL_BY_RUID, - requestUid, - ); err != nil { - LogError("postgres", err) - if row != nil { - row.Close() - } - return nil, err - } else { - defer row.Close() - LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_RUID) - collected, err := pgx.CollectOneRow(row, pgx.RowToAddrOfStructByName[Withdrawal]) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - return nil, err - } - return collected, nil - } -} - -func (db *C2ECPostgres) GetWithdrawalById(withdrawalId int) (*Withdrawal, error) { - - if row, err := db.pool.Query( - db.ctx, - PS_GET_WITHDRAWAL_BY_ID, - withdrawalId, - ); err != nil { - LogError("postgres", err) - if row != nil { - row.Close() - } - return nil, err - } else { - - defer row.Close() - LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_ID) - return pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Withdrawal]) - } -} - -func (db *C2ECPostgres) GetWithdrawalByWopid(wopid []byte) (*Withdrawal, error) { - - if row, err := db.pool.Query( - db.ctx, - PS_GET_WITHDRAWAL_BY_WOPID, - wopid, - ); err != nil { - LogError("postgres", err) - if row != nil { - row.Close() - } - return nil, err - } else { - - defer row.Close() - LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_WOPID) - return pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Withdrawal]) - } -} - -func (db *C2ECPostgres) GetWithdrawalByProviderTransactionId(tid string) (*Withdrawal, error) { - if row, err := db.pool.Query( - db.ctx, - PS_GET_WITHDRAWAL_BY_PTID, - tid, - ); err != nil { - LogInfo("postgres", "failed query="+PS_GET_WITHDRAWAL_BY_PTID) - LogError("postgres", err) - if row != nil { - row.Close() - } - return nil, err - } else { - - defer row.Close() - LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_PTID) - return pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Withdrawal]) - } -} - -func (db *C2ECPostgres) NotifyPayment( - wopid []byte, - providerTransactionId string, - terminalId int, - fees Amount, -) error { - - res, err := db.pool.Exec( - db.ctx, - PS_PAYMENT_NOTIFICATION, - fees.Value, - fees.Fraction, - fees.Currency, - providerTransactionId, - terminalId, - wopid, - ) - if err != nil { - LogError("postgres", err) - return err - } - LogInfo("postgres", "query="+PS_PAYMENT_NOTIFICATION+", affected rows="+strconv.Itoa(int(res.RowsAffected()))) - return nil -} - -func (db *C2ECPostgres) GetWithdrawalsForConfirmation() ([]*Withdrawal, error) { - - if row, err := db.pool.Query( - db.ctx, - PS_GET_UNCONFIRMED_WITHDRAWALS, - ); err != nil { - LogError("postgres", err) - if row != nil { - row.Close() - } - return nil, err - } else { - - defer row.Close() - - withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Withdrawal]) - if err != nil { - LogError("postgres", err) - return nil, err - } - - // potentially fills the logs - // LogInfo("postgres", "query="+PS_GET_UNCONFIRMED_WITHDRAWALS) - return removeNulls(withdrawals), nil - } -} - -func (db *C2ECPostgres) FinaliseWithdrawal( - withdrawalId int, - confirmOrAbort WithdrawalOperationStatus, - completionProof []byte, -) error { - - if confirmOrAbort != CONFIRMED && confirmOrAbort != ABORTED { - return errors.New("can only finalise payment when new status is either confirmed or aborted") - } - - query := PS_FINALISE_PAYMENT - if withdrawalId <= 1 { - // tweak to intially set confirmed_row_id. Can be removed once confirmed_row_id field is obsolete - query = "UPDATE c2ec.withdrawal SET (withdrawal_status,completion_proof,confirmed_row_id) = ($1,$2,1) WHERE withdrawal_row_id=$3" - } - - _, err := db.pool.Exec( - db.ctx, - query, - confirmOrAbort, - completionProof, - withdrawalId, - ) - if err != nil { - LogError("postgres", err) - return err - } - LogInfo("postgres", "query="+query) - return nil -} - -func (db *C2ECPostgres) SetLastRetry(withdrawalId int, lastRetryTsUnix int64) error { - - _, err := db.pool.Exec( - db.ctx, - PS_SET_LAST_RETRY, - lastRetryTsUnix, - withdrawalId, - ) - if err != nil { - LogError("postgres", err) - return err - } - LogInfo("postgres", "query="+PS_SET_LAST_RETRY) - return nil -} - -func (db *C2ECPostgres) SetRetryCounter(withdrawalId int, retryCounter int) error { - - _, err := db.pool.Exec( - db.ctx, - PS_SET_RETRY_COUNTER, - retryCounter, - withdrawalId, - ) - if err != nil { - LogError("postgres", err) - return err - } - LogInfo("postgres", "query="+PS_SET_RETRY_COUNTER) - return nil -} - -// The query at the postgres database works as specified by the -// wire gateway api. -func (db *C2ECPostgres) GetConfirmedWithdrawals(start int, delta int, since time.Time) ([]*Withdrawal, error) { - - // +d / +s - query := PS_CONFIRMED_TRANSACTIONS_ASC - if delta < 0 { - // d negatives indicates DESC ordering and backwards reading - // -d / +s - query = PS_CONFIRMED_TRANSACTIONS_DESC - if start < 0 { - // start negative indicates not explicitly given - // since -d is the case here we try reading the latest entries - // -d / -s - query = PS_CONFIRMED_TRANSACTIONS_DESC_MAX - } - } else { - if start < 0 { - // +d / -s - query = PS_CONFIRMED_TRANSACTIONS_ASC_MAX - } - } - - limit := int(math.Abs(float64(delta))) - offset := start - if offset < 0 { - offset = 0 - } - - if start < 0 { - start = 0 - } - - LogInfo("postgres", fmt.Sprintf("selected query=%s (\nparameters:\n delta=%d,\n start=%d, limit=%d,\n offset=%d,\n since=%d\n)", query, delta, start, limit, offset, since.Unix())) - - var row pgx.Rows - var err error - - if strings.Count(query, "$") == 1 { - row, err = db.pool.Query( - db.ctx, - query, - limit, - ) - } else { - row, err = db.pool.Query( - db.ctx, - query, - offset, - limit, - ) - } - - LogInfo("postgres", "query="+query) - if err != nil { - LogError("postgres", err) - if row != nil { - row.Close() - } - return nil, err - } else { - - defer row.Close() - - withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Withdrawal]) - if err != nil { - LogError("postgres", err) - return nil, err - } - - return removeNulls(withdrawals), nil - } -} - -func (db *C2ECPostgres) GetProviderByTerminal(terminalId int) (*Provider, error) { - - if row, err := db.pool.Query( - db.ctx, - PS_GET_PROVIDER_BY_TERMINAL, - terminalId, - ); err != nil { - LogWarn("postgres", "failed query="+PS_GET_PROVIDER_BY_TERMINAL) - LogError("postgres", err) - if row != nil { - row.Close() - } - return nil, err - } else { - - defer row.Close() - - provider, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Provider]) - if err != nil { - LogError("postgres", err) - return nil, err - } - - LogInfo("postgres", "query="+PS_GET_PROVIDER_BY_TERMINAL) - return provider, nil - } -} - -func (db *C2ECPostgres) GetTerminalProviderByName(name string) (*Provider, error) { - - if row, err := db.pool.Query( - db.ctx, - PS_GET_PROVIDER_BY_NAME, - name, - ); err != nil { - LogWarn("postgres", "failed query="+PS_GET_PROVIDER_BY_NAME) - LogError("postgres", err) - if row != nil { - row.Close() - } - return nil, err - } else { - - defer row.Close() - - provider, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Provider]) - if err != nil { - LogWarn("postgres", "failed query="+PS_GET_PROVIDER_BY_NAME) - LogError("postgres", err) - return nil, err - } - - LogInfo("postgres", "query="+PS_GET_PROVIDER_BY_NAME) - return provider, nil - } -} - -func (db *C2ECPostgres) GetTerminalProviderByPaytoTargetType(paytoTargetType string) (*Provider, error) { - - LogInfo("postgres", "loading provider for payto-target-type="+paytoTargetType) - if row, err := db.pool.Query( - db.ctx, - PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE, - paytoTargetType, - ); err != nil { - LogWarn("postgres", "failed query="+PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE) - LogError("postgres", err) - if row != nil { - row.Close() - } - return nil, err - } else { - - defer row.Close() - - provider, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Provider]) - if err != nil { - LogWarn("postgres", "failed query="+PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE) - LogError("postgres", err) - return nil, err - } - - LogInfo("postgres", "query="+PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE) - return provider, nil - } -} - -func (db *C2ECPostgres) GetTerminalById(id int) (*Terminal, error) { - - if row, err := db.pool.Query( - db.ctx, - PS_GET_TERMINAL_BY_ID, - id, - ); err != nil { - LogWarn("postgres", "failed query="+PS_GET_TERMINAL_BY_ID) - LogError("postgres", err) - if row != nil { - row.Close() - } - return nil, err - } else { - - defer row.Close() - - terminal, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Terminal]) - if err != nil { - LogWarn("postgres", "failed query="+PS_GET_TERMINAL_BY_ID) - LogError("postgres", err) - return nil, err - } - - LogInfo("postgres", "query="+PS_GET_TERMINAL_BY_ID) - return terminal, nil - } -} - -func (db *C2ECPostgres) GetTransferById(requestUid []byte) (*Transfer, error) { - - if rows, err := db.pool.Query( - db.ctx, - PS_GET_TRANSFER_BY_ID, - requestUid, - ); err != nil { - LogWarn("postgres", "failed query="+PS_GET_TRANSFER_BY_ID) - LogError("postgres", err) - if rows != nil { - rows.Close() - } - return nil, err - } else { - - defer rows.Close() - - transfer, err := pgx.CollectOneRow(rows, pgx.RowToAddrOfStructByName[Transfer]) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - LogError("postgres", err) - return nil, err - } - - LogInfo("postgres", "query="+PS_GET_TRANSFER_BY_ID) - return transfer, nil - } -} - -func (db *C2ECPostgres) GetTransfersByCreditAccount(creditAccount string) ([]*Transfer, error) { - - if rows, err := db.pool.Query( - db.ctx, - PS_GET_TRANSFER_BY_CREDIT_ACCOUNT, - creditAccount, - ); err != nil { - LogWarn("postgres", "failed query="+PS_GET_TRANSFER_BY_CREDIT_ACCOUNT) - LogError("postgres", err) - if rows != nil { - rows.Close() - } - return nil, err - } else { - - defer rows.Close() - - transfers, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Transfer]) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return make([]*Transfer, 0), nil - } - LogError("postgres", err) - return nil, err - } - - LogInfo("postgres", "query="+PS_GET_TRANSFER_BY_CREDIT_ACCOUNT) - return removeNulls(transfers), nil - } -} - -func (db *C2ECPostgres) AddTransfer( - requestUid []byte, - amount *Amount, - exchangeBaseUrl string, - wtid string, - credit_account string, - ts time.Time, -) error { - - dbAmount := TalerAmountCurrency{ - Val: int64(amount.Value), - Frac: int32(amount.Fraction), - Curr: amount.Currency, - } - - _, err := db.pool.Exec( - db.ctx, - PS_ADD_TRANSFER, - requestUid, - dbAmount, - exchangeBaseUrl, - wtid, - credit_account, - ts.Unix(), - ) - if err != nil { - LogInfo("postgres", "failed query="+PS_ADD_TRANSFER) - LogError("postgres", err) - return err - } - LogInfo("postgres", "query="+PS_ADD_TRANSFER) - return nil -} - -func (db *C2ECPostgres) UpdateTransfer( - rowId int, - requestUid []byte, - timestamp int64, - status int16, - retries int16, -) error { - - query := PS_UPDATE_TRANSFER - if rowId <= 1 { - // tweak to intially set transferred_row_id. Can be removed once transferred_row_id field is obsolete - query = "UPDATE c2ec.transfer SET (transfer_ts, transfer_status, retries, transferred_row_id) = ($1,$2,$3,1) WHERE request_uid=$4" - } - - _, err := db.pool.Exec( - db.ctx, - query, - timestamp, - status, - retries, - requestUid, - ) - if err != nil { - LogInfo("postgres", "failed query="+query) - LogError("postgres", err) - return err - } - LogInfo("postgres", "query="+query) - return nil -} - -func (db *C2ECPostgres) GetTransfers(start int, delta int, since time.Time) ([]*Transfer, error) { - - // +d / +s - query := PS_GET_TRANSFERS_ASC - if delta < 0 { - // d negatives indicates DESC ordering and backwards reading - // -d / +s - query = PS_GET_TRANSFERS_DESC - if start < 0 { - // start negative indicates not explicitly given - // since -d is the case here we try reading the latest entries - // -d / -s - query = PS_GET_TRANSFERS_DESC_MAX - } - } else { - if start < 0 { - // +d / -s - query = PS_GET_TRANSFERS_ASC_MAX - } - } - - limit := int(math.Abs(float64(delta))) - offset := start - if offset < 0 { - offset = 0 - } - - if start < 0 { - start = 0 - } - - LogInfo("postgres", fmt.Sprintf("selected query=%s (\nparameters:\n delta=%d,\n start=%d, limit=%d,\n offset=%d,\n since=%d\n)", query, delta, start, limit, offset, since.Unix())) - - var row pgx.Rows - var err error - - if strings.Count(query, "$") == 1 { - row, err = db.pool.Query( - db.ctx, - query, - limit, - ) - } else { - row, err = db.pool.Query( - db.ctx, - query, - offset, - limit, - ) - } - - if err != nil { - LogWarn("postgres", "failed query="+query) - LogError("postgres", err) - if row != nil { - row.Close() - } - return nil, err - } else { - - defer row.Close() - - transfers, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Transfer]) - if err != nil { - LogWarn("postgres", "failed query="+query) - LogError("postgres", err) - return nil, err - } - - return removeNulls(transfers), nil - } -} - -func (db *C2ECPostgres) GetTransfersByState(status int) ([]*Transfer, error) { - - if rows, err := db.pool.Query( - db.ctx, - PS_GET_TRANSFERS_BY_STATUS, - status, - ); err != nil { - LogError("postgres", err) - if rows != nil { - rows.Close() - } - return nil, err - } else { - - defer rows.Close() - - transfers, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Transfer]) - if err != nil { - LogWarn("postgres", "failed query="+PS_GET_TRANSFERS_BY_STATUS) - LogError("postgres", err) - return nil, err - } - - // this will fill up the logs... - // LogInfo("postgres", "query="+PS_GET_TRANSFERS_BY_STATUS) - // LogInfo("postgres", "size of transfer list="+strconv.Itoa(len(transfers))) - return removeNulls(transfers), nil - } -} - -// Sets up a a listener for the given channel. -// Notifications will be sent through the out channel. -func (db *C2ECPostgres) NewListener( - cn string, - out chan *Notification, -) (func(context.Context) error, error) { - - connectionString := PostgresConnectionString(&CONFIG.Database) - cfg, err := pgx.ParseConfig(connectionString) - if err != nil { - return nil, err - } - - listener := &pgxlisten.Listener{ - Connect: func(ctx context.Context) (*pgx.Conn, error) { - LogInfo("postgres", "listener connecting to the database") - return pgx.ConnectConfig(ctx, cfg) - }, - } - - LogInfo("postgres", "handling notifications on channel="+cn) - listener.Handle(cn, pgxlisten.HandlerFunc(func(ctx context.Context, notification *pgconn.Notification, conn *pgx.Conn) error { - LogInfo("postgres", fmt.Sprintf("handling postgres notification. channel=%s", notification.Channel)) - out <- &Notification{ - Channel: notification.Channel, - Payload: notification.Payload, - } - return nil - })) - - return listener.Listen, nil -} - -func removeNulls[T any](l []*T) []*T { - - withoutNulls := make([]*T, 0) - for _, e := range l { - if e != nil { - withoutNulls = append(withoutNulls, e) - } - } - return withoutNulls -} diff --git a/c2ec/db.go b/c2ec/db.go @@ -1,272 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "context" - "time" -) - -const PROVIDER_TABLE_NAME = "c2ec.provider" -const PROVIDER_FIELD_NAME_ID = "provider_id" -const PROVIDER_FIELD_NAME_NAME = "name" -const PROVIDER_FIELD_NAME_PAYTO_TARGET_TYPE = "payto_target_type" -const PROVIDER_FIELD_NAME_BACKEND_URL = "backend_base_url" -const PROVIDER_FIELD_NAME_BACKEND_CREDENTIALS = "backend_credentials" - -const TERMINAL_TABLE_NAME = "c2ec.terminal" -const TERMINAL_FIELD_NAME_ID = "terminal_id" -const TERMINAL_FIELD_NAME_ACCESS_TOKEN = "access_token" -const TERMINAL_FIELD_NAME_ACTIVE = "active" -const TERMINAL_FIELD_NAME_DESCRIPTION = "description" -const TERMINAL_FIELD_NAME_PROVIDER_ID = "provider_id" - -const WITHDRAWAL_TABLE_NAME = "c2ec.withdrawal" -const WITHDRAWAL_FIELD_NAME_ID = "withdrawal_row_id" -const WITHDRAWAL_FIELD_NAME_CONFIRMED_ROW_ID = "confirmed_row_id" -const WITHDRAWAL_FIELD_NAME_RUID = "request_uid" -const WITHDRAWAL_FIELD_NAME_WOPID = "wopid" -const WITHDRAWAL_FIELD_NAME_RESPUBKEY = "reserve_pub_key" -const WITHDRAWAL_FIELD_NAME_TS = "registration_ts" -const WITHDRAWAL_FIELD_NAME_AMOUNT = "amount" -const WITHDRAWAL_FIELD_NAME_SUGGESTED_AMOUNT = "suggested_amount" -const WITHDRAWAL_FIELD_NAME_FEES = "terminal_fees" -const WITHDRAWAL_FIELD_NAME_STATUS = "withdrawal_status" -const WITHDRAWAL_FIELD_NAME_TERMINAL_ID = "terminal_id" -const WITHDRAWAL_FIELD_NAME_TRANSACTION_ID = "provider_transaction_id" -const WITHDRAWAL_FIELD_NAME_LAST_RETRY = "last_retry_ts" -const WITHDRAWAL_FIELD_NAME_RETRY_COUNTER = "retry_counter" -const WITHDRAWAL_FIELD_NAME_COMPLETION_PROOF = "completion_proof" - -const TRANSFER_TABLE_NAME = "c2ec.transfer" -const TRANSFER_FIELD_NAME_ID = "request_uid" -const TRANSFER_FIELD_NAME_ROW_ID = "row_id" -const TRANSFER_FIELD_NAME_TRANSFERRED_ROW_ID = "transferred_row_id" -const TRANSFER_FIELD_NAME_AMOUNT = "amount" -const TRANSFER_FIELD_NAME_EXCHANGE_BASE_URL = "exchange_base_url" -const TRANSFER_FIELD_NAME_WTID = "wtid" -const TRANSFER_FIELD_NAME_CREDIT_ACCOUNT = "credit_account" -const TRANSFER_FIELD_NAME_TS = "transfer_ts" -const TRANSFER_FIELD_NAME_STATUS = "transfer_status" -const TRANSFER_FIELD_NAME_RETRIES = "retries" - -type Provider struct { - ProviderId int64 `db:"provider_id"` - Name string `db:"name"` - PaytoTargetType string `db:"payto_target_type"` - BackendBaseURL string `db:"backend_base_url"` - BackendCredentials string `db:"backend_credentials"` -} - -type Terminal struct { - TerminalId int64 `db:"terminal_id"` - AccessToken string `db:"access_token"` - Active bool `db:"active"` - Description string `db:"description"` - ProviderId int64 `db:"provider_id"` -} - -type Withdrawal struct { - WithdrawalRowId uint64 `db:"withdrawal_row_id"` - ConfirmedRowId *uint64 `db:"confirmed_row_id"` - RequestUid string `db:"request_uid"` - Wopid []byte `db:"wopid"` - ReservePubKey []byte `db:"reserve_pub_key"` - RegistrationTs int64 `db:"registration_ts"` - Amount *TalerAmountCurrency `db:"amount" scan:"follow"` - SuggestedAmount *TalerAmountCurrency `db:"suggested_amount" scan:"follow"` - TerminalFees *TalerAmountCurrency `db:"terminal_fees" scan:"follow"` - WithdrawalStatus WithdrawalOperationStatus `db:"withdrawal_status"` - TerminalId int `db:"terminal_id"` - ProviderTransactionId *string `db:"provider_transaction_id"` - LastRetryTs *int64 `db:"last_retry_ts"` - RetryCounter int32 `db:"retry_counter"` - CompletionProof []byte `db:"completion_proof"` -} - -type TalerAmountCurrency struct { - Val int64 `db:"val"` - Frac int32 `db:"frac"` - Curr string `db:"curr"` -} - -type Transfer struct { - RowId int `db:"row_id"` - TransferredRowId *int `db:"transferred_row_id"` - RequestUid []byte `db:"request_uid"` - Amount *TalerAmountCurrency `db:"amount"` - ExchangeBaseUrl string `db:"exchange_base_url"` - Wtid string `db:"wtid"` - CreditAccount string `db:"credit_account"` - TransferTs int64 `db:"transfer_ts"` - Status int16 `db:"transfer_status"` - Retries int16 `db:"retries"` -} - -type Notification struct { - Channel string - Payload string -} - -// C2ECDatabase defines the operations which a -// C2EC compliant database interface must implement -// in order to be bound to the c2ec API. -type C2ECDatabase interface { - // A terminal sets up a withdrawal - // with this query. - // This initiates the withdrawal. - SetupWithdrawal( - wopid []byte, - suggestedAmount Amount, - amount Amount, - terminalId int, - providerTransactionId string, - terminalFees Amount, - requestUid string, - ) error - - // Registers a reserve public key - // belonging to the respective wopid. - RegisterWithdrawalParameters( - wopid []byte, - resPubKey EddsaPublicKey, - ) error - - // Get the withdrawal associated with the given request uid. - GetWithdrawalByRequestUid(requestUid string) (*Withdrawal, error) - - // Get the withdrawal associated with the given withdrawal identifier. - GetWithdrawalById(withdrawalId int) (*Withdrawal, error) - - // Get the withdrawal associated with the given wopid. - GetWithdrawalByWopid(wopid []byte) (*Withdrawal, error) - - // Get the withdrawal associated with the provider specific transaction id. - GetWithdrawalByProviderTransactionId(tid string) (*Withdrawal, error) - - // When the terminal receives the notification of the - // Provider, that the payment went through, this will - // save the provider specific transaction id in the database - NotifyPayment( - wopid []byte, - providerTransactionId string, - terminalId int, - fees Amount, - ) error - - // Returns all withdrawals which can be attested by - // a provider backend. This means that the provider - // specific transaction id was set and the status is - // 'selected'. The attestor can then attest and finalise - // the payments. - GetWithdrawalsForConfirmation() ([]*Withdrawal, error) - - // When an confirmation (or fail message) could be - // retrieved by the provider, the withdrawal can - // be finalised storing the correct final state - // and the proof of completion of the provider. - FinaliseWithdrawal( - withdrawalId int, - confirmOrAbort WithdrawalOperationStatus, - completionProof []byte, - ) error - - // Set retry will set the last_retry_ts field - // on the database. A trigger will then start - // the retry process. The timestamp must be a - // unix timestamp - SetLastRetry(withdrawalId int, lastRetryTsUnix int64) error - - // Sets the retry counter for the given withdrawal. - SetRetryCounter(withdrawalId int, retryCounter int) error - - // The wire gateway allows the exchange to retrieve transactions - // starting at a certain starting point up until a certain delta - // if the delta is negative, previous transactions relative to the - // starting point are considered. When start is negative, the latest - // id shall be used as starting point. - GetConfirmedWithdrawals(start int, delta int, since time.Time) ([]*Withdrawal, error) - - // Get the provider of a terminal by the terminals id - GetProviderByTerminal(terminalId int) (*Provider, error) - - // Get a provider entry by its name - GetTerminalProviderByName(name string) (*Provider, error) - - // Get a provider entry by its name - GetTerminalProviderByPaytoTargetType(paytoTargetType string) (*Provider, error) - - // Get a terminal entry by its identifier - GetTerminalById(id int) (*Terminal, error) - - // Returns the transfer for the given hashcode. - GetTransferById(requestUid []byte) (*Transfer, error) - - // Inserts a new transfer into the database. - AddTransfer( - requestUid []byte, - amount *Amount, - exchangeBaseUrl string, - wtid string, - credit_account string, - ts time.Time, - ) error - - // Updates the transfer, if retries is changed, the transfer will be - // triggered again. - UpdateTransfer( - rowId int, - requestUid []byte, - timestamp int64, - status int16, - retries int16, - ) error - - // The wire gateway allows the exchange to retrieve transactions - // starting at a certain starting point up until a certain delta - // if the delta is negative, previous transactions relative to the - // starting point are considered. When start is negative, the latest - // id shall be used as starting point. - GetTransfers(start int, delta int, since time.Time) ([]*Transfer, error) - - // Load all transfers asscociated with the same credit_account. - // The query is used to control that the current limitation of - // only allowing full refunds (partial refunds are currently not supported) - // is not harmed. It is assumed that the credit_account is unique, which currently - // is the case, because it depends on the WOPID of the respective - // withdrawal. This query is part of the limitation to only allow - // full refunds and not partial refunds. It might be possible to - // remove this API when partial refunds are implemented. - GetTransfersByCreditAccount(creditAccount string) ([]*Transfer, error) - - // Returns the transfer entries in the given state. - // This can be used for retry operations. - GetTransfersByState(status int) ([]*Transfer, error) - - // A listener can listen for notifications ont the specified - // channel. Returns a listen function, which must be called - // by the caller to start listening on the channel. The returned - // listen function will return an error if it fails, and takes - // a context as argument which allows the underneath implementation - // to control the execution context of the listener. - NewListener( - channel string, - out chan *Notification, - ) (func(context.Context) error, error) -} diff --git a/c2ec/encoding.go b/c2ec/encoding.go @@ -1,156 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "errors" - "math" - "strings" -) - -func talerBinaryEncode(byts []byte) string { - - return encodeCrock(byts) -} - -func talerBinaryDecode(str string) ([]byte, error) { - - return decodeCrock(str) -} - -func ParseWopid(wopid string) ([]byte, error) { - - wopidBytes, err := talerBinaryDecode(wopid) - if err != nil { - return nil, err - } - - if len(wopidBytes) != 32 { - err = errors.New("invalid wopid") - LogError("encoding", err) - return nil, err - } - - return wopidBytes, nil -} - -func FormatWopid(wopid []byte) string { - - return talerBinaryEncode(wopid) -} - -func ParseEddsaPubKey(key EddsaPublicKey) ([]byte, error) { - - return talerBinaryDecode(string(key)) -} - -func FormatEddsaPubKey(key []byte) EddsaPublicKey { - - return EddsaPublicKey(talerBinaryEncode(key)) -} - -func decodeCrock(e string) ([]byte, error) { - size := len(e) - bitpos := 0 - bitbuf := 0 - readPosition := 0 - outLen := int(math.Floor((float64(size) * 5.0) / 8.0)) - out := make([]byte, outLen) - outPos := 0 - - getValue := func(c byte) (int, error) { - alphabet := "0123456789ABCDEFGHJKMNPQRSTVWXYZ" - switch c { - case 'o', 'O': - return 0, nil - case 'i', 'I', 'l', 'L': - return 1, nil - case 'u', 'U': - return 27, nil - } - - i := strings.IndexRune(alphabet, rune(c)) - if i > -1 && i < 32 { - return i, nil - } - - return -1, errors.New("crockford decoding error") - } - - for readPosition < size || bitpos > 0 { - if readPosition < size { - v, err := getValue(e[readPosition]) - if err != nil { - return nil, err - } - readPosition++ - bitbuf = bitbuf<<5 | v - bitpos += 5 - } - for bitpos >= 8 { - d := byte(bitbuf >> (bitpos - 8) & 0xff) - out[outPos] = d - outPos++ - bitpos -= 8 - } - if readPosition == size && bitpos > 0 { - bitbuf = bitbuf << (8 - bitpos) & 0xff - if bitbuf == 0 { - bitpos = 0 - } else { - bitpos = 8 - } - } - } - return out, nil -} - -func encodeCrock(data []byte) string { - out := "" - bitbuf := 0 - bitpos := 0 - - encodeValue := func(value int) byte { - alphabet := "ABCDEFGHJKMNPQRSTVWXYZ" - switch { - case value >= 0 && value <= 9: - return byte('0' + value) - case value >= 10 && value <= 31: - return alphabet[value-10] - default: - panic("Invalid value for encoding") - } - } - - for _, b := range data { - bitbuf = bitbuf<<8 | int(b&0xff) - bitpos += 8 - for bitpos >= 5 { - value := bitbuf >> (bitpos - 5) & 0x1f - out += string(encodeValue(value)) - bitpos -= 5 - } - } - if bitpos > 0 { - bitbuf = bitbuf << (5 - bitpos) - value := bitbuf & 0x1f - out += string(encodeValue(value)) - } - return out -} diff --git a/c2ec/encoding_test.go b/c2ec/encoding_test.go @@ -1,152 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "crypto/rand" - "testing" -) - -func TestWopidEncodeDecode(t *testing.T) { - - wopid := make([]byte, 32) - n, err := rand.Read(wopid) - if err != nil || n != 32 { - t.Log("failed because retrieving random 32 bytes failed") - t.FailNow() - } - - encodedWopid := FormatWopid(wopid) - t.Log("encoded wopid:", encodedWopid) - decodedWopid, err := ParseWopid(encodedWopid) - if err != nil { - t.Error(err) - t.FailNow() - } - - if len(decodedWopid) != len(wopid) { - t.Log("uneven length.", len(decodedWopid), "!=", len(wopid)) - t.FailNow() - } - - for i, b := range wopid { - - if b != decodedWopid[i] { - t.Log("unequal at position", i) - t.FailNow() - } - } -} - -func TestTalerBase32(t *testing.T) { - - input := []byte("This is some text") - t.Log("in:", string(input)) - t.Log("in:", input) - encoded := talerBinaryEncode(input) - t.Log("encoded:", encoded) - out, err := talerBinaryDecode(encoded) - if err != nil { - t.Error(err) - t.FailNow() - } - t.Log("decoded:", out) - t.Log("decoded:", string(out)) - - if len(out) != len(input) { - t.Log("uneven length.", len(out), "!=", len(input)) - t.FailNow() - } - - for i, b := range input { - - if b != out[i] { - t.Log("unequal at position", i) - t.FailNow() - } - } -} - -func TestTalerBase32Rand32(t *testing.T) { - - input := make([]byte, 32) - n, err := rand.Read(input) - if err != nil || n != 32 { - t.Log("failed because retrieving random 32 bytes failed") - t.FailNow() - } - - t.Log("in:", input) - encoded := talerBinaryEncode(input) - t.Log("encoded:", encoded) - out, err := talerBinaryDecode(encoded) - if err != nil { - t.Error(err) - t.FailNow() - } - t.Log("decoded:", out) - t.Log("decoded:", string(out)) - - if len(out) != len(input) { - t.Log("uneven length.", len(out), "!=", len(input)) - t.FailNow() - } - - for i, b := range input { - - if b != out[i] { - t.Log("unequal at position", i) - t.FailNow() - } - } -} - -func TestTalerBase32Rand64(t *testing.T) { - - input := make([]byte, 64) - n, err := rand.Read(input) - if err != nil || n != 64 { - t.Log("failed because retrieving random 64 bytes failed") - t.FailNow() - } - - t.Log("in:", input) - encoded := talerBinaryEncode(input) - t.Log("encoded:", encoded) - out, err := talerBinaryDecode(encoded) - if err != nil { - t.Error(err) - t.FailNow() - } - t.Log("decoded:", out) - t.Log("decoded:", string(out)) - - if len(out) != len(input) { - t.Log("uneven length.", len(out), "!=", len(input)) - t.FailNow() - } - - for i, b := range input { - - if b != out[i] { - t.Log("unequal at position", i) - t.FailNow() - } - } -} diff --git a/c2ec/exponential-backoff.go b/c2ec/exponential-backoff.go @@ -1,95 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "crypto/rand" - "fmt" - "math" - "math/big" - "time" -) - -const EXPONENTIAL_BACKOFF_BASE = 2 - -const RANDOMIZATION_THRESHOLD_FACTOR = 0.2 // +/- 20% - -/* -Generic implementation of a limited exponential backoff -algorithm. It includes a randomization to prevent -self-synchronization issues. - -Parameters: - - - lastExecution: time of the last execution - - retryCount : number of the retries - - limitMs : field shall be the maximal milliseconds to backoff before retry happens -*/ -func ShouldStartRetry( - lastExecution time.Time, - retryCount int, - limitMs int, -) bool { - - backoffMs := exponentialBackoffMs(retryCount) - randomizedBackoffSeconds := int64(limitMs) / 1000 - if backoffMs < int64(limitMs) { - randomizedBackoffSeconds = randomizeBackoff(backoffMs) - } else { - LogInfo("exponential-backoff", fmt.Sprintf("backoff limit exceeded. setting manual limit: %d", limitMs)) - } - - now := time.Now().Unix() - backoffTime := lastExecution.Unix() + randomizedBackoffSeconds - // LogInfo("exponential-backoff", fmt.Sprintf("lastExec=%d, now=%d, backoffTime=%d, shouldStartRetry=%s", lastExecution.Unix(), now, backoffTime, strconv.FormatBool(now >= backoffTime))) - return now >= backoffTime -} - -func exponentialBackoffMs(retries int) int64 { - - return int64(math.Pow(EXPONENTIAL_BACKOFF_BASE, float64(retries))) -} - -func randomizeBackoff(backoff int64) int64 { - - // it's about randomizing on millisecond base... we mustn't care about rounding - threshold := int64(math.Floor(float64(backoff)*RANDOMIZATION_THRESHOLD_FACTOR)) + 1 // +1 to guarantee positive threshold - randomizedThreshold, err := rand.Int(rand.Reader, big.NewInt(backoff+threshold)) - if err != nil { - LogError("exponential-backoff", err) - } - subtract, err := rand.Int(rand.Reader, big.NewInt(100)) // upper boundary is exclusive (value is between 0 and 99) - if err != nil { - LogError("exponential-backoff", err) - } - - if !randomizedThreshold.IsInt64() { - LogWarn("exponential-backoff", "the threshold is not int64") - return backoff - } - - if subtract.Int64() < 50 { - subtracted := backoff - randomizedThreshold.Int64() - if subtracted < 0 { - return 0 - } - return subtracted - } - return backoff + randomizedThreshold.Int64() -} diff --git a/c2ec/exponential-backoff_test.go b/c2ec/exponential-backoff_test.go @@ -1,80 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "fmt" - "testing" - "time" -) - -func TestShouldRetryYes(t *testing.T) { - - lastExecution := time.Now().Add(-(time.Duration(10 * time.Second))) // 10 seconds ago - retries := 4 // no retries - limitMs := 1000 // second - - retry := ShouldStartRetry(lastExecution, retries, limitMs) - if !retry { - fmt.Println("expected retry = true but was false") - t.FailNow() - } -} - -func TestShouldRetryNo(t *testing.T) { - - lastExecution := time.Now().Add(-(time.Duration(10 * time.Second))) // 10 seconds ago - retries := 1 // three retries - limitMs := 1000 // second - - retry := ShouldStartRetry(lastExecution, retries, limitMs) - if retry { - fmt.Println("expected retry = false but was true") - t.FailNow() - } -} - -func TestBackoff(t *testing.T) { - - expectations := []int{1, 2, 4, 8, 16, 32, 64, 128, 256} - for i := range []int{0, 1, 2, 3, 4, 5, 6, 7, 8} { - backoff := exponentialBackoffMs(i) - if backoff != int64(expectations[i]) { - fmt.Printf("expected %d, but got %d", expectations[i], backoff) - t.FailNow() - } - } -} - -func TestRandomization(t *testing.T) { - - input := 100 - lowerBoundary := 80 // -20% - upperBoundary := 120 // +20% - rounds := 1000 - currentRound := 0 - for currentRound < rounds { - randomized := randomizeBackoff(int64(input)) - if randomized < int64(lowerBoundary) || randomized > int64(upperBoundary) { - fmt.Printf("round %d failed. Expected value between %d and %d but got %d", currentRound, lowerBoundary, upperBoundary, randomized) - t.FailNow() - } - currentRound++ - } -} diff --git a/c2ec/go.mod b/c2ec/go.mod @@ -3,6 +3,17 @@ module c2ec go 1.22.0 require ( + internal/api v1.0.0 + internal/postgres v1.0.0 + internal/proc v1.0.0 + internal/provider v1.0.0 + internal/utils v1.0.0 + + pkg/config v1.0.0 + pkg/db v1.0.0 + pkg/encoding v1.0.0 + pkg/provider v1.0.0 + github.com/jackc/pgx/v5 v5.5.5 github.com/jackc/pgxlisten v0.0.0-20230728233309-2632bad3185a golang.org/x/crypto v0.22.0 @@ -11,6 +22,19 @@ require ( gotest.tools/v3 v3.5.1 ) +replace ( + internal/api => ./internal/api + internal/postgres => ./internal/db/postgres + internal/proc => ./internal/proc + internal/provider => ./internal/provider + internal/utils => ./internal/utils + + pkg/config => ./pkg/config + pkg/db => ./pkg/db + pkg/encoding => ./pkg/encoding + pkg/provider => ./pkg/provider +) + require ( github.com/google/go-cmp v0.5.9 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/c2ec/http-util.go b/c2ec/http-util.go @@ -1,292 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "bytes" - "errors" - "fmt" - "io" - "net/http" - "strings" -) - -const HTTP_GET = "GET" -const HTTP_POST = "POST" - -const HTTP_OK = 200 -const HTTP_NO_CONTENT = 204 -const HTTP_BAD_REQUEST = 400 -const HTTP_UNAUTHORIZED = 401 -const HTTP_NOT_FOUND = 404 -const HTTP_METHOD_NOT_ALLOWED = 405 -const HTTP_CONFLICT = 409 -const HTTP_INTERNAL_SERVER_ERROR = 500 -const HTTP_NOT_IMPLEMENTED = 501 - -const CONTENT_TYPE_HEADER = "Content-Type" - -// Function reads and validates a param of a request in the -// correct format according to the transform function supplied. -// When the transform fails, it returns false as second return -// value. This indicates the caller, that the request shall not -// be further processed and the handle must be returned by the -// caller. Since the parameter is optional, it can be null, even -// if the boolean return value is set to true. -func AcceptOptionalParamOrWriteResponse[T any]( - name string, - transform func(s string) (T, error), - req *http.Request, - res http.ResponseWriter, -) (*T, bool) { - - ptr, err := OptionalQueryParamOrError(name, transform, req) - if err != nil { - setLastResponseCodeForLogger(HTTP_BAD_REQUEST) - res.WriteHeader(HTTP_BAD_REQUEST) - return nil, false - } - - if ptr == nil { - LogInfo("http", "optional parameter "+name+" was not set") - return nil, true - } - - obj := *ptr - assertedObj, ok := any(obj).(T) - if !ok { - // this should generally not happen (due to the implementation) - setLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) - res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) - return nil, false - } - return &assertedObj, true -} - -// The function parses a parameter of the query -// of the request. If the parameter is not present -// (empty string) it will not create an error and -// just return nil. -func OptionalQueryParamOrError[T any]( - name string, - transform func(s string) (T, error), - req *http.Request, -) (*T, error) { - - paramStr := req.URL.Query().Get(name) - if paramStr != "" { - - if t, err := transform(paramStr); err != nil { - return nil, err - } else { - return &t, nil - } - } - return nil, nil -} - -// Reads a generic argument struct from the requests -// body. It takes the codec as argument which is used to -// decode the struct from the request. If an error occurs -// nil and the error are returned. -func ReadStructFromBody[T any](req *http.Request, codec Codec[T]) (*T, error) { - - bodyBytes, err := ReadBody(req) - if err != nil { - return nil, err - } - - return codec.Decode(bytes.NewReader(bodyBytes)) -} - -// Reads the body of a request into a byte array. -// If the body is empty, an empty array is returned. -// If an error occurs while reading the body, nil and -// the respective error is returned. -func ReadBody(req *http.Request) ([]byte, error) { - - if req.ContentLength < 0 { - return nil, errors.New("malformed body") - } - - body, err := io.ReadAll(req.Body) - if err != nil { - LogError("http-util", err) - return nil, err - } - LogInfo("http-util", "read body from request. body="+string(body)) - return body, nil -} - -// Executes a GET request at the given url. -// Use FormatUrl for to build the url. -// Headers can be defined using the headers map. -func HttpGet[T any]( - url string, - headers map[string]string, - codec Codec[T], -) (*T, int, error) { - - req, err := http.NewRequest(HTTP_GET, url, bytes.NewBufferString("")) - if err != nil { - return nil, -1, err - } - - for k, v := range headers { - req.Header.Add(k, v) - } - req.Header.Add("Accept", codec.HttpApplicationContentHeader()) - - res, err := http.DefaultClient.Do(req) - if err != nil { - return nil, -1, err - } - - if codec == nil { - return nil, res.StatusCode, err - } else { - b, err := io.ReadAll(res.Body) - if err != nil { - LogError("http-util", err) - if res.StatusCode > 299 { - return nil, res.StatusCode, nil - } - return nil, -1, err - } - if res.StatusCode > 299 { - LogInfo("http-util", fmt.Sprintf("response: %s", string(b))) - return nil, res.StatusCode, nil - } - resBody, err := codec.Decode(bytes.NewReader(b)) - return resBody, res.StatusCode, err - } -} - -// execute a POST request and parse response or retrieve error -// path- and query-parameters can be set to add query and path parameters -func HttpPost[T any, R any]( - url string, - headers map[string]string, - body *T, - reqCodec Codec[T], - resCodec Codec[R], -) (*R, int, error) { - - bodyEncoded, err := reqCodec.EncodeToBytes(body) - if err != nil { - return nil, -1, err - } - LogInfo("http-util", string(bodyEncoded)) - - req, err := http.NewRequest(HTTP_POST, url, bytes.NewBuffer(bodyEncoded)) - if err != nil { - return nil, -1, err - } - - for k, v := range headers { - req.Header.Add(k, v) - } - if resCodec != nil { - req.Header.Add("Accept", resCodec.HttpApplicationContentHeader()) - } - req.Header.Add("Content-Type", reqCodec.HttpApplicationContentHeader()) - - res, err := http.DefaultClient.Do(req) - if err != nil { - return nil, -1, err - } - - if resCodec == nil { - return nil, res.StatusCode, err - } else { - b, err := io.ReadAll(res.Body) - if err != nil { - LogError("http-util", err) - if res.StatusCode > 299 { - return nil, res.StatusCode, nil - } - return nil, -1, err - } - if res.StatusCode > 299 { - LogInfo("http-util", fmt.Sprintf("response: %s", string(b))) - return nil, res.StatusCode, nil - } - resBody, err := resCodec.Decode(bytes.NewReader(b)) - return resBody, res.StatusCode, err - } -} - -// builds request URL containing the path and query -// parameters of the respective parameter map. -func FormatUrl( - req string, - pathParams map[string]string, - queryParams map[string]string, -) string { - - return setUrlQuery(setUrlPath(req, pathParams), queryParams) -} - -// Sets the parameters which are part of the url. -// The function expects each parameter in the path to be prefixed -// using a ':'. The function handles url as follows: -// -// /some/:param/tobereplaced -> ':param' will be replaced with value. -// -// For replacements, the pathParams map must be supplied. The map contains -// the name of the parameter with the value mapped to it. -// The names MUST not contain the prefix ':'! -func setUrlPath( - req string, - pathParams map[string]string, -) string { - - if pathParams == nil || len(pathParams) < 1 { - return req - } - - var url = req - for k, v := range pathParams { - - if !strings.HasPrefix(k, "/") { - // prevent scheme postfix replacements - url = strings.Replace(url, ":"+k, v, 1) - } - } - return url -} - -func setUrlQuery( - req string, - queryParams map[string]string, -) string { - - if queryParams == nil || len(queryParams) < 1 { - return req - } - - var url = req + "?" - for k, v := range queryParams { - - url = strings.Join([]string{url, k, "=", v, "&"}, "") - } - - url, _ = strings.CutSuffix(url, "&") - return url -} diff --git a/c2ec/http-util_test.go b/c2ec/http-util_test.go @@ -1,90 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "fmt" - "testing" -) - -const URL_GET = "https://jsonplaceholder.typicode.com/todos/:id" -const URL_POST = "https://jsonplaceholder.typicode.com/posts" - -type TestStruct struct { - UserId int `json:"userId"` - Id int `json:"id"` - Title string `json:"title"` - Completed bool `json:"completed"` -} - -func TestGET(t *testing.T) { - - url := FormatUrl( - URL_GET, - map[string]string{ - "id": "1", - }, - map[string]string{}, - ) - - codec := NewJsonCodec[TestStruct]() - res, status, err := HttpGet( - url, - map[string]string{}, - codec, - ) - - if err != nil { - t.Errorf("%s", err.Error()) - t.FailNow() - } - - fmt.Println("res:", res, ", status:", status) -} - -func TestPOST(t *testing.T) { - - url := FormatUrl( - URL_POST, - map[string]string{ - "id": "1", - }, - map[string]string{}, - ) - - res, status, err := HttpPost( - url, - map[string]string{}, - &TestStruct{ - UserId: 1, - Id: 1, - Title: "TEST", - Completed: false, - }, - NewJsonCodec[TestStruct](), - NewJsonCodec[TestStruct](), - ) - - if err != nil { - t.Errorf("%s", err.Error()) - t.FailNow() - } - - fmt.Println("res:", res, ", status:", status) -} diff --git a/c2ec/internal/api/api-agpl.go b/c2ec/internal/api/api-agpl.go @@ -0,0 +1,12 @@ +package internal_api + +import ( + "c2ec/pkg/config" + "net/http" +) + +func Agpl(res http.ResponseWriter, req *http.Request) { + + res.Header().Set("Location", config.CONFIG.Server.Source) + res.WriteHeader(301) +} diff --git a/c2ec/internal/api/api-auth.go b/c2ec/internal/api/api-auth.go @@ -0,0 +1,226 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_api + +import ( + internal_utils "c2ec/internal/utils" + "c2ec/pkg/config" + "c2ec/pkg/db" + "encoding/base64" + "errors" + "fmt" + "net/http" + "strconv" + "strings" +) + +const AUTHORIZATION_HEADER = "Authorization" +const BASIC_AUTH_PREFIX = "Basic " + +// Authenticates the Exchange against C2EC +// returns true if authentication was successful, otherwise false +// when not successful, the api shall return immediately +// The exchange is specified to use basic auth +func AuthenticateExchange(req *http.Request) bool { + + auth := req.Header.Get(AUTHORIZATION_HEADER) + if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found { + + ba := fmt.Sprintf("%s:%s", config.CONFIG.Server.WireGateway.Username, config.CONFIG.Server.WireGateway.Password) + encoded := base64.StdEncoding.EncodeToString([]byte(ba)) + return encoded == basicAuth + } + return false +} + +// Authenticates a terminal against C2EC +// returns true if authentication was successful, otherwise false +// when not successful, the api shall return immediately +// +// Terminals are authenticated using basic auth. +// The basic authorization header MUST be base64 encoded. +// The username part is the name of the provider (case sensitive) a '-' sign, followed +// by the id of the terminal, which is a number. +func AuthenticateTerminal(req *http.Request) bool { + + auth := req.Header.Get(AUTHORIZATION_HEADER) + if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found { + + decoded, err := base64.StdEncoding.DecodeString(basicAuth) + if err != nil { + internal_utils.LogWarn("auth", "failed decoding basic auth header from base64") + return false + } + + username, password, err := parseBasicAuth(string(decoded)) + if err != nil { + internal_utils.LogWarn("auth", "failed parsing username password from basic auth") + return false + } + + provider, terminalId, err := parseTerminalUser(username) + if err != nil { + internal_utils.LogWarn("auth", "failed parsing terminal from username in basic auth") + return false + } + internal_utils.LogInfo("auth", fmt.Sprintf("req=%s by terminal with id=%d, provider=%s", req.RequestURI, terminalId, provider)) + + terminal, err := db.DB.GetTerminalById(terminalId) + if err != nil { + return false + } + + if !terminal.Active { + internal_utils.LogWarn("auth", fmt.Sprintf("request from inactive terminal. id=%d", terminalId)) + return false + } + + prvdr, err := db.DB.GetTerminalProviderByName(provider) + if err != nil { + internal_utils.LogWarn("auth", fmt.Sprintf("failed requesting provider by name %s", err.Error())) + return false + } + + if terminal.ProviderId != prvdr.ProviderId { + internal_utils.LogWarn("auth", "terminal's provider id did not match provider id of supplied provider") + return false + } + + return internal_utils.ValidPassword(password, terminal.AccessToken) + } + internal_utils.LogWarn("auth", "basic auth prefix did not match") + return false +} + +func AuthenticateWirewatcher(req *http.Request) bool { + + auth := req.Header.Get(AUTHORIZATION_HEADER) + if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found { + + decoded, err := base64.StdEncoding.DecodeString(basicAuth) + if err != nil { + internal_utils.LogWarn("auth", "failed decoding basic auth header from base64") + return false + } + + username, password, err := parseBasicAuth(string(decoded)) + if err != nil { + internal_utils.LogWarn("auth", "failed parsing username password from basic auth") + return false + } + + if strings.EqualFold(username, config.CONFIG.Server.WireGateway.Username) && + strings.EqualFold(password, config.CONFIG.Server.WireGateway.Password) { + + return true + } + } else { + internal_utils.LogWarn("auth", "expecting exact 'Basic' prefix!") + } + internal_utils.LogWarn("auth", "basic auth prefix did not match") + return false +} + +func parseBasicAuth(basicAuth string) (string, string, error) { + + parts := strings.Split(basicAuth, ":") + if len(parts) != 2 { + return "", "", errors.New("malformed basic auth") + } + return parts[0], parts[1], nil +} + +// parses the username of the basic auth param of the terminal. +// the username has following format: +// +// [PROVIDER_NAME]-[TERMINAL_ID] +func parseTerminalUser(username string) (string, int, error) { + + parts := strings.Split(username, "-") + if len(parts) != 2 { + return "", -1, errors.New("malformed basic auth username") + } + + providerName := parts[0] + terminalId, err := strconv.Atoi(parts[1]) + if err != nil { + return "", -1, errors.New("malformed basic auth username") + } + + return providerName, terminalId, nil +} + +// Parses the terminal id from the token. +// This function is used to determine the terminal +// which orchestrates the withdrawal. +func parseTerminalId(req *http.Request) int { + auth := req.Header.Get(AUTHORIZATION_HEADER) + if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found { + + decoded, err := base64.StdEncoding.DecodeString(basicAuth) + if err != nil { + return -1 + } + + username, _, err := parseBasicAuth(string(decoded)) + if err != nil { + return -1 + } + + _, terminalId, err := parseTerminalUser(username) + if err != nil { + return -1 + } + + return terminalId + } + + return -1 +} + +func parseProvider(req *http.Request) (*db.Provider, error) { + + auth := req.Header.Get(AUTHORIZATION_HEADER) + if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found { + + decoded, err := base64.StdEncoding.DecodeString(basicAuth) + if err != nil { + return nil, err + } + + username, _, err := parseBasicAuth(string(decoded)) + if err != nil { + return nil, err + } + + providerName, _, err := parseTerminalUser(username) + if err != nil { + return nil, err + } + + p, err := db.DB.GetTerminalProviderByName(providerName) + if err != nil { + return nil, err + } + + return p, nil + } + + return nil, errors.New("authorization header did not match expectations") +} diff --git a/c2ec/internal/api/api-auth_test.go b/c2ec/internal/api/api-auth_test.go @@ -0,0 +1,66 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_api + +import ( + internal_utils "c2ec/internal/utils" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "testing" + + "golang.org/x/crypto/argon2" +) + +func TestValidPassword(t *testing.T) { + + pw := "verygoodpassword" + hashedEncodedPw, err := pbkdf(pw) + if err != nil { + fmt.Println("pbkdf failed") + t.FailNow() + } + + if !internal_utils.ValidPassword(pw, hashedEncodedPw) { + fmt.Println("password check failed") + t.FailNow() + } +} + +// copied from the cli tool. this function is used to obtain a base64 encoded password hash. +func pbkdf(pw string) (string, error) { + + rfcTime := 3 + rfcMemory := 32 * 1024 + salt := make([]byte, 16) + _, err := rand.Read(salt) + if err != nil { + return "", err + } + key := argon2.Key([]byte(pw), salt, uint32(rfcTime), uint32(rfcMemory), 4, 32) + + keyAndSalt := make([]byte, 0, 48) + keyAndSalt = append(keyAndSalt, key...) + keyAndSalt = append(keyAndSalt, salt...) + if len(keyAndSalt) != 48 { + return "", errors.New("invalid password hash and salt") + } + return base64.StdEncoding.EncodeToString(keyAndSalt), nil +} diff --git a/c2ec/internal/api/api-bank-integration.go b/c2ec/internal/api/api-bank-integration.go @@ -0,0 +1,435 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_api + +import ( + internal_utils "c2ec/internal/utils" + "c2ec/pkg/config" + "c2ec/pkg/db" + "c2ec/pkg/provider" + "context" + "encoding/base64" + "errors" + "fmt" + http "net/http" + "strconv" + "time" +) + +const DEFAULT_LONG_POLL_MS = 1000 +const DEFAULT_OLD_STATE = internal_utils.PENDING + +// https://docs.taler.net/core/api-exchange.html#tsref-type-CurrencySpecification +type CurrencySpecification struct { + Name string `json:"name"` + Currency string `json:"currency"` + NumFractionalInputDigits int `json:"num_fractional_input_digits"` + NumFractionalNormalDigits int `json:"num_fractional_normal_digits"` + NumFractionalTrailingZeroDigits int `json:"num_fractional_trailing_zero_digits"` + AltUnitNames map[string]string `json:"alt_unit_names"` +} + +// https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankIntegrationConfig +type BankIntegrationConfig struct { + Name string `json:"name"` + Version string `json:"version"` + Implementation string `json:"implementation"` + Currency string `json:"currency"` + CurrencySpecification CurrencySpecification `json:"currency_specification"` +} + +type BankWithdrawalOperationPostRequest struct { + ReservePubKey internal_utils.EddsaPublicKey `json:"reserve_pub"` + SelectedExchange string `json:"selected_exchange"` + Amount *internal_utils.Amount `json:"amount"` +} + +type BankWithdrawalOperationPostResponse struct { + Status internal_utils.WithdrawalOperationStatus `json:"status"` + ConfirmTransferUrl string `json:"confirm_transfer_url"` + TransferDone bool `json:"transfer_done"` +} + +type BankWithdrawalOperationStatus struct { + Status internal_utils.WithdrawalOperationStatus `json:"status"` + Amount string `json:"amount"` + CardFees string `json:"card_fees"` + SenderWire string `json:"sender_wire"` + WireTypes []string `json:"wire_types"` + ReservePubKey internal_utils.EddsaPublicKey `json:"selected_reserve_pub"` + SuggestedExchange string `json:"suggested_exchange"` + RequiredExchange string `json:"required_exchange"` + Aborted bool `json:"aborted"` + SelectionDone bool `json:"selection_done"` + TransferDone bool `json:"transfer_done"` +} + +func BankIntegrationConfigApi(res http.ResponseWriter, req *http.Request) { + + internal_utils.LogInfo("bank-integration-api", "reading config") + cfg := BankIntegrationConfig{ + Name: "taler-bank-integration", + Version: "4:8:2", + Currency: config.CONFIG.Server.Currency, + CurrencySpecification: CurrencySpecification{ + Name: config.CONFIG.Server.Currency, + Currency: config.CONFIG.Server.Currency, + NumFractionalInputDigits: config.CONFIG.Server.CurrencyFractionDigits, + NumFractionalNormalDigits: config.CONFIG.Server.CurrencyFractionDigits, + NumFractionalTrailingZeroDigits: 0, + AltUnitNames: map[string]string{ + "0": config.CONFIG.Server.Currency, + }, + }, + } + + encoder := internal_utils.NewJsonCodec[BankIntegrationConfig]() + serializedCfg, err := encoder.EncodeToBytes(&cfg) + if err != nil { + internal_utils.LogInfo("bank-integration-api", fmt.Sprintf("failed serializing config: %s", err.Error())) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_OK) + res.WriteHeader(internal_utils.HTTP_OK) + res.Write(serializedCfg) +} + +func HandleParameterRegistration(res http.ResponseWriter, req *http.Request) { + + jsonCodec := internal_utils.NewJsonCodec[BankWithdrawalOperationPostRequest]() + registration, err := internal_utils.ReadStructFromBody[BankWithdrawalOperationPostRequest](req, jsonCodec) + if err != nil { + internal_utils.LogWarn("bank-integration-api", fmt.Sprintf("invalid body for withdrawal registration error=%s", err.Error())) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + // read and validate the wopid path parameter + wopid := req.PathValue(WOPID_PARAMETER) + wpd, err := internal_utils.ParseWopid(wopid) + if err != nil { + internal_utils.LogWarn("bank-integration-api", "wopid "+wopid+" not valid") + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + if w, err := db.DB.GetWithdrawalByWopid(wpd); err != nil { + internal_utils.LogError("bank-integration-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NOT_FOUND) + res.WriteHeader(internal_utils.HTTP_NOT_FOUND) + return + } else { + if w.ReservePubKey != nil || len(w.ReservePubKey) > 0 { + internal_utils.LogWarn("bank-integration-api", "tried registering a withdrawal-operation with already existing wopid") + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_CONFLICT) + res.WriteHeader(internal_utils.HTTP_CONFLICT) + return + } + } + + if err = db.DB.RegisterWithdrawalParameters( + wpd, + registration.ReservePubKey, + ); err != nil { + internal_utils.LogError("bank-integration-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + withdrawal, err := db.DB.GetWithdrawalByWopid(wpd) + if err != nil { + internal_utils.LogError("bank-integration-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + } + + resbody := &BankWithdrawalOperationPostResponse{ + Status: withdrawal.WithdrawalStatus, + ConfirmTransferUrl: "", // not used in our case + TransferDone: withdrawal.WithdrawalStatus == internal_utils.CONFIRMED, + } + + encoder := internal_utils.NewJsonCodec[BankWithdrawalOperationPostResponse]() + resbyts, err := encoder.EncodeToBytes(resbody) + if err != nil { + internal_utils.LogError("bank-integration-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + } + + res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) + res.Write(resbyts) +} + +// Get status of withdrawal associated with the given WOPID +// +// Parameters: +// - long_poll_ms (optional): +// milliseconds to wait for state to change +// given old_state until responding +// - old_state (optional): +// Default is 'pending' +func HandleWithdrawalStatus(res http.ResponseWriter, req *http.Request) { + + // read and validate request query parameters + shouldStartLongPoll := true + longPollMilli := DEFAULT_LONG_POLL_MS + oldState := DEFAULT_OLD_STATE + if longPollMilliPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( + "long_poll_ms", strconv.Atoi, req, res, + ); accepted { + if longPollMilliPtr != nil { + longPollMilli = *longPollMilliPtr + if oldStatePtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( + "old_state", internal_utils.ToWithdrawalOperationStatus, req, res, + ); accepted { + if oldStatePtr != nil { + oldState = *oldStatePtr + } + } + } else { + // this means parameter was not given. + // no long polling (simple get) + internal_utils.LogInfo("bank-integration-api", "will not start long-polling") + shouldStartLongPoll = false + } + } else { + internal_utils.LogInfo("bank-integration-api", "will not start long-polling") + shouldStartLongPoll = false + } + + // read and validate the wopid path parameter + wopid := req.PathValue(WOPID_PARAMETER) + wpd, err := internal_utils.ParseWopid(wopid) + if err != nil { + internal_utils.LogWarn("bank-integration-api", "wopid "+wopid+" not valid") + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + var timeoutCtx context.Context + notifications := make(chan *db.Notification) + w := make(chan []byte) + errStat := make(chan int) + if shouldStartLongPoll { + + go func() { + // when the current state differs from the old_state + // of the request, return immediately. This goroutine + // does this check and sends the withdrawal to through + // the specified channel, if the withdrawal was already + // changed. + withdrawal, err := db.DB.GetWithdrawalByWopid(wpd) + if err != nil { + internal_utils.LogError("bank-integration-api", err) + } + if withdrawal == nil { + // do nothing because other goroutine might deliver result + return + } + if withdrawal.WithdrawalStatus != oldState { + byts, status := formatWithdrawalOrErrorStatus(withdrawal) + if status != internal_utils.HTTP_OK { + errStat <- status + } else { + w <- byts + } + } + }() + + var cancelFunc context.CancelFunc + timeoutCtx, cancelFunc = context.WithTimeout( + req.Context(), + time.Duration(longPollMilli)*time.Millisecond, + ) + defer cancelFunc() + + channel := "w_" + base64.StdEncoding.EncodeToString(wpd) + + listenFunc, err := db.DB.NewListener( + channel, + notifications, + ) + + if err != nil { + internal_utils.LogError("bank-integration-api", err) + errStat <- internal_utils.HTTP_INTERNAL_SERVER_ERROR + } else { + go listenFunc(timeoutCtx) + } + } else { + wthdrl, stat := getWithdrawalOrError(wpd) + internal_utils.LogInfo("bank-integration-api", "loaded withdrawal") + if stat != internal_utils.HTTP_OK { + internal_utils.LogWarn("bank-integration-api", "tried loading withdrawal but got error") + //errStat <- stat + internal_utils.SetLastResponseCodeForLogger(stat) + res.WriteHeader(stat) + return + } else { + //w <- wthdrl + res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, "application/json") + res.Write(wthdrl) + return + } + } + + for wait := true; wait; { + select { + case <-timeoutCtx.Done(): + internal_utils.LogInfo("bank-integration-api", "long poll time exceeded") + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NO_CONTENT) + res.WriteHeader(internal_utils.HTTP_NO_CONTENT) + wait = false + case <-notifications: + wthdrl, stat := getWithdrawalOrError(wpd) + if stat != 200 { + internal_utils.SetLastResponseCodeForLogger(stat) + res.WriteHeader(stat) + } else { + res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, "application/json") + res.Write(wthdrl) + } + wait = false + case wthdrl := <-w: + res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, "application/json") + res.Write(wthdrl) + wait = false + case status := <-errStat: + internal_utils.LogInfo("bank-integration-api", "got unsucessful state for withdrawal operation request") + internal_utils.SetLastResponseCodeForLogger(status) + res.WriteHeader(status) + wait = false + } + } + internal_utils.LogInfo("bank-integration-api", "withdrawal operation status request finished") +} + +func HandleWithdrawalAbort(res http.ResponseWriter, req *http.Request) { + + // read and validate the wopid path parameter + wopid := req.PathValue(WOPID_PARAMETER) + wpd, err := internal_utils.ParseWopid(wopid) + if err != nil { + internal_utils.LogWarn("bank-integration-api", "wopid "+wopid+" not valid") + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + withdrawal, err := db.DB.GetWithdrawalByWopid(wpd) + if err != nil { + internal_utils.LogError("bank-integration-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NOT_FOUND) + res.WriteHeader(internal_utils.HTTP_NOT_FOUND) + return + } + + if withdrawal.WithdrawalStatus == internal_utils.CONFIRMED { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_CONFLICT) + res.WriteHeader(internal_utils.HTTP_CONFLICT) + return + } + + err = db.DB.FinaliseWithdrawal(int(withdrawal.WithdrawalRowId), internal_utils.ABORTED, make([]byte, 0)) + if err != nil { + internal_utils.LogError("bank-integration-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NO_CONTENT) + res.WriteHeader(internal_utils.HTTP_NO_CONTENT) +} + +// Tries to load a WithdrawalOperationStatus from the database. If no +// entry could been found, it will write the correct error to the response. +func getWithdrawalOrError(wopid []byte) ([]byte, int) { + // read the withdrawal from the database + withdrawal, err := db.DB.GetWithdrawalByWopid(wopid) + if err != nil { + internal_utils.LogError("bank-integration-api", err) + return nil, internal_utils.HTTP_NOT_FOUND + } + + if withdrawal == nil { + // not found -> 404 + return nil, internal_utils.HTTP_NOT_FOUND + } + + // return the C2ECWithdrawalStatus + return formatWithdrawalOrErrorStatus(withdrawal) +} + +func formatWithdrawalOrErrorStatus(w *db.Withdrawal) ([]byte, int) { + + if w == nil { + return nil, internal_utils.HTTP_INTERNAL_SERVER_ERROR + } + + operator, err := db.DB.GetProviderByTerminal(w.TerminalId) + if err != nil { + internal_utils.LogError("bank-integration-api", err) + return nil, internal_utils.HTTP_INTERNAL_SERVER_ERROR + } + + client := provider.PROVIDER_CLIENTS[operator.Name] + if client == nil { + internal_utils.LogError("bank-integration-api", errors.New("no provider client registered for provider "+operator.Name)) + return nil, internal_utils.HTTP_INTERNAL_SERVER_ERROR + } + + if amount, err := internal_utils.ToAmount(w.Amount); err != nil { + internal_utils.LogError("bank-integration-api", err) + return nil, internal_utils.HTTP_INTERNAL_SERVER_ERROR + } else { + if fees, err := internal_utils.ToAmount(w.TerminalFees); err != nil { + internal_utils.LogError("bank-integration-api", err) + return nil, internal_utils.HTTP_INTERNAL_SERVER_ERROR + } else { + withdrawalStatusBytes, err := internal_utils.NewJsonCodec[BankWithdrawalOperationStatus]().EncodeToBytes(&BankWithdrawalOperationStatus{ + Status: w.WithdrawalStatus, + Amount: internal_utils.FormatAmount(amount, config.CONFIG.Server.CurrencyFractionDigits), + CardFees: internal_utils.FormatAmount(fees, config.CONFIG.Server.CurrencyFractionDigits), + SenderWire: client.FormatPayto(w), + WireTypes: []string{operator.PaytoTargetType, "iban"}, + ReservePubKey: internal_utils.EddsaPublicKey((internal_utils.TalerBinaryEncode(w.ReservePubKey))), + SuggestedExchange: config.CONFIG.Server.ExchangeBaseUrl, + RequiredExchange: config.CONFIG.Server.ExchangeBaseUrl, + Aborted: w.WithdrawalStatus == internal_utils.ABORTED, + SelectionDone: w.WithdrawalStatus == internal_utils.SELECTED, + TransferDone: w.WithdrawalStatus == internal_utils.CONFIRMED, + }) + if err != nil { + internal_utils.LogError("bank-integration-api", err) + return nil, internal_utils.HTTP_INTERNAL_SERVER_ERROR + } + return withdrawalStatusBytes, internal_utils.HTTP_OK + } + } +} diff --git a/c2ec/internal/api/api-consts.go b/c2ec/internal/api/api-consts.go @@ -0,0 +1,3 @@ +package internal_api + +const WOPID_PARAMETER = "wopid" diff --git a/c2ec/internal/api/api-terminals.go b/c2ec/internal/api/api-terminals.go @@ -0,0 +1,402 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_api + +import ( + internal_utils "c2ec/internal/utils" + "c2ec/pkg/config" + "c2ec/pkg/db" + "crypto/rand" + "errors" + "fmt" + "net/http" +) + +type TerminalConfig struct { + 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 { + Amount string `json:"amount"` + SuggestedAmount string `json:"suggested_amount"` + ProviderTransactionId string `json:"provider_transaction_id"` + TerminalFees string `json:"terminal_fees"` + RequestUid string `json:"request_uid"` + UserUuid string `json:"user_uuid"` + Lock string `json:"lock"` +} + +type TerminalWithdrawalSetupResponse struct { + Wopid string `json:"withdrawal_id"` +} + +type TerminalWithdrawalConfirmationRequest struct { + ProviderTransactionId string `json:"provider_transaction_id"` + TerminalFees string `json:"terminal_fees"` + UserUuid string `json:"user_uuid"` + Lock string `json:"lock"` +} + +func HandleTerminalConfig(res http.ResponseWriter, req *http.Request) { + + p, auth, err := authAndParseProvider(req) + if !auth { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_UNAUTHORIZED) + res.WriteHeader(internal_utils.HTTP_UNAUTHORIZED) + return + } + + if err != nil || p == nil { + internal_utils.LogError("terminals-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + encoder := internal_utils.NewJsonCodec[TerminalConfig]() + cfg, err := encoder.EncodeToBytes(&TerminalConfig{ + Name: "taler-terminal", + Version: "0:0:0", + ProviderName: p.Name, + Currency: config.CONFIG.Server.Currency, + WithdrawalFees: config.CONFIG.Server.WithdrawalFees, + WireType: p.PaytoTargetType, + }) + if err != nil { + internal_utils.LogError("terminals-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_OK) + res.WriteHeader(internal_utils.HTTP_OK) + res.Write(cfg) +} + +func HandleWithdrawalSetup(res http.ResponseWriter, req *http.Request) { + + p, auth, err := authAndParseProvider(req) + if !auth { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_UNAUTHORIZED) + res.WriteHeader(internal_utils.HTTP_UNAUTHORIZED) + return + } + if err != nil || p == nil { + internal_utils.LogError("terminals-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + jsonCodec := internal_utils.NewJsonCodec[TerminalWithdrawalSetup]() + setup, err := internal_utils.ReadStructFromBody[TerminalWithdrawalSetup](req, jsonCodec) + if err != nil { + internal_utils.LogWarn("terminals-api", fmt.Sprintf("invalid body for withdrawal registration error=%s", err.Error())) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + if hasConflict(setup) { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_CONFLICT) + res.WriteHeader(internal_utils.HTTP_CONFLICT) + return + } + + terminalId := parseTerminalId(req) + if terminalId == -1 { + internal_utils.LogWarn("terminals-api", "terminal id could not be read from authorization header") + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + // generate wopid + generatedWopid := make([]byte, 32) + _, err = rand.Read(generatedWopid) + if err != nil { + internal_utils.LogWarn("terminals-api", "unable to generate correct wopid") + internal_utils.LogError("terminals-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + } + + suggstdAmnt, err := parseAmount(setup.SuggestedAmount) + if err != nil { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + amnt, err := parseAmount(setup.Amount) + if err != nil { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + fees, err := parseAmount(setup.TerminalFees) + if err != nil { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + err = db.DB.SetupWithdrawal( + generatedWopid, + suggstdAmnt, + amnt, + terminalId, + setup.ProviderTransactionId, + fees, + setup.RequestUid, + ) + + if err != nil { + internal_utils.LogError("terminals-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + encoder := internal_utils.NewJsonCodec[TerminalWithdrawalSetupResponse]() + encodedBody, err := encoder.EncodeToBytes( + &TerminalWithdrawalSetupResponse{ + Wopid: internal_utils.TalerBinaryEncode(generatedWopid), + }, + ) + if err != nil { + internal_utils.LogError("terminal-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) + res.Write(encodedBody) +} + +func HandleWithdrawalCheck(res http.ResponseWriter, req *http.Request) { + + p, auth, err := authAndParseProvider(req) + if !auth { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_UNAUTHORIZED) + res.WriteHeader(internal_utils.HTTP_UNAUTHORIZED) + return + } + + if err != nil || p == nil { + internal_utils.LogError("terminals-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + wopid := req.PathValue(WOPID_PARAMETER) + wpd, err := internal_utils.ParseWopid(wopid) + if err != nil { + internal_utils.LogWarn("terminals-api", "wopid "+wopid+" not valid") + if wopid == "" { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + } + + jsonCodec := internal_utils.NewJsonCodec[TerminalWithdrawalConfirmationRequest]() + paymentNotification, err := internal_utils.ReadStructFromBody[TerminalWithdrawalConfirmationRequest](req, jsonCodec) + if err != nil { + internal_utils.LogError("terminals-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + internal_utils.LogInfo("terminals-api", "received payment notification") + + terminalId := parseTerminalId(req) + if terminalId == -1 { + internal_utils.LogWarn("terminals-api", "terminal id could not be read from authorization header") + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + trmlFees, err := internal_utils.ParseAmount(paymentNotification.TerminalFees, config.CONFIG.Server.CurrencyFractionDigits) + if err != nil { + internal_utils.LogError("terminals-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + exchangeFees, err := parseAmount(config.CONFIG.Server.WithdrawalFees) + if err != nil { + internal_utils.LogError("terminals-api", errors.New("unable to parse withdrawal fees - FATAL SHOULD NEVER HAPPEN")) + internal_utils.LogError("terminals-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + // Fees are optional here and since the Exchange can specify + // zero fees, the value can be zero as well. The case that the + // the terminal sends no fees and the exchange does not charge + // fees needs to be covered as compliant request, currently done + // by the trmlFees < exchangeFees check. + // Check that fees are at least as high as the configured withdrawal fees. + // a higher value would indicate that the payment service provider does + // also charge fees. + // incoming fees >= specified fees + if smaller, err := trmlFees.IsSmallerThan(exchangeFees); smaller || err != nil { + if err != nil { + internal_utils.LogError("terminals-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + if smaller { + internal_utils.LogError("terminals-api", errors.New("terminal did specify uncorrect fees")) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + } + + internal_utils.LogInfo("terminals-api", "received valid check request for provider_transaction_id="+paymentNotification.ProviderTransactionId) + err = db.DB.NotifyPayment( + wpd, + paymentNotification.ProviderTransactionId, + terminalId, + preventNilAmount(trmlFees), + ) + if err != nil { + internal_utils.LogError("terminals-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NO_CONTENT) + res.WriteHeader(internal_utils.HTTP_NO_CONTENT) +} + +func HandleWithdrawalStatusTerminal(res http.ResponseWriter, req *http.Request) { + + _, auth, err := authAndParseProvider(req) + if err != nil || !auth { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_UNAUTHORIZED) + res.WriteHeader(internal_utils.HTTP_UNAUTHORIZED) + return + } + + HandleWithdrawalStatus(res, req) +} + +func HandleWithdrawalAbortTerminal(res http.ResponseWriter, req *http.Request) { + + _, auth, err := authAndParseProvider(req) + if err != nil || !auth { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_UNAUTHORIZED) + res.WriteHeader(internal_utils.HTTP_UNAUTHORIZED) + return + } + + HandleWithdrawalAbort(res, req) +} + +func parseAmount(amountStr string) (internal_utils.Amount, error) { + + a, err := internal_utils.ParseAmount(amountStr, config.CONFIG.Server.CurrencyFractionDigits) + if err != nil { + return internal_utils.Amount{Currency: "", Value: 0, Fraction: 0}, err + } + return preventNilAmount(a), nil +} + +func preventNilAmount(exchangeFees *internal_utils.Amount) internal_utils.Amount { + + if exchangeFees == nil { + return internal_utils.Amount{Currency: "", Value: 0, Fraction: 0} + } + + return *exchangeFees +} + +func hasConflict(t *TerminalWithdrawalSetup) bool { + + w, err := db.DB.GetWithdrawalByRequestUid(t.RequestUid) + if err != nil { + internal_utils.LogError("terminals-api", err) + return true + } + + if w == nil { + return false // no request with this uid + } + + suggstdAmnt, err := parseAmount(t.SuggestedAmount) + if err != nil { + internal_utils.LogError("terminals-api", err) + return true + } + amnt, err := parseAmount(t.Amount) + if err != nil { + internal_utils.LogError("terminals-api", err) + return true + } + fees, err := parseAmount(t.TerminalFees) + if err != nil { + internal_utils.LogError("terminals-api", err) + return true + } + + isEqual := w.Amount.Curr == amnt.Currency && + w.Amount.Val == int64(amnt.Value) && + w.Amount.Frac == int32(amnt.Fraction) && + w.TerminalFees.Curr == fees.Currency && + uint64(w.TerminalFees.Val) == fees.Value && + uint64(w.TerminalFees.Frac) == fees.Fraction && + w.SuggestedAmount.Curr == suggstdAmnt.Currency && + uint64(w.SuggestedAmount.Val) == suggstdAmnt.Value && + uint64(w.SuggestedAmount.Frac) == suggstdAmnt.Fraction && + w.ProviderTransactionId == &t.ProviderTransactionId && + w.RequestUid == t.RequestUid + + return !isEqual +} + +func authAndParseProvider(req *http.Request) (*db.Provider, bool, error) { + + if authenticated := AuthenticateTerminal(req); !authenticated { + return nil, false, nil + } + + p, err := parseProvider(req) + if err != nil { + return nil, true, err + } + + return p, true, nil +} diff --git a/c2ec/internal/api/api-wire-gateway.go b/c2ec/internal/api/api-wire-gateway.go @@ -0,0 +1,556 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_api + +import ( + "bytes" + internal_utils "c2ec/internal/utils" + "c2ec/pkg/config" + "c2ec/pkg/db" + "c2ec/pkg/provider" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "time" +) + +const INCOMING_RESERVE_TRANSACTION_TYPE = "RESERVE" + +// https://docs.taler.net/core/api-bank-wire.html#tsref-type-WireConfig +type WireConfig struct { + Name string `json:"name"` + Version string `json:"version"` + Currency string `json:"currency"` + Implementation string `json:"implementation"` +} + +// https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferRequest +type TransferRequest struct { + RequestUid string `json:"request_uid"` + Amount string `json:"amount"` + ExchangeBaseUrl string `json:"exchange_base_url"` + Wtid string `json:"wtid"` + CreditAccount string `json:"credit_account"` +} + +// https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferResponse +type TransferResponse struct { + Timestamp internal_utils.Timestamp `json:"timestamp"` + RowId int `json:"row_id"` +} + +// https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingHistory +type IncomingHistory struct { + IncomingTransactions []IncomingReserveTransaction `json:"incoming_transactions"` + CreditAccount string `json:"credit_account"` +} + +// type RESERVE | https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingReserveTransaction +type IncomingReserveTransaction struct { + Type string `json:"type"` + RowId int `json:"row_id"` + Date internal_utils.Timestamp `json:"date"` + Amount string `json:"amount"` + DebitAccount string `json:"debit_account"` + ReservePub string `json:"reserve_pub"` +} + +type OutgoingHistory struct { + OutgoingTransactions []*OutgoingBankTransaction `json:"outgoing_transactions"` + DebitAccount string `json:"debit_account"` +} + +type OutgoingBankTransaction struct { + RowId uint64 `json:"row_id"` + Date internal_utils.Timestamp `json:"date"` + Amount string `json:"amount"` + CreditAccount string `json:"credit_account"` + Wtid internal_utils.ShortHashCode `json:"wtid"` + ExchangeBaseUrl string `json:"exchange_base_url"` +} + +func NewIncomingReserveTransaction(w *db.Withdrawal) *IncomingReserveTransaction { + + if w == nil { + internal_utils.LogWarn("wire-gateway", "the withdrawal was nil") + return nil + } + + prvdr, err := db.DB.GetProviderByTerminal(w.TerminalId) + if err != nil { + internal_utils.LogError("wire-gateway", err) + return nil + } + + client := provider.PROVIDER_CLIENTS[prvdr.Name] + if client == nil { + internal_utils.LogError("wire-gateway", errors.New("no provider client with name="+prvdr.Name)) + return nil + } + + t := new(IncomingReserveTransaction) + a, err := internal_utils.ToAmount(w.Amount) + if err != nil { + internal_utils.LogError("wire-gateway", err) + return nil + } + t.Amount = internal_utils.FormatAmount(a, config.CONFIG.Server.CurrencyFractionDigits) + t.Date = internal_utils.Timestamp{ + Ts: int(w.RegistrationTs), + } + t.DebitAccount = client.FormatPayto(w) + t.ReservePub = internal_utils.FormatEddsaPubKey(w.ReservePubKey) + if w.ConfirmedRowId == nil { + internal_utils.LogError("wire-gateway", fmt.Errorf("expected non-nil confirmed_row_id for withdrawal_row_id=%d", w.WithdrawalRowId)) + return nil + } + t.RowId = int(*w.ConfirmedRowId) + t.Type = INCOMING_RESERVE_TRANSACTION_TYPE + return t +} + +func NewOutgoingBankTransaction(tr *db.Transfer) *OutgoingBankTransaction { + t := new(OutgoingBankTransaction) + a, err := internal_utils.ToAmount(tr.Amount) + if err != nil { + internal_utils.LogError("wire-gateway", err) + return nil + } + t.Amount = internal_utils.FormatAmount(a, config.CONFIG.Server.CurrencyFractionDigits) + t.Date = internal_utils.Timestamp{ + Ts: int(tr.TransferTs), + } + t.CreditAccount = tr.CreditAccount + t.ExchangeBaseUrl = tr.ExchangeBaseUrl + if tr.TransferredRowId == nil { + internal_utils.LogError("wire-gateway", fmt.Errorf("expected non-nil transferred_row_id for row_id=%d", tr.RowId)) + return nil + } + t.RowId = uint64(*tr.TransferredRowId) + t.Wtid = internal_utils.ShortHashCode(tr.Wtid) + return t +} + +func WireGatewayConfig(res http.ResponseWriter, req *http.Request) { + + cfg := WireConfig{ + Name: "taler-wire-gateway", + Currency: config.CONFIG.Server.Currency, + Version: "0:0:1", + Implementation: "", + } + + serializedCfg, err := internal_utils.NewJsonCodec[WireConfig]().EncodeToBytes(&cfg) + if err != nil { + log.Default().Printf("failed serializing config: %s", err.Error()) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_OK) + res.WriteHeader(internal_utils.HTTP_OK) + res.Write(serializedCfg) +} + +func Transfer(res http.ResponseWriter, req *http.Request) { + + auth := AuthenticateWirewatcher(req) + if !auth { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_UNAUTHORIZED) + res.WriteHeader(internal_utils.HTTP_UNAUTHORIZED) + return + } + + jsonCodec := internal_utils.NewJsonCodec[TransferRequest]() + transfer, err := internal_utils.ReadStructFromBody[TransferRequest](req, jsonCodec) + if err != nil { + internal_utils.LogError("wire-gateway-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + if transfer.Amount == "" || transfer.CreditAccount == "" || transfer.RequestUid == "" { + internal_utils.LogError("wire-gateway-api", errors.New("invalid request")) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + paytoTargetType, tid, err := internal_utils.ParsePaytoUri(transfer.CreditAccount) + internal_utils.LogInfo("wire-gateway-api", fmt.Sprintf("parsed payto-target-type='%s'", paytoTargetType)) + if err != nil { + internal_utils.LogError("wire-gateway-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) + res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) + return + } + + p, err := db.DB.GetTerminalProviderByPaytoTargetType(paytoTargetType) + if err != nil { + internal_utils.LogWarn("wire-gateway-api", "unable to find provider for provider-target-type="+paytoTargetType) + internal_utils.LogError("wire-gateway-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + decodedRequestUid := bytes.NewBufferString(transfer.RequestUid).Bytes() + t, err := db.DB.GetTransferById(decodedRequestUid) + if err != nil { + internal_utils.LogWarn("wire-gateway-api", "failed retrieving transfer for requestUid="+transfer.RequestUid) + internal_utils.LogError("wire-gateway-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + if t == nil { + + // limitation: currently only full refunds are implemented. + // this means that we also check that no other transaction + // to the same recipient with this credit_account is present. + transfers, err := db.DB.GetTransfersByCreditAccount(transfer.CreditAccount) + if err != nil { + internal_utils.LogWarn("wire-gateway-api", "looking for transfers with the credit account failed") + internal_utils.LogError("wire-gateway-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + if len(transfers) > 0 { + // when the withdrawal was already refunded we act like everything is + // ok, because the transfer was registered earlier and the customer + // will get their money back (or already have). The Exchange will + // not loose money on the other hand because the refund is done twice. + internal_utils.LogWarn("wire-gateway-api", "full refunds only limitation") + internal_utils.LogError("wire-gateway-api", fmt.Errorf("currently only full refunds are supported. Withdrawal %s already refunded", transfer.CreditAccount)) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_OK) + res.WriteHeader(internal_utils.HTTP_OK) + return + } + + // no transfer for this request_id -> generate new + amount, err := internal_utils.ParseAmount(transfer.Amount, config.CONFIG.Server.CurrencyFractionDigits) + if err != nil { + internal_utils.LogWarn("wire-gateway-api", "failed parsing amount") + internal_utils.LogError("wire-gateway-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + err = db.DB.AddTransfer( + decodedRequestUid, + amount, + transfer.ExchangeBaseUrl, + string(transfer.Wtid), + transfer.CreditAccount, + time.Now(), + ) + if err != nil { + internal_utils.LogWarn("wire-gateway-api", "failed adding new transfer entry to database") + internal_utils.LogError("wire-gateway-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + } else { + + // check that the wanted provider is configured. + refundClient := provider.PROVIDER_CLIENTS[p.Name] + if refundClient == nil { + internal_utils.LogError("wire-gateway-api", errors.New("client for provider "+p.Name+" not initialized")) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + // the transfer is only processed if the body matches. + ta, err := internal_utils.ToAmount(t.Amount) + if err != nil { + internal_utils.LogError("wire-gateway-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + if transfer.Amount != internal_utils.FormatAmount(ta, config.CONFIG.Server.CurrencyFractionDigits) || + transfer.ExchangeBaseUrl != t.ExchangeBaseUrl || + transfer.Wtid != t.Wtid || + transfer.CreditAccount != t.CreditAccount { + + internal_utils.LogWarn("wire-gateway-api", "idempotency violation") + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_CONFLICT) + res.WriteHeader(internal_utils.HTTP_CONFLICT) + return + } + + w, err := db.DB.GetWithdrawalByProviderTransactionId(tid) + if err != nil || w == nil { + internal_utils.LogWarn("wire-gateway-api", "unable to find withdrawal with given provider transaction id") + internal_utils.LogError("wire-gateway-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + } + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_OK) +} + +// :query start: *Optional.* +// +// Row identifier to explicitly set the *starting point* of the query. +// +// :query delta: +// +// The *delta* value that determines the range of the query. +// +// :query long_poll_ms: *Optional.* +// +// If this parameter is specified and the result of the query would be empty, +// the bank will wait up to ``long_poll_ms`` milliseconds for new transactions +// that match the query to arrive and only then send the HTTP response. +// A client must never rely on this behavior, as the bank may return a response +// immediately or after waiting only a fraction of ``long_poll_ms``. +func HistoryIncoming(res http.ResponseWriter, req *http.Request) { + + auth := AuthenticateWirewatcher(req) + if !auth { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_UNAUTHORIZED) + res.WriteHeader(internal_utils.HTTP_UNAUTHORIZED) + return + } + + // read and validate request query parameters + timeOfReq := time.Now() + shouldStartLongPoll := true + var longPollMilli int + if longPollMilliPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( + "long_poll_ms", strconv.Atoi, req, res, + ); accepted { + if longPollMilliPtr != nil { + longPollMilli = *longPollMilliPtr + } else { + // this means parameter was not given. + // no long polling (simple get) + shouldStartLongPoll = false + } + } + + var start = 0 // read most recent entries by default + if startPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( + "start", strconv.Atoi, req, res, + ); accepted { + if startPtr != nil { + start = *startPtr + } + } else { + res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, "application/json") + internal_utils.LogWarn("wire-gateway-api", "invalid parameter") + return + } + + var delta = 0 + if deltaPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( + "delta", strconv.Atoi, req, res, + ); accepted { + if deltaPtr != nil { + delta = *deltaPtr + } + } else { + res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, "application/json") + internal_utils.LogWarn("wire-gateway-api", "invalid parameter") + return + } + + if delta == 0 { + delta = 10 + } + + if shouldStartLongPoll { + + // this will just wait / block until the milliseconds are exceeded. + time.Sleep(time.Duration(longPollMilli) * time.Millisecond) + } + + withdrawals, err := db.DB.GetConfirmedWithdrawals(start, delta, timeOfReq) + + if err != nil { + internal_utils.LogError("wire-gateway-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + if len(withdrawals) < 1 { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NO_CONTENT) + res.WriteHeader(internal_utils.HTTP_NO_CONTENT) + return + } + + transactions := make([]IncomingReserveTransaction, 0) + for _, w := range withdrawals { + if w.Amount.Val == 0 && w.Amount.Frac == 0 { + internal_utils.LogInfo("wire-gateway-api", "ignoring zero amount withdrawal") + continue + } + if w.ReservePubKey == nil || len(w.ReservePubKey) == 0 { + internal_utils.LogWarn("wire-gateway-api", "ignoring confirmed withdrawal with no reserve public key (probably a test transaction)") + continue + } + transaction := NewIncomingReserveTransaction(w) + if transaction != nil { + transactions = append(transactions, *transaction) + } + } + + hist := IncomingHistory{ + IncomingTransactions: transactions, + CreditAccount: config.CONFIG.Server.CreditAccount, + } + + encoder := internal_utils.NewJsonCodec[IncomingHistory]() + enc, err := encoder.EncodeToBytes(&hist) + if err != nil { + internal_utils.LogError("wire-gateway-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_OK) + res.WriteHeader(internal_utils.HTTP_OK) + res.Write(enc) +} + +func HistoryOutgoing(res http.ResponseWriter, req *http.Request) { + + auth := AuthenticateWirewatcher(req) + if !auth { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_UNAUTHORIZED) + res.WriteHeader(internal_utils.HTTP_UNAUTHORIZED) + return + } + + // read and validate request query parameters + timeOfReq := time.Now() + shouldStartLongPoll := true + var longPollMilli int + if longPollMilliPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( + "long_poll_ms", strconv.Atoi, req, res, + ); accepted { + } else { + if longPollMilliPtr != nil { + longPollMilli = *longPollMilliPtr + } else { + // this means parameter was not given. + // no long polling (simple get) + shouldStartLongPoll = false + } + } + + var start int + if startPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( + "start", strconv.Atoi, req, res, + ); accepted { + } else { + if startPtr != nil { + start = *startPtr + } + } + + var delta int + if deltaPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( + "delta", strconv.Atoi, req, res, + ); accepted { + } else { + if deltaPtr != nil { + delta = *deltaPtr + } + } + + if delta == 0 { + delta = 10 + } + + if shouldStartLongPoll { + + // this will just wait / block until the milliseconds are exceeded. + time.Sleep(time.Duration(longPollMilli) * time.Millisecond) + } + + transfers, err := db.DB.GetTransfers(start, delta, timeOfReq) + + if err != nil { + internal_utils.LogError("wire-gateway-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + filtered := make([]*db.Transfer, 0) + for _, t := range transfers { + if t.Status == 0 { + // only consider transfer which were successful + filtered = append(filtered, t) + } + } + + if len(filtered) < 1 { + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NO_CONTENT) + res.WriteHeader(internal_utils.HTTP_NO_CONTENT) + return + } + + transactions := make([]*OutgoingBankTransaction, len(filtered)) + for _, t := range filtered { + transactions = append(transactions, NewOutgoingBankTransaction(t)) + } + transactions = internal_utils.RemoveNulls(transactions) + + outgoingHistory := OutgoingHistory{ + OutgoingTransactions: transactions, + DebitAccount: config.CONFIG.Server.CreditAccount, + } + encoder := internal_utils.NewJsonCodec[OutgoingHistory]() + enc, err := encoder.EncodeToBytes(&outgoingHistory) + if err != nil { + internal_utils.LogError("wire-gateway-api", err) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) + return + } + + res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_OK) + res.WriteHeader(internal_utils.HTTP_OK) + res.Write(enc) +} + +// This method is currently dead and implemented for API conformance +func AdminAddIncoming(res http.ResponseWriter, req *http.Request) { + + // not implemented, because not used + internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NOT_IMPLEMENTED) + res.WriteHeader(internal_utils.HTTP_NOT_IMPLEMENTED) +} diff --git a/c2ec/internal/c2ec.go b/c2ec/internal/c2ec.go @@ -0,0 +1,342 @@ +package internal + +import ( + internal_api "c2ec/internal/api" + internal_postgres "c2ec/internal/db/postgres" + internal_proc "c2ec/internal/proc" + internal_provider_simulation "c2ec/internal/provider/simulation" + internal_provider_wallee "c2ec/internal/provider/wallee" + internal_utils "c2ec/internal/utils" + "c2ec/pkg/config" + "c2ec/pkg/db" + "c2ec/pkg/provider" + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "syscall" +) + +const GET = "GET " +const POST = "POST " +const DELETE = "DELETE " + +// https://docs.taler.net/core/api-terminal.html#endpoints-for-integrated-sub-apis +const BANK_INTEGRATION_API = "/taler-integration" +const WIRE_GATEWAY_API = "/taler-wire-gateway" + +const WIRE_GATEWAY_CONFIG_ENDPOINT = "/config" +const WIRE_GATEWAY_HISTORY_ENDPOINT = "/history" + +const WIRE_GATEWAY_CONFIG_PATTERN = WIRE_GATEWAY_CONFIG_ENDPOINT +const WIRE_TRANSFER_PATTERN = "/transfer" +const WIRE_HISTORY_INCOMING_PATTERN = WIRE_GATEWAY_HISTORY_ENDPOINT + "/incoming" +const WIRE_HISTORY_OUTGOING_PATTERN = WIRE_GATEWAY_HISTORY_ENDPOINT + "/outgoing" +const WIRE_ADMIN_ADD_INCOMING_PATTERN = "/admin/add-incoming" + +const WITHDRAWAL_OPERATION = "/withdrawal-operation" + +const WOPID_PARAMETER = "wopid" +const BANK_INTEGRATION_CONFIG_PATTERN = "/config" +const WITHDRAWAL_OPERATION_PATTERN = WITHDRAWAL_OPERATION +const WITHDRAWAL_OPERATION_BY_WOPID_PATTERN = WITHDRAWAL_OPERATION + "/{" + WOPID_PARAMETER + "}" +const WITHDRAWAL_OPERATION_ABORTION_PATTERN = WITHDRAWAL_OPERATION_BY_WOPID_PATTERN + "/abort" + +const TERMINAL_API_CONFIG = "/config" +const TERMINAL_API_REGISTER_WITHDRAWAL = "/withdrawals" +const TERMINAL_API_WITHDRAWAL_STATUS = "/withdrawals/{wopid}" +const TERMINAL_API_CHECK_WITHDRAWAL = "/withdrawals/{wopid}/check" +const TERMINAL_API_ABORT_WITHDRAWAL = "/withdrawals/{wopid}/abort" + +func C2EC() { + + d, err := setupDatabase(&config.CONFIG.Database) + if err != nil { + panic("unable to connect to datatbase: " + err.Error()) + } + db.DB = d + + err = setupProviderClients(&config.CONFIG) + if err != nil { + panic("unable initialize provider clients: " + err.Error()) + } + internal_utils.LogInfo("c2ec", "provider clients are setup") + + retryCtx, retryCancel := context.WithCancel(context.Background()) + defer retryCancel() + retryErrs := make(chan error) + internal_proc.RunRetrier(retryCtx, retryErrs) + internal_utils.LogInfo("c2ec", "retrier is running") + + attestorCtx, attestorCancel := context.WithCancel(context.Background()) + defer attestorCancel() + attestorErrs := make(chan error) + internal_proc.RunAttestor(attestorCtx, attestorErrs) + internal_utils.LogInfo("c2ec", "attestor is running") + + transferCtx, transferCancel := context.WithCancel(context.Background()) + defer transferCancel() + transferErrs := make(chan error) + internal_proc.RunTransferrer(transferCtx, transferErrs) + internal_utils.LogInfo("c2ec", "refunder is running") + + router := http.NewServeMux() + routerErrs := make(chan error) + + setupBankIntegrationRoutes(router) + setupWireGatewayRoutes(router) + setupTerminalRoutes(router) + setupAgplRoutes(router) + + startListening(router, routerErrs) + + // since listening for incoming request, attesting payments and + // retrying payments are separated processes who can fail + // we must take care of this here. The main process is used to + // dispatch incoming http request and parent of the confirmation + // and retry processes. If the main process fails somehow, also + // confirmation and retries will end. But if somehow the confirmation + // or retry process fail, they will be restarted and the error is + // written to the log. If some setup tasks are failing, the program + // panics. + for { + select { + case routerError := <-routerErrs: + internal_utils.LogError("c2ec", routerError) + attestorCancel() + retryCancel() + transferCancel() + panic(routerError) + case <-attestorCtx.Done(): + attestorCancel() // first run old cancellation function + attestorCtx, attestorCancel = context.WithCancel(context.Background()) + internal_proc.RunAttestor(attestorCtx, attestorErrs) + case <-retryCtx.Done(): + retryCancel() // first run old cancellation function + retryCtx, retryCancel = context.WithCancel(context.Background()) + internal_proc.RunRetrier(retryCtx, retryErrs) + case <-transferCtx.Done(): + transferCancel() // first run old cancellation function + transferCtx, transferCancel = context.WithCancel(context.Background()) + internal_proc.RunTransferrer(transferCtx, transferErrs) + case confirmationError := <-attestorErrs: + internal_utils.LogError("c2ec-from-proc-attestor", confirmationError) + case retryError := <-retryErrs: + internal_utils.LogError("c2ec-from-proc-retrier", retryError) + case transferError := <-transferErrs: + internal_utils.LogError("c2ec-from-proc-transfer", transferError) + } + } +} + +func setupDatabase(cfg *config.C2ECDatabseConfig) (db.C2ECDatabase, error) { + + return internal_postgres.NewC2ECPostgres(cfg) +} + +func setupProviderClients(cfg *config.C2ECConfig) error { + + if db.DB == nil { + return errors.New("setup database first") + } + + for _, provider := range cfg.Providers { + + p, err := db.DB.GetTerminalProviderByName(provider.Name) + if err != nil { + return err + } + + if p == nil { + if cfg.Server.IsProd || cfg.Server.StrictAttestors { + panic("no provider entry for " + provider.Name) + } else { + internal_utils.LogWarn("non-strict attestor initialization. skipping", provider.Name) + continue + } + } + + if !cfg.Server.IsProd { + // Prevent simulation client to be loaded in productive environments. + if p.Name == "Simulation" { + + simulationClient := new(internal_provider_simulation.SimulationClient) + err := simulationClient.SetupClient(p) + if err != nil { + return err + } + internal_utils.LogInfo("c2ec", "setup the Simulation provider") + } + } + + if p.Name == "Wallee" { + + walleeClient := new(internal_provider_wallee.WalleeClient) + err := walleeClient.SetupClient(p) + if err != nil { + return err + } + internal_utils.LogInfo("c2ec", "setup the Wallee provider") + } + + // For new added provider, add the respective if-clause + } + + for _, p := range config.CONFIG.Providers { + if provider.PROVIDER_CLIENTS[p.Name] == nil { + err := errors.New("no provider client initialized for provider " + p.Name) + internal_utils.LogError("retrier", err) + return err + } + } + + return nil +} + +func setupBankIntegrationRoutes(router *http.ServeMux) { + + router.HandleFunc( + GET+BANK_INTEGRATION_API+BANK_INTEGRATION_CONFIG_PATTERN, + internal_api.BankIntegrationConfigApi, + ) + internal_utils.LogInfo("c2ec", "setup "+GET+BANK_INTEGRATION_API+BANK_INTEGRATION_CONFIG_PATTERN) + + router.HandleFunc( + GET+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_BY_WOPID_PATTERN, + internal_api.HandleWithdrawalStatus, + ) + internal_utils.LogInfo("c2ec", "setup "+GET+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_BY_WOPID_PATTERN) + + router.HandleFunc( + POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_BY_WOPID_PATTERN, + internal_api.HandleParameterRegistration, + ) + internal_utils.LogInfo("c2ec", "setup "+POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_BY_WOPID_PATTERN) + + router.HandleFunc( + POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_ABORTION_PATTERN, + internal_api.HandleWithdrawalAbort, + ) + internal_utils.LogInfo("c2ec", "setup "+POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_ABORTION_PATTERN) +} + +func setupWireGatewayRoutes(router *http.ServeMux) { + + router.HandleFunc( + GET+WIRE_GATEWAY_API+WIRE_GATEWAY_CONFIG_PATTERN, + internal_api.WireGatewayConfig, + ) + internal_utils.LogInfo("c2ec", "setup "+GET+WIRE_GATEWAY_API+WIRE_GATEWAY_CONFIG_PATTERN) + + router.HandleFunc( + POST+WIRE_GATEWAY_API+WIRE_TRANSFER_PATTERN, + internal_api.Transfer, + ) + internal_utils.LogInfo("c2ec", "setup "+POST+WIRE_GATEWAY_API+WIRE_TRANSFER_PATTERN) + + router.HandleFunc( + GET+WIRE_GATEWAY_API+WIRE_HISTORY_INCOMING_PATTERN, + internal_api.HistoryIncoming, + ) + internal_utils.LogInfo("c2ec", "setup "+GET+WIRE_GATEWAY_API+WIRE_HISTORY_INCOMING_PATTERN) + + router.HandleFunc( + GET+WIRE_GATEWAY_API+WIRE_HISTORY_OUTGOING_PATTERN, + internal_api.HistoryOutgoing, + ) + internal_utils.LogInfo("c2ec", "setup "+GET+WIRE_GATEWAY_API+WIRE_HISTORY_OUTGOING_PATTERN) + + router.HandleFunc( + POST+WIRE_GATEWAY_API+WIRE_ADMIN_ADD_INCOMING_PATTERN, + internal_api.AdminAddIncoming, + ) + internal_utils.LogInfo("c2ec", "setup "+POST+WIRE_GATEWAY_API+WIRE_ADMIN_ADD_INCOMING_PATTERN) +} + +func setupTerminalRoutes(router *http.ServeMux) { + + router.HandleFunc( + GET+TERMINAL_API_CONFIG, + internal_api.HandleTerminalConfig, + ) + internal_utils.LogInfo("c2ec", "setup "+GET+TERMINAL_API_CONFIG) + + router.HandleFunc( + POST+TERMINAL_API_REGISTER_WITHDRAWAL, + internal_api.HandleWithdrawalSetup, + ) + internal_utils.LogInfo("c2ec", "setup "+POST+TERMINAL_API_REGISTER_WITHDRAWAL) + + router.HandleFunc( + POST+TERMINAL_API_CHECK_WITHDRAWAL, + internal_api.HandleWithdrawalCheck, + ) + internal_utils.LogInfo("c2ec", "setup "+POST+TERMINAL_API_CHECK_WITHDRAWAL) + + router.HandleFunc( + GET+TERMINAL_API_WITHDRAWAL_STATUS, + internal_api.HandleWithdrawalStatusTerminal, + ) + internal_utils.LogInfo("c2ec", "setup "+GET+TERMINAL_API_WITHDRAWAL_STATUS) + + router.HandleFunc( + DELETE+TERMINAL_API_ABORT_WITHDRAWAL, + internal_api.HandleWithdrawalAbortTerminal, + ) + internal_utils.LogInfo("c2ec", "setup "+DELETE+TERMINAL_API_ABORT_WITHDRAWAL) +} + +func setupAgplRoutes(router *http.ServeMux) { + + router.HandleFunc( + GET+"/agpl", + internal_api.Agpl, + ) + internal_utils.LogInfo("c2ec", "setup "+GET+"/agpl") +} + +func startListening(router *http.ServeMux, errs chan error) { + + server := http.Server{ + Handler: internal_utils.LoggingHandler(router), + } + + if config.CONFIG.Server.UseUnixDomainSocket { + + internal_utils.LogInfo("c2ec", "using domain sockets") + socket, err := net.Listen("unix", config.CONFIG.Server.UnixSocketPath) + if err != nil { + panic("failed listening on socket: " + err.Error()) + } + + // cleans up socket when process fails and is shutdown. + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + <-c + os.Remove(config.CONFIG.Server.UnixSocketPath) + os.Exit(1) + }() + + go func() { + internal_utils.LogInfo("c2ec", "serving at unix-domain-socket "+server.Addr) + if err = server.Serve(socket); err != nil { + errs <- err + } + }() + } else { + + internal_utils.LogInfo("c2ec", "using tcp") + go func() { + server.Addr = fmt.Sprintf("%s:%d", config.CONFIG.Server.Host, config.CONFIG.Server.Port) + internal_utils.LogInfo("c2ec", "serving at "+server.Addr) + if err := server.ListenAndServe(); err != nil { + internal_utils.LogError("c2ec", err) + errs <- err + } + }() + } +} diff --git a/c2ec/internal/db/db.go b/c2ec/internal/db/db.go @@ -0,0 +1,50 @@ +package internal_db + +import "c2ec/pkg/db" + +const PROVIDER_TABLE_NAME = "c2ec.provider" +const PROVIDER_FIELD_NAME_ID = "provider_id" +const PROVIDER_FIELD_NAME_NAME = "name" +const PROVIDER_FIELD_NAME_PAYTO_TARGET_TYPE = "payto_target_type" +const PROVIDER_FIELD_NAME_BACKEND_URL = "backend_base_url" +const PROVIDER_FIELD_NAME_BACKEND_CREDENTIALS = "backend_credentials" + +const TERMINAL_TABLE_NAME = "c2ec.terminal" +const TERMINAL_FIELD_NAME_ID = "terminal_id" +const TERMINAL_FIELD_NAME_ACCESS_TOKEN = "access_token" +const TERMINAL_FIELD_NAME_ACTIVE = "active" +const TERMINAL_FIELD_NAME_DESCRIPTION = "description" +const TERMINAL_FIELD_NAME_PROVIDER_ID = "provider_id" + +const WITHDRAWAL_TABLE_NAME = "c2ec.withdrawal" +const WITHDRAWAL_FIELD_NAME_ID = "withdrawal_row_id" +const WITHDRAWAL_FIELD_NAME_CONFIRMED_ROW_ID = "confirmed_row_id" +const WITHDRAWAL_FIELD_NAME_RUID = "request_uid" +const WITHDRAWAL_FIELD_NAME_WOPID = "wopid" +const WITHDRAWAL_FIELD_NAME_RESPUBKEY = "reserve_pub_key" +const WITHDRAWAL_FIELD_NAME_TS = "registration_ts" +const WITHDRAWAL_FIELD_NAME_AMOUNT = "amount" +const WITHDRAWAL_FIELD_NAME_SUGGESTED_AMOUNT = "suggested_amount" +const WITHDRAWAL_FIELD_NAME_FEES = "terminal_fees" +const WITHDRAWAL_FIELD_NAME_STATUS = "withdrawal_status" +const WITHDRAWAL_FIELD_NAME_TERMINAL_ID = "terminal_id" +const WITHDRAWAL_FIELD_NAME_TRANSACTION_ID = "provider_transaction_id" +const WITHDRAWAL_FIELD_NAME_LAST_RETRY = "last_retry_ts" +const WITHDRAWAL_FIELD_NAME_RETRY_COUNTER = "retry_counter" +const WITHDRAWAL_FIELD_NAME_COMPLETION_PROOF = "completion_proof" + +const TRANSFER_TABLE_NAME = "c2ec.transfer" +const TRANSFER_FIELD_NAME_ID = "request_uid" +const TRANSFER_FIELD_NAME_ROW_ID = "row_id" +const TRANSFER_FIELD_NAME_TRANSFERRED_ROW_ID = "transferred_row_id" +const TRANSFER_FIELD_NAME_AMOUNT = "amount" +const TRANSFER_FIELD_NAME_EXCHANGE_BASE_URL = "exchange_base_url" +const TRANSFER_FIELD_NAME_WTID = "wtid" +const TRANSFER_FIELD_NAME_CREDIT_ACCOUNT = "credit_account" +const TRANSFER_FIELD_NAME_TS = "transfer_ts" +const TRANSFER_FIELD_NAME_STATUS = "transfer_status" +const TRANSFER_FIELD_NAME_RETRIES = "retries" + +// holds the instance to the database layer at runtime +// initialized by startup +var DB db.C2ECDatabase diff --git a/c2ec/internal/db/postgres/db-postgres.go b/c2ec/internal/db/postgres/db-postgres.go @@ -0,0 +1,953 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_postgres + +import ( + internal_db "c2ec/internal/db" + internal_utils "c2ec/internal/utils" + "c2ec/pkg/config" + "c2ec/pkg/db" + public_db "c2ec/pkg/db" + "context" + "errors" + "fmt" + "math" + "os" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgxlisten" +) + +const PS_INSERT_WITHDRAWAL = "INSERT INTO " + internal_db.WITHDRAWAL_TABLE_NAME + " (" + + internal_db.WITHDRAWAL_FIELD_NAME_WOPID + ", " + internal_db.WITHDRAWAL_FIELD_NAME_RUID + ", " + + internal_db.WITHDRAWAL_FIELD_NAME_SUGGESTED_AMOUNT + ", " + internal_db.WITHDRAWAL_FIELD_NAME_AMOUNT + ", " + + internal_db.WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + ", " + internal_db.WITHDRAWAL_FIELD_NAME_FEES + ", " + + internal_db.WITHDRAWAL_FIELD_NAME_TS + ", " + internal_db.WITHDRAWAL_FIELD_NAME_TERMINAL_ID + + ") VALUES ($1,$2,($3,$4,$5),($6,$7,$8),$9,($10,$11,$12),$13,$14)" + +const PS_REGISTER_WITHDRAWAL_PARAMS = "UPDATE " + internal_db.WITHDRAWAL_TABLE_NAME + " SET (" + + internal_db.WITHDRAWAL_FIELD_NAME_RESPUBKEY + "," + + internal_db.WITHDRAWAL_FIELD_NAME_STATUS + "," + + internal_db.WITHDRAWAL_FIELD_NAME_TS + ")" + + " = ($1,$2,$3)" + + " WHERE " + internal_db.WITHDRAWAL_FIELD_NAME_WOPID + "=$4" + +const PS_GET_UNCONFIRMED_WITHDRAWALS = "SELECT * FROM " + internal_db.WITHDRAWAL_TABLE_NAME + + " WHERE " + internal_db.WITHDRAWAL_FIELD_NAME_STATUS + " = '" + string(internal_utils.SELECTED) + "'" + +const PS_PAYMENT_NOTIFICATION = "UPDATE " + internal_db.WITHDRAWAL_TABLE_NAME + " SET (" + + internal_db.WITHDRAWAL_FIELD_NAME_FEES + "," + internal_db.WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + "," + + internal_db.WITHDRAWAL_FIELD_NAME_TERMINAL_ID + ")" + + " = (($1,$2,$3),$4,$5)" + + " WHERE " + internal_db.WITHDRAWAL_FIELD_NAME_WOPID + "=$6" + +const PS_FINALISE_PAYMENT = "UPDATE " + internal_db.WITHDRAWAL_TABLE_NAME + " SET (" + + internal_db.WITHDRAWAL_FIELD_NAME_STATUS + "," + + internal_db.WITHDRAWAL_FIELD_NAME_COMPLETION_PROOF + "," + + internal_db.WITHDRAWAL_FIELD_NAME_CONFIRMED_ROW_ID + ")" + + " = ($1, $2, (SELECT ((SELECT MAX(" + internal_db.WITHDRAWAL_FIELD_NAME_CONFIRMED_ROW_ID + ") FROM " + internal_db.WITHDRAWAL_TABLE_NAME + " WHERE " + internal_db.WITHDRAWAL_FIELD_NAME_STATUS + "='" + string(internal_utils.CONFIRMED) + "')+1)))" + + " WHERE " + internal_db.WITHDRAWAL_FIELD_NAME_ID + "=$3" + +const PS_SET_LAST_RETRY = "UPDATE " + internal_db.WITHDRAWAL_TABLE_NAME + + " SET " + internal_db.WITHDRAWAL_FIELD_NAME_LAST_RETRY + "=$1" + + " WHERE " + internal_db.WITHDRAWAL_FIELD_NAME_ID + "=$2" + +const PS_SET_RETRY_COUNTER = "UPDATE " + internal_db.WITHDRAWAL_TABLE_NAME + + " SET " + internal_db.WITHDRAWAL_FIELD_NAME_RETRY_COUNTER + "=$1" + + " WHERE " + internal_db.WITHDRAWAL_FIELD_NAME_ID + "=$2" + +const PS_GET_WITHDRAWAL_BY_RUID = "SELECT * FROM " + internal_db.WITHDRAWAL_TABLE_NAME + + " WHERE " + internal_db.WITHDRAWAL_FIELD_NAME_RUID + "=$1" + +const PS_GET_WITHDRAWAL_BY_ID = "SELECT * FROM " + internal_db.WITHDRAWAL_TABLE_NAME + + " WHERE " + internal_db.WITHDRAWAL_FIELD_NAME_ID + "=$1" + +const PS_GET_WITHDRAWAL_BY_WOPID = "SELECT * FROM " + internal_db.WITHDRAWAL_TABLE_NAME + + " WHERE " + internal_db.WITHDRAWAL_FIELD_NAME_WOPID + "=$1" + +const PS_GET_WITHDRAWAL_BY_PTID = "SELECT * FROM " + internal_db.WITHDRAWAL_TABLE_NAME + + " WHERE " + internal_db.WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + "=$1" + +const PS_GET_PROVIDER_BY_TERMINAL = "SELECT * FROM " + internal_db.PROVIDER_TABLE_NAME + + " WHERE " + internal_db.PROVIDER_FIELD_NAME_ID + + " = (SELECT " + internal_db.TERMINAL_FIELD_NAME_PROVIDER_ID + " FROM " + internal_db.TERMINAL_TABLE_NAME + + " WHERE " + internal_db.TERMINAL_FIELD_NAME_ID + "=$1)" + +const PS_GET_PROVIDER_BY_NAME = "SELECT * FROM " + internal_db.PROVIDER_TABLE_NAME + + " WHERE " + internal_db.PROVIDER_FIELD_NAME_NAME + "=$1" + +const PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE = "SELECT * FROM " + internal_db.PROVIDER_TABLE_NAME + + " WHERE " + internal_db.PROVIDER_FIELD_NAME_PAYTO_TARGET_TYPE + "=$1" + +const PS_GET_TERMINAL_BY_ID = "SELECT * FROM " + internal_db.TERMINAL_TABLE_NAME + + " WHERE " + internal_db.TERMINAL_FIELD_NAME_ID + "=$1" + +const PS_GET_TRANSFER_BY_ID = "SELECT * FROM " + internal_db.TRANSFER_TABLE_NAME + + " WHERE " + internal_db.TRANSFER_FIELD_NAME_ID + "=$1" + +const PS_GET_TRANSFER_BY_CREDIT_ACCOUNT = "SELECT * FROM " + internal_db.TRANSFER_TABLE_NAME + + " WHERE " + internal_db.TRANSFER_FIELD_NAME_CREDIT_ACCOUNT + "=$1" + +const PS_ADD_TRANSFER = "INSERT INTO " + internal_db.TRANSFER_TABLE_NAME + + " (" + internal_db.TRANSFER_FIELD_NAME_ID + ", " + internal_db.TRANSFER_FIELD_NAME_AMOUNT + ", " + + internal_db.TRANSFER_FIELD_NAME_EXCHANGE_BASE_URL + ", " + internal_db.TRANSFER_FIELD_NAME_WTID + ", " + + internal_db.TRANSFER_FIELD_NAME_CREDIT_ACCOUNT + ", " + internal_db.TRANSFER_FIELD_NAME_TS + + ") VALUES ($1,$2,$3,$4,$5,$6)" + +const PS_UPDATE_TRANSFER = "UPDATE " + internal_db.TRANSFER_TABLE_NAME + " SET (" + + internal_db.TRANSFER_FIELD_NAME_TS + ", " + internal_db.TRANSFER_FIELD_NAME_STATUS + ", " + + internal_db.TRANSFER_FIELD_NAME_RETRIES + ", " + internal_db.TRANSFER_FIELD_NAME_TRANSFERRED_ROW_ID + ") = ($1,$2,$3," + + "(SELECT ((SELECT MAX(" + internal_db.TRANSFER_FIELD_NAME_TRANSFERRED_ROW_ID + ") FROM " + internal_db.TRANSFER_TABLE_NAME + " WHERE " + internal_db.TRANSFER_FIELD_NAME_STATUS + "=0)+1))" + + ") WHERE " + internal_db.TRANSFER_FIELD_NAME_ID + "=$4" + +const PS_CONFIRMED_TRANSACTIONS_ASC = "SELECT * FROM c2ec.withdrawal WHERE confirmed_row_id > $1 ORDER BY confirmed_row_id ASC LIMIT $2" + +const PS_CONFIRMED_TRANSACTIONS_DESC = "SELECT * FROM c2ec.withdrawal WHERE confirmed_row_id < $1 ORDER BY confirmed_row_id DESC LIMIT $2" + +const PS_CONFIRMED_TRANSACTIONS_ASC_MAX = "SELECT * FROM c2ec.withdrawal WHERE confirmed_row_id > $1 ORDER BY confirmed_row_id ASC LIMIT $2" + +const PS_CONFIRMED_TRANSACTIONS_DESC_MAX = "SELECT * FROM c2ec.withdrawal WHERE confirmed_row_id < (SELECT MAX(confirmed_row_id) FROM c2ec.withdrawal) ORDER BY confirmed_row_id DESC LIMIT $1" + +const PS_GET_TRANSFERS_ASC = "SELECT * FROM c2ec.transfer WHERE transferred_row_id > $1 ORDER BY transferred_row_id ASC LIMIT $2" + +const PS_GET_TRANSFERS_DESC = "SELECT * FROM c2ec.transfer WHERE transferred_row_id < $1 ORDER BY transferred_row_id DESC LIMIT $2" + +const PS_GET_TRANSFERS_ASC_MAX = "SELECT * FROM c2ec.transfer WHERE transferred_row_id > $1 ORDER BY transferred_row_id ASC LIMIT $2" + +const PS_GET_TRANSFERS_DESC_MAX = "SELECT * FROM c2ec.transfer WHERE transferred_row_id < (SELECT MAX(transferred_row_id) FROM c2ec.transfer) ORDER BY transferred_row_id DESC LIMIT $1" + +const PS_GET_TRANSFERS_BY_STATUS = "SELECT * FROM " + internal_db.TRANSFER_TABLE_NAME + + " WHERE " + internal_db.TRANSFER_FIELD_NAME_STATUS + "=$1" + +// Postgres implementation of the C2ECDatabase +type C2ECPostgres struct { + public_db.C2ECDatabase + + ctx context.Context + pool *pgxpool.Pool +} + +func PostgresConnectionString(cfg *config.C2ECDatabseConfig) string { + + if cfg.ConnectionString != "" { + return cfg.ConnectionString + } + + pgHost := os.Getenv("PGHOST") + if pgHost != "" { + internal_utils.LogInfo("postgres", "pghost was set") + } else { + pgHost = cfg.Host + } + + pgPort := os.Getenv("PGPORT") + if pgPort != "" { + internal_utils.LogInfo("postgres", "pgport was set") + } else { + pgPort = strconv.Itoa(cfg.Port) + } + + pgUsername := os.Getenv("PGUSER") + if pgUsername != "" { + internal_utils.LogInfo("postgres", "pghost was set") + } else { + pgUsername = cfg.Username + } + + pgPassword := os.Getenv("PGPASSWORD") + if pgPassword != "" { + internal_utils.LogInfo("postgres", "pghost was set") + } else { + pgPassword = cfg.Password + } + + pgDb := os.Getenv("PGDATABASE") + if pgDb != "" { + internal_utils.LogInfo("postgres", "pghost was set") + } else { + pgDb = cfg.Database + } + + return fmt.Sprintf( + "postgres://%s:%s@%s:%s/%s", + pgUsername, + pgPassword, + pgHost, + pgPort, + pgDb, + ) +} + +func NewC2ECPostgres(cfg *config.C2ECDatabseConfig) (*C2ECPostgres, error) { + + ctx := context.Background() + db := new(C2ECPostgres) + + connectionString := PostgresConnectionString(cfg) + + dbConnCfg, err := pgxpool.ParseConfig(connectionString) + if err != nil { + panic(err.Error()) + } + dbConnCfg.AfterConnect = db.registerCustomTypesHook + db.pool, err = pgxpool.NewWithConfig(context.Background(), dbConnCfg) + if err != nil { + panic(err.Error()) + } + + db.ctx = ctx + + return db, nil +} + +func (db *C2ECPostgres) registerCustomTypesHook(ctx context.Context, conn *pgx.Conn) error { + + t, err := conn.LoadType(ctx, "c2ec.taler_amount_currency") + if err != nil { + return err + } + + conn.TypeMap().RegisterType(t) + return nil +} + +func (db *C2ECPostgres) SetupWithdrawal( + wopid []byte, + suggestedAmount internal_utils.Amount, + amount internal_utils.Amount, + terminalId int, + providerTransactionId string, + terminalFees internal_utils.Amount, + requestUid string, +) error { + + ts := time.Now() + res, err := db.pool.Exec( + db.ctx, + PS_INSERT_WITHDRAWAL, + wopid, + requestUid, + suggestedAmount.Value, + suggestedAmount.Fraction, + suggestedAmount.Currency, + amount.Value, + amount.Fraction, + amount.Currency, + providerTransactionId, + terminalFees.Value, + terminalFees.Fraction, + terminalFees.Currency, + ts.Unix(), + terminalId, + ) + if err != nil { + internal_utils.LogError("postgres", err) + return err + } + internal_utils.LogInfo("postgres", "query="+PS_INSERT_WITHDRAWAL) + internal_utils.LogInfo("postgres", "setup withdrawal successfully. affected rows="+strconv.Itoa(int(res.RowsAffected()))) + return nil +} + +func (db *C2ECPostgres) RegisterWithdrawalParameters( + wopid []byte, + resPubKey internal_utils.EddsaPublicKey, +) error { + + resPubKeyBytes, err := internal_utils.ParseEddsaPubKey(resPubKey) + if err != nil { + return err + } + + ts := time.Now() + res, err := db.pool.Exec( + db.ctx, + PS_REGISTER_WITHDRAWAL_PARAMS, + resPubKeyBytes, + internal_utils.SELECTED, + ts.Unix(), + wopid, + ) + if err != nil { + internal_utils.LogError("postgres", err) + return err + } + internal_utils.LogInfo("postgres", "query="+PS_REGISTER_WITHDRAWAL_PARAMS) + internal_utils.LogInfo("postgres", "registered withdrawal successfully. affected rows="+strconv.Itoa(int(res.RowsAffected()))) + return nil +} + +func (db *C2ECPostgres) GetWithdrawalByRequestUid(requestUid string) (*db.Withdrawal, error) { + + if row, err := db.pool.Query( + db.ctx, + PS_GET_WITHDRAWAL_BY_RUID, + requestUid, + ); err != nil { + internal_utils.LogError("postgres", err) + if row != nil { + row.Close() + } + return nil, err + } else { + defer row.Close() + internal_utils.LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_RUID) + collected, err := pgx.CollectOneRow(row, pgx.RowToAddrOfStructByName[public_db.Withdrawal]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return collected, nil + } +} + +func (db *C2ECPostgres) GetWithdrawalById(withdrawalId int) (*db.Withdrawal, error) { + + if row, err := db.pool.Query( + db.ctx, + PS_GET_WITHDRAWAL_BY_ID, + withdrawalId, + ); err != nil { + internal_utils.LogError("postgres", err) + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + internal_utils.LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_ID) + return pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[public_db.Withdrawal]) + } +} + +func (db *C2ECPostgres) GetWithdrawalByWopid(wopid []byte) (*public_db.Withdrawal, error) { + + if row, err := db.pool.Query( + db.ctx, + PS_GET_WITHDRAWAL_BY_WOPID, + wopid, + ); err != nil { + internal_utils.LogError("postgres", err) + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + internal_utils.LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_WOPID) + return pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[public_db.Withdrawal]) + } +} + +func (db *C2ECPostgres) GetWithdrawalByProviderTransactionId(tid string) (*public_db.Withdrawal, error) { + if row, err := db.pool.Query( + db.ctx, + PS_GET_WITHDRAWAL_BY_PTID, + tid, + ); err != nil { + internal_utils.LogInfo("postgres", "failed query="+PS_GET_WITHDRAWAL_BY_PTID) + internal_utils.LogError("postgres", err) + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + internal_utils.LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_PTID) + return pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[public_db.Withdrawal]) + } +} + +func (db *C2ECPostgres) NotifyPayment( + wopid []byte, + providerTransactionId string, + terminalId int, + fees internal_utils.Amount, +) error { + + res, err := db.pool.Exec( + db.ctx, + PS_PAYMENT_NOTIFICATION, + fees.Value, + fees.Fraction, + fees.Currency, + providerTransactionId, + terminalId, + wopid, + ) + if err != nil { + internal_utils.LogError("postgres", err) + return err + } + internal_utils.LogInfo("postgres", "query="+PS_PAYMENT_NOTIFICATION+", affected rows="+strconv.Itoa(int(res.RowsAffected()))) + return nil +} + +func (db *C2ECPostgres) GetWithdrawalsForConfirmation() ([]*public_db.Withdrawal, error) { + + if row, err := db.pool.Query( + db.ctx, + PS_GET_UNCONFIRMED_WITHDRAWALS, + ); err != nil { + internal_utils.LogError("postgres", err) + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + + withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[public_db.Withdrawal]) + if err != nil { + internal_utils.LogError("postgres", err) + return nil, err + } + + // potentially fills the logs + // internal_utils.LogInfo("postgres", "query="+PS_GET_UNCONFIRMED_WITHDRAWALS) + return internal_utils.RemoveNulls(withdrawals), nil + } +} + +func (db *C2ECPostgres) FinaliseWithdrawal( + withdrawalId int, + confirmOrAbort internal_utils.WithdrawalOperationStatus, + completionProof []byte, +) error { + + if confirmOrAbort != internal_utils.CONFIRMED && confirmOrAbort != internal_utils.ABORTED { + return errors.New("can only finalise payment when new status is either confirmed or aborted") + } + + query := PS_FINALISE_PAYMENT + if withdrawalId <= 1 { + // tweak to intially set confirmed_row_id. Can be removed once confirmed_row_id field is obsolete + query = "UPDATE c2ec.withdrawal SET (withdrawal_status,completion_proof,confirmed_row_id) = ($1,$2,1) WHERE withdrawal_row_id=$3" + } + + _, err := db.pool.Exec( + db.ctx, + query, + confirmOrAbort, + completionProof, + withdrawalId, + ) + if err != nil { + internal_utils.LogError("postgres", err) + return err + } + internal_utils.LogInfo("postgres", "query="+query) + return nil +} + +func (db *C2ECPostgres) SetLastRetry(withdrawalId int, lastRetryTsUnix int64) error { + + _, err := db.pool.Exec( + db.ctx, + PS_SET_LAST_RETRY, + lastRetryTsUnix, + withdrawalId, + ) + if err != nil { + internal_utils.LogError("postgres", err) + return err + } + internal_utils.LogInfo("postgres", "query="+PS_SET_LAST_RETRY) + return nil +} + +func (db *C2ECPostgres) SetRetryCounter(withdrawalId int, retryCounter int) error { + + _, err := db.pool.Exec( + db.ctx, + PS_SET_RETRY_COUNTER, + retryCounter, + withdrawalId, + ) + if err != nil { + internal_utils.LogError("postgres", err) + return err + } + internal_utils.LogInfo("postgres", "query="+PS_SET_RETRY_COUNTER) + return nil +} + +// The query at the postgres database works as specified by the +// wire gateway api. +func (db *C2ECPostgres) GetConfirmedWithdrawals(start int, delta int, since time.Time) ([]*public_db.Withdrawal, error) { + + // +d / +s + query := PS_CONFIRMED_TRANSACTIONS_ASC + if delta < 0 { + // d negatives indicates DESC ordering and backwards reading + // -d / +s + query = PS_CONFIRMED_TRANSACTIONS_DESC + if start < 0 { + // start negative indicates not explicitly given + // since -d is the case here we try reading the latest entries + // -d / -s + query = PS_CONFIRMED_TRANSACTIONS_DESC_MAX + } + } else { + if start < 0 { + // +d / -s + query = PS_CONFIRMED_TRANSACTIONS_ASC_MAX + } + } + + limit := int(math.Abs(float64(delta))) + offset := start + if offset < 0 { + offset = 0 + } + + if start < 0 { + start = 0 + } + + internal_utils.LogInfo("postgres", fmt.Sprintf("selected query=%s (\nparameters:\n delta=%d,\n start=%d, limit=%d,\n offset=%d,\n since=%d\n)", query, delta, start, limit, offset, since.Unix())) + + var row pgx.Rows + var err error + + if strings.Count(query, "$") == 1 { + row, err = db.pool.Query( + db.ctx, + query, + limit, + ) + } else { + row, err = db.pool.Query( + db.ctx, + query, + offset, + limit, + ) + } + + internal_utils.LogInfo("postgres", "query="+query) + if err != nil { + internal_utils.LogError("postgres", err) + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + + withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[public_db.Withdrawal]) + if err != nil { + internal_utils.LogError("postgres", err) + return nil, err + } + + return internal_utils.RemoveNulls(withdrawals), nil + } +} + +func (db *C2ECPostgres) GetProviderByTerminal(terminalId int) (*public_db.Provider, error) { + + if row, err := db.pool.Query( + db.ctx, + PS_GET_PROVIDER_BY_TERMINAL, + terminalId, + ); err != nil { + internal_utils.LogWarn("postgres", "failed query="+PS_GET_PROVIDER_BY_TERMINAL) + internal_utils.LogError("postgres", err) + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + + provider, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[public_db.Provider]) + if err != nil { + internal_utils.LogError("postgres", err) + return nil, err + } + + internal_utils.LogInfo("postgres", "query="+PS_GET_PROVIDER_BY_TERMINAL) + return provider, nil + } +} + +func (db *C2ECPostgres) GetTerminalProviderByName(name string) (*public_db.Provider, error) { + + if row, err := db.pool.Query( + db.ctx, + PS_GET_PROVIDER_BY_NAME, + name, + ); err != nil { + internal_utils.LogWarn("postgres", "failed query="+PS_GET_PROVIDER_BY_NAME) + internal_utils.LogError("postgres", err) + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + + provider, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[public_db.Provider]) + if err != nil { + internal_utils.LogWarn("postgres", "failed query="+PS_GET_PROVIDER_BY_NAME) + internal_utils.LogError("postgres", err) + return nil, err + } + + internal_utils.LogInfo("postgres", "query="+PS_GET_PROVIDER_BY_NAME) + return provider, nil + } +} + +func (db *C2ECPostgres) GetTerminalProviderByPaytoTargetType(paytoTargetType string) (*public_db.Provider, error) { + + internal_utils.LogInfo("postgres", "loading provider for payto-target-type="+paytoTargetType) + if row, err := db.pool.Query( + db.ctx, + PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE, + paytoTargetType, + ); err != nil { + internal_utils.LogWarn("postgres", "failed query="+PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE) + internal_utils.LogError("postgres", err) + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + + provider, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[public_db.Provider]) + if err != nil { + internal_utils.LogWarn("postgres", "failed query="+PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE) + internal_utils.LogError("postgres", err) + return nil, err + } + + internal_utils.LogInfo("postgres", "query="+PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE) + return provider, nil + } +} + +func (db *C2ECPostgres) GetTerminalById(id int) (*public_db.Terminal, error) { + + if row, err := db.pool.Query( + db.ctx, + PS_GET_TERMINAL_BY_ID, + id, + ); err != nil { + internal_utils.LogWarn("postgres", "failed query="+PS_GET_TERMINAL_BY_ID) + internal_utils.LogError("postgres", err) + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + + terminal, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[public_db.Terminal]) + if err != nil { + internal_utils.LogWarn("postgres", "failed query="+PS_GET_TERMINAL_BY_ID) + internal_utils.LogError("postgres", err) + return nil, err + } + + internal_utils.LogInfo("postgres", "query="+PS_GET_TERMINAL_BY_ID) + return terminal, nil + } +} + +func (db *C2ECPostgres) GetTransferById(requestUid []byte) (*public_db.Transfer, error) { + + if rows, err := db.pool.Query( + db.ctx, + PS_GET_TRANSFER_BY_ID, + requestUid, + ); err != nil { + internal_utils.LogWarn("postgres", "failed query="+PS_GET_TRANSFER_BY_ID) + internal_utils.LogError("postgres", err) + if rows != nil { + rows.Close() + } + return nil, err + } else { + + defer rows.Close() + + transfer, err := pgx.CollectOneRow(rows, pgx.RowToAddrOfStructByName[public_db.Transfer]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + internal_utils.LogError("postgres", err) + return nil, err + } + + internal_utils.LogInfo("postgres", "query="+PS_GET_TRANSFER_BY_ID) + return transfer, nil + } +} + +func (db *C2ECPostgres) GetTransfersByCreditAccount(creditAccount string) ([]*public_db.Transfer, error) { + + if rows, err := db.pool.Query( + db.ctx, + PS_GET_TRANSFER_BY_CREDIT_ACCOUNT, + creditAccount, + ); err != nil { + internal_utils.LogWarn("postgres", "failed query="+PS_GET_TRANSFER_BY_CREDIT_ACCOUNT) + internal_utils.LogError("postgres", err) + if rows != nil { + rows.Close() + } + return nil, err + } else { + + defer rows.Close() + + transfers, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[public_db.Transfer]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return make([]*public_db.Transfer, 0), nil + } + internal_utils.LogError("postgres", err) + return nil, err + } + + internal_utils.LogInfo("postgres", "query="+PS_GET_TRANSFER_BY_CREDIT_ACCOUNT) + return internal_utils.RemoveNulls(transfers), nil + } +} + +func (db *C2ECPostgres) AddTransfer( + requestUid []byte, + amount *internal_utils.Amount, + exchangeBaseUrl string, + wtid string, + credit_account string, + ts time.Time, +) error { + + dbAmount := internal_utils.TalerAmountCurrency{ + Val: int64(amount.Value), + Frac: int32(amount.Fraction), + Curr: amount.Currency, + } + + _, err := db.pool.Exec( + db.ctx, + PS_ADD_TRANSFER, + requestUid, + dbAmount, + exchangeBaseUrl, + wtid, + credit_account, + ts.Unix(), + ) + if err != nil { + internal_utils.LogInfo("postgres", "failed query="+PS_ADD_TRANSFER) + internal_utils.LogError("postgres", err) + return err + } + internal_utils.LogInfo("postgres", "query="+PS_ADD_TRANSFER) + return nil +} + +func (db *C2ECPostgres) UpdateTransfer( + rowId int, + requestUid []byte, + timestamp int64, + status int16, + retries int16, +) error { + + query := PS_UPDATE_TRANSFER + if rowId <= 1 { + // tweak to intially set transferred_row_id. Can be removed once transferred_row_id field is obsolete + query = "UPDATE c2ec.transfer SET (transfer_ts, transfer_status, retries, transferred_row_id) = ($1,$2,$3,1) WHERE request_uid=$4" + } + + _, err := db.pool.Exec( + db.ctx, + query, + timestamp, + status, + retries, + requestUid, + ) + if err != nil { + internal_utils.LogInfo("postgres", "failed query="+query) + internal_utils.LogError("postgres", err) + return err + } + internal_utils.LogInfo("postgres", "query="+query) + return nil +} + +func (db *C2ECPostgres) GetTransfers(start int, delta int, since time.Time) ([]*public_db.Transfer, error) { + + // +d / +s + query := PS_GET_TRANSFERS_ASC + if delta < 0 { + // d negatives indicates DESC ordering and backwards reading + // -d / +s + query = PS_GET_TRANSFERS_DESC + if start < 0 { + // start negative indicates not explicitly given + // since -d is the case here we try reading the latest entries + // -d / -s + query = PS_GET_TRANSFERS_DESC_MAX + } + } else { + if start < 0 { + // +d / -s + query = PS_GET_TRANSFERS_ASC_MAX + } + } + + limit := int(math.Abs(float64(delta))) + offset := start + if offset < 0 { + offset = 0 + } + + if start < 0 { + start = 0 + } + + internal_utils.LogInfo("postgres", fmt.Sprintf("selected query=%s (\nparameters:\n delta=%d,\n start=%d, limit=%d,\n offset=%d,\n since=%d\n)", query, delta, start, limit, offset, since.Unix())) + + var row pgx.Rows + var err error + + if strings.Count(query, "$") == 1 { + row, err = db.pool.Query( + db.ctx, + query, + limit, + ) + } else { + row, err = db.pool.Query( + db.ctx, + query, + offset, + limit, + ) + } + + if err != nil { + internal_utils.LogWarn("postgres", "failed query="+query) + internal_utils.LogError("postgres", err) + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + + transfers, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[public_db.Transfer]) + if err != nil { + internal_utils.LogWarn("postgres", "failed query="+query) + internal_utils.LogError("postgres", err) + return nil, err + } + + return internal_utils.RemoveNulls(transfers), nil + } +} + +func (db *C2ECPostgres) GetTransfersByState(status int) ([]*public_db.Transfer, error) { + + if rows, err := db.pool.Query( + db.ctx, + PS_GET_TRANSFERS_BY_STATUS, + status, + ); err != nil { + internal_utils.LogError("postgres", err) + if rows != nil { + rows.Close() + } + return nil, err + } else { + + defer rows.Close() + + transfers, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[public_db.Transfer]) + if err != nil { + internal_utils.LogWarn("postgres", "failed query="+PS_GET_TRANSFERS_BY_STATUS) + internal_utils.LogError("postgres", err) + return nil, err + } + + // this will fill up the logs... + // internal_utils.LogInfo("postgres", "query="+PS_GET_TRANSFERS_BY_STATUS) + // internal_utils.LogInfo("postgres", "size of transfer list="+strconv.Itoa(len(transfers))) + return internal_utils.RemoveNulls(transfers), nil + } +} + +// Sets up a a listener for the given channel. +// Notifications will be sent through the out channel. +func (db *C2ECPostgres) NewListener( + cn string, + out chan *public_db.Notification, +) (func(context.Context) error, error) { + + connectionString := PostgresConnectionString(&config.CONFIG.Database) + cfg, err := pgx.ParseConfig(connectionString) + if err != nil { + return nil, err + } + + listener := &pgxlisten.Listener{ + Connect: func(ctx context.Context) (*pgx.Conn, error) { + internal_utils.LogInfo("postgres", "listener connecting to the database") + return pgx.ConnectConfig(ctx, cfg) + }, + } + + internal_utils.LogInfo("postgres", "handling notifications on channel="+cn) + listener.Handle(cn, pgxlisten.HandlerFunc(func(ctx context.Context, notification *pgconn.Notification, conn *pgx.Conn) error { + internal_utils.LogInfo("postgres", fmt.Sprintf("handling postgres notification. channel=%s", notification.Channel)) + out <- &public_db.Notification{ + Channel: notification.Channel, + Payload: notification.Payload, + } + return nil + })) + + return listener.Listen, nil +} diff --git a/c2ec/internal/proc/proc-attestor.go b/c2ec/internal/proc/proc-attestor.go @@ -0,0 +1,209 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_proc + +import ( + internal_utils "c2ec/internal/utils" + "c2ec/pkg/config" + "c2ec/pkg/db" + "c2ec/pkg/provider" + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +const PAYMENT_NOTIFICATION_CHANNEL_BUFFER_SIZE = 10 +const PS_PAYMENT_NOTIFICATION_CHANNEL = "payment_notification" +const MAX_BACKOFF_MS = 30 * 60000 // thirty minutes + +// Sets up and runs an attestor in the background. This must be called at startup. +func RunAttestor( + ctx context.Context, + errs chan error, +) { + + go RunListener( + ctx, + PS_PAYMENT_NOTIFICATION_CHANNEL, + confirmationCallback, + make(chan *db.Notification, PAYMENT_NOTIFICATION_CHANNEL_BUFFER_SIZE), + errs, + ) +} + +func confirmationCallback(notification *db.Notification, errs chan error) { + + internal_utils.LogInfo("proc-attestor", fmt.Sprintf("retrieved information on channel=%s with payload=%s", notification.Channel, notification.Payload)) + + // The payload is formatted like: "{PROVIDER_NAME}|{WITHDRAWAL_ID}|{PROVIDER_TRANSACTION_ID}" + // the validation is strict. This means, that the dispatcher emits an error + // and returns, if a property is malformed. + payload := strings.Split(notification.Payload, "|") + if len(payload) != 3 { + errs <- errors.New("malformed notification payload: " + notification.Payload) + return + } + + providerName := payload[0] + if providerName == "" { + errs <- errors.New("the provider of the payment is not specified") + return + } + withdrawalRowId, err := strconv.Atoi(payload[1]) + if err != nil { + errs <- errors.New("malformed withdrawal_row_id: " + err.Error()) + return + } + providerTransactionId := payload[2] + + client := provider.PROVIDER_CLIENTS[providerName] + if client == nil { + errs <- errors.New("no provider client registered for provider " + providerName) + } + + transaction, err := client.GetTransaction(providerTransactionId) + if err != nil { + internal_utils.LogError("proc-attestor", err) + prepareRetryOrAbort(withdrawalRowId, errs) + return + } + + finaliseOrSetRetry( + transaction, + withdrawalRowId, + errs, + ) +} + +func finaliseOrSetRetry( + transaction provider.ProviderTransaction, + withdrawalRowId int, + errs chan error, +) { + + if transaction == nil { + err := errors.New("transaction was nil. will set retry or abort") + internal_utils.LogError("proc-attestor", err) + errs <- err + prepareRetryOrAbort(withdrawalRowId, errs) + return + } + + if w, err := db.DB.GetWithdrawalById(withdrawalRowId); err != nil { + internal_utils.LogError("proc-attestor", err) + errs <- err + prepareRetryOrAbort(withdrawalRowId, errs) + return + } else { + if w.WithdrawalStatus == internal_utils.CONFIRMED || w.WithdrawalStatus == internal_utils.ABORTED { + return + } + if err := transaction.Confirm(w); err != nil { + internal_utils.LogError("proc-attestor", err) + errs <- err + prepareRetryOrAbort(withdrawalRowId, errs) + return + } + } + + completionProof := transaction.Bytes() + if len(completionProof) > 0 { + // only allow finalization operation, when the completion + // proof of the transaction could be retrieved + if transaction.AllowWithdrawal() { + + err := db.DB.FinaliseWithdrawal(withdrawalRowId, internal_utils.CONFIRMED, completionProof) + if err != nil { + internal_utils.LogError("proc-attestor", err) + prepareRetryOrAbort(withdrawalRowId, errs) + } + } else { + // when the received transaction is not allowed, we first check if the + // transaction is in a final state which will not allow the withdrawal + // and therefore the operation can be aborted, without further retries. + if transaction.AbortWithdrawal() { + err := db.DB.FinaliseWithdrawal(withdrawalRowId, internal_utils.ABORTED, completionProof) + if err != nil { + internal_utils.LogError("proc-attestor", err) + prepareRetryOrAbort(withdrawalRowId, errs) + return + } + } + prepareRetryOrAbort(withdrawalRowId, errs) + } + return + } + // when the transaction proof was not present (empty proof), retry. + prepareRetryOrAbort(withdrawalRowId, errs) +} + +// Checks wether the maximal amount of retries was already +// reached and the withdrawal operation shall be aborted or +// triggers the next retry by setting the last_retry_ts field +// which will trigger the stored procedure triggering the retry +// process. The retry counter of the retries is handled by the +// retrier logic and shall not be set here! +func prepareRetryOrAbort( + withdrawalRowId int, + errs chan error, +) { + + withdrawal, err := db.DB.GetWithdrawalById(withdrawalRowId) + if err != nil { + internal_utils.LogError("proc-attestor", err) + errs <- err + return + } + + if config.CONFIG.Server.MaxRetries < 0 { + prepareRetry(withdrawal, errs) + } else { + + if withdrawal.RetryCounter >= config.CONFIG.Server.MaxRetries { + + internal_utils.LogInfo("proc-attestor", fmt.Sprintf("max retries for withdrawal with id=%d was reached. withdrawal is aborted.", withdrawal.WithdrawalRowId)) + err := db.DB.FinaliseWithdrawal(withdrawalRowId, internal_utils.ABORTED, make([]byte, 0)) + if err != nil { + internal_utils.LogError("proc-attestor", err) + } + } else { + prepareRetry(withdrawal, errs) + } + } +} + +func prepareRetry(w *db.Withdrawal, errs chan error) { + // refactor this section to set retry counter and last retry field in one query... + err := db.DB.SetRetryCounter(int(w.WithdrawalRowId), int(w.RetryCounter)+1) + if err != nil { + internal_utils.LogError("proc-attestor", err) + errs <- err + return + } + lastRetryTs := time.Now().Unix() + err = db.DB.SetLastRetry(int(w.WithdrawalRowId), lastRetryTs) + if err != nil { + internal_utils.LogError("proc-attestor", err) + errs <- err + return + } +} diff --git a/c2ec/internal/proc/proc-listener.go b/c2ec/internal/proc/proc-listener.go @@ -0,0 +1,67 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_proc + +import ( + internal_utils "c2ec/internal/utils" + "c2ec/pkg/db" + "context" + "errors" +) + +func RunListener( + ctx context.Context, + channel string, + callback func(*db.Notification, chan error), + notifications chan *db.Notification, + errs chan error, +) { + + listenFunc, err := db.DB.NewListener(channel, notifications) + if err != nil { + internal_utils.LogError("listener", err) + errs <- errors.New("failed setting up listener") + return + } + + go func() { + internal_utils.LogInfo("listener", "listener starts listening for notifications at the db for channel="+channel) + err := listenFunc(ctx) + if err != nil { + internal_utils.LogError("listener", err) + errs <- err + } + close(notifications) + close(errs) + }() + + // Listen is started async. We can therefore block here and must + // not run the retrieval logic in own goroutine + for { + select { + case notification := <-notifications: + // the dispatching and further processing can be done asynchronously + // thus not blocking further incoming notifications. + go callback(notification, errs) + case <-ctx.Done(): + errs <- ctx.Err() + return + } + } +} diff --git a/c2ec/internal/proc/proc-retrier.go b/c2ec/internal/proc/proc-retrier.go @@ -0,0 +1,130 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_proc + +import ( + internal_utils "c2ec/internal/utils" + "c2ec/pkg/config" + "c2ec/pkg/db" + "c2ec/pkg/provider" + "context" + "errors" + "fmt" + "time" +) + +const RETRY_CHANNEL_BUFFER_SIZE = 10 +const PS_RETRY_CHANNEL = "retry" + +func RunRetrier(ctx context.Context, errs chan error) { + + // go RunListener( + // ctx, + // PS_RETRY_CHANNEL, + // retryCallback, + // make(chan *Notification, RETRY_CHANNEL_BUFFER_SIZE), + // errs, + // ) + + go func() { + lastlog := time.Now().Add(time.Minute * -3) + for { + withdrawals, err := db.DB.GetWithdrawalsForConfirmation() + time.Sleep(time.Duration(1000 * time.Millisecond)) + if err != nil { + internal_utils.LogError("proc-retrier", err) + errs <- err + continue + } + if lastlog.Before(time.Now().Add(time.Second * -30)) { + internal_utils.LogInfo("proc-retrier", fmt.Sprintf("retrier confirming 'selected' withdrawals. found %d ready for confirmation", len(withdrawals))) + lastlog = time.Now() + } + for _, w := range withdrawals { + retryOrSkip(w, errs) + } + } + }() +} + +// func retryCallback(n *Notification, errs chan error) { + +// withdrawalId, err := strconv.Atoi(n.Payload) +// if err != nil { +// internal_utils.LogError("proc-retrier", err) +// errs <- err +// return +// } + +// w, err := DB.GetWithdrawalById(withdrawalId) +// if err != nil { +// internal_utils.LogError("proc-retrier", err) +// errs <- err +// return +// } + +// retryOrSkip(w, errs) +// } + +func retryOrSkip(w *db.Withdrawal, errs chan error) { + var lastRetryTs int64 = 0 + if w.LastRetryTs != nil { + lastRetryTs = *w.LastRetryTs + if internal_utils.ShouldStartRetry(time.Unix(lastRetryTs, 0), int(w.RetryCounter), config.CONFIG.Server.RetryDelayMs) { + internal_utils.LogInfo("proc-retrier", "retrying for wopid="+internal_utils.TalerBinaryEncode(w.Wopid)) + confirmRetryOrAbort(w, errs) + } + } else { + internal_utils.LogInfo("proc-retrier", "first retry confirming wopid="+internal_utils.TalerBinaryEncode(w.Wopid)) + confirmRetryOrAbort(w, errs) + } +} + +func confirmRetryOrAbort(withdrawal *db.Withdrawal, errs chan error) { + + if withdrawal == nil { + err := errors.New("withdrawal was null") + internal_utils.LogError("proc-retrier", err) + errs <- err + return + } + + prvdr, err := db.DB.GetProviderByTerminal(withdrawal.TerminalId) + if err != nil { + internal_utils.LogError("proc-retrier", err) + errs <- err + return + } + + client := provider.PROVIDER_CLIENTS[prvdr.Name] + if client == nil { + err := fmt.Errorf("the provider client for provider with name=%s is not configured", prvdr.Name) + internal_utils.LogError("proc-retrier", err) + errs <- err + return + } + transaction, err := client.GetTransaction(*withdrawal.ProviderTransactionId) + if err != nil { + internal_utils.LogError("proc-retrier", err) + errs <- err + return + } + + finaliseOrSetRetry(transaction, int(withdrawal.WithdrawalRowId), errs) +} diff --git a/c2ec/internal/proc/proc-transfer.go b/c2ec/internal/proc/proc-transfer.go @@ -0,0 +1,251 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_proc + +import ( + internal_utils "c2ec/internal/utils" + "c2ec/pkg/db" + "c2ec/pkg/provider" + "context" + "errors" + "fmt" + "strings" + "time" +) + +const REFUND_RETRY_INTERVAL_SECONDS = 1 + +const REFUND_CHANNEL_BUFFER_SIZE = 10 +const PS_REFUND_CHANNEL = "transfer" + +const TRANSFER_STATUS_SUCCESS = 0 +const TRANSFER_STATUS_RETRY = 1 +const TRANSFER_STATUS_FAILED = -1 + +const MAX_TRANSFER_BACKOFF_MS = 24 * 60 * 60 * 1000 // 1 day + +// Sets up and runs an attestor in the background. This must be called at startup. +func RunTransferrer( + ctx context.Context, + errs chan error, +) { + + // go RunListener( + // ctx, + // PS_REFUND_CHANNEL, + // transferCallback, + // make(chan *Notification, REFUND_CHANNEL_BUFFER_SIZE), + // errs, + // ) + + go func() { + lastlog := time.Now().Add(time.Minute * -3) + lastlog2 := time.Now().Add(time.Minute * -3) + for { + time.Sleep(REFUND_RETRY_INTERVAL_SECONDS * time.Second) + if lastlog.Before(time.Now().Add(time.Second * -30)) { + internal_utils.LogInfo("proc-transfer", "transferrer executing transfers") + lastlog = time.Now() + } + executePendingTransfers(errs, lastlog2) + if lastlog2.Before(time.Now().Add(time.Second * -30)) { + lastlog2 = time.Now() + } + } + }() +} + +// func transferCallback(notification *Notification, errs chan error) { + +// internal_utils.LogInfo("proc-transfer", fmt.Sprintf("retrieved information on channel=%s with payload=%s", notification.Channel, notification.Payload)) + +// transferRequestUidBase64 := notification.Payload +// if transferRequestUidBase64 == "" { +// errs <- errors.New("the transfer to refund is not specified") +// return +// } + +// transferRequestUid, err := base64.StdEncoding.DecodeString(transferRequestUidBase64) +// if err != nil { +// errs <- errors.New("malformed transfer request uid: " + err.Error()) +// return +// } + +// transfer, err := db.DB.GetTransferById(transferRequestUid) +// if err != nil { +// internal_utils.LogWarn("proc-transfer", "unable to retrieve transfer with requestUid") +// internal_utils.LogError("proc-transfer", err) +// transferFailed(transfer, errs) +// errs <- err +// return +// } + +// if transfer == nil { +// err := errors.New("expected an existing transfer. very strange") +// internal_utils.LogError("proc-transfer", err) +// transferFailed(transfer, errs) +// errs <- err +// return +// } + +// paytoTargetType, tid, err := ParsePaytoUri(transfer.CreditAccount) +// internal_utils.LogInfo("proc-transfer", "parsed payto-target-type="+paytoTargetType) +// if err != nil { +// internal_utils.LogWarn("proc-transfer", "unable to parse payto-uri="+transfer.CreditAccount) +// errs <- errors.New("malformed transfer request uid: " + err.Error()) +// transferFailed(transfer, errs) +// return +// } + +// provider, err := db.DB.GetTerminalProviderByPaytoTargetType(paytoTargetType) +// if err != nil { +// internal_utils.LogWarn("proc-transfer", "unable to find provider for provider-target-type="+paytoTargetType) +// internal_utils.LogError("proc-transfer", err) +// transferFailed(transfer, errs) +// errs <- err +// } + +// client := PROVIDER_CLIENTS[provider.Name] +// if client == nil { +// errs <- errors.New("no provider client registered for provider " + provider.Name) +// } + +// err = client.Refund(tid) +// if err != nil { +// internal_utils.LogError("proc-transfer", err) +// transferFailed(transfer, errs) +// return +// } + +// err = db.DB.UpdateTransfer( +// transfer.RequestUid, +// time.Now().Unix(), +// TRANSFER_STATUS_SUCCESS, // success +// transfer.Retries, +// ) +// if err != nil { +// errs <- err +// } +// } + +func executePendingTransfers(errs chan error, lastlog time.Time) { + + transfers, err := db.DB.GetTransfersByState(TRANSFER_STATUS_RETRY) + if err != nil { + internal_utils.LogError("proc-transfer-1", err) + errs <- err + return + } + + if lastlog.Before(time.Now().Add(time.Second * -30)) { + internal_utils.LogInfo("proc-transfer", fmt.Sprintf("found %d pending transfers", len(transfers))) + } + for _, t := range transfers { + + shouldRetry := internal_utils.ShouldStartRetry(time.Unix(t.TransferTs, 0), int(t.Retries), MAX_TRANSFER_BACKOFF_MS) + if !shouldRetry { + if lastlog.Before(time.Now().Add(time.Second * -30)) { + internal_utils.LogInfo("proc-transfer", fmt.Sprintf("not retrying transfer id=%d, because backoff not yet exceeded", t.RowId)) + } + continue + } + + paytoTargetType, tid, err := internal_utils.ParsePaytoUri(t.CreditAccount) + internal_utils.LogInfo("proc-transfer", "parsed payto-target-type="+paytoTargetType) + if err != nil { + internal_utils.LogWarn("proc-transfer", "parsing payto-target-type failed") + internal_utils.LogError("proc-transfer-2", err) + continue + } + + prvdr, err := db.DB.GetTerminalProviderByPaytoTargetType(paytoTargetType) + if err != nil { + internal_utils.LogWarn("proc-transfer", "finding terminal by payto target type failed") + internal_utils.LogError("proc-transfer-3", err) + continue + } + + client := provider.PROVIDER_CLIENTS[prvdr.Name] + if client == nil { + errs <- errors.New("no provider client registered for provider " + prvdr.Name) + continue + } + + internal_utils.LogInfo("proc-transfer", "refunding transaction "+tid) + err = client.Refund(strings.Trim(tid, " \n")) + if err != nil { + internal_utils.LogWarn("proc-transfer", "refunding using provider client failed") + internal_utils.LogError("proc-transfer-4", err) + transferFailed(t, errs) + continue + } + + internal_utils.LogInfo("proc-transfer", "setting transfer to success state") + err = db.DB.UpdateTransfer( + t.RowId, + t.RequestUid, + time.Now().Unix(), + TRANSFER_STATUS_SUCCESS, // success + t.Retries, + ) + if err != nil { + internal_utils.LogWarn("proc-transfer", "failed setting refund to success state") + internal_utils.LogError("proc-transfer", err) + } + } +} + +func transferFailed( + transfer *db.Transfer, + errs chan error, +) { + + err := db.DB.UpdateTransfer( + transfer.RowId, + transfer.RequestUid, + time.Now().Unix(), + TRANSFER_STATUS_RETRY, // retry transfer. + transfer.Retries+1, + ) + if err != nil { + errs <- err + } + + // if transfer.Retries > 2 { + // err := db.DB.UpdateTransfer( + // transfer.RequestUid, + // time.Now().Unix(), + // TRANSFER_STATUS_FAILED, // transfer ultimatively failed. + // transfer.Retries, + // ) + // if err != nil { + // errs <- err + // } + // } else { + // err := db.DB.UpdateTransfer( + // transfer.RequestUid, + // time.Now().Unix(), + // TRANSFER_STATUS_RETRY, // retry transfer. + // transfer.Retries+1, + // ) + // if err != nil { + // errs <- err + // } + //} +} diff --git a/c2ec/internal/provider/simulation/simulation-client.go b/c2ec/internal/provider/simulation/simulation-client.go @@ -0,0 +1,95 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_provider_simulation + +import ( + "bytes" + internal_utils "c2ec/internal/utils" + "c2ec/pkg/db" + "c2ec/pkg/provider" + "fmt" + "time" +) + +type SimulationTransaction struct { + provider.ProviderTransaction + + allow bool +} + +type SimulationClient struct { + provider.ProviderClient + + // toggle this to simulate failed transactions. + AllowNextWithdrawal bool + + // simulates the provider client fetching confirmation at the providers backend. + providerBackendConfirmationDelayMs int +} + +func (st *SimulationTransaction) AllowWithdrawal() bool { + + return st.allow +} + +func (st *SimulationTransaction) AbortWithdrawal() bool { + + return false +} + +func (st *SimulationTransaction) Confirm(w *db.Withdrawal) error { + + return nil +} + +func (st *SimulationTransaction) Bytes() []byte { + + return bytes.NewBufferString("this is a simulated transaction and therefore has no content.").Bytes() +} + +func (sc *SimulationClient) FormatPayto(w *db.Withdrawal) string { + + return fmt.Sprintf("payto://void/%s", *w.ProviderTransactionId) +} + +func (sc *SimulationClient) SetupClient(p *db.Provider) error { + + internal_utils.LogInfo("simulation-client", "setting up simulation client. probably not what you want in production") + fmt.Println("setting up simulation client. probably not what you want in production") + + sc.AllowNextWithdrawal = true + sc.providerBackendConfirmationDelayMs = 1000 // one second, might be a lot but for testing this is fine. + provider.PROVIDER_CLIENTS["Simulation"] = sc + return nil +} + +func (sc *SimulationClient) GetTransaction(transactionId string) (provider.ProviderTransaction, error) { + + internal_utils.LogInfo("simulation-client", "getting transaction from simulation provider") + time.Sleep(time.Duration(sc.providerBackendConfirmationDelayMs) * time.Millisecond) + st := new(SimulationTransaction) + st.allow = sc.AllowNextWithdrawal + return st, nil +} + +func (*SimulationClient) Refund(transactionId string) error { + + internal_utils.LogInfo("simulation-client", "refund triggered for simulation provider with transaction id: "+transactionId) + return nil +} diff --git a/c2ec/internal/provider/wallee/wallee-client.go b/c2ec/internal/provider/wallee/wallee-client.go @@ -0,0 +1,408 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_provider_wallee + +import ( + "bytes" + internal_utils "c2ec/internal/utils" + "c2ec/pkg/config" + "c2ec/pkg/db" + "c2ec/pkg/provider" + "crypto/hmac" + "crypto/sha512" + "encoding/base64" + "errors" + "fmt" + "io" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +const WALLEE_AUTH_HEADER_VERSION = "x-mac-version" +const WALLEE_AUTH_HEADER_USERID = "x-mac-userid" +const WALLEE_AUTH_HEADER_TIMESTAMP = "x-mac-timestamp" +const WALLEE_AUTH_HEADER_MAC = "x-mac-value" + +const WALLEE_READ_TRANSACTION_API = "/api/transaction/read" +const WALLEE_SEARCH_TRANSACTION_API = "/api/transaction/search" +const WALLEE_CREATE_REFUND_API = "/api/refund/refund" + +const WALLEE_API_SPACEID_PARAM_NAME = "spaceId" + +type WalleeCredentials struct { + SpaceId int `json:"spaceId"` + UserId int `json:"userId"` + ApplicationUserKey string `json:"application-user-key"` +} + +type WalleeClient struct { + provider.ProviderClient + + name string + baseUrl string + credentials *WalleeCredentials +} + +func (wt *WalleeTransaction) AllowWithdrawal() bool { + + return strings.EqualFold(string(wt.State), string(StateFulfill)) +} + +func (wt *WalleeTransaction) AbortWithdrawal() bool { + // guaranteed abortion is given when the state of + // the transaction is a final state but not the + // success case (which is FULFILL) + return strings.EqualFold(string(wt.State), string(StateFailed)) || + strings.EqualFold(string(wt.State), string(StateVoided)) || + strings.EqualFold(string(wt.State), string(StateDecline)) +} + +func (wt *WalleeTransaction) Confirm(w *db.Withdrawal) error { + + if wt.MerchantReference != *w.ProviderTransactionId { + + return errors.New("the merchant reference does not match the withdrawal") + } + + amountFloatFrmt := strconv.FormatFloat(wt.CompletedAmount, 'f', config.CONFIG.Server.CurrencyFractionDigits, 64) + internal_utils.LogInfo("wallee-client", fmt.Sprintf("converted %f (float) to %s (string)", wt.CompletedAmount, amountFloatFrmt)) + completedAmountStr := fmt.Sprintf("%s:%s", config.CONFIG.Server.Currency, amountFloatFrmt) + completedAmount, err := internal_utils.ParseAmount(completedAmountStr, config.CONFIG.Server.CurrencyFractionDigits) + if err != nil { + internal_utils.LogError("wallee-client", err) + return err + } + + withdrawAmount, err := internal_utils.ToAmount(w.Amount) + if err != nil { + return err + } + withdrawFees, err := internal_utils.ToAmount(w.TerminalFees) + if err != nil { + return err + } + if completedAmountMinusFees, err := completedAmount.Sub(*withdrawFees, config.CONFIG.Server.CurrencyFractionDigits); err == nil { + if smaller, err := completedAmountMinusFees.IsSmallerThan(*withdrawAmount); smaller || err != nil { + + if err != nil { + return err + } + + return fmt.Errorf("the confirmed amount (%s) minus the fees (%s) was smaller than the withdraw amount (%s)", + completedAmountStr, + withdrawFees.String(config.CONFIG.Server.CurrencyFractionDigits), + withdrawAmount.String(config.CONFIG.Server.CurrencyFractionDigits), + ) + } + } + + return nil +} + +func (wt *WalleeTransaction) Bytes() []byte { + + reader, err := internal_utils.NewJsonCodec[WalleeTransaction]().Encode(wt) + if err != nil { + internal_utils.LogError("wallee-client", err) + return make([]byte, 0) + } + bytes, err := io.ReadAll(reader) + if err != nil { + internal_utils.LogError("wallee-client", err) + return make([]byte, 0) + } + return bytes +} + +func (w *WalleeClient) SetupClient(p *db.Provider) error { + + cfg, err := config.ConfigForProvider(p.Name) + if err != nil { + return err + } + + creds, err := parseCredentials(p.BackendCredentials, cfg) + if err != nil { + return err + } + + w.name = p.Name + w.baseUrl = p.BackendBaseURL + w.credentials = creds + + provider.PROVIDER_CLIENTS[w.name] = w + + internal_utils.LogInfo("wallee-client", fmt.Sprintf("Wallee client is setup (user=%d, spaceId=%d, backend=%s)", w.credentials.UserId, w.credentials.SpaceId, w.baseUrl)) + + return nil +} + +func (w *WalleeClient) GetTransaction(transactionId string) (provider.ProviderTransaction, error) { + + if transactionId == "" { + return nil, errors.New("transaction id must be specified but was blank") + } + + call := fmt.Sprintf("%s%s", w.baseUrl, WALLEE_SEARCH_TRANSACTION_API) + queryParams := map[string]string{ + WALLEE_API_SPACEID_PARAM_NAME: strconv.Itoa(w.credentials.SpaceId), + } + url := internal_utils.FormatUrl(call, map[string]string{}, queryParams) + + hdrs, err := prepareWalleeHeaders(url, internal_utils.HTTP_POST, w.credentials.UserId, w.credentials.ApplicationUserKey) + if err != nil { + return nil, err + } + + filter := WalleeSearchFilter{ + FieldName: "merchantReference", + Operator: EQUALS, + Type: LEAF, + Value: transactionId, + } + + req := WalleeTransactionSearchRequest{ + Filter: filter, + Language: "en", + NumberOfEntities: 1, + StartingEntity: 0, + } + + t, status, err := internal_utils.HttpPost( + url, + hdrs, + &req, + internal_utils.NewJsonCodec[WalleeTransactionSearchRequest](), + internal_utils.NewJsonCodec[[]*WalleeTransaction](), + ) + if err != nil { + return nil, err + } + if status != internal_utils.HTTP_OK { + return nil, errors.New("no result") + } + if t == nil { + return nil, errors.New("no such transaction for merchantReference=" + transactionId) + } + derefRes := *t + if len(derefRes) < 1 { + return nil, errors.New("no such transaction for merchantReference=" + transactionId) + } + return derefRes[0], nil +} + +func (sc *WalleeClient) FormatPayto(w *db.Withdrawal) string { + + if w == nil || w.ProviderTransactionId == nil { + internal_utils.LogError("wallee-client", errors.New("withdrawal or provider transaction identifier was nil")) + return "" + } + return fmt.Sprintf("payto://wallee-transaction/%s", *w.ProviderTransactionId) +} + +func (w *WalleeClient) Refund(transactionId string) error { + + internal_utils.LogInfo("wallee-client", "trying to refund provider transaction "+transactionId) + call := fmt.Sprintf("%s%s", w.baseUrl, WALLEE_CREATE_REFUND_API) + queryParams := map[string]string{ + WALLEE_API_SPACEID_PARAM_NAME: strconv.Itoa(w.credentials.SpaceId), + } + url := internal_utils.FormatUrl(call, map[string]string{}, queryParams) + internal_utils.LogInfo("wallee-client", "refund url "+url) + + hdrs, err := prepareWalleeHeaders(url, internal_utils.HTTP_POST, w.credentials.UserId, w.credentials.ApplicationUserKey) + if err != nil { + internal_utils.LogError("wallee-client", err) + return err + } + + withdrawal, err := db.DB.GetWithdrawalByProviderTransactionId(transactionId) + if err != nil { + err = errors.New("error unable to find withdrawal belonging to transactionId=" + transactionId) + internal_utils.LogError("wallee-client", err) + return err + } + if withdrawal == nil { + err = errors.New("withdrawal is nil unable to find withdrawal belonging to transactionId=" + transactionId) + internal_utils.LogError("wallee-client", err) + return err + } + + decodedWalleeTransaction, err := internal_utils.NewJsonCodec[WalleeTransaction]().Decode(bytes.NewBuffer(withdrawal.CompletionProof)) + if err != nil { + internal_utils.LogError("wallee-client", err) + return err + } + + refundAmount, err := internal_utils.ToAmount(withdrawal.Amount) + if err != nil { + internal_utils.LogError("wallee-client", err) + return err + } + + refundableAmount := refundAmount.String(config.CONFIG.Server.CurrencyFractionDigits) + refundableAmount, _ = strings.CutPrefix(refundableAmount, config.CONFIG.Server.Currency+":") + internal_utils.LogInfo("wallee-client", fmt.Sprintf("stripped currency from amount %s", refundableAmount)) + refund := &WalleeRefund{ + Amount: refundableAmount, + ExternalID: internal_utils.TalerBinaryEncode(withdrawal.Wopid), + MerchantReference: decodedWalleeTransaction.MerchantReference, + Transaction: WalleeRefundTransaction{ + Id: int64(decodedWalleeTransaction.Id), + }, + Type: "MERCHANT_INITIATED_ONLINE", // this type will refund the transaction using the responsible processor (e.g. VISA, MasterCard, TWINT, etc.) + } + + _, status, err := internal_utils.HttpPost[WalleeRefund, any]( + url, + hdrs, + refund, + internal_utils.NewJsonCodec[WalleeRefund](), + nil, + ) + if err != nil { + internal_utils.LogError("wallee-client", err) + return err + } + if status != internal_utils.HTTP_OK { + return errors.New("failed refunding the transaction at the wallee-backend. statuscode=" + strconv.Itoa(status)) + } + + return nil +} + +func prepareWalleeHeaders( + url string, + method string, + userId int, + applicationUserKey string, +) (map[string]string, error) { + + timestamp := time.Time.Unix(time.Now()) + + base64Mac, err := calculateWalleeAuthToken( + userId, + timestamp, + method, + url, + applicationUserKey, + ) + if err != nil { + return nil, err + } + + headers := map[string]string{ + WALLEE_AUTH_HEADER_VERSION: "1", + WALLEE_AUTH_HEADER_USERID: strconv.Itoa(userId), + WALLEE_AUTH_HEADER_TIMESTAMP: strconv.Itoa(int(timestamp)), + WALLEE_AUTH_HEADER_MAC: base64Mac, + } + + return headers, nil +} + +func parseCredentials(raw string, cfg *config.C2ECProviderConfig) (*WalleeCredentials, error) { + + credsJson := make([]byte, len(raw)) + _, err := base64.StdEncoding.Decode(credsJson, []byte(raw)) + if err != nil { + return nil, err + } + + creds, err := internal_utils.NewJsonCodec[WalleeCredentials]().Decode(bytes.NewBuffer(credsJson)) + if err != nil { + return nil, err + } + + if !internal_utils.ValidPassword(cfg.Key, creds.ApplicationUserKey) { + return nil, errors.New("invalid application user key in wallee client configuration") + } + + // correct application user key. + creds.ApplicationUserKey = cfg.Key + return creds, nil +} + +// This function calculates the authentication token according +// to the documentation of wallee: +// https://app-wallee.com/en-us/doc/api/web-service#_authentication +// the function returns the token in Base64 format. +func calculateWalleeAuthToken( + userId int, + unixTimestamp int64, + httpMethod string, + pathWithParams string, + userKeyBase64 string, +) (string, error) { + + // Put together the correct formatted string + // Version | UserId | Timestamp | Method | Path + authMsgStr := fmt.Sprintf("%d|%d|%d|%s|%s", + 1, // version is static + userId, + unixTimestamp, + httpMethod, + cutSchemeAndHost(pathWithParams), + ) + + authMsg := make([]byte, 0) + if valid := utf8.ValidString(authMsgStr); !valid { + + // encode the string using utf8 + for _, r := range authMsgStr { + rbytes := make([]byte, 4) + utf8.EncodeRune(rbytes, r) + authMsg = append(authMsg, rbytes...) + } + } else { + authMsg = bytes.NewBufferString(authMsgStr).Bytes() + } + + internal_utils.LogInfo("wallee-client", fmt.Sprintf("authMsg (utf-8 encoded): %s", string(authMsg))) + + key := make([]byte, 32) + _, err := base64.StdEncoding.Decode(key, []byte(userKeyBase64)) + if err != nil { + internal_utils.LogError("wallee-client", err) + return "", err + } + + if len(key) != 32 { + return "", errors.New("malformed secret") + } + + macer := hmac.New(sha512.New, key) + _, err = macer.Write(authMsg) + if err != nil { + internal_utils.LogError("wallee-client", err) + return "", err + } + mac := macer.Sum(make([]byte, 0)) + + return base64.StdEncoding.EncodeToString(mac), nil +} + +func cutSchemeAndHost(url string) string { + + reg := regexp.MustCompile(`https?:\/\/[\w-\.]{1,}`) + return reg.ReplaceAllString(url, "") +} diff --git a/c2ec/internal/provider/wallee/wallee-client_test.go b/c2ec/internal/provider/wallee/wallee-client_test.go @@ -0,0 +1,179 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_provider_wallee + +import ( + internal_utils "c2ec/internal/utils" + "errors" + "fmt" + "strconv" + "strings" + "testing" +) + +// integration tests shall be executed manually +// because of needed stuff credentials. +const ENABLE_WALLEE_INTEGRATION_TEST = false + +// configure the INT_* constants to to run integration tests +// be aware that this can possibly trigger and tamper real +// transactions (in case of the refund). +const INT_TEST_SPACE_ID = 0 +const INT_TEST_USER_ID = 0 +const INT_TEST_ACCESS_TOKEN = "" + +const INT_TEST_REFUND_AMOUNT = "0" +const INT_TEST_REFUND_EXT_ID = "" // can be anything -> idempotency +const INT_TEST_REFUND_MERCHANT_REFERENCE = "" +const INT_TEST_REFUND_TRANSACTION_ID = 0 + +func TestCutSchemeAndHost(t *testing.T) { + + urls := []string{ + "https://app-wallee.com/api/transaction/search?spaceId=54275", + "https://app-wallee.com/api/transaction/search?spaceId=54275?spaceId=54275&id=212156032", + "/api/transaction/search?spaceId=54275?spaceId=54275&id=212156032", + "http://test.com.ag.ch.de-en/api/transaction/search?spaceId=54275?spaceId=54275&id=212156032", + } + + for _, url := range urls { + cutted := cutSchemeAndHost(url) + fmt.Println(cutted) + if !strings.HasPrefix(cutted, "/api") { + t.FailNow() + } + } +} + +func TestWalleeMac(t *testing.T) { + + // https://app-wallee.com/en-us/doc/api/web-service#_java + // assuming the java example on the website of wallee is correct + // the following parameters should result to the given expected + // result using my Golang implementation. + + // authStr := "1|100000|1715454671|GET|/api/transaction/read?spaceId=10000&id=200000000" + secret := "OWOMg2gnaSx1nukAM6SN2vxedfY1yLPONvcTKbhDv7I=" + expected := "PNqpGIkv+4jVcdIYqp5Pp2tKGWSjO1bNdEAIPgllWb7A6BDRvQQ/I2fnZF20roAIJrP22pe1LvHH8lWpIzJbWg==" + + calculated, err := calculateWalleeAuthToken(100000, int64(1715454671), "GET", "https://some.domain/api/transaction/read?spaceId=10000&id=200000000", secret) + if err != nil { + t.Error(err) + t.FailNow() + } + + fmt.Println("expected:", expected) + fmt.Println("calcultd:", calculated) + + if expected != calculated { + t.Error(errors.New("calculated auth token not equal to expected token")) + t.FailNow() + } +} + +func TestTransactionSearchIntegration(t *testing.T) { + + if !ENABLE_WALLEE_INTEGRATION_TEST { + fmt.Println("info: integration test disabled") + return + } + + filter := WalleeSearchFilter{ + FieldName: "merchantReference", + Operator: EQUALS, + Type: LEAF, + Value: "TTZQFA2QQ14AARC82F7Z2Q9JCH40ZHXCE3BMXJV1FG87BP2GA3P0", + } + + req := WalleeTransactionSearchRequest{ + Filter: filter, + Language: "en", + NumberOfEntities: 1, + StartingEntity: 0, + } + + api := "https://app-wallee.com/api/transaction/search" + api = internal_utils.FormatUrl(api, map[string]string{}, map[string]string{"spaceId": strconv.Itoa(INT_TEST_SPACE_ID)}) + + hdrs, err := prepareWalleeHeaders(api, "POST", INT_TEST_USER_ID, INT_TEST_ACCESS_TOKEN) + if err != nil { + fmt.Println("Error preparing headers (req1): ", err.Error()) + t.FailNow() + } + + for k, v := range hdrs { + fmt.Println("req1", k, v) + } + + p, s, err := internal_utils.HttpPost( + api, + hdrs, + &req, + internal_utils.NewJsonCodec[WalleeTransactionSearchRequest](), + internal_utils.NewJsonCodec[[]*WalleeTransaction](), + ) + if err != nil { + fmt.Println("Error executing request: ", err.Error()) + fmt.Println("Status: ", s) + } else { + fmt.Println("wallee response status: ", s) + fmt.Println("wallee response: ", p) + } +} + +func TestRefundIntegration(t *testing.T) { + + if !ENABLE_WALLEE_INTEGRATION_TEST { + fmt.Println("info: integration test disabled") + return + } + + url := "https://app-wallee.com/api/refund/refund" + url = internal_utils.FormatUrl(url, map[string]string{}, map[string]string{"spaceId": strconv.Itoa(INT_TEST_SPACE_ID)}) + + hdrs, err := prepareWalleeHeaders(url, "POST", INT_TEST_USER_ID, INT_TEST_ACCESS_TOKEN) + if err != nil { + fmt.Println("Error preparing headers: ", err.Error()) + t.FailNow() + } + + for k, v := range hdrs { + fmt.Println("req", k, v) + } + + refund := &WalleeRefund{ + Amount: INT_TEST_REFUND_AMOUNT, + ExternalID: INT_TEST_REFUND_EXT_ID, + MerchantReference: INT_TEST_REFUND_MERCHANT_REFERENCE, + Transaction: WalleeRefundTransaction{ + Id: INT_TEST_REFUND_TRANSACTION_ID, + }, + Type: "MERCHANT_INITIATED_ONLINE", // this type will refund the transaction using the responsible processor (e.g. VISA, MasterCard, TWINT, etc.) + } + + _, status, err := internal_utils.HttpPost[WalleeRefund, any](url, hdrs, refund, internal_utils.NewJsonCodec[WalleeRefund](), nil) + if err != nil { + fmt.Println("Error sending refund request:", err) + t.FailNow() + } + if status != internal_utils.HTTP_OK { + fmt.Println("Received unsuccessful status code:", status) + t.FailNow() + } +} diff --git a/c2ec/internal/provider/wallee/wallee-models.go b/c2ec/internal/provider/wallee/wallee-models.go @@ -0,0 +1,437 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_provider_wallee + +import ( + "c2ec/pkg/provider" + "time" +) + +type WalleeSearchOperator string + +type WalleeSearchType string + +const ( + LEAF WalleeSearchType = "LEAF" +) + +const ( + EQUALS WalleeSearchOperator = "EQUALS" +) + +type WalleeSearchFilter struct { + FieldName string `json:"fieldName"` + Operator WalleeSearchOperator `json:"operator"` + Type WalleeSearchType `json:"type"` + Value string `json:"value"` +} + +type WalleeTransactionSearchRequest struct { + Filter WalleeSearchFilter `json:"filter"` + Language string `json:"language"` + NumberOfEntities int `json:"numberOfEntities"` + StartingEntity int `json:"startingEntity"` +} + +type WalleeTransactionCompletion struct { + Amount float64 `json:"amount"` + BaseLineItems []WalleeLineItem `json:"baseLineItems"` + CreatedBy int64 `json:"createdBy"` + CreatedOn time.Time `json:"createdOn"` + ExternalID string `json:"externalId"` + FailedOn time.Time `json:"failedOn"` + FailureReason string `json:"failureReason"` + ID int64 `json:"id"` + InvoiceMerchantRef string `json:"invoiceMerchantReference"` + Labels []WalleeLabel `json:"labels"` + Language string `json:"language"` + LastCompletion bool `json:"lastCompletion"` + LineItemVersion string `json:"lineItemVersion"` + LineItems []WalleeLineItem `json:"lineItems"` + LinkedSpaceID int64 `json:"linkedSpaceId"` + LinkedTransaction int64 `json:"linkedTransaction"` + Mode string `json:"mode"` + NextUpdateOn time.Time `json:"nextUpdateOn"` + PaymentInformation string `json:"paymentInformation"` + PlannedPurgeDate time.Time `json:"plannedPurgeDate"` + ProcessingOn time.Time `json:"processingOn"` + ProcessorReference string `json:"processorReference"` + RemainingLineItems []WalleeLineItem `json:"remainingLineItems"` + SpaceViewID int64 `json:"spaceViewId"` + State string `json:"state"` + StatementDescriptor string `json:"statementDescriptor"` + SucceededOn time.Time `json:"succeededOn"` + TaxAmount float64 `json:"taxAmount"` + TimeZone string `json:"timeZone"` + TimeoutOn time.Time `json:"timeoutOn"` + Version int `json:"version"` +} + +/* + { + "amount": "14.00", + "externalId": "1", + "merchantReference": "1BQMAGTYTQVM0B1EM40PDS4H4REVMNCEN9867SJQ26Q43C38RDDG", + "transaction": { + "id": 213103343 + }, + "type": "MERCHANT_INITIATED_ONLINE" + } +*/ +type WalleeRefund struct { + Amount string `json:"amount"` + ExternalID string `json:"externalId"` // idempotence support + MerchantReference string `json:"merchantReference"` + Transaction WalleeRefundTransaction `json:"transaction"` + /* + Refund Type (for testing (not triggered at processor): MERCHANT_INITIATED_OFFLINE + For real world (triggering at the processor): MERCHANT_INITIATED_ONLINE + */ + Type string `json:"type"` +} + +type WalleeRefundTransaction struct { + Id int64 `json:"id"` +} + +// type WalleeRefund struct { +// Amount float64 `json:"amount"` +// Completion int64 `json:"completion"` // ID of WalleeTransactionCompletion +// ExternalID string `json:"externalId"` // Unique per transaction +// MerchantReference string `json:"merchantReference"` +// Reductions []WalleeLineItemReduction `json:"reductions"` +// Transaction int64 `json:"transaction"` // ID of WalleeTransaction +// Type string `json:"type"` // Refund Type +// } + +type WalleeLabel struct { + Content []byte `json:"content"` + ContentAsString string `json:"contentAsString"` + Descriptor WalleeLabelDescriptor `json:"descriptor"` + ID int64 `json:"id"` + Version int `json:"version"` +} + +type WalleeLabelDescriptor struct { + Category string `json:"category"` + Description map[string]string `json:"description"` + Features []int64 `json:"features"` + Group int64 `json:"group"` + ID int64 `json:"id"` + Name map[string]string `json:"name"` + Type int64 `json:"type"` + Weight int `json:"weight"` +} + +type WalleeLineItemReduction struct { + LineItemUniqueId string + QuantityReduction float64 + UnitPriceReduction float64 +} + +type WalleeLineItemAttribute struct { + Label string + Value string +} + +type WalleeTax struct { + Rate float64 + Title string +} + +type WalleeLineItem struct { + AggregatedTaxRate float64 `json:"aggregatedTaxRate"` + AmountExcludingTax float64 `json:"amountExcludingTax"` + AmountIncludingTax float64 `json:"amountIncludingTax"` + Attributes map[string]WalleeLineItemAttribute `json:"attributes"` + DiscountExcludingTax float64 `json:"discountExcludingTax"` + DiscountIncludingTax float64 `json:"discountIncludingTax"` + Name string `json:"name"` + Quantity float64 `json:"quantity"` + ShippingRequired bool `json:"shippingRequired"` + SKU string `json:"sku"` + TaxAmount float64 `json:"taxAmount"` + TaxAmountPerUnit float64 `json:"taxAmountPerUnit"` + Taxes []WalleeTax `json:"taxes"` + Type string `json:"type"` + UndiscountedAmountExcludingTax float64 `json:"undiscountedAmountExcludingTax"` + UndiscountedAmountIncludingTax float64 `json:"undiscountedAmountIncludingTax"` + UndiscountedUnitPriceExclTax float64 `json:"undiscountedUnitPriceExcludingTax"` + UndiscountedUnitPriceInclTax float64 `json:"undiscountedUnitPriceIncludingTax"` + UniqueID string `json:"uniqueId"` + UnitPriceExcludingTax float64 `json:"unitPriceExcludingTax"` + UnitPriceIncludingTax float64 `json:"unitPriceIncludingTax"` +} + +type WalleeTransactionState string + +const ( + StateCreate WalleeTransactionState = "CREATE" + StatePending WalleeTransactionState = "PENDING" + StateConfirmed WalleeTransactionState = "CONFIRMED" + StateProcessing WalleeTransactionState = "PROCESSING" + StateFailed WalleeTransactionState = "FAILED" + StateAuthorized WalleeTransactionState = "AUTHORIZED" + StateCompleted WalleeTransactionState = "COMPLETED" + StateFulfill WalleeTransactionState = "FULFILL" + StateDecline WalleeTransactionState = "DECLINE" + StateVoided WalleeTransactionState = "VOIDED" +) + +type WalleeTransaction struct { + provider.ProviderTransaction + AcceptHeader interface{} `json:"acceptHeader"` + AcceptLanguageHeader interface{} `json:"acceptLanguageHeader"` + AllowedPaymentMethodBrands []interface{} `json:"allowedPaymentMethodBrands"` + AllowedPaymentMethodConfigurations []interface{} `json:"allowedPaymentMethodConfigurations"` + AuthorizationAmount float64 `json:"authorizationAmount"` + AuthorizationEnvironment string `json:"authorizationEnvironment"` + AuthorizationSalesChannel int64 `json:"authorizationSalesChannel"` + AuthorizationTimeoutOn time.Time `json:"authorizationTimeoutOn"` + AuthorizedOn time.Time `json:"authorizedOn"` + AutoConfirmationEnabled bool `json:"autoConfirmationEnabled"` + BillingAddress interface{} `json:"billingAddress"` + ChargeRetryEnabled bool `json:"chargeRetryEnabled"` + CompletedAmount float64 `json:"completedAmount"` + CompletedOn interface{} `json:"completedOn"` + CompletionBehavior string `json:"completionBehavior"` + CompletionTimeoutOn interface{} `json:"completionTimeoutOn"` + ConfirmedBy int `json:"confirmedBy"` + ConfirmedOn time.Time `json:"confirmedOn"` + CreatedBy int `json:"createdBy"` + CreatedOn time.Time `json:"createdOn"` + Currency string `json:"currency"` + CustomerEmailAddress interface{} `json:"customerEmailAddress"` + CustomerId interface{} `json:"customerId"` + CustomersPresence string `json:"customersPresence"` + DeliveryDecisionMadeOn interface{} `json:"deliveryDecisionMadeOn"` + DeviceSessionIdentifier interface{} `json:"deviceSessionIdentifier"` + EmailsDisabled bool `json:"emailsDisabled"` + EndOfLife time.Time `json:"endOfLife"` + Environment string `json:"environment"` + EnvironmentSelectionStrategy string `json:"environmentSelectionStrategy"` + FailedOn interface{} `json:"failedOn"` + FailedUrl interface{} `json:"failedUrl"` + FailureReason interface{} `json:"failureReason"` + Group WalleeGroup `json:"group"` + Id int64 `json:"id"` + InternetProtocolAddress interface{} `json:"internetProtocolAddress"` + InternetProtocolAddressCountry interface{} `json:"internetProtocolAddressCountry"` + InvoiceMerchantReference string `json:"invoiceMerchantReference"` + JavaEnabled interface{} `json:"javaEnabled"` + Language string `json:"language"` + LineItems []WalleeLineItem `json:"lineItems"` + LinkedSpaceId int `json:"linkedSpaceId"` + MerchantReference string `json:"merchantReference"` + MetaData struct{} `json:"metaData"` + Parent interface{} `json:"parent"` + PaymentConnectorConfiguration WalleePaymentConnectorConfiguration `json:"paymentConnectorConfiguration"` + PlannedPurgeDate time.Time `json:"plannedPurgeDate"` + ProcessingOn time.Time `json:"processingOn"` + RefundedAmount float64 `json:"refundedAmount"` + ScreenColorDepth interface{} `json:"screenColorDepth"` + ScreenHeight interface{} `json:"screenHeight"` + ScreenWidth interface{} `json:"screenWidth"` + ShippingAddress interface{} `json:"shippingAddress"` + ShippingMethod interface{} `json:"shippingMethod"` + SpaceViewId interface{} `json:"spaceViewId"` + State string `json:"state"` + SuccessUrl interface{} `json:"successUrl"` + Terminal WalleeTerminal `json:"terminal"` + TimeZone interface{} `json:"timeZone"` + Token interface{} `json:"token"` + TokenizationMode interface{} `json:"tokenizationMode"` + TotalAppliedFees float64 `json:"totalAppliedFees"` + TotalSettledAmount float64 `json:"totalSettledAmount"` + UserAgentHeader interface{} `json:"userAgentHeader"` + UserFailureMessage interface{} `json:"userFailureMessage"` + UserInterfaceType string `json:"userInterfaceType"` + Version int `json:"version"` + WindowHeight interface{} `json:"windowHeight"` + WindowWidth interface{} `json:"windowWidth"` + YearsToKeep int `json:"yearsToKeep"` +} + +type WalleeGroup struct { + BeginDate time.Time `json:"beginDate"` + CustomerId interface{} `json:"customerId"` + EndDate time.Time `json:"endDate"` + Id int `json:"id"` + LinkedSpaceId int `json:"linkedSpaceId"` + PlannedPurgeDate time.Time `json:"plannedPurgeDate"` + State string `json:"state"` + Version int `json:"version"` +} + +type WalleePaymentConnectorConfiguration struct { + ApplicableForTransactionProcessing bool `json:"applicableForTransactionProcessing"` + Conditions []interface{} `json:"conditions"` + Connector int64 `json:"connector"` + EnabledSalesChannels []WalleeEnabledSalesChannels `json:"enabledSalesChannels"` + EnabledSpaceViews []interface{} `json:"enabledSpaceViews"` + Id int `json:"id"` + ImagePath string `json:"imagePath"` + LinkedSpaceId int `json:"linkedSpaceId"` + Name string `json:"name"` + PaymentMethodConfiguration WalleePaymentMethodConfiguration `json:"paymentMethodConfiguration"` + PlannedPurgeDate interface{} `json:"plannedPurgeDate"` + Priority int `json:"priority"` + ProcessorConfiguration WalleeProcessorConfiguration `json:"processorConfiguration"` + State string `json:"state"` + Version int `json:"version"` +} + +type WalleeEnabledSalesChannels struct { + Description WalleeMultilangProperty `json:"description"` + Icon string `json:"icon"` + Id int64 `json:"id"` + Name WalleeMultilangProperty `json:"name"` + SortOrder int `json:"sortOrder"` +} + +type WalleeMultilangProperty struct { + DeDE string `json:"de-DE"` + EnUS string `json:"en-US"` + FrFR string `json:"fr-FR"` + ItIT string `json:"it-IT"` +} + +type WalleePaymentMethodConfiguration struct { + DataCollectionType string `json:"dataCollectionType"` + Description struct{} `json:"description"` + Id int `json:"id"` + ImageResourcePath interface{} `json:"imageResourcePath"` + LinkedSpaceId int `json:"linkedSpaceId"` + Name string `json:"name"` + OneClickPaymentMode string `json:"oneClickPaymentMode"` + PaymentMethod int64 `json:"paymentMethod"` + PlannedPurgeDate interface{} `json:"plannedPurgeDate"` + ResolvedDescription WalleeMultilangProperty `json:"resolvedDescription"` + ResolvedImageUrl string `json:"resolvedImageUrl"` + ResolvedTitle WalleeMultilangProperty `json:"resolvedTitle"` + SortOrder int `json:"sortOrder"` + SpaceId int `json:"spaceId"` + State string `json:"state"` + Title struct{} `json:"title"` + Version int `json:"version"` +} + +type WalleeProcessorConfiguration struct { + ApplicationManaged bool `json:"applicationManaged"` + ContractId interface{} `json:"contractId"` + Id int `json:"id"` + LinkedSpaceId int `json:"linkedSpaceId"` + Name string `json:"name"` + PlannedPurgeDate interface{} `json:"plannedPurgeDate"` + Processor int64 `json:"processor"` + State string `json:"state"` + Version int `json:"version"` +} + +type WalleeTerminal struct { + ConfigurationVersion WalleeConfigurationVersion `json:"configurationVersion"` + DefaultCurrency string `json:"defaultCurrency"` + DeviceName interface{} `json:"deviceName"` + DeviceSerialNumber string `json:"deviceSerialNumber"` + ExternalId string `json:"externalId"` + Id int `json:"id"` + Identifier string `json:"identifier"` + LinkedSpaceId int `json:"linkedSpaceId"` + LocationVersion WalleeLocationVersion `json:"locationVersion"` + Name string `json:"name"` + PlannedPurgeDate interface{} `json:"plannedPurgeDate"` + State string `json:"state"` + Type WalleeType `json:"type"` + Version int `json:"version"` +} + +type WalleeConfigurationVersion struct { + Configuration WalleeConfiguration `json:"configuration"` + ConnectorConfigurations []int `json:"connectorConfigurations"` + CreatedBy int `json:"createdBy"` + CreatedOn time.Time `json:"createdOn"` + DefaultCurrency interface{} `json:"defaultCurrency"` + Id int `json:"id"` + LinkedSpaceId int `json:"linkedSpaceId"` + MaintenanceWindowDuration string `json:"maintenanceWindowDuration"` + MaintenanceWindowStart string `json:"maintenanceWindowStart"` + PlannedPurgeDate interface{} `json:"plannedPurgeDate"` + State string `json:"state"` + TimeZone string `json:"timeZone"` + Version int `json:"version"` + VersionAppliedImmediately bool `json:"versionAppliedImmediately"` +} + +type WalleeConfiguration struct { + Id int `json:"id"` + LinkedSpaceId int `json:"linkedSpaceId"` + Name string `json:"name"` + PlannedPurgeDate interface{} `json:"plannedPurgeDate"` + State string `json:"state"` + Type WalleeType `json:"type"` + Version int `json:"version"` +} + +type WalleeType struct { + Description WalleeMultilangProperty `json:"description"` + Id int64 `json:"id"` + Name WalleeMultilangProperty `json:"name"` +} + +type WalleeLocationVersion struct { + Address WalleeAddress `json:"address"` + ContactAddress interface{} `json:"contactAddress"` + CreatedBy int `json:"createdBy"` + CreatedOn time.Time `json:"createdOn"` + Id int `json:"id"` + LinkedSpaceId int `json:"linkedSpaceId"` + Location WalleeLocation `json:"location"` + PlannedPurgeDate interface{} `json:"plannedPurgeDate"` + State string `json:"state"` + Version int `json:"version"` + VersionAppliedImmediately bool `json:"versionAppliedImmediately"` +} + +type WalleeAddress struct { + City string `json:"city"` + Country string `json:"country"` + DependentLocality string `json:"dependentLocality"` + EmailAddress string `json:"emailAddress"` + FamilyName string `json:"familyName"` + GivenName string `json:"givenName"` + MobilePhoneNumber string `json:"mobilePhoneNumber"` + OrganizationName string `json:"organizationName"` + PhoneNumber string `json:"phoneNumber"` + PostalState interface{} `json:"postalState"` + Postcode string `json:"postcode"` + PostCode string `json:"postCode"` + Salutation string `json:"salutation"` + SortingCode string `json:"sortingCode"` + Street string `json:"street"` +} + +type WalleeLocation struct { + ExternalId string `json:"externalId"` + Id int `json:"id"` + LinkedSpaceId int `json:"linkedSpaceId"` + Name string `json:"name"` + PlannedPurgeDate interface{} `json:"plannedPurgeDate"` + State string `json:"state"` + Version int `json:"version"` +} diff --git a/c2ec/internal/utils/amount.go b/c2ec/internal/utils/amount.go @@ -0,0 +1,263 @@ +// 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 +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// Taler Go 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_utils + +import ( + "c2ec/pkg/config" + "errors" + "fmt" + "math" + "strconv" + "strings" +) + +// The GNU Taler Amount object +type Amount struct { + + // The type of currency, e.g. EUR + Currency string `json:"currency"` + + // The value (before the ".") + Value uint64 `json:"value"` + + // The fraction (after the ".", optional) + Fraction uint64 `json:"fraction"` +} + +type TalerAmountCurrency struct { + Val int64 `db:"val"` + Frac int32 `db:"frac"` + Curr string `db:"curr"` +} + +func ToAmount(amount *TalerAmountCurrency) (*Amount, error) { + + if amount == nil { + return &Amount{ + Currency: "", + Value: 0, + Fraction: 0, + }, nil + } + a := new(Amount) + a.Currency = amount.Curr + a.Value = uint64(amount.Val) + a.Fraction = uint64(amount.Frac) + return a, nil +} + +func FormatAmount(amount *Amount, fractionalDigits int) string { + + if amount == nil { + return "" + } + + 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) +const FractionalLength = 8 + +// The base of the fraction. +const FractionalBase = 1e8 + +// The maximum value +var MaxAmountValue = uint64(math.Pow(2, 52)) + +// Create a new amount from value and fraction in a currency +func NewAmount(currency string, value uint64, fraction uint64) Amount { + return Amount{ + Currency: currency, + Value: value, + Fraction: fraction, + } +} + +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) +} + +// checks if a < b +// returns error if the currencies do not match. +func (a *Amount) IsSmallerThan(b Amount) (bool, error) { + + if !strings.EqualFold(a.Currency, b.Currency) { + return false, errors.New("unable tos compare different currencies") + } + + if a.Value < b.Value { + return true, nil + } + + if a.Value == b.Value && a.Fraction < b.Fraction { + return true, nil + } + + return false, nil +} + +// checks if a = b +// returns error if the currencies do not match. +func (a *Amount) IsEqualTo(b Amount) (bool, error) { + + if !strings.EqualFold(a.Currency, b.Currency) { + return false, errors.New("unable tos compare different currencies") + } + + return a.Value == b.Value && a.Fraction == b.Fraction, nil +} + +// 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, fractionalDigits int) (*Amount, error) { + if a.Currency != b.Currency { + return nil, errors.New("currency mismatch") + } + v := a.Value + f := a.Fraction + if a.Fraction < b.Fraction { + v -= 1 + f += uint64(math.Pow10(fractionalDigits)) + } + f -= b.Fraction + if v < b.Value { + return nil, errors.New("amount overflow") + } + v -= b.Value + r := Amount{ + Currency: a.Currency, + Value: v, + Fraction: f, + } + return &r, nil +} + +// Add b to a and return the result. +// Returns an error if the currencies do not match or the addition would +// cause an overflow of the value +func (a *Amount) Add(b Amount, fractionalDigits int) (*Amount, error) { + if a.Currency != b.Currency { + return nil, errors.New("currency mismatch") + } + v := a.Value + + b.Value + + uint64(math.Floor((float64(a.Fraction)+float64(b.Fraction))/FractionalBase)) + + if v >= MaxAmountValue { + return nil, fmt.Errorf("amount overflow (%d > %d)", v, MaxAmountValue) + } + f := uint64((a.Fraction + b.Fraction) % uint64(math.Pow10(fractionalDigits))) + r := Amount{ + Currency: a.Currency, + Value: v, + Fraction: f, + } + return &r, nil +} + +// Parses an amount string in the format <currency>:<value>[.<fraction>] +func ParseAmount(s string, fractionDigits int) (*Amount, error) { + + if s == "" { + return &Amount{config.CONFIG.Server.Currency, 0, 0}, nil + } + + if !strings.Contains(s, ":") { + return nil, fmt.Errorf("invalid amount: %s", s) + } + + currencyAndAmount := strings.Split(s, ":") + if len(currencyAndAmount) != 2 { + return nil, fmt.Errorf("invalid amount: %s", s) + } + + 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.Atoi(valueAndFraction[0]) + if err != nil { + LogError("amount", err) + return nil, fmt.Errorf("invalid value in amount %s", s) + } + + 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) + } + k := 0 + if len(valueAndFraction[1]) < fractionDigits { + k = fractionDigits - len(valueAndFraction[1]) + } + fractionInt, err := strconv.Atoi(valueAndFraction[1]) + if err != nil { + LogError("amount", err) + return nil, fmt.Errorf("invalid fraction in amount %s", s) + } + fraction = fractionInt * int(math.Pow10(k)) + } + + a := NewAmount(currency, uint64(value), uint64(fraction)) + return &a, nil +} + +// Check if this amount is zero +func (a *Amount) IsZero() bool { + return (a.Value == 0) && (a.Fraction == 0) +} + +// Returns the string representation of the amount: <currency>:<value>[.<fraction>] +// Omits trailing zeroes. +func (a *Amount) String(fractionalDigits int) string { + + return FormatAmount(a, fractionalDigits) +} diff --git a/c2ec/internal/utils/amount_test.go b/c2ec/internal/utils/amount_test.go @@ -0,0 +1,424 @@ +// 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 +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// Taler Go 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_utils_test + +import ( + internal_utils "c2ec/internal/utils" + "fmt" + "strconv" + "testing" +) + +var a = internal_utils.Amount{ + Currency: "EUR", + Value: 1, + Fraction: 50000000, +} +var b = internal_utils.Amount{ + Currency: "EUR", + Value: 23, + Fraction: 70007000, +} +var c = internal_utils.Amount{ + Currency: "EUR", + Value: 25, + Fraction: 20007000, +} + +func TestAmountAdd(t *testing.T) { + d, err := a.Add(b, 8) + if err != nil { + t.Errorf("Failed adding amount") + } + if c.String(8) != d.String(8) { + t.Errorf("Failed to add to correct amount") + } +} + +func TestAmountSub(t *testing.T) { + d, err := c.Sub(b, 8) + if err != nil { + t.Errorf("Failed substracting amount") + } + if a.String(8) != d.String(8) { + t.Errorf("Failed to substract to correct amount") + } +} + +func TestAmountLarge(t *testing.T) { + x, err := internal_utils.ParseAmount("EUR:50", 2) + if err != nil { + fmt.Println(err) + t.Errorf("Failed") + } + _, err = x.Add(a, 2) + if err != nil { + fmt.Println(err) + t.Errorf("Failed") + } +} + +func TestAmountSub2(t *testing.T) { + + amnts := []string{ + "CHF:30", + "EUR:20.34", + "CHF:23.99", + "CHF:50.35", + "USD:109992332", + "CHF:0.0", + "EUR:00.0", + "USD:0.00", + "CHF:00.00", + } + + for _, a := range amnts { + am, err := internal_utils.ParseAmount(a, 2) + if err != nil { + fmt.Println("parsing failed!", a, err) + t.FailNow() + } + fmt.Println("subtracting", am.String(2)) + a2, err := am.Sub(*am, 2) + if err != nil { + fmt.Println("subtracting failed!", a, err) + t.FailNow() + } + fmt.Println("subtraction result", a2.String(2)) + if !a2.IsZero() { + fmt.Println("subtracting failure... expected zero amount but was", a2.String(2)) + } + } +} + +func TestAmountSub3(t *testing.T) { + + amnts := []string{ + "CHF:30.0004", + "CHF:30.004", + "CHF:30.04", + "CHF:30.4", + "CHF:30", + } + + for _, a := range amnts { + am, err := internal_utils.ParseAmount(a, 4) + if err != nil { + fmt.Println("parsing failed!", a, err) + t.FailNow() + } + fmt.Println("subtracting", am.String(4)) + a2, err := am.Sub(*am, 4) + if err != nil { + fmt.Println("subtracting failed!", a, err) + t.FailNow() + } + fmt.Println("subtraction result", a2.String(4)) + if !a2.IsZero() && a2.String(4) != am.Currency+":0" { + fmt.Println("subtracting failure... expected zero amount but was", a2.String(4)) + } + } +} + +func TestParseValid(t *testing.T) { + + amnts := []string{ + "CHF:30", + "EUR:20.34", + "CHF:23.99", + "CHF:50.35", + "USD:109992332", + "CHF:0.0", + "EUR:00.0", + "USD:0.00", + "CHF:00.00", + } + + for _, a := range amnts { + _, err := internal_utils.ParseAmount(a, 2) + if err != nil { + fmt.Println("failed!", a) + t.FailNow() + } + } +} + +func TestParseInvalid(t *testing.T) { + + amnts := []string{ + "CHF", + "EUR:.34", + "CHF:23.", + "EUR:452:001", + "USD:1099928583593859583332", + "CHF:4564:005", + "CHF:.40", + } + + for _, a := range amnts { + _, err := internal_utils.ParseAmount(a, 2) + if err == nil { + fmt.Println("failed! (expected error)", a) + t.FailNow() + } + } +} + +func TestFormatAmountValid(t *testing.T) { + + amnts := []string{ + "CHF:30", + "EUR:20.34", + "CHF:23.99", + "USD:109992332", + "CHF:20.05", + "USD:109992332.01", + "CHF:10.00", + "", + } + amntsParsed := make([]internal_utils.Amount, 0) + for _, a := range amnts { + a, err := internal_utils.ParseAmount(a, 2) + if err != nil { + fmt.Println("failed!", err) + t.FailNow() + } + amntsParsed = append(amntsParsed, *a) + } + + amntsFormatted := make([]string, 0) + for _, a := range amntsParsed { + amntsFormatted = append(amntsFormatted, internal_utils.FormatAmount(&a, 2)) + } + + for i, frmtd := range amntsFormatted { + fmt.Println(frmtd) + expectation, err1 := internal_utils.ParseAmount(amnts[i], 2) + reality, err2 := internal_utils.ParseAmount(frmtd, 2) + if err1 != nil || err2 != nil { + fmt.Println("failed!", err1, err2) + t.FailNow() + } + + if expectation.Currency != reality.Currency || + expectation.Value != reality.Value || + expectation.Fraction != reality.Fraction { + + fmt.Println("failed!", amnts[i], frmtd) + t.FailNow() + } + + fmt.Println("success!", amnts[i], frmtd) + } +} + +func TestFormatAmountInvalid(t *testing.T) { + + amnts := []string{ + "CHF:30", + "EUR:20.34", + "CHF:23.99", + "USD:109992332", + "USD:30.30", + } + amntsParsed := make([]internal_utils.Amount, 0) + for _, a := range amnts { + a, err := internal_utils.ParseAmount(a, 2) + if err != nil { + fmt.Println("failed!", err) + t.FailNow() + } + amntsParsed = append(amntsParsed, *a) + } + + amntsFormatted := make([]string, 0) + for _, a := range amntsParsed { + amntsFormatted = append(amntsFormatted, internal_utils.FormatAmount(&a, 2)) + } + + for i, frmtd := range amntsFormatted { + fmt.Println(frmtd) + expectation, err1 := internal_utils.ParseAmount(amnts[i], 2) + reality, err2 := internal_utils.ParseAmount(frmtd, 2) + if err1 != nil || err2 != nil { + fmt.Println("failed!", err1, err2) + t.FailNow() + } + + if expectation.Currency != reality.Currency || + expectation.Value != reality.Value || + expectation.Fraction != reality.Fraction { + + fmt.Println("failed!", amnts[i], frmtd) + t.FailNow() + } + } +} + +func TestFeesSub(t *testing.T) { + + amountWithFeesStr := fmt.Sprintf("%s:%s", "CHF", "5.00") + amountWithFees, err := internal_utils.ParseAmount(amountWithFeesStr, 3) + if err != nil { + fmt.Println("failed!", err) + t.FailNow() + } + + fees, err := internal_utils.ParseAmount("CHF:0.005", 3) + if err != nil { + fmt.Println("failed!", err) + t.FailNow() + } + + refundAmount, err := amountWithFees.Sub(*fees, 3) + if err != nil { + fmt.Println("failed!", err) + t.FailNow() + } + + if amnt := refundAmount.String(3); amnt != "CHF:4.995" { + fmt.Println("expected the refund amount to be CHF:4.995, but it was", amnt) + } else { + fmt.Println("refundable amount:", amnt) + } +} + +func TestFloat64ToAmount(t *testing.T) { + + type tuple struct { + a string + b int + } + + floats := map[float64]tuple{ + 2.345: {"2.345", 3}, + 4.204: {"4.204", 3}, + 1293.2: {"1293.2", 1}, + 1294.2: {"1294.20", 2}, + 1295.02: {"1295.02", 2}, + 2424.003: {"2424.003", 3}, + } + + for k, v := range floats { + + str := strconv.FormatFloat(k, 'f', v.b, 64) + if str != v.a { + fmt.Println("failed! expected", v.a, "got", str) + t.FailNow() + } + } +} + +func TestIsSmallerThan(t *testing.T) { + amnts := []string{ + "CHF:0", + "CHF:0.01", + "CHF:0.1", + "CHF:10", + "CHF:20", + "CHF:20.01", + "CHF:20.02", + "CHF:20.023", + } + amntsParsed := make([]internal_utils.Amount, 0) + for _, a := range amnts { + a, err := internal_utils.ParseAmount(a, 3) + if err != nil { + fmt.Println("failed!", err) + t.FailNow() + } + amntsParsed = append(amntsParsed, *a) + } + + for i, current := range amntsParsed { + if i == 0 { + continue + } + + last := amntsParsed[i-1] + fmt.Printf("checking: %s < %s\n", last.String(3), current.String(3)) + if smaller, err := last.IsSmallerThan(current); !smaller || err != nil { + fmt.Println("failed!", err) + t.FailNow() + } + } +} + +func TestIsSmallerThanNegative(t *testing.T) { + amnts := []string{ + "EUR:20.05", + "EUR:0.05", + "EUR:0.05", + } + amntsParsed := make([]internal_utils.Amount, 0) + for _, a := range amnts { + a, err := internal_utils.ParseAmount(a, 2) + if err != nil { + fmt.Println("failed!", err) + t.FailNow() + } + amntsParsed = append(amntsParsed, *a) + } + + for i, current := range amntsParsed { + if i == 0 { + continue + } + + last := amntsParsed[i-1] + fmt.Printf("checking (negative): %s < %s\n", last.String(2), current.String(2)) + if smaller, err := last.IsSmallerThan(current); smaller || err != nil { + fmt.Println("failed!", err) + t.FailNow() + } + } +} + +func TestIsEqualTo(t *testing.T) { + amnts := []string{ + "CHF:10", + "CHF:10.00", + "CHF:10.1", + "CHF:10.10", + "CHF:10.01", + "CHF:10.01", + } + amntsParsed := make([]internal_utils.Amount, 0) + for _, a := range amnts { + a, err := internal_utils.ParseAmount(a, 2) + if err != nil { + fmt.Println("failed!", err) + t.FailNow() + } + amntsParsed = append(amntsParsed, *a) + } + + doubleJump := 1 + for doubleJump <= len(amntsParsed) { + current := amntsParsed[doubleJump] + last := amntsParsed[doubleJump-1] + fmt.Printf("checking: %s = %s\n", last.String(2), current.String(2)) + if equal, err := last.IsEqualTo(current); !equal || err != nil { + fmt.Println("failed!", err) + t.FailNow() + } + doubleJump += 2 + } +} diff --git a/c2ec/internal/utils/codec.go b/c2ec/internal/utils/codec.go @@ -0,0 +1,75 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_utils + +import ( + "bytes" + "encoding/json" + "io" +) + +type Codec[T any] interface { + HttpApplicationContentHeader() string + Encode(*T) (io.Reader, error) + EncodeToBytes(body *T) ([]byte, error) + Decode(io.Reader) (*T, error) +} + +type JsonCodec[T any] struct { + Codec[T] +} + +func NewJsonCodec[T any]() Codec[T] { + + return new(JsonCodec[T]) +} + +func (*JsonCodec[T]) HttpApplicationContentHeader() string { + return "application/json" +} + +func (*JsonCodec[T]) Encode(body *T) (io.Reader, error) { + + encodedBytes, err := json.Marshal(body) + if err != nil { + return nil, err + } + + return bytes.NewReader(encodedBytes), err +} + +func (c *JsonCodec[T]) EncodeToBytes(body *T) ([]byte, error) { + + reader, err := c.Encode(body) + if err != nil { + return make([]byte, 0), err + } + buf, err := io.ReadAll(reader) + if err != nil { + return make([]byte, 0), err + } + return buf, nil +} + +func (*JsonCodec[T]) Decode(reader io.Reader) (*T, error) { + + body := new(T) + err := json.NewDecoder(reader).Decode(body) + return body, err +} diff --git a/c2ec/internal/utils/codec_test.go b/c2ec/internal/utils/codec_test.go @@ -0,0 +1,100 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_utils_test + +import ( + "bytes" + internal_api "c2ec/internal/api" + internal_utils "c2ec/internal/utils" + "fmt" + "testing" + + "gotest.tools/v3/assert" +) + +func TestJsonCodecRoundTrip(t *testing.T) { + + type TestStruct struct { + A string + B int + C []string + D byte + E []byte + F *TestStruct + } + + testObj := TestStruct{ + "TestA", + 1, + []string{"first", "second"}, + 'A', + []byte{0xdf, 0x01, 0x34}, + &TestStruct{ + "TestAA", + 2, + []string{"third", "fourth", "fifth"}, + 'B', + []byte{0xdf, 0x01, 0x34}, + nil, + }, + } + + jsonCodec := new(internal_utils.JsonCodec[TestStruct]) + + encodedTestObj, err := jsonCodec.Encode(&testObj) + if err != nil { + fmt.Println("error happened while encoding test obj", err.Error()) + t.FailNow() + } + + encodedTestObjBytes := make([]byte, 200) + _, err = encodedTestObj.Read(encodedTestObjBytes) + if err != nil { + fmt.Println("error happened while encoding test obj to byte array", err.Error()) + t.FailNow() + } + + encodedTestObjReader := bytes.NewReader(encodedTestObjBytes) + decodedTestObj, err := jsonCodec.Decode(encodedTestObjReader) + if err != nil { + fmt.Println("error happened while encoding test obj to byte array", err.Error()) + t.FailNow() + } + + assert.DeepEqual(t, &testObj, decodedTestObj) +} + +func TestTransferRequest(t *testing.T) { + + reqStr := "{\"request_uid\":\"test-1\",\"amount\":\"CHF:4.95\",\"exchange_base_url\":\"https://exchange.chf.taler.net\",\"wtid\":\"\",\"credit_account\":\"payto://wallee-transaction/R361ZT45TZ026EQ0S909C88F0E2YJY11HXV0VQTCHKR2VHA7DQCG\"}" + + fmt.Println("request string:", reqStr) + + codec := new(internal_utils.JsonCodec[internal_api.TransferRequest]) + + rdr := bytes.NewReader([]byte(reqStr)) + + req, err := codec.Decode(rdr) + if err != nil { + fmt.Println("error:", err) + t.FailNow() + } + + fmt.Println(req) +} diff --git a/c2ec/internal/utils/encoding.go b/c2ec/internal/utils/encoding.go @@ -0,0 +1,156 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_utils + +import ( + "errors" + "math" + "strings" +) + +func TalerBinaryEncode(byts []byte) string { + + return encodeCrock(byts) +} + +func TalerBinaryDecode(str string) ([]byte, error) { + + return decodeCrock(str) +} + +func ParseWopid(wopid string) ([]byte, error) { + + wopidBytes, err := TalerBinaryDecode(wopid) + if err != nil { + return nil, err + } + + if len(wopidBytes) != 32 { + err = errors.New("invalid wopid") + LogError("encoding", err) + return nil, err + } + + return wopidBytes, nil +} + +func FormatWopid(wopid []byte) string { + + return TalerBinaryEncode(wopid) +} + +func ParseEddsaPubKey(key EddsaPublicKey) ([]byte, error) { + + return TalerBinaryDecode(string(key)) +} + +func FormatEddsaPubKey(key []byte) string { + + return TalerBinaryEncode(key) +} + +func decodeCrock(e string) ([]byte, error) { + size := len(e) + bitpos := 0 + bitbuf := 0 + readPosition := 0 + outLen := int(math.Floor((float64(size) * 5.0) / 8.0)) + out := make([]byte, outLen) + outPos := 0 + + getValue := func(c byte) (int, error) { + alphabet := "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + switch c { + case 'o', 'O': + return 0, nil + case 'i', 'I', 'l', 'L': + return 1, nil + case 'u', 'U': + return 27, nil + } + + i := strings.IndexRune(alphabet, rune(c)) + if i > -1 && i < 32 { + return i, nil + } + + return -1, errors.New("crockford decoding error") + } + + for readPosition < size || bitpos > 0 { + if readPosition < size { + v, err := getValue(e[readPosition]) + if err != nil { + return nil, err + } + readPosition++ + bitbuf = bitbuf<<5 | v + bitpos += 5 + } + for bitpos >= 8 { + d := byte(bitbuf >> (bitpos - 8) & 0xff) + out[outPos] = d + outPos++ + bitpos -= 8 + } + if readPosition == size && bitpos > 0 { + bitbuf = bitbuf << (8 - bitpos) & 0xff + if bitbuf == 0 { + bitpos = 0 + } else { + bitpos = 8 + } + } + } + return out, nil +} + +func encodeCrock(data []byte) string { + out := "" + bitbuf := 0 + bitpos := 0 + + encodeValue := func(value int) byte { + alphabet := "ABCDEFGHJKMNPQRSTVWXYZ" + switch { + case value >= 0 && value <= 9: + return byte('0' + value) + case value >= 10 && value <= 31: + return alphabet[value-10] + default: + panic("Invalid value for encoding") + } + } + + for _, b := range data { + bitbuf = bitbuf<<8 | int(b&0xff) + bitpos += 8 + for bitpos >= 5 { + value := bitbuf >> (bitpos - 5) & 0x1f + out += string(encodeValue(value)) + bitpos -= 5 + } + } + if bitpos > 0 { + bitbuf = bitbuf << (5 - bitpos) + value := bitbuf & 0x1f + out += string(encodeValue(value)) + } + return out +} diff --git a/c2ec/internal/utils/encoding_test.go b/c2ec/internal/utils/encoding_test.go @@ -0,0 +1,152 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_utils + +import ( + "crypto/rand" + "testing" +) + +func TestWopidEncodeDecode(t *testing.T) { + + wopid := make([]byte, 32) + n, err := rand.Read(wopid) + if err != nil || n != 32 { + t.Log("failed because retrieving random 32 bytes failed") + t.FailNow() + } + + encodedWopid := FormatWopid(wopid) + t.Log("encoded wopid:", encodedWopid) + decodedWopid, err := ParseWopid(encodedWopid) + if err != nil { + t.Error(err) + t.FailNow() + } + + if len(decodedWopid) != len(wopid) { + t.Log("uneven length.", len(decodedWopid), "!=", len(wopid)) + t.FailNow() + } + + for i, b := range wopid { + + if b != decodedWopid[i] { + t.Log("unequal at position", i) + t.FailNow() + } + } +} + +func TestTalerBase32(t *testing.T) { + + input := []byte("This is some text") + t.Log("in:", string(input)) + t.Log("in:", input) + encoded := TalerBinaryEncode(input) + t.Log("encoded:", encoded) + out, err := TalerBinaryDecode(encoded) + if err != nil { + t.Error(err) + t.FailNow() + } + t.Log("decoded:", out) + t.Log("decoded:", string(out)) + + if len(out) != len(input) { + t.Log("uneven length.", len(out), "!=", len(input)) + t.FailNow() + } + + for i, b := range input { + + if b != out[i] { + t.Log("unequal at position", i) + t.FailNow() + } + } +} + +func TestTalerBase32Rand32(t *testing.T) { + + input := make([]byte, 32) + n, err := rand.Read(input) + if err != nil || n != 32 { + t.Log("failed because retrieving random 32 bytes failed") + t.FailNow() + } + + t.Log("in:", input) + encoded := TalerBinaryEncode(input) + t.Log("encoded:", encoded) + out, err := TalerBinaryDecode(encoded) + if err != nil { + t.Error(err) + t.FailNow() + } + t.Log("decoded:", out) + t.Log("decoded:", string(out)) + + if len(out) != len(input) { + t.Log("uneven length.", len(out), "!=", len(input)) + t.FailNow() + } + + for i, b := range input { + + if b != out[i] { + t.Log("unequal at position", i) + t.FailNow() + } + } +} + +func TestTalerBase32Rand64(t *testing.T) { + + input := make([]byte, 64) + n, err := rand.Read(input) + if err != nil || n != 64 { + t.Log("failed because retrieving random 64 bytes failed") + t.FailNow() + } + + t.Log("in:", input) + encoded := TalerBinaryEncode(input) + t.Log("encoded:", encoded) + out, err := TalerBinaryDecode(encoded) + if err != nil { + t.Error(err) + t.FailNow() + } + t.Log("decoded:", out) + t.Log("decoded:", string(out)) + + if len(out) != len(input) { + t.Log("uneven length.", len(out), "!=", len(input)) + t.FailNow() + } + + for i, b := range input { + + if b != out[i] { + t.Log("unequal at position", i) + t.FailNow() + } + } +} diff --git a/c2ec/internal/utils/exponential-backoff.go b/c2ec/internal/utils/exponential-backoff.go @@ -0,0 +1,95 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_utils + +import ( + "crypto/rand" + "fmt" + "math" + "math/big" + "time" +) + +const EXPONENTIAL_BACKOFF_BASE = 2 + +const RANDOMIZATION_THRESHOLD_FACTOR = 0.2 // +/- 20% + +/* +Generic implementation of a limited exponential backoff +algorithm. It includes a randomization to prevent +self-synchronization issues. + +Parameters: + + - lastExecution: time of the last execution + - retryCount : number of the retries + - limitMs : field shall be the maximal milliseconds to backoff before retry happens +*/ +func ShouldStartRetry( + lastExecution time.Time, + retryCount int, + limitMs int, +) bool { + + backoffMs := exponentialBackoffMs(retryCount) + randomizedBackoffSeconds := int64(limitMs) / 1000 + if backoffMs < int64(limitMs) { + randomizedBackoffSeconds = randomizeBackoff(backoffMs) + } else { + LogInfo("exponential-backoff", fmt.Sprintf("backoff limit exceeded. setting manual limit: %d", limitMs)) + } + + now := time.Now().Unix() + backoffTime := lastExecution.Unix() + randomizedBackoffSeconds + // LogInfo("exponential-backoff", fmt.Sprintf("lastExec=%d, now=%d, backoffTime=%d, shouldStartRetry=%s", lastExecution.Unix(), now, backoffTime, strconv.FormatBool(now >= backoffTime))) + return now >= backoffTime +} + +func exponentialBackoffMs(retries int) int64 { + + return int64(math.Pow(EXPONENTIAL_BACKOFF_BASE, float64(retries))) +} + +func randomizeBackoff(backoff int64) int64 { + + // it's about randomizing on millisecond base... we mustn't care about rounding + threshold := int64(math.Floor(float64(backoff)*RANDOMIZATION_THRESHOLD_FACTOR)) + 1 // +1 to guarantee positive threshold + randomizedThreshold, err := rand.Int(rand.Reader, big.NewInt(backoff+threshold)) + if err != nil { + LogError("exponential-backoff", err) + } + subtract, err := rand.Int(rand.Reader, big.NewInt(100)) // upper boundary is exclusive (value is between 0 and 99) + if err != nil { + LogError("exponential-backoff", err) + } + + if !randomizedThreshold.IsInt64() { + LogWarn("exponential-backoff", "the threshold is not int64") + return backoff + } + + if subtract.Int64() < 50 { + subtracted := backoff - randomizedThreshold.Int64() + if subtracted < 0 { + return 0 + } + return subtracted + } + return backoff + randomizedThreshold.Int64() +} diff --git a/c2ec/internal/utils/exponential-backoff_test.go b/c2ec/internal/utils/exponential-backoff_test.go @@ -0,0 +1,80 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_utils + +import ( + "fmt" + "testing" + "time" +) + +func TestShouldRetryYes(t *testing.T) { + + lastExecution := time.Now().Add(-(time.Duration(10 * time.Second))) + retries := 4 + limitMs := 1000 + + retry := ShouldStartRetry(lastExecution, retries, limitMs) + if !retry { + fmt.Println("expected retry = true but was false") + t.FailNow() + } +} + +func TestShouldRetryNo(t *testing.T) { + + lastExecution := time.Now().Add(-(time.Duration(10 * time.Second))) + retries := 1 + limitMs := 1000 + + retry := ShouldStartRetry(lastExecution, retries, limitMs) + if retry { + fmt.Println("expected retry = false but was true") + t.FailNow() + } +} + +func TestBackoff(t *testing.T) { + + expectations := []int{1, 2, 4, 8, 16, 32, 64, 128, 256} + for i := range []int{0, 1, 2, 3, 4, 5, 6, 7, 8} { + backoff := exponentialBackoffMs(i) + if backoff != int64(expectations[i]) { + fmt.Printf("expected %d, but got %d", expectations[i], backoff) + t.FailNow() + } + } +} + +func TestRandomization(t *testing.T) { + + input := 100 + lowerBoundary := 80 // -20% + upperBoundary := 120 // +20% + rounds := 1000 + currentRound := 0 + for currentRound < rounds { + randomized := randomizeBackoff(int64(input)) + if randomized < int64(lowerBoundary) || randomized > int64(upperBoundary) { + fmt.Printf("round %d failed. Expected value between %d and %d but got %d", currentRound, lowerBoundary, upperBoundary, randomized) + t.FailNow() + } + currentRound++ + } +} diff --git a/c2ec/internal/utils/http-util.go b/c2ec/internal/utils/http-util.go @@ -0,0 +1,292 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_utils + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +const HTTP_GET = "GET" +const HTTP_POST = "POST" + +const HTTP_OK = 200 +const HTTP_NO_CONTENT = 204 +const HTTP_BAD_REQUEST = 400 +const HTTP_UNAUTHORIZED = 401 +const HTTP_NOT_FOUND = 404 +const HTTP_METHOD_NOT_ALLOWED = 405 +const HTTP_CONFLICT = 409 +const HTTP_INTERNAL_SERVER_ERROR = 500 +const HTTP_NOT_IMPLEMENTED = 501 + +const CONTENT_TYPE_HEADER = "Content-Type" + +// Function reads and validates a param of a request in the +// correct format according to the transform function supplied. +// When the transform fails, it returns false as second return +// value. This indicates the caller, that the request shall not +// be further processed and the handle must be returned by the +// caller. Since the parameter is optional, it can be null, even +// if the boolean return value is set to true. +func AcceptOptionalParamOrWriteResponse[T any]( + name string, + transform func(s string) (T, error), + req *http.Request, + res http.ResponseWriter, +) (*T, bool) { + + ptr, err := OptionalQueryParamOrError(name, transform, req) + if err != nil { + SetLastResponseCodeForLogger(HTTP_BAD_REQUEST) + res.WriteHeader(HTTP_BAD_REQUEST) + return nil, false + } + + if ptr == nil { + LogInfo("http", "optional parameter "+name+" was not set") + return nil, true + } + + obj := *ptr + assertedObj, ok := any(obj).(T) + if !ok { + // this should generally not happen (due to the implementation) + SetLastResponseCodeForLogger(HTTP_INTERNAL_SERVER_ERROR) + res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) + return nil, false + } + return &assertedObj, true +} + +// The function parses a parameter of the query +// of the request. If the parameter is not present +// (empty string) it will not create an error and +// just return nil. +func OptionalQueryParamOrError[T any]( + name string, + transform func(s string) (T, error), + req *http.Request, +) (*T, error) { + + paramStr := req.URL.Query().Get(name) + if paramStr != "" { + + if t, err := transform(paramStr); err != nil { + return nil, err + } else { + return &t, nil + } + } + return nil, nil +} + +// Reads a generic argument struct from the requests +// body. It takes the codec as argument which is used to +// decode the struct from the request. If an error occurs +// nil and the error are returned. +func ReadStructFromBody[T any](req *http.Request, codec Codec[T]) (*T, error) { + + bodyBytes, err := ReadBody(req) + if err != nil { + return nil, err + } + + return codec.Decode(bytes.NewReader(bodyBytes)) +} + +// Reads the body of a request into a byte array. +// If the body is empty, an empty array is returned. +// If an error occurs while reading the body, nil and +// the respective error is returned. +func ReadBody(req *http.Request) ([]byte, error) { + + if req.ContentLength < 0 { + return nil, errors.New("malformed body") + } + + body, err := io.ReadAll(req.Body) + if err != nil { + LogError("http-util", err) + return nil, err + } + LogInfo("http-util", "read body from request. body="+string(body)) + return body, nil +} + +// Executes a GET request at the given url. +// Use FormatUrl for to build the url. +// Headers can be defined using the headers map. +func HttpGet[T any]( + url string, + headers map[string]string, + codec Codec[T], +) (*T, int, error) { + + req, err := http.NewRequest(HTTP_GET, url, bytes.NewBufferString("")) + if err != nil { + return nil, -1, err + } + + for k, v := range headers { + req.Header.Add(k, v) + } + req.Header.Add("Accept", codec.HttpApplicationContentHeader()) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, -1, err + } + + if codec == nil { + return nil, res.StatusCode, err + } else { + b, err := io.ReadAll(res.Body) + if err != nil { + LogError("http-util", err) + if res.StatusCode > 299 { + return nil, res.StatusCode, nil + } + return nil, -1, err + } + if res.StatusCode > 299 { + LogInfo("http-util", fmt.Sprintf("response: %s", string(b))) + return nil, res.StatusCode, nil + } + resBody, err := codec.Decode(bytes.NewReader(b)) + return resBody, res.StatusCode, err + } +} + +// execute a POST request and parse response or retrieve error +// path- and query-parameters can be set to add query and path parameters +func HttpPost[T any, R any]( + url string, + headers map[string]string, + body *T, + reqCodec Codec[T], + resCodec Codec[R], +) (*R, int, error) { + + bodyEncoded, err := reqCodec.EncodeToBytes(body) + if err != nil { + return nil, -1, err + } + LogInfo("http-util", string(bodyEncoded)) + + req, err := http.NewRequest(HTTP_POST, url, bytes.NewBuffer(bodyEncoded)) + if err != nil { + return nil, -1, err + } + + for k, v := range headers { + req.Header.Add(k, v) + } + if resCodec != nil { + req.Header.Add("Accept", resCodec.HttpApplicationContentHeader()) + } + req.Header.Add("Content-Type", reqCodec.HttpApplicationContentHeader()) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, -1, err + } + + if resCodec == nil { + return nil, res.StatusCode, err + } else { + b, err := io.ReadAll(res.Body) + if err != nil { + LogError("http-util", err) + if res.StatusCode > 299 { + return nil, res.StatusCode, nil + } + return nil, -1, err + } + if res.StatusCode > 299 { + LogInfo("http-util", fmt.Sprintf("response: %s", string(b))) + return nil, res.StatusCode, nil + } + resBody, err := resCodec.Decode(bytes.NewReader(b)) + return resBody, res.StatusCode, err + } +} + +// builds request URL containing the path and query +// parameters of the respective parameter map. +func FormatUrl( + req string, + pathParams map[string]string, + queryParams map[string]string, +) string { + + return setUrlQuery(setUrlPath(req, pathParams), queryParams) +} + +// Sets the parameters which are part of the url. +// The function expects each parameter in the path to be prefixed +// using a ':'. The function handles url as follows: +// +// /some/:param/tobereplaced -> ':param' will be replaced with value. +// +// For replacements, the pathParams map must be supplied. The map contains +// the name of the parameter with the value mapped to it. +// The names MUST not contain the prefix ':'! +func setUrlPath( + req string, + pathParams map[string]string, +) string { + + if pathParams == nil || len(pathParams) < 1 { + return req + } + + var url = req + for k, v := range pathParams { + + if !strings.HasPrefix(k, "/") { + // prevent scheme postfix replacements + url = strings.Replace(url, ":"+k, v, 1) + } + } + return url +} + +func setUrlQuery( + req string, + queryParams map[string]string, +) string { + + if queryParams == nil || len(queryParams) < 1 { + return req + } + + var url = req + "?" + for k, v := range queryParams { + + url = strings.Join([]string{url, k, "=", v, "&"}, "") + } + + url, _ = strings.CutSuffix(url, "&") + return url +} diff --git a/c2ec/internal/utils/http-util_test.go b/c2ec/internal/utils/http-util_test.go @@ -0,0 +1,90 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_utils + +import ( + "fmt" + "testing" +) + +const URL_GET = "https://jsonplaceholder.typicode.com/todos/:id" +const URL_POST = "https://jsonplaceholder.typicode.com/posts" + +type TestStruct struct { + UserId int `json:"userId"` + Id int `json:"id"` + Title string `json:"title"` + Completed bool `json:"completed"` +} + +func TestGET(t *testing.T) { + + url := FormatUrl( + URL_GET, + map[string]string{ + "id": "1", + }, + map[string]string{}, + ) + + codec := NewJsonCodec[TestStruct]() + res, status, err := HttpGet( + url, + map[string]string{}, + codec, + ) + + if err != nil { + t.Errorf("%s", err.Error()) + t.FailNow() + } + + fmt.Println("res:", res, ", status:", status) +} + +func TestPOST(t *testing.T) { + + url := FormatUrl( + URL_POST, + map[string]string{ + "id": "1", + }, + map[string]string{}, + ) + + res, status, err := HttpPost( + url, + map[string]string{}, + &TestStruct{ + UserId: 1, + Id: 1, + Title: "TEST", + Completed: false, + }, + NewJsonCodec[TestStruct](), + NewJsonCodec[TestStruct](), + ) + + if err != nil { + t.Errorf("%s", err.Error()) + t.FailNow() + } + + fmt.Println("res:", res, ", status:", status) +} diff --git a/c2ec/internal/utils/logger.go b/c2ec/internal/utils/logger.go @@ -0,0 +1,115 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_utils + +import ( + "fmt" + "net/http" + "os" + "time" +) + +const LOG_PATH = "c2ec-log.txt" + +// LEVEL | TIME | SRC | MESSAGE +const LOG_PATTERN = "level=%d | time=%s | src=%s | %s" +const TIME_FORMAT = "yyyy-MM-dd hh:mm:ss" + +type LogLevel int + +const ( + INFO LogLevel = iota + WARN + ERROR +) + +var lastResponseStatus = 0 + +func LoggingHandler(next http.Handler) http.Handler { + + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + + LogRequest("http-logger", r) + + next.ServeHTTP(w, r) + + LogResponse("http-logger", r) + }, + ) +} + +func SetLastResponseCodeForLogger(s int) { + lastResponseStatus = s +} + +func LogRequest(src string, req *http.Request) { + + LogInfo(src, fmt.Sprintf("%s - %s (origin=%s)", req.Method, req.URL, req.RemoteAddr)) +} + +func LogResponse(src string, req *http.Request) { + + LogInfo(src, fmt.Sprintf("code=%d: responding to %s (destination=%s)", lastResponseStatus, req.RequestURI, req.RemoteAddr)) +} + +func LogError(src string, err error) { + + go logAppendError(src, ERROR, err) +} + +func LogWarn(src string, msg string) { + + go logAppend(src, WARN, msg) +} + +func LogInfo(src string, msg string) { + + go logAppend(src, INFO, msg) +} + +func logAppendError(src string, level LogLevel, err error) { + + if err == nil { + fmt.Println("wanted to log from " + src + " but err was nil") + return + } + logAppend(src, level, err.Error()) +} + +func logAppend(src string, level LogLevel, msg string) { + + openAppendClose(fmt.Sprintf(LOG_PATTERN, level, time.Now().Format(time.UnixDate), src, msg)) +} + +func openAppendClose(s string) { + + // first try opening only append + f, err := os.OpenFile(LOG_PATH, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil || f == nil { + // if file does not yet exist, open with create flag. + f, err = os.OpenFile(LOG_PATH, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil || f == nil { + fmt.Println("error: ", err.Error()) + panic("failed opening or creating log file") + } + } + f.WriteString(s + "\n") + f.Close() +} diff --git a/c2ec/internal/utils/payto.go b/c2ec/internal/utils/payto.go @@ -0,0 +1,106 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_utils + +import ( + "errors" + "fmt" + "strings" +) + +const PAYTO_PARTS_SEPARATOR = "/" + +const PAYTO_SCHEME_PREFIX = "payto://" +const PAYTO_TAGRET_TYPE_IBAN = "iban" +const PAYTO_TARGET_TYPE_WALLEE_TRANSACTION = "wallee-transaction" + +var REGISTERED_TARGET_TYPES = []string{ + "ach", + "bic", + "iban", + "upi", + "bitcoin", + "ilp", + "void", + "ldap", + "eth", + "interac-etransfer", + "wallee-transaction", +} + +// This function parses a payto-uri (RFC 8905: https://www.rfc-editor.org/rfc/rfc8905.html) +// The function only parses the target type "wallee-transaction" as specified +// in the payto GANA registry (https://gana.gnunet.org/payto-payment-target-types/payto_payment_target_types.html) +func ParsePaytoWalleeTransaction(uri string) (string, string, error) { + + if t, i, err := ParsePaytoUri(uri); err != nil { + + if t != "wallee-transaction" { + return "", "", errors.New("expected payto target type 'wallee-transaction'") + } + + return t, i, nil + } else { + return t, "", err + } +} + +// returns the Payto Target Type and Target Identifier as string +// if the uri is malformed, an error is returned (target type and +// identifier will be empty strings). +func ParsePaytoUri(uri string) (string, string, error) { + + if raw, found := strings.CutPrefix(uri, PAYTO_SCHEME_PREFIX); found { + + parts := strings.Split(raw, PAYTO_PARTS_SEPARATOR) + if len(parts) < 2 { + return "", "", errors.New("invalid payto-uri") + } + + return parts[0], parts[1], nil + } + return "", "", errors.New("invalid payto-uri") +} + +func FormatPaytoWalleeTransaction(tid int) string { + return fmt.Sprintf("%s%s/%d", + PAYTO_SCHEME_PREFIX, + PAYTO_TARGET_TYPE_WALLEE_TRANSACTION, + tid, + ) +} + +func ParsePaytoTargetType(uri string) (string, error) { + + if raw, found := strings.CutPrefix(uri, PAYTO_SCHEME_PREFIX); found { + + parts := strings.Split(raw, PAYTO_PARTS_SEPARATOR) + if len(parts) < 2 { + return "", errors.New("invalid wallee-transaction payto-uri") + } + + for _, target := range REGISTERED_TARGET_TYPES { + if strings.EqualFold(target, parts[0]) { + return parts[0], nil + } + } + return "", errors.New("target type '" + parts[0] + "' is not registered") + } + return "", errors.New("invalid payto-uri") +} diff --git a/c2ec/internal/utils/utils.go b/c2ec/internal/utils/utils.go @@ -0,0 +1,124 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package internal_utils + +import ( + "encoding/base64" + "errors" + "strings" + + "golang.org/x/crypto/argon2" +) + +// https://docs.taler.net/core/api-common.html#hash-codes +type WithdrawalIdentifier string + +// https://docs.taler.net/core/api-common.html#cryptographic-primitives +type EddsaPublicKey string + +// https://docs.taler.net/core/api-common.html#hash-codes +type HashCode string + +// https://docs.taler.net/core/api-common.html#hash-codes +type ShortHashCode string + +// https://docs.taler.net/core/api-common.html#timestamps +type Timestamp struct { + Ts int `json:"t_s"` +} + +// https://docs.taler.net/core/api-common.html#wadid +type WadId [6]uint32 + +// according to https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankWithdrawalOperationStatus +type WithdrawalOperationStatus string + +const ( + PENDING WithdrawalOperationStatus = "pending" + SELECTED WithdrawalOperationStatus = "selected" + ABORTED WithdrawalOperationStatus = "aborted" + CONFIRMED WithdrawalOperationStatus = "confirmed" +) + +func ToWithdrawalOperationStatus(s string) (WithdrawalOperationStatus, error) { + switch strings.ToLower(s) { + case string(PENDING): + return PENDING, nil + case string(SELECTED): + return SELECTED, nil + case string(ABORTED): + return ABORTED, nil + case string(CONFIRMED): + return CONFIRMED, nil + default: + return "", errors.New("invalid withdrawal operation status '" + s + "'") + } +} + +func RemoveNulls[T any](l []*T) []*T { + + withoutNulls := make([]*T, 0) + for _, e := range l { + if e != nil { + withoutNulls = append(withoutNulls, e) + } + } + return withoutNulls +} + +// takes a password and a base64 encoded password hash, including salt and checks +// the password supplied against it. +// the format of the password hash is expected to be the following: +// +// [32 BYTES HASH][16 BYTES SALT] = Bytes array with length of 48 bytes. +// +// returns true if password matches the password hash. Otherwise false. +func ValidPassword(pw string, base64EncodedHashAndSalt string) bool { + + hashedBytes := make([]byte, 48) + decodedLen, err := base64.StdEncoding.Decode(hashedBytes, []byte(base64EncodedHashAndSalt)) + if err != nil { + return false + } + + if decodedLen != 48 { + // malformed credentials + return false + } + + salt := hashedBytes[32:48] + rfcTime := 3 + rfcMemory := 32 * 1024 + key := argon2.Key([]byte(pw), salt, uint32(rfcTime), uint32(rfcMemory), 4, 32) + + if len(key) != 32 { + // length mismatch + return false + } + + for i := range key { + if key[i] != hashedBytes[i] { + // wrong password (application user key) + return false + } + } + + // password correct. + return true +} diff --git a/c2ec/logger.go b/c2ec/logger.go @@ -1,115 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "fmt" - "net/http" - "os" - "time" -) - -const LOG_PATH = "c2ec-log.txt" - -// LEVEL | TIME | SRC | MESSAGE -const LOG_PATTERN = "level=%d | time=%s | src=%s | %s" -const TIME_FORMAT = "yyyy-MM-dd hh:mm:ss" - -type LogLevel int - -const ( - INFO LogLevel = iota - WARN - ERROR -) - -var lastResponseStatus = 0 - -func LoggingHandler(next http.Handler) http.Handler { - - return http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - - LogRequest("http-logger", r) - - next.ServeHTTP(w, r) - - LogResponse("http-logger", r) - }, - ) -} - -func setLastResponseCodeForLogger(s int) { - lastResponseStatus = s -} - -func LogRequest(src string, req *http.Request) { - - LogInfo(src, fmt.Sprintf("%s - %s (origin=%s)", req.Method, req.URL, req.RemoteAddr)) -} - -func LogResponse(src string, req *http.Request) { - - LogInfo(src, fmt.Sprintf("code=%d: responding to %s (destination=%s)", lastResponseStatus, req.RequestURI, req.RemoteAddr)) -} - -func LogError(src string, err error) { - - go logAppendError(src, ERROR, err) -} - -func LogWarn(src string, msg string) { - - go logAppend(src, WARN, msg) -} - -func LogInfo(src string, msg string) { - - go logAppend(src, INFO, msg) -} - -func logAppendError(src string, level LogLevel, err error) { - - if err == nil { - fmt.Println("wanted to log from " + src + " but err was nil") - return - } - logAppend(src, level, err.Error()) -} - -func logAppend(src string, level LogLevel, msg string) { - - openAppendClose(fmt.Sprintf(LOG_PATTERN, level, time.Now().Format(time.UnixDate), src, msg)) -} - -func openAppendClose(s string) { - - // first try opening only append - f, err := os.OpenFile(LOG_PATH, os.O_APPEND|os.O_WRONLY, 0600) - if err != nil || f == nil { - // if file does not yet exist, open with create flag. - f, err = os.OpenFile(LOG_PATH, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) - if err != nil || f == nil { - fmt.Println("error: ", err.Error()) - panic("failed opening or creating log file") - } - } - f.WriteString(s + "\n") - f.Close() -} diff --git a/c2ec/main.go b/c2ec/main.go @@ -19,35 +19,15 @@ package main import ( - "context" - "errors" + "c2ec/internal" + utils "c2ec/internal/utils" + "c2ec/pkg/config" "fmt" - "net" - http "net/http" "os" - "os/signal" - "syscall" "time" ) -const GET = "GET " -const POST = "POST " -const DELETE = "DELETE " - -// https://docs.taler.net/core/api-terminal.html#endpoints-for-integrated-sub-apis -const BANK_INTEGRATION_API = "/taler-integration" -const WIRE_GATEWAY_API = "/taler-wire-gateway" - -const DEFAULT_C2EC_CONFIG_PATH = "c2ec-config.yaml" // "c2ec-config.conf" - -var CONFIG C2ECConfig - -var DB C2ECDatabase - -// This map contains all clients initialized during the -// startup of the application. The clients SHALL register -// themselfs during the setup!! -var PROVIDER_CLIENTS = map[string]ProviderClient{} +const DEFAULT_C2EC_CONFIG_PATH = "./configs/c2ec-config.yaml" // "c2ec-config.conf" // Starts the c2ec process. // The program takes following arguments (ordered): @@ -84,291 +64,17 @@ func main() { } } - LogInfo("main", fmt.Sprintf("starting c2ec at %s", time.Now().Format(time.UnixDate))) - cfg, err := Parse(cfgPath) + utils.LogInfo("main", fmt.Sprintf("starting c2ec at %s", time.Now().Format(time.UnixDate))) + cfg, err := config.Parse(cfgPath) if err != nil { panic("unable to load config: " + err.Error()) } if cfg == nil { panic("config is nil") } - CONFIG = *cfg - - DB, err = setupDatabase(&CONFIG.Database) - if err != nil { - panic("unable to connect to datatbase: " + err.Error()) - } - - err = setupProviderClients(&CONFIG) - if err != nil { - panic("unable initialize provider clients: " + err.Error()) - } - LogInfo("main", "provider clients are setup") - - retryCtx, retryCancel := context.WithCancel(context.Background()) - defer retryCancel() - retryErrs := make(chan error) - RunRetrier(retryCtx, retryErrs) - LogInfo("main", "retrier is running") - - attestorCtx, attestorCancel := context.WithCancel(context.Background()) - defer attestorCancel() - attestorErrs := make(chan error) - RunAttestor(attestorCtx, attestorErrs) - LogInfo("main", "attestor is running") - - transferCtx, transferCancel := context.WithCancel(context.Background()) - defer transferCancel() - transferErrs := make(chan error) - RunTransferrer(transferCtx, transferErrs) - LogInfo("main", "refunder is running") - - router := http.NewServeMux() - routerErrs := make(chan error) - - setupBankIntegrationRoutes(router) - setupWireGatewayRoutes(router) - setupTerminalRoutes(router) - - startListening(router, routerErrs) - - // since listening for incoming request, attesting payments and - // retrying payments are separated processes who can fail - // we must take care of this here. The main process is used to - // dispatch incoming http request and parent of the confirmation - // and retry processes. If the main process fails somehow, also - // confirmation and retries will end. But if somehow the confirmation - // or retry process fail, they will be restarted and the error is - // written to the log. If some setup tasks are failing, the program - // panics. - for { - select { - case routerError := <-routerErrs: - LogError("main", routerError) - attestorCancel() - retryCancel() - transferCancel() - panic(routerError) - case <-attestorCtx.Done(): - attestorCancel() // first run old cancellation function - attestorCtx, attestorCancel = context.WithCancel(context.Background()) - RunAttestor(attestorCtx, attestorErrs) - case <-retryCtx.Done(): - retryCancel() // first run old cancellation function - retryCtx, retryCancel = context.WithCancel(context.Background()) - RunRetrier(retryCtx, retryErrs) - case <-transferCtx.Done(): - transferCancel() // first run old cancellation function - transferCtx, transferCancel = context.WithCancel(context.Background()) - RunTransferrer(transferCtx, transferErrs) - case confirmationError := <-attestorErrs: - LogError("main-from-proc-attestor", confirmationError) - case retryError := <-retryErrs: - LogError("main-from-proc-retrier", retryError) - case transferError := <-transferErrs: - LogError("main-from-proc-transfer", transferError) - } - } -} + config.CONFIG = *cfg -func setupDatabase(cfg *C2ECDatabseConfig) (C2ECDatabase, error) { - - return NewC2ECPostgres(cfg) -} - -func setupProviderClients(cfg *C2ECConfig) error { - - if DB == nil { - return errors.New("setup database first") - } - - for _, provider := range cfg.Providers { - - p, err := DB.GetTerminalProviderByName(provider.Name) - if err != nil { - return err - } - - if p == nil { - if cfg.Server.IsProd || cfg.Server.StrictAttestors { - panic("no provider entry for " + provider.Name) - } else { - LogWarn("non-strict attestor initialization. skipping", provider.Name) - continue - } - } - - if !cfg.Server.IsProd { - // Prevent simulation client to be loaded in productive environments. - if p.Name == "Simulation" { - - simulationClient := new(SimulationClient) - err := simulationClient.SetupClient(p) - if err != nil { - return err - } - LogInfo("main", "setup the Simulation provider") - } - } - - if p.Name == "Wallee" { - - walleeClient := new(WalleeClient) - err := walleeClient.SetupClient(p) - if err != nil { - return err - } - LogInfo("main", "setup the Wallee provider") - } - - // For new added provider, add the respective if-clause - } - - for _, p := range CONFIG.Providers { - if PROVIDER_CLIENTS[p.Name] == nil { - err := errors.New("no provider client initialized for provider " + p.Name) - LogError("retrier", err) - return err - } - } - - return nil -} - -func setupBankIntegrationRoutes(router *http.ServeMux) { - - router.HandleFunc( - GET+BANK_INTEGRATION_API+BANK_INTEGRATION_CONFIG_PATTERN, - bankIntegrationConfig, - ) - LogInfo("main", "setup "+GET+BANK_INTEGRATION_API+BANK_INTEGRATION_CONFIG_PATTERN) - - router.HandleFunc( - GET+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_BY_WOPID_PATTERN, - handleWithdrawalStatus, - ) - LogInfo("main", "setup "+GET+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_BY_WOPID_PATTERN) - - router.HandleFunc( - POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_BY_WOPID_PATTERN, - handleParameterRegistration, - ) - LogInfo("main", "setup "+POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_BY_WOPID_PATTERN) - - router.HandleFunc( - POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_ABORTION_PATTERN, - handleWithdrawalAbort, - ) - LogInfo("main", "setup "+POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_ABORTION_PATTERN) -} - -func setupWireGatewayRoutes(router *http.ServeMux) { - - router.HandleFunc( - GET+WIRE_GATEWAY_API+WIRE_GATEWAY_CONFIG_PATTERN, - wireGatewayConfig, - ) - LogInfo("main", "setup "+GET+WIRE_GATEWAY_API+WIRE_GATEWAY_CONFIG_PATTERN) - - router.HandleFunc( - POST+WIRE_GATEWAY_API+WIRE_TRANSFER_PATTERN, - transfer, - ) - LogInfo("main", "setup "+POST+WIRE_GATEWAY_API+WIRE_TRANSFER_PATTERN) - - router.HandleFunc( - GET+WIRE_GATEWAY_API+WIRE_HISTORY_INCOMING_PATTERN, - historyIncoming, - ) - LogInfo("main", "setup "+GET+WIRE_GATEWAY_API+WIRE_HISTORY_INCOMING_PATTERN) - - router.HandleFunc( - GET+WIRE_GATEWAY_API+WIRE_HISTORY_OUTGOING_PATTERN, - historyOutgoing, - ) - LogInfo("main", "setup "+GET+WIRE_GATEWAY_API+WIRE_HISTORY_OUTGOING_PATTERN) - - router.HandleFunc( - POST+WIRE_GATEWAY_API+WIRE_ADMIN_ADD_INCOMING_PATTERN, - adminAddIncoming, - ) - LogInfo("main", "setup "+POST+WIRE_GATEWAY_API+WIRE_ADMIN_ADD_INCOMING_PATTERN) -} - -func setupTerminalRoutes(router *http.ServeMux) { - - router.HandleFunc( - GET+TERMINAL_API_CONFIG, - handleTerminalConfig, - ) - LogInfo("main", "setup "+GET+TERMINAL_API_CONFIG) - - router.HandleFunc( - POST+TERMINAL_API_REGISTER_WITHDRAWAL, - handleWithdrawalSetup, - ) - LogInfo("main", "setup "+POST+TERMINAL_API_REGISTER_WITHDRAWAL) - - router.HandleFunc( - POST+TERMINAL_API_CHECK_WITHDRAWAL, - handleWithdrawalCheck, - ) - LogInfo("main", "setup "+POST+TERMINAL_API_CHECK_WITHDRAWAL) - - router.HandleFunc( - GET+TERMINAL_API_WITHDRAWAL_STATUS, - handleWithdrawalStatusTerminal, - ) - LogInfo("main", "setup "+GET+TERMINAL_API_WITHDRAWAL_STATUS) - - router.HandleFunc( - DELETE+TERMINAL_API_ABORT_WITHDRAWAL, - handleWithdrawalAbortTerminal, - ) - LogInfo("main", "setup "+DELETE+TERMINAL_API_ABORT_WITHDRAWAL) -} - -func startListening(router *http.ServeMux, errs chan error) { - - server := http.Server{ - Handler: LoggingHandler(router), - } - - if CONFIG.Server.UseUnixDomainSocket { - - LogInfo("main", "using domain sockets") - socket, err := net.Listen("unix", CONFIG.Server.UnixSocketPath) - if err != nil { - panic("failed listening on socket: " + err.Error()) - } - - // cleans up socket when process fails and is shutdown. - c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt, syscall.SIGTERM) - go func() { - <-c - os.Remove(CONFIG.Server.UnixSocketPath) - os.Exit(1) - }() - - go func() { - LogInfo("main", "serving at unix-domain-socket "+server.Addr) - if err = server.Serve(socket); err != nil { - errs <- err - } - }() - } else { - - LogInfo("main", "using tcp") - go func() { - server.Addr = fmt.Sprintf("%s:%d", CONFIG.Server.Host, CONFIG.Server.Port) - LogInfo("main", "serving at "+server.Addr) - if err := server.ListenAndServe(); err != nil { - LogError("main", err) - errs <- err - } - }() - } + internal.C2EC() } func helpAndExit(unknownOptionOrEmpty string) { diff --git a/c2ec/payto.go b/c2ec/payto.go @@ -1,106 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "errors" - "fmt" - "strings" -) - -const PAYTO_PARTS_SEPARATOR = "/" - -const PAYTO_SCHEME_PREFIX = "payto://" -const PAYTO_TAGRET_TYPE_IBAN = "iban" -const PAYTO_TARGET_TYPE_WALLEE_TRANSACTION = "wallee-transaction" - -var REGISTERED_TARGET_TYPES = []string{ - "ach", - "bic", - "iban", - "upi", - "bitcoin", - "ilp", - "void", - "ldap", - "eth", - "interac-etransfer", - "wallee-transaction", -} - -// This method parses a payto-uri (RFC 8905: https://www.rfc-editor.org/rfc/rfc8905.html) -// The method only parses the target type "wallee-transaction" as specified -// in the payto GANA registry (https://gana.gnunet.org/payto-payment-target-types/payto_payment_target_types.html) -func ParsePaytoWalleeTransaction(uri string) (string, string, error) { - - if t, i, err := ParsePaytoUri(uri); err != nil { - - if t != "wallee-transaction" { - return "", "", errors.New("expected payto target type 'wallee-transaction'") - } - - return t, i, nil - } else { - return t, "", err - } -} - -// returns the Payto Target Type and Target Identifier as string -// if the uri is malformed, an error is returned (target type and -// identifier will be empty strings). -func ParsePaytoUri(uri string) (string, string, error) { - - if raw, found := strings.CutPrefix(uri, PAYTO_SCHEME_PREFIX); found { - - parts := strings.Split(raw, PAYTO_PARTS_SEPARATOR) - if len(parts) < 2 { - return "", "", errors.New("invalid payto-uri") - } - - return parts[0], parts[1], nil - } - return "", "", errors.New("invalid payto-uri") -} - -func FormatPaytoWalleeTransaction(tid int) string { - return fmt.Sprintf("%s%s/%d", - PAYTO_SCHEME_PREFIX, - PAYTO_TARGET_TYPE_WALLEE_TRANSACTION, - tid, - ) -} - -func ParsePaytoTargetType(uri string) (string, error) { - - if raw, found := strings.CutPrefix(uri, PAYTO_SCHEME_PREFIX); found { - - parts := strings.Split(raw, PAYTO_PARTS_SEPARATOR) - if len(parts) < 2 { - return "", errors.New("invalid wallee-transaction payto-uri") - } - - for _, target := range REGISTERED_TARGET_TYPES { - if strings.EqualFold(target, parts[0]) { - return parts[0], nil - } - } - return "", errors.New("target type '" + parts[0] + "' is not registered") - } - return "", errors.New("invalid payto-uri") -} diff --git a/c2ec/pkg/config/config.go b/c2ec/pkg/config/config.go @@ -0,0 +1,396 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package config + +import ( + "errors" + "fmt" + "math" + "os" + "strconv" + "strings" + + "gopkg.in/ini.v1" + "gopkg.in/yaml.v3" +) + +var CONFIG C2ECConfig + +type C2ECConfig struct { + Server C2ECServerConfig `yaml:"c2ec"` + Database C2ECDatabseConfig `yaml:"db"` + Providers []C2ECProviderConfig `yaml:"providers"` +} + +type C2ECServerConfig struct { + Source string `yaml:"source"` + IsProd bool `yaml:"prod"` + Host string `yaml:"host"` + Port int `yaml:"port"` + UseUnixDomainSocket bool `yaml:"unix-domain-socket"` + UnixSocketPath string `yaml:"unix-socket-path"` + UnixPathMode int `yaml:"unix-path-mode"` + StrictAttestors bool `yaml:"fail-on-missing-attestors"` + ExchangeBaseUrl string `yaml:"exchange-base-url"` + 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"` +} + +type C2ECWireGatewayConfig struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +type C2ECDatabseConfig struct { + ConnectionString string `yaml:"connstr"` + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Database string `yaml:"database"` +} + +type C2ECProviderConfig struct { + Name string `yaml:"name"` + Key string `yaml:"key"` +} + +func Parse(path string) (*C2ECConfig, error) { + + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return nil, err + } + + content := make([]byte, stat.Size()) + _, err = f.Read(content) + if err != nil { + return nil, err + } + + if strings.HasSuffix(path, ".yml") || strings.HasSuffix(path, ".yaml") { + cfg := new(C2ECConfig) + err = yaml.Unmarshal(content, cfg) + if err != nil { + return nil, err + } + return cfg, nil + } + + cfg, err := ParseIni(content) + if err != nil { + return nil, err + } + + 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") + } + + return cfg, nil +} + +func ConfigForProvider(name string) (*C2ECProviderConfig, error) { + + for _, provider := range CONFIG.Providers { + + if provider.Name == name { + return &provider, nil + } + } + return nil, errors.New("no such provider") +} + +func ParseIni(content []byte) (*C2ECConfig, error) { + + ini, err := ini.Load(content) + if err != nil { + return nil, err + } + + cfg := new(C2ECConfig) + for _, s := range ini.Sections() { + + if s.Name() == "c2ec" { + + value, err := s.GetKey("SOURCE") + if err != nil { + return nil, err + } + cfg.Server.Source = value.String() + + value, err = s.GetKey("PROD") + if err != nil { + return nil, err + } + + cfg.Server.IsProd, err = value.Bool() + if err != nil { + return nil, err + } + + value, err = s.GetKey("SERVE") + if err != nil { + return nil, err + } + + str := value.String() + cfg.Server.UseUnixDomainSocket = str == "unix" + + value, err = s.GetKey("HOST") + if err != nil { + return nil, err + } + + cfg.Server.Host = value.String() + + value, err = s.GetKey("PORT") + if err != nil { + return nil, err + } + + cfg.Server.Port, err = value.Int() + if err != nil { + return nil, err + } + + value, err = s.GetKey("UNIXPATH") + if err != nil { + return nil, err + } + + cfg.Server.UnixSocketPath = value.String() + + value, err = s.GetKey("UNIXPATH_MODE") + if err != nil { + return nil, err + } + + cfg.Server.UnixSocketPath = value.String() + + value, err = s.GetKey("FAIL_ON_MISSING_ATTESTORS") + if err != nil { + return nil, err + } + cfg.Server.StrictAttestors, err = value.Bool() + if err != nil { + return nil, err + } + + value, err = s.GetKey("EXCHANGE_BASE_URL") + if err != nil { + return nil, err + } + cfg.Server.ExchangeBaseUrl = value.String() + + value, err = s.GetKey("EXCHANGE_ACCOUNT") + if err != nil { + return nil, err + } + cfg.Server.CreditAccount = value.String() + + value, err = s.GetKey("CURRENCY") + if err != nil { + return nil, err + } + cfg.Server.Currency = value.String() + + value, err = s.GetKey("CURRENCY_FRACTION_DIGITS") + if err != nil { + return nil, err + } + num, err := value.Int() + if err != nil { + return nil, err + } + 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 + } + + num, err = value.Int() + if err != nil { + return nil, err + } + cfg.Server.MaxRetries = int32(num) + + value, err = s.GetKey("RETRY_DELAY_MS") + if err != nil { + return nil, err + } + + cfg.Server.RetryDelayMs, err = value.Int() + if err != nil { + return nil, err + } + + } + + if s.Name() == "wire-gateway" { + + value, err := s.GetKey("USERNAME") + if err != nil { + return nil, err + } + cfg.Server.WireGateway.Username = value.String() + + value, err = s.GetKey("PASSWORD") + if err != nil { + return nil, err + } + cfg.Server.WireGateway.Password = value.String() + } + + if s.Name() == "database" { + + value, err := s.GetKey("CONFIG") + if err != nil { + return nil, err + } + + connstr := value.String() + + cfg.Database.ConnectionString = connstr + } + + if strings.HasPrefix(s.Name(), "provider-") { + + provider := C2ECProviderConfig{} + + value, err := s.GetKey("NAME") + if err != nil { + return nil, err + } + provider.Name = value.String() + + value, err = s.GetKey("KEY") + if err != nil { + return nil, err + } + provider.Key = value.String() + + cfg.Providers = append(cfg.Providers, provider) + } + } + return cfg, nil +} + +/* + START + + COPIED FROM internal/utils/amount.go + due to structuring issues. Must be resolved later. +*/ + +// The GNU Taler amount object +type amount struct { + + // The type of currency, e.g. EUR + Currency string `json:"currency"` + + // The value (before the ".") + Value uint64 `json:"value"` + + // The fraction (after the ".", optional) + Fraction uint64 `json:"fraction"` +} + +// Create a new amount from value and fraction in a currency +func newAmount(currency string, value uint64, fraction uint64) amount { + return amount{ + Currency: currency, + Value: value, + Fraction: fraction, + } +} + +// Parses an amount string in the format <currency>:<value>[.<fraction>] +func parseAmount(s string, fractionDigits int) (*amount, error) { + + if s == "" { + return &amount{CONFIG.Server.Currency, 0, 0}, nil + } + + if !strings.Contains(s, ":") { + return nil, fmt.Errorf("invalid amount: %s", s) + } + + currencyAndAmount := strings.Split(s, ":") + if len(currencyAndAmount) != 2 { + return nil, fmt.Errorf("invalid amount: %s", s) + } + + 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.Atoi(valueAndFraction[0]) + if err != nil { + return nil, fmt.Errorf("invalid value in amount %s", s) + } + + 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) + } + k := 0 + if len(valueAndFraction[1]) < fractionDigits { + k = fractionDigits - len(valueAndFraction[1]) + } + fractionInt, err := strconv.Atoi(valueAndFraction[1]) + if err != nil { + return nil, fmt.Errorf("invalid fraction in amount %s", s) + } + fraction = fractionInt * int(math.Pow10(k)) + } + + a := newAmount(currency, uint64(value), uint64(fraction)) + return &a, nil +} + +/* + END + + COPIED FROM internal/utils/amount.go + due to structuring issues. Must be resolved later. +*/ diff --git a/c2ec/pkg/db/db.go b/c2ec/pkg/db/db.go @@ -0,0 +1,226 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package db + +import ( + internal_utils "c2ec/internal/utils" + "context" + "time" +) + +var DB C2ECDatabase + +type Provider struct { + ProviderId int64 `db:"provider_id"` + Name string `db:"name"` + PaytoTargetType string `db:"payto_target_type"` + BackendBaseURL string `db:"backend_base_url"` + BackendCredentials string `db:"backend_credentials"` +} + +type Terminal struct { + TerminalId int64 `db:"terminal_id"` + AccessToken string `db:"access_token"` + Active bool `db:"active"` + Description string `db:"description"` + ProviderId int64 `db:"provider_id"` +} + +type Withdrawal struct { + WithdrawalRowId uint64 `db:"withdrawal_row_id"` + ConfirmedRowId *uint64 `db:"confirmed_row_id"` + RequestUid string `db:"request_uid"` + Wopid []byte `db:"wopid"` + ReservePubKey []byte `db:"reserve_pub_key"` + RegistrationTs int64 `db:"registration_ts"` + Amount *internal_utils.TalerAmountCurrency `db:"amount" scan:"follow"` + SuggestedAmount *internal_utils.TalerAmountCurrency `db:"suggested_amount" scan:"follow"` + TerminalFees *internal_utils.TalerAmountCurrency `db:"terminal_fees" scan:"follow"` + WithdrawalStatus internal_utils.WithdrawalOperationStatus `db:"withdrawal_status"` + TerminalId int `db:"terminal_id"` + ProviderTransactionId *string `db:"provider_transaction_id"` + LastRetryTs *int64 `db:"last_retry_ts"` + RetryCounter int32 `db:"retry_counter"` + CompletionProof []byte `db:"completion_proof"` +} + +type Transfer struct { + RowId int `db:"row_id"` + TransferredRowId *int `db:"transferred_row_id"` + RequestUid []byte `db:"request_uid"` + Amount *internal_utils.TalerAmountCurrency `db:"amount"` + ExchangeBaseUrl string `db:"exchange_base_url"` + Wtid string `db:"wtid"` + CreditAccount string `db:"credit_account"` + TransferTs int64 `db:"transfer_ts"` + Status int16 `db:"transfer_status"` + Retries int16 `db:"retries"` +} + +type Notification struct { + Channel string + Payload string +} + +// C2ECDatabase defines the operations which a +// C2EC compliant database interface must implement +// in order to be bound to the c2ec API. +type C2ECDatabase interface { + // A terminal sets up a withdrawal + // with this query. + // This initiates the withdrawal. + SetupWithdrawal( + wopid []byte, + suggestedAmount internal_utils.Amount, + amount internal_utils.Amount, + terminalId int, + providerTransactionId string, + terminalFees internal_utils.Amount, + requestUid string, + ) error + + // Registers a reserve public key + // belonging to the respective wopid. + RegisterWithdrawalParameters( + wopid []byte, + resPubKey internal_utils.EddsaPublicKey, + ) error + + // Get the withdrawal associated with the given request uid. + GetWithdrawalByRequestUid(requestUid string) (*Withdrawal, error) + + // Get the withdrawal associated with the given withdrawal identifier. + GetWithdrawalById(withdrawalId int) (*Withdrawal, error) + + // Get the withdrawal associated with the given wopid. + GetWithdrawalByWopid(wopid []byte) (*Withdrawal, error) + + // Get the withdrawal associated with the provider specific transaction id. + GetWithdrawalByProviderTransactionId(tid string) (*Withdrawal, error) + + // When the terminal receives the notification of the + // Provider, that the payment went through, this will + // save the provider specific transaction id in the database + NotifyPayment( + wopid []byte, + providerTransactionId string, + terminalId int, + fees internal_utils.Amount, + ) error + + // Returns all withdrawals which can be attested by + // a provider backend. This means that the provider + // specific transaction id was set and the status is + // 'selected'. The attestor can then attest and finalise + // the payments. + GetWithdrawalsForConfirmation() ([]*Withdrawal, error) + + // When an confirmation (or fail message) could be + // retrieved by the provider, the withdrawal can + // be finalised storing the correct final state + // and the proof of completion of the provider. + FinaliseWithdrawal( + withdrawalId int, + confirmOrAbort internal_utils.WithdrawalOperationStatus, + completionProof []byte, + ) error + + // Set retry will set the last_retry_ts field + // on the database. A trigger will then start + // the retry process. The timestamp must be a + // unix timestamp + SetLastRetry(withdrawalId int, lastRetryTsUnix int64) error + + // Sets the retry counter for the given withdrawal. + SetRetryCounter(withdrawalId int, retryCounter int) error + + // The wire gateway allows the exchange to retrieve transactions + // starting at a certain starting point up until a certain delta + // if the delta is negative, previous transactions relative to the + // starting point are considered. When start is negative, the latest + // id shall be used as starting point. + GetConfirmedWithdrawals(start int, delta int, since time.Time) ([]*Withdrawal, error) + + // Get the provider of a terminal by the terminals id + GetProviderByTerminal(terminalId int) (*Provider, error) + + // Get a provider entry by its name + GetTerminalProviderByName(name string) (*Provider, error) + + // Get a provider entry by its name + GetTerminalProviderByPaytoTargetType(paytoTargetType string) (*Provider, error) + + // Get a terminal entry by its identifier + GetTerminalById(id int) (*Terminal, error) + + // Returns the transfer for the given hashcode. + GetTransferById(requestUid []byte) (*Transfer, error) + + // Inserts a new transfer into the database. + AddTransfer( + requestUid []byte, + amount *internal_utils.Amount, + exchangeBaseUrl string, + wtid string, + credit_account string, + ts time.Time, + ) error + + // Updates the transfer, if retries is changed, the transfer will be + // triggered again. + UpdateTransfer( + rowId int, + requestUid []byte, + timestamp int64, + status int16, + retries int16, + ) error + + // The wire gateway allows the exchange to retrieve transactions + // starting at a certain starting point up until a certain delta + // if the delta is negative, previous transactions relative to the + // starting point are considered. When start is negative, the latest + // id shall be used as starting point. + GetTransfers(start int, delta int, since time.Time) ([]*Transfer, error) + + // Load all transfers asscociated with the same credit_account. + // The query is used to control that the current limitation of + // only allowing full refunds (partial refunds are currently not supported) + // is not harmed. It is assumed that the credit_account is unique, which currently + // is the case, because it depends on the WOPID of the respective + // withdrawal. This query is part of the limitation to only allow + // full refunds and not partial refunds. It might be possible to + // remove this API when partial refunds are implemented. + GetTransfersByCreditAccount(creditAccount string) ([]*Transfer, error) + + // Returns the transfer entries in the given state. + // This can be used for retry operations. + GetTransfersByState(status int) ([]*Transfer, error) + + // A listener can listen for notifications ont the specified + // channel. Returns a listen function, which must be called + // by the caller to start listening on the channel. The returned + // listen function will return an error if it fails, and takes + // a context as argument which allows the underneath implementation + // to control the execution context of the listener. + NewListener( + channel string, + out chan *Notification, + ) (func(context.Context) error, error) +} diff --git a/c2ec/pkg/provider/provider.go b/c2ec/pkg/provider/provider.go @@ -0,0 +1,40 @@ +// This file is part of taler-cashless2ecash. +// Copyright (C) 2024 Joel Häberli +// +// taler-cashless2ecash is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// taler-cashless2ecash 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 +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package provider + +import "c2ec/pkg/db" + +// This map contains all clients initialized during the +// startup of the application. The clients SHALL register +// themselfs during the setup!! +var PROVIDER_CLIENTS = map[string]ProviderClient{} + +type ProviderTransaction interface { + AllowWithdrawal() bool + AbortWithdrawal() bool + Confirm(w *db.Withdrawal) error + Bytes() []byte +} + +type ProviderClient interface { + SetupClient(provider *db.Provider) error + GetTransaction(transactionId string) (ProviderTransaction, error) + Refund(transactionId string) error + FormatPayto(w *db.Withdrawal) string +} diff --git a/c2ec/proc-attestor.go b/c2ec/proc-attestor.go @@ -1,205 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "context" - "errors" - "fmt" - "strconv" - "strings" - "time" -) - -const PAYMENT_NOTIFICATION_CHANNEL_BUFFER_SIZE = 10 -const PS_PAYMENT_NOTIFICATION_CHANNEL = "payment_notification" -const MAX_BACKOFF_MS = 30 * 60000 // thirty minutes - -// Sets up and runs an attestor in the background. This must be called at startup. -func RunAttestor( - ctx context.Context, - errs chan error, -) { - - go RunListener( - ctx, - PS_PAYMENT_NOTIFICATION_CHANNEL, - confirmationCallback, - make(chan *Notification, PAYMENT_NOTIFICATION_CHANNEL_BUFFER_SIZE), - errs, - ) -} - -func confirmationCallback(notification *Notification, errs chan error) { - - LogInfo("proc-attestor", fmt.Sprintf("retrieved information on channel=%s with payload=%s", notification.Channel, notification.Payload)) - - // The payload is formatted like: "{PROVIDER_NAME}|{WITHDRAWAL_ID}|{PROVIDER_TRANSACTION_ID}" - // the validation is strict. This means, that the dispatcher emits an error - // and returns, if a property is malformed. - payload := strings.Split(notification.Payload, "|") - if len(payload) != 3 { - errs <- errors.New("malformed notification payload: " + notification.Payload) - return - } - - providerName := payload[0] - if providerName == "" { - errs <- errors.New("the provider of the payment is not specified") - return - } - withdrawalRowId, err := strconv.Atoi(payload[1]) - if err != nil { - errs <- errors.New("malformed withdrawal_row_id: " + err.Error()) - return - } - providerTransactionId := payload[2] - - client := PROVIDER_CLIENTS[providerName] - if client == nil { - errs <- errors.New("no provider client registered for provider " + providerName) - } - - transaction, err := client.GetTransaction(providerTransactionId) - if err != nil { - LogError("proc-attestor", err) - prepareRetryOrAbort(withdrawalRowId, errs) - return - } - - finaliseOrSetRetry( - transaction, - withdrawalRowId, - errs, - ) -} - -func finaliseOrSetRetry( - transaction ProviderTransaction, - withdrawalRowId int, - errs chan error, -) { - - if transaction == nil { - err := errors.New("transaction was nil. will set retry or abort") - LogError("proc-attestor", err) - errs <- err - prepareRetryOrAbort(withdrawalRowId, errs) - return - } - - if w, err := DB.GetWithdrawalById(withdrawalRowId); err != nil { - LogError("proc-attestor", err) - errs <- err - prepareRetryOrAbort(withdrawalRowId, errs) - return - } else { - if w.WithdrawalStatus == CONFIRMED || w.WithdrawalStatus == ABORTED { - return - } - if err := transaction.Confirm(w); err != nil { - LogError("proc-attestor", err) - errs <- err - prepareRetryOrAbort(withdrawalRowId, errs) - return - } - } - - completionProof := transaction.Bytes() - if len(completionProof) > 0 { - // only allow finalization operation, when the completion - // proof of the transaction could be retrieved - if transaction.AllowWithdrawal() { - - err := DB.FinaliseWithdrawal(withdrawalRowId, CONFIRMED, completionProof) - if err != nil { - LogError("proc-attestor", err) - prepareRetryOrAbort(withdrawalRowId, errs) - } - } else { - // when the received transaction is not allowed, we first check if the - // transaction is in a final state which will not allow the withdrawal - // and therefore the operation can be aborted, without further retries. - if transaction.AbortWithdrawal() { - err := DB.FinaliseWithdrawal(withdrawalRowId, ABORTED, completionProof) - if err != nil { - LogError("proc-attestor", err) - prepareRetryOrAbort(withdrawalRowId, errs) - return - } - } - prepareRetryOrAbort(withdrawalRowId, errs) - } - return - } - // when the transaction proof was not present (empty proof), retry. - prepareRetryOrAbort(withdrawalRowId, errs) -} - -// Checks wether the maximal amount of retries was already -// reached and the withdrawal operation shall be aborted or -// triggers the next retry by setting the last_retry_ts field -// which will trigger the stored procedure triggering the retry -// process. The retry counter of the retries is handled by the -// retrier logic and shall not be set here! -func prepareRetryOrAbort( - withdrawalRowId int, - errs chan error, -) { - - withdrawal, err := DB.GetWithdrawalById(withdrawalRowId) - if err != nil { - LogError("proc-attestor", err) - errs <- err - return - } - - if CONFIG.Server.MaxRetries < 0 { - prepareRetry(withdrawal, errs) - } else { - - if withdrawal.RetryCounter >= CONFIG.Server.MaxRetries { - - LogInfo("proc-attestor", fmt.Sprintf("max retries for withdrawal with id=%d was reached. withdrawal is aborted.", withdrawal.WithdrawalRowId)) - err := DB.FinaliseWithdrawal(withdrawalRowId, ABORTED, make([]byte, 0)) - if err != nil { - LogError("proc-attestor", err) - } - } else { - prepareRetry(withdrawal, errs) - } - } -} - -func prepareRetry(w *Withdrawal, errs chan error) { - // refactor this section to set retry counter and last retry field in one query... - err := DB.SetRetryCounter(int(w.WithdrawalRowId), int(w.RetryCounter)+1) - if err != nil { - LogError("proc-attestor", err) - errs <- err - return - } - lastRetryTs := time.Now().Unix() - err = DB.SetLastRetry(int(w.WithdrawalRowId), lastRetryTs) - if err != nil { - LogError("proc-attestor", err) - errs <- err - return - } -} diff --git a/c2ec/proc-listener.go b/c2ec/proc-listener.go @@ -1,65 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "context" - "errors" -) - -func RunListener( - ctx context.Context, - channel string, - callback func(*Notification, chan error), - notifications chan *Notification, - errs chan error, -) { - - listenFunc, err := DB.NewListener(channel, notifications) - if err != nil { - LogError("listener", err) - errs <- errors.New("failed setting up listener") - return - } - - go func() { - LogInfo("listener", "listener starts listening for notifications at the db for channel="+channel) - err := listenFunc(ctx) - if err != nil { - LogError("listener", err) - errs <- err - } - close(notifications) - close(errs) - }() - - // Listen is started async. We can therefore block here and must - // not run the retrieval logic in own goroutine - for { - select { - case notification := <-notifications: - // the dispatching and further processing can be done asynchronously - // thus not blocking further incoming notifications. - go callback(notification, errs) - case <-ctx.Done(): - errs <- ctx.Err() - return - } - } -} diff --git a/c2ec/proc-retrier.go b/c2ec/proc-retrier.go @@ -1,126 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "context" - "errors" - "fmt" - "time" -) - -const RETRY_CHANNEL_BUFFER_SIZE = 10 -const PS_RETRY_CHANNEL = "retry" - -func RunRetrier(ctx context.Context, errs chan error) { - - // go RunListener( - // ctx, - // PS_RETRY_CHANNEL, - // retryCallback, - // make(chan *Notification, RETRY_CHANNEL_BUFFER_SIZE), - // errs, - // ) - - go func() { - lastlog := time.Now().Add(time.Minute * -3) - for { - withdrawals, err := DB.GetWithdrawalsForConfirmation() - time.Sleep(time.Duration(1000 * time.Millisecond)) - if err != nil { - LogError("proc-retrier", err) - errs <- err - continue - } - if lastlog.Before(time.Now().Add(time.Second * -30)) { - LogInfo("proc-retrier", fmt.Sprintf("retrier confirming 'selected' withdrawals. found %d ready for confirmation", len(withdrawals))) - lastlog = time.Now() - } - for _, w := range withdrawals { - retryOrSkip(w, errs) - } - } - }() -} - -// func retryCallback(n *Notification, errs chan error) { - -// withdrawalId, err := strconv.Atoi(n.Payload) -// if err != nil { -// LogError("proc-retrier", err) -// errs <- err -// return -// } - -// w, err := DB.GetWithdrawalById(withdrawalId) -// if err != nil { -// LogError("proc-retrier", err) -// errs <- err -// return -// } - -// retryOrSkip(w, errs) -// } - -func retryOrSkip(w *Withdrawal, errs chan error) { - var lastRetryTs int64 = 0 - if w.LastRetryTs != nil { - lastRetryTs = *w.LastRetryTs - if ShouldStartRetry(time.Unix(lastRetryTs, 0), int(w.RetryCounter), CONFIG.Server.RetryDelayMs) { - LogInfo("proc-retrier", "retrying for wopid="+encodeCrock(w.Wopid)) - confirmRetryOrAbort(w, errs) - } - } else { - LogInfo("proc-retrier", "first retry confirming wopid="+encodeCrock(w.Wopid)) - confirmRetryOrAbort(w, errs) - } -} - -func confirmRetryOrAbort(withdrawal *Withdrawal, errs chan error) { - - if withdrawal == nil { - err := errors.New("withdrawal was null") - LogError("proc-retrier", err) - errs <- err - return - } - - provider, err := DB.GetProviderByTerminal(withdrawal.TerminalId) - if err != nil { - LogError("proc-retrier", err) - errs <- err - return - } - - client := PROVIDER_CLIENTS[provider.Name] - if client == nil { - err := fmt.Errorf("the provider client for provider with name=%s is not configured", provider.Name) - LogError("proc-retrier", err) - errs <- err - return - } - transaction, err := client.GetTransaction(*withdrawal.ProviderTransactionId) - if err != nil { - LogError("proc-retrier", err) - errs <- err - return - } - - finaliseOrSetRetry(transaction, int(withdrawal.WithdrawalRowId), errs) -} diff --git a/c2ec/proc-transfer.go b/c2ec/proc-transfer.go @@ -1,248 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "context" - "errors" - "fmt" - "strings" - "time" -) - -const REFUND_RETRY_INTERVAL_SECONDS = 1 - -const REFUND_CHANNEL_BUFFER_SIZE = 10 -const PS_REFUND_CHANNEL = "transfer" - -const TRANSFER_STATUS_SUCCESS = 0 -const TRANSFER_STATUS_RETRY = 1 -const TRANSFER_STATUS_FAILED = -1 - -const MAX_TRANSFER_BACKOFF_MS = 24 * 60 * 60 * 1000 // 1 day - -// Sets up and runs an attestor in the background. This must be called at startup. -func RunTransferrer( - ctx context.Context, - errs chan error, -) { - - // go RunListener( - // ctx, - // PS_REFUND_CHANNEL, - // transferCallback, - // make(chan *Notification, REFUND_CHANNEL_BUFFER_SIZE), - // errs, - // ) - - go func() { - lastlog := time.Now().Add(time.Minute * -3) - lastlog2 := time.Now().Add(time.Minute * -3) - for { - time.Sleep(REFUND_RETRY_INTERVAL_SECONDS * time.Second) - if lastlog.Before(time.Now().Add(time.Second * -30)) { - LogInfo("proc-transfer", "transferrer executing transfers") - lastlog = time.Now() - } - executePendingTransfers(errs, lastlog2) - if lastlog2.Before(time.Now().Add(time.Second * -30)) { - lastlog2 = time.Now() - } - } - }() -} - -// func transferCallback(notification *Notification, errs chan error) { - -// LogInfo("proc-transfer", fmt.Sprintf("retrieved information on channel=%s with payload=%s", notification.Channel, notification.Payload)) - -// transferRequestUidBase64 := notification.Payload -// if transferRequestUidBase64 == "" { -// errs <- errors.New("the transfer to refund is not specified") -// return -// } - -// transferRequestUid, err := base64.StdEncoding.DecodeString(transferRequestUidBase64) -// if err != nil { -// errs <- errors.New("malformed transfer request uid: " + err.Error()) -// return -// } - -// transfer, err := DB.GetTransferById(transferRequestUid) -// if err != nil { -// LogWarn("proc-transfer", "unable to retrieve transfer with requestUid") -// LogError("proc-transfer", err) -// transferFailed(transfer, errs) -// errs <- err -// return -// } - -// if transfer == nil { -// err := errors.New("expected an existing transfer. very strange") -// LogError("proc-transfer", err) -// transferFailed(transfer, errs) -// errs <- err -// return -// } - -// paytoTargetType, tid, err := ParsePaytoUri(transfer.CreditAccount) -// LogInfo("proc-transfer", "parsed payto-target-type="+paytoTargetType) -// if err != nil { -// LogWarn("proc-transfer", "unable to parse payto-uri="+transfer.CreditAccount) -// errs <- errors.New("malformed transfer request uid: " + err.Error()) -// transferFailed(transfer, errs) -// return -// } - -// provider, err := DB.GetTerminalProviderByPaytoTargetType(paytoTargetType) -// if err != nil { -// LogWarn("proc-transfer", "unable to find provider for provider-target-type="+paytoTargetType) -// LogError("proc-transfer", err) -// transferFailed(transfer, errs) -// errs <- err -// } - -// client := PROVIDER_CLIENTS[provider.Name] -// if client == nil { -// errs <- errors.New("no provider client registered for provider " + provider.Name) -// } - -// err = client.Refund(tid) -// if err != nil { -// LogError("proc-transfer", err) -// transferFailed(transfer, errs) -// return -// } - -// err = DB.UpdateTransfer( -// transfer.RequestUid, -// time.Now().Unix(), -// TRANSFER_STATUS_SUCCESS, // success -// transfer.Retries, -// ) -// if err != nil { -// errs <- err -// } -// } - -func executePendingTransfers(errs chan error, lastlog time.Time) { - - transfers, err := DB.GetTransfersByState(TRANSFER_STATUS_RETRY) - if err != nil { - LogError("proc-transfer-1", err) - errs <- err - return - } - - if lastlog.Before(time.Now().Add(time.Second * -30)) { - LogInfo("proc-transfer", fmt.Sprintf("found %d pending transfers", len(transfers))) - } - for _, t := range transfers { - - shouldRetry := ShouldStartRetry(time.Unix(t.TransferTs, 0), int(t.Retries), MAX_TRANSFER_BACKOFF_MS) - if !shouldRetry { - if lastlog.Before(time.Now().Add(time.Second * -30)) { - LogInfo("proc-transfer", fmt.Sprintf("not retrying transfer id=%d, because backoff not yet exceeded", t.RowId)) - } - continue - } - - paytoTargetType, tid, err := ParsePaytoUri(t.CreditAccount) - LogInfo("proc-transfer", "parsed payto-target-type="+paytoTargetType) - if err != nil { - LogWarn("proc-transfer", "parsing payto-target-type failed") - LogError("proc-transfer-2", err) - continue - } - - provider, err := DB.GetTerminalProviderByPaytoTargetType(paytoTargetType) - if err != nil { - LogWarn("proc-transfer", "finding terminal by payto target type failed") - LogError("proc-transfer-3", err) - continue - } - - client := PROVIDER_CLIENTS[provider.Name] - if client == nil { - errs <- errors.New("no provider client registered for provider " + provider.Name) - continue - } - - LogInfo("proc-transfer", "refunding transaction "+tid) - err = client.Refund(strings.Trim(tid, " \n")) - if err != nil { - LogWarn("proc-transfer", "refunding using provider client failed") - LogError("proc-transfer-4", err) - transferFailed(t, errs) - continue - } - - LogInfo("proc-transfer", "setting transfer to success state") - err = DB.UpdateTransfer( - t.RowId, - t.RequestUid, - time.Now().Unix(), - TRANSFER_STATUS_SUCCESS, // success - t.Retries, - ) - if err != nil { - LogWarn("proc-transfer", "failed setting refund to success state") - LogError("proc-transfer", err) - } - } -} - -func transferFailed( - transfer *Transfer, - errs chan error, -) { - - err := DB.UpdateTransfer( - transfer.RowId, - transfer.RequestUid, - time.Now().Unix(), - TRANSFER_STATUS_RETRY, // retry transfer. - transfer.Retries+1, - ) - if err != nil { - errs <- err - } - - // if transfer.Retries > 2 { - // err := DB.UpdateTransfer( - // transfer.RequestUid, - // time.Now().Unix(), - // TRANSFER_STATUS_FAILED, // transfer ultimatively failed. - // transfer.Retries, - // ) - // if err != nil { - // errs <- err - // } - // } else { - // err := DB.UpdateTransfer( - // transfer.RequestUid, - // time.Now().Unix(), - // TRANSFER_STATUS_RETRY, // retry transfer. - // transfer.Retries+1, - // ) - // if err != nil { - // errs <- err - // } - //} -} diff --git a/c2ec/provider.go b/c2ec/provider.go @@ -1,33 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -type ProviderTransaction interface { - AllowWithdrawal() bool - AbortWithdrawal() bool - Confirm(w *Withdrawal) error - Bytes() []byte -} - -type ProviderClient interface { - SetupClient(provider *Provider) error - GetTransaction(transactionId string) (ProviderTransaction, error) - Refund(transactionId string) error - FormatPayto(w *Withdrawal) string -} diff --git a/c2ec/simulation-client.go b/c2ec/simulation-client.go @@ -1,92 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "bytes" - "fmt" - "time" -) - -type SimulationTransaction struct { - ProviderTransaction - - allow bool -} - -type SimulationClient struct { - ProviderClient - - // toggle this to simulate failed transactions. - AllowNextWithdrawal bool - - // simulates the provider client fetching confirmation at the providers backend. - providerBackendConfirmationDelayMs int -} - -func (st *SimulationTransaction) AllowWithdrawal() bool { - - return st.allow -} - -func (st *SimulationTransaction) AbortWithdrawal() bool { - - return false -} - -func (st *SimulationTransaction) Confirm(w *Withdrawal) error { - - return nil -} - -func (st *SimulationTransaction) Bytes() []byte { - - return bytes.NewBufferString("this is a simulated transaction and therefore has no content.").Bytes() -} - -func (sc *SimulationClient) FormatPayto(w *Withdrawal) string { - - return fmt.Sprintf("payto://void/%s", *w.ProviderTransactionId) -} - -func (sc *SimulationClient) SetupClient(p *Provider) error { - - LogInfo("simulation-client", "setting up simulation client. probably not what you want in production") - fmt.Println("setting up simulation client. probably not what you want in production") - - sc.AllowNextWithdrawal = true - sc.providerBackendConfirmationDelayMs = 1000 // one second, might be a lot but for testing this is fine. - PROVIDER_CLIENTS["Simulation"] = sc - return nil -} - -func (sc *SimulationClient) GetTransaction(transactionId string) (ProviderTransaction, error) { - - LogInfo("simulation-client", "getting transaction from simulation provider") - time.Sleep(time.Duration(sc.providerBackendConfirmationDelayMs) * time.Millisecond) - st := new(SimulationTransaction) - st.allow = sc.AllowNextWithdrawal - return st, nil -} - -func (*SimulationClient) Refund(transactionId string) error { - - LogInfo("simulation-client", "refund triggered for simulation provider with transaction id: "+transactionId) - return nil -} diff --git a/c2ec/utils.go b/c2ec/utils.go @@ -1,69 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "errors" - "strings" -) - -// https://docs.taler.net/core/api-common.html#hash-codes -type WithdrawalIdentifier string - -// https://docs.taler.net/core/api-common.html#cryptographic-primitives -type EddsaPublicKey string - -// https://docs.taler.net/core/api-common.html#hash-codes -type HashCode string - -// https://docs.taler.net/core/api-common.html#hash-codes -type ShortHashCode string - -// https://docs.taler.net/core/api-common.html#timestamps -type Timestamp struct { - Ts int `json:"t_s"` -} - -// https://docs.taler.net/core/api-common.html#wadid -type WadId [6]uint32 - -// according to https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankWithdrawalOperationStatus -type WithdrawalOperationStatus string - -const ( - PENDING WithdrawalOperationStatus = "pending" - SELECTED WithdrawalOperationStatus = "selected" - ABORTED WithdrawalOperationStatus = "aborted" - CONFIRMED WithdrawalOperationStatus = "confirmed" -) - -func ToWithdrawalOperationStatus(s string) (WithdrawalOperationStatus, error) { - switch strings.ToLower(s) { - case string(PENDING): - return PENDING, nil - case string(SELECTED): - return SELECTED, nil - case string(ABORTED): - return ABORTED, nil - case string(CONFIRMED): - return CONFIRMED, nil - default: - return "", errors.New("invalid withdrawal operation status '" + s + "'") - } -} diff --git a/c2ec/wallee-client.go b/c2ec/wallee-client.go @@ -1,404 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "bytes" - "crypto/hmac" - "crypto/sha512" - "encoding/base64" - "errors" - "fmt" - "io" - "regexp" - "strconv" - "strings" - "time" - "unicode/utf8" -) - -const WALLEE_AUTH_HEADER_VERSION = "x-mac-version" -const WALLEE_AUTH_HEADER_USERID = "x-mac-userid" -const WALLEE_AUTH_HEADER_TIMESTAMP = "x-mac-timestamp" -const WALLEE_AUTH_HEADER_MAC = "x-mac-value" - -const WALLEE_READ_TRANSACTION_API = "/api/transaction/read" -const WALLEE_SEARCH_TRANSACTION_API = "/api/transaction/search" -const WALLEE_CREATE_REFUND_API = "/api/refund/refund" - -const WALLEE_API_SPACEID_PARAM_NAME = "spaceId" - -type WalleeCredentials struct { - SpaceId int `json:"spaceId"` - UserId int `json:"userId"` - ApplicationUserKey string `json:"application-user-key"` -} - -type WalleeClient struct { - ProviderClient - - name string - baseUrl string - credentials *WalleeCredentials -} - -func (wt *WalleeTransaction) AllowWithdrawal() bool { - - return strings.EqualFold(string(wt.State), string(StateFulfill)) -} - -func (wt *WalleeTransaction) AbortWithdrawal() bool { - // guaranteed abortion is given when the state of - // the transaction is a final state but not the - // success case (which is FULFILL) - return strings.EqualFold(string(wt.State), string(StateFailed)) || - strings.EqualFold(string(wt.State), string(StateVoided)) || - strings.EqualFold(string(wt.State), string(StateDecline)) -} - -func (wt *WalleeTransaction) Confirm(w *Withdrawal) error { - - if wt.MerchantReference != *w.ProviderTransactionId { - - return errors.New("the merchant reference does not match the withdrawal") - } - - amountFloatFrmt := strconv.FormatFloat(wt.CompletedAmount, 'f', CONFIG.Server.CurrencyFractionDigits, 64) - LogInfo("wallee-client", fmt.Sprintf("converted %f (float) to %s (string)", wt.CompletedAmount, amountFloatFrmt)) - completedAmountStr := fmt.Sprintf("%s:%s", CONFIG.Server.Currency, amountFloatFrmt) - completedAmount, err := ParseAmount(completedAmountStr, CONFIG.Server.CurrencyFractionDigits) - if err != nil { - LogError("wallee-client", err) - return err - } - - withdrawAmount, err := ToAmount(w.Amount) - if err != nil { - return err - } - withdrawFees, err := ToAmount(w.TerminalFees) - if err != nil { - return err - } - if completedAmountMinusFees, err := completedAmount.Sub(*withdrawFees, CONFIG.Server.CurrencyFractionDigits); err == nil { - if smaller, err := completedAmountMinusFees.IsSmallerThan(*withdrawAmount); smaller || err != nil { - - if err != nil { - return err - } - - return fmt.Errorf("the confirmed amount (%s) minus the fees (%s) was smaller than the withdraw amount (%s)", - completedAmountStr, - withdrawFees.String(CONFIG.Server.CurrencyFractionDigits), - withdrawAmount.String(CONFIG.Server.CurrencyFractionDigits), - ) - } - } - - return nil -} - -func (wt *WalleeTransaction) Bytes() []byte { - - reader, err := NewJsonCodec[WalleeTransaction]().Encode(wt) - if err != nil { - LogError("wallee-client", err) - return make([]byte, 0) - } - bytes, err := io.ReadAll(reader) - if err != nil { - LogError("wallee-client", err) - return make([]byte, 0) - } - return bytes -} - -func (w *WalleeClient) SetupClient(p *Provider) error { - - cfg, err := ConfigForProvider(p.Name) - if err != nil { - return err - } - - creds, err := parseCredentials(p.BackendCredentials, cfg) - if err != nil { - return err - } - - w.name = p.Name - w.baseUrl = p.BackendBaseURL - w.credentials = creds - - PROVIDER_CLIENTS[w.name] = w - - LogInfo("wallee-client", fmt.Sprintf("Wallee client is setup (user=%d, spaceId=%d, backend=%s)", w.credentials.UserId, w.credentials.SpaceId, w.baseUrl)) - - return nil -} - -func (w *WalleeClient) GetTransaction(transactionId string) (ProviderTransaction, error) { - - if transactionId == "" { - return nil, errors.New("transaction id must be specified but was blank") - } - - call := fmt.Sprintf("%s%s", w.baseUrl, WALLEE_SEARCH_TRANSACTION_API) - queryParams := map[string]string{ - WALLEE_API_SPACEID_PARAM_NAME: strconv.Itoa(w.credentials.SpaceId), - } - url := FormatUrl(call, map[string]string{}, queryParams) - - hdrs, err := prepareWalleeHeaders(url, HTTP_POST, w.credentials.UserId, w.credentials.ApplicationUserKey) - if err != nil { - return nil, err - } - - filter := WalleeSearchFilter{ - FieldName: "merchantReference", - Operator: EQUALS, - Type: LEAF, - Value: transactionId, - } - - req := WalleeTransactionSearchRequest{ - Filter: filter, - Language: "en", - NumberOfEntities: 1, - StartingEntity: 0, - } - - t, status, err := HttpPost( - url, - hdrs, - &req, - NewJsonCodec[WalleeTransactionSearchRequest](), - NewJsonCodec[[]*WalleeTransaction](), - ) - if err != nil { - return nil, err - } - if status != HTTP_OK { - return nil, errors.New("no result") - } - if t == nil { - return nil, errors.New("no such transaction for merchantReference=" + transactionId) - } - derefRes := *t - if len(derefRes) < 1 { - return nil, errors.New("no such transaction for merchantReference=" + transactionId) - } - return derefRes[0], nil -} - -func (sc *WalleeClient) FormatPayto(w *Withdrawal) string { - - if w == nil || w.ProviderTransactionId == nil { - LogError("wallee-client", errors.New("withdrawal or provider transaction identifier was nil")) - return "" - } - return fmt.Sprintf("payto://wallee-transaction/%s", *w.ProviderTransactionId) -} - -func (w *WalleeClient) Refund(transactionId string) error { - - LogInfo("wallee-client", "trying to refund provider transaction "+transactionId) - call := fmt.Sprintf("%s%s", w.baseUrl, WALLEE_CREATE_REFUND_API) - queryParams := map[string]string{ - WALLEE_API_SPACEID_PARAM_NAME: strconv.Itoa(w.credentials.SpaceId), - } - url := FormatUrl(call, map[string]string{}, queryParams) - LogInfo("wallee-client", "refund url "+url) - - hdrs, err := prepareWalleeHeaders(url, HTTP_POST, w.credentials.UserId, w.credentials.ApplicationUserKey) - if err != nil { - LogError("wallee-client", err) - return err - } - - withdrawal, err := DB.GetWithdrawalByProviderTransactionId(transactionId) - if err != nil { - err = errors.New("error unable to find withdrawal belonging to transactionId=" + transactionId) - LogError("wallee-client", err) - return err - } - if withdrawal == nil { - err = errors.New("withdrawal is nil unable to find withdrawal belonging to transactionId=" + transactionId) - LogError("wallee-client", err) - return err - } - - decodedWalleeTransaction, err := NewJsonCodec[WalleeTransaction]().Decode(bytes.NewBuffer(withdrawal.CompletionProof)) - if err != nil { - LogError("wallee-client", err) - return err - } - - refundAmount, err := ToAmount(withdrawal.Amount) - if err != nil { - LogError("wallee-client", err) - return err - } - - refundableAmount := refundAmount.String(CONFIG.Server.CurrencyFractionDigits) - refundableAmount, _ = strings.CutPrefix(refundableAmount, CONFIG.Server.Currency+":") - LogInfo("wallee-client", fmt.Sprintf("stripped currency from amount %s", refundableAmount)) - refund := &WalleeRefund{ - Amount: refundableAmount, - ExternalID: encodeCrock(withdrawal.Wopid), - MerchantReference: decodedWalleeTransaction.MerchantReference, - Transaction: WalleeRefundTransaction{ - Id: int64(decodedWalleeTransaction.Id), - }, - Type: "MERCHANT_INITIATED_ONLINE", // this type will refund the transaction using the responsible processor (e.g. VISA, MasterCard, TWINT, etc.) - } - - _, status, err := HttpPost[WalleeRefund, any]( - url, - hdrs, - refund, - NewJsonCodec[WalleeRefund](), - nil, - ) - if err != nil { - LogError("wallee-client", err) - return err - } - if status != HTTP_OK { - return errors.New("failed refunding the transaction at the wallee-backend. statuscode=" + strconv.Itoa(status)) - } - - return nil -} - -func prepareWalleeHeaders( - url string, - method string, - userId int, - applicationUserKey string, -) (map[string]string, error) { - - timestamp := time.Time.Unix(time.Now()) - - base64Mac, err := calculateWalleeAuthToken( - userId, - timestamp, - method, - url, - applicationUserKey, - ) - if err != nil { - return nil, err - } - - headers := map[string]string{ - WALLEE_AUTH_HEADER_VERSION: "1", - WALLEE_AUTH_HEADER_USERID: strconv.Itoa(userId), - WALLEE_AUTH_HEADER_TIMESTAMP: strconv.Itoa(int(timestamp)), - WALLEE_AUTH_HEADER_MAC: base64Mac, - } - - return headers, nil -} - -func parseCredentials(raw string, cfg *C2ECProviderConfig) (*WalleeCredentials, error) { - - credsJson := make([]byte, len(raw)) - _, err := base64.StdEncoding.Decode(credsJson, []byte(raw)) - if err != nil { - return nil, err - } - - creds, err := NewJsonCodec[WalleeCredentials]().Decode(bytes.NewBuffer(credsJson)) - if err != nil { - return nil, err - } - - if !ValidPassword(cfg.Key, creds.ApplicationUserKey) { - return nil, errors.New("invalid application user key in wallee client configuration") - } - - // correct application user key. - creds.ApplicationUserKey = cfg.Key - return creds, nil -} - -// This function calculates the authentication token according -// to the documentation of wallee: -// https://app-wallee.com/en-us/doc/api/web-service#_authentication -// the function returns the token in Base64 format. -func calculateWalleeAuthToken( - userId int, - unixTimestamp int64, - httpMethod string, - pathWithParams string, - userKeyBase64 string, -) (string, error) { - - // Put together the correct formatted string - // Version | UserId | Timestamp | Method | Path - authMsgStr := fmt.Sprintf("%d|%d|%d|%s|%s", - 1, // version is static - userId, - unixTimestamp, - httpMethod, - cutSchemeAndHost(pathWithParams), - ) - - authMsg := make([]byte, 0) - if valid := utf8.ValidString(authMsgStr); !valid { - - // encode the string using utf8 - for _, r := range authMsgStr { - rbytes := make([]byte, 4) - utf8.EncodeRune(rbytes, r) - authMsg = append(authMsg, rbytes...) - } - } else { - authMsg = bytes.NewBufferString(authMsgStr).Bytes() - } - - LogInfo("wallee-client", fmt.Sprintf("authMsg (utf-8 encoded): %s", string(authMsg))) - - key := make([]byte, 32) - _, err := base64.StdEncoding.Decode(key, []byte(userKeyBase64)) - if err != nil { - LogError("wallee-client", err) - return "", err - } - - if len(key) != 32 { - return "", errors.New("malformed secret") - } - - macer := hmac.New(sha512.New, key) - _, err = macer.Write(authMsg) - if err != nil { - LogError("wallee-client", err) - return "", err - } - mac := macer.Sum(make([]byte, 0)) - - return base64.StdEncoding.EncodeToString(mac), nil -} - -func cutSchemeAndHost(url string) string { - - reg := regexp.MustCompile(`https?:\/\/[\w-\.]{1,}`) - return reg.ReplaceAllString(url, "") -} diff --git a/c2ec/wallee-client_test.go b/c2ec/wallee-client_test.go @@ -1,178 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "errors" - "fmt" - "strconv" - "strings" - "testing" -) - -// integration tests shall be executed manually -// because of needed stuff credentials. -const ENABLE_WALLEE_INTEGRATION_TEST = false - -// configure the INT_* constants to to run integration tests -// be aware that this can possibly trigger and tamper real -// transactions (in case of the refund). -const INT_TEST_SPACE_ID = 0 -const INT_TEST_USER_ID = 0 -const INT_TEST_ACCESS_TOKEN = "" - -const INT_TEST_REFUND_AMOUNT = "0" -const INT_TEST_REFUND_EXT_ID = "" // can be anything -> idempotency -const INT_TEST_REFUND_MERCHANT_REFERENCE = "" -const INT_TEST_REFUND_TRANSACTION_ID = 0 - -func TestCutSchemeAndHost(t *testing.T) { - - urls := []string{ - "https://app-wallee.com/api/transaction/search?spaceId=54275", - "https://app-wallee.com/api/transaction/search?spaceId=54275?spaceId=54275&id=212156032", - "/api/transaction/search?spaceId=54275?spaceId=54275&id=212156032", - "http://test.com.ag.ch.de-en/api/transaction/search?spaceId=54275?spaceId=54275&id=212156032", - } - - for _, url := range urls { - cutted := cutSchemeAndHost(url) - fmt.Println(cutted) - if !strings.HasPrefix(cutted, "/api") { - t.FailNow() - } - } -} - -func TestWalleeMac(t *testing.T) { - - // https://app-wallee.com/en-us/doc/api/web-service#_java - // assuming the java example on the website of wallee is correct - // the following parameters should result to the given expected - // result using my Golang implementation. - - // authStr := "1|100000|1715454671|GET|/api/transaction/read?spaceId=10000&id=200000000" - secret := "OWOMg2gnaSx1nukAM6SN2vxedfY1yLPONvcTKbhDv7I=" - expected := "PNqpGIkv+4jVcdIYqp5Pp2tKGWSjO1bNdEAIPgllWb7A6BDRvQQ/I2fnZF20roAIJrP22pe1LvHH8lWpIzJbWg==" - - calculated, err := calculateWalleeAuthToken(100000, int64(1715454671), "GET", "https://some.domain/api/transaction/read?spaceId=10000&id=200000000", secret) - if err != nil { - t.Error(err) - t.FailNow() - } - - fmt.Println("expected:", expected) - fmt.Println("calcultd:", calculated) - - if expected != calculated { - t.Error(errors.New("calculated auth token not equal to expected token")) - t.FailNow() - } -} - -func TestTransactionSearchIntegration(t *testing.T) { - - if !ENABLE_WALLEE_INTEGRATION_TEST { - fmt.Println("info: integration test disabled") - return - } - - filter := WalleeSearchFilter{ - FieldName: "merchantReference", - Operator: EQUALS, - Type: LEAF, - Value: "TTZQFA2QQ14AARC82F7Z2Q9JCH40ZHXCE3BMXJV1FG87BP2GA3P0", - } - - req := WalleeTransactionSearchRequest{ - Filter: filter, - Language: "en", - NumberOfEntities: 1, - StartingEntity: 0, - } - - api := "https://app-wallee.com/api/transaction/search" - api = FormatUrl(api, map[string]string{}, map[string]string{"spaceId": strconv.Itoa(INT_TEST_SPACE_ID)}) - - hdrs, err := prepareWalleeHeaders(api, "POST", INT_TEST_USER_ID, INT_TEST_ACCESS_TOKEN) - if err != nil { - fmt.Println("Error preparing headers (req1): ", err.Error()) - t.FailNow() - } - - for k, v := range hdrs { - fmt.Println("req1", k, v) - } - - p, s, err := HttpPost( - api, - hdrs, - &req, - NewJsonCodec[WalleeTransactionSearchRequest](), - NewJsonCodec[[]*WalleeTransaction](), - ) - if err != nil { - fmt.Println("Error executing request: ", err.Error()) - fmt.Println("Status: ", s) - } else { - fmt.Println("wallee response status: ", s) - fmt.Println("wallee response: ", p) - } -} - -func TestRefundIntegration(t *testing.T) { - - if !ENABLE_WALLEE_INTEGRATION_TEST { - fmt.Println("info: integration test disabled") - return - } - - url := "https://app-wallee.com/api/refund/refund" - url = FormatUrl(url, map[string]string{}, map[string]string{"spaceId": strconv.Itoa(INT_TEST_SPACE_ID)}) - - hdrs, err := prepareWalleeHeaders(url, "POST", INT_TEST_USER_ID, INT_TEST_ACCESS_TOKEN) - if err != nil { - fmt.Println("Error preparing headers: ", err.Error()) - t.FailNow() - } - - for k, v := range hdrs { - fmt.Println("req", k, v) - } - - refund := &WalleeRefund{ - Amount: INT_TEST_REFUND_AMOUNT, - ExternalID: INT_TEST_REFUND_EXT_ID, - MerchantReference: INT_TEST_REFUND_MERCHANT_REFERENCE, - Transaction: WalleeRefundTransaction{ - Id: INT_TEST_REFUND_TRANSACTION_ID, - }, - Type: "MERCHANT_INITIATED_ONLINE", // this type will refund the transaction using the responsible processor (e.g. VISA, MasterCard, TWINT, etc.) - } - - _, status, err := HttpPost[WalleeRefund, any](url, hdrs, refund, NewJsonCodec[WalleeRefund](), nil) - if err != nil { - fmt.Println("Error sending refund request:", err) - t.FailNow() - } - if status != HTTP_OK { - fmt.Println("Received unsuccessful status code:", status) - t.FailNow() - } -} diff --git a/c2ec/wallee-models.go b/c2ec/wallee-models.go @@ -1,436 +0,0 @@ -// This file is part of taler-cashless2ecash. -// Copyright (C) 2024 Joel Häberli -// -// taler-cashless2ecash is free software: you can redistribute it and/or modify it -// under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, -// or (at your option) any later version. -// -// taler-cashless2ecash 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 -// Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. -// -// SPDX-License-Identifier: AGPL3.0-or-later - -package main - -import ( - "time" -) - -type WalleeSearchOperator string - -type WalleeSearchType string - -const ( - LEAF WalleeSearchType = "LEAF" -) - -const ( - EQUALS WalleeSearchOperator = "EQUALS" -) - -type WalleeSearchFilter struct { - FieldName string `json:"fieldName"` - Operator WalleeSearchOperator `json:"operator"` - Type WalleeSearchType `json:"type"` - Value string `json:"value"` -} - -type WalleeTransactionSearchRequest struct { - Filter WalleeSearchFilter `json:"filter"` - Language string `json:"language"` - NumberOfEntities int `json:"numberOfEntities"` - StartingEntity int `json:"startingEntity"` -} - -type WalleeTransactionCompletion struct { - Amount float64 `json:"amount"` - BaseLineItems []WalleeLineItem `json:"baseLineItems"` - CreatedBy int64 `json:"createdBy"` - CreatedOn time.Time `json:"createdOn"` - ExternalID string `json:"externalId"` - FailedOn time.Time `json:"failedOn"` - FailureReason string `json:"failureReason"` - ID int64 `json:"id"` - InvoiceMerchantRef string `json:"invoiceMerchantReference"` - Labels []WalleeLabel `json:"labels"` - Language string `json:"language"` - LastCompletion bool `json:"lastCompletion"` - LineItemVersion string `json:"lineItemVersion"` - LineItems []WalleeLineItem `json:"lineItems"` - LinkedSpaceID int64 `json:"linkedSpaceId"` - LinkedTransaction int64 `json:"linkedTransaction"` - Mode string `json:"mode"` - NextUpdateOn time.Time `json:"nextUpdateOn"` - PaymentInformation string `json:"paymentInformation"` - PlannedPurgeDate time.Time `json:"plannedPurgeDate"` - ProcessingOn time.Time `json:"processingOn"` - ProcessorReference string `json:"processorReference"` - RemainingLineItems []WalleeLineItem `json:"remainingLineItems"` - SpaceViewID int64 `json:"spaceViewId"` - State string `json:"state"` - StatementDescriptor string `json:"statementDescriptor"` - SucceededOn time.Time `json:"succeededOn"` - TaxAmount float64 `json:"taxAmount"` - TimeZone string `json:"timeZone"` - TimeoutOn time.Time `json:"timeoutOn"` - Version int `json:"version"` -} - -/* - { - "amount": "14.00", - "externalId": "1", - "merchantReference": "1BQMAGTYTQVM0B1EM40PDS4H4REVMNCEN9867SJQ26Q43C38RDDG", - "transaction": { - "id": 213103343 - }, - "type": "MERCHANT_INITIATED_ONLINE" - } -*/ -type WalleeRefund struct { - Amount string `json:"amount"` - ExternalID string `json:"externalId"` // idempotence support - MerchantReference string `json:"merchantReference"` - Transaction WalleeRefundTransaction `json:"transaction"` - /* - Refund Type (for testing (not triggered at processor): MERCHANT_INITIATED_OFFLINE - For real world (triggering at the processor): MERCHANT_INITIATED_ONLINE - */ - Type string `json:"type"` -} - -type WalleeRefundTransaction struct { - Id int64 `json:"id"` -} - -// type WalleeRefund struct { -// Amount float64 `json:"amount"` -// Completion int64 `json:"completion"` // ID of WalleeTransactionCompletion -// ExternalID string `json:"externalId"` // Unique per transaction -// MerchantReference string `json:"merchantReference"` -// Reductions []WalleeLineItemReduction `json:"reductions"` -// Transaction int64 `json:"transaction"` // ID of WalleeTransaction -// Type string `json:"type"` // Refund Type -// } - -type WalleeLabel struct { - Content []byte `json:"content"` - ContentAsString string `json:"contentAsString"` - Descriptor WalleeLabelDescriptor `json:"descriptor"` - ID int64 `json:"id"` - Version int `json:"version"` -} - -type WalleeLabelDescriptor struct { - Category string `json:"category"` - Description map[string]string `json:"description"` - Features []int64 `json:"features"` - Group int64 `json:"group"` - ID int64 `json:"id"` - Name map[string]string `json:"name"` - Type int64 `json:"type"` - Weight int `json:"weight"` -} - -type WalleeLineItemReduction struct { - LineItemUniqueId string - QuantityReduction float64 - UnitPriceReduction float64 -} - -type WalleeLineItemAttribute struct { - Label string - Value string -} - -type WalleeTax struct { - Rate float64 - Title string -} - -type WalleeLineItem struct { - AggregatedTaxRate float64 `json:"aggregatedTaxRate"` - AmountExcludingTax float64 `json:"amountExcludingTax"` - AmountIncludingTax float64 `json:"amountIncludingTax"` - Attributes map[string]WalleeLineItemAttribute `json:"attributes"` - DiscountExcludingTax float64 `json:"discountExcludingTax"` - DiscountIncludingTax float64 `json:"discountIncludingTax"` - Name string `json:"name"` - Quantity float64 `json:"quantity"` - ShippingRequired bool `json:"shippingRequired"` - SKU string `json:"sku"` - TaxAmount float64 `json:"taxAmount"` - TaxAmountPerUnit float64 `json:"taxAmountPerUnit"` - Taxes []WalleeTax `json:"taxes"` - Type string `json:"type"` - UndiscountedAmountExcludingTax float64 `json:"undiscountedAmountExcludingTax"` - UndiscountedAmountIncludingTax float64 `json:"undiscountedAmountIncludingTax"` - UndiscountedUnitPriceExclTax float64 `json:"undiscountedUnitPriceExcludingTax"` - UndiscountedUnitPriceInclTax float64 `json:"undiscountedUnitPriceIncludingTax"` - UniqueID string `json:"uniqueId"` - UnitPriceExcludingTax float64 `json:"unitPriceExcludingTax"` - UnitPriceIncludingTax float64 `json:"unitPriceIncludingTax"` -} - -type WalleeTransactionState string - -const ( - StateCreate WalleeTransactionState = "CREATE" - StatePending WalleeTransactionState = "PENDING" - StateConfirmed WalleeTransactionState = "CONFIRMED" - StateProcessing WalleeTransactionState = "PROCESSING" - StateFailed WalleeTransactionState = "FAILED" - StateAuthorized WalleeTransactionState = "AUTHORIZED" - StateCompleted WalleeTransactionState = "COMPLETED" - StateFulfill WalleeTransactionState = "FULFILL" - StateDecline WalleeTransactionState = "DECLINE" - StateVoided WalleeTransactionState = "VOIDED" -) - -type WalleeTransaction struct { - ProviderTransaction - AcceptHeader interface{} `json:"acceptHeader"` - AcceptLanguageHeader interface{} `json:"acceptLanguageHeader"` - AllowedPaymentMethodBrands []interface{} `json:"allowedPaymentMethodBrands"` - AllowedPaymentMethodConfigurations []interface{} `json:"allowedPaymentMethodConfigurations"` - AuthorizationAmount float64 `json:"authorizationAmount"` - AuthorizationEnvironment string `json:"authorizationEnvironment"` - AuthorizationSalesChannel int64 `json:"authorizationSalesChannel"` - AuthorizationTimeoutOn time.Time `json:"authorizationTimeoutOn"` - AuthorizedOn time.Time `json:"authorizedOn"` - AutoConfirmationEnabled bool `json:"autoConfirmationEnabled"` - BillingAddress interface{} `json:"billingAddress"` - ChargeRetryEnabled bool `json:"chargeRetryEnabled"` - CompletedAmount float64 `json:"completedAmount"` - CompletedOn interface{} `json:"completedOn"` - CompletionBehavior string `json:"completionBehavior"` - CompletionTimeoutOn interface{} `json:"completionTimeoutOn"` - ConfirmedBy int `json:"confirmedBy"` - ConfirmedOn time.Time `json:"confirmedOn"` - CreatedBy int `json:"createdBy"` - CreatedOn time.Time `json:"createdOn"` - Currency string `json:"currency"` - CustomerEmailAddress interface{} `json:"customerEmailAddress"` - CustomerId interface{} `json:"customerId"` - CustomersPresence string `json:"customersPresence"` - DeliveryDecisionMadeOn interface{} `json:"deliveryDecisionMadeOn"` - DeviceSessionIdentifier interface{} `json:"deviceSessionIdentifier"` - EmailsDisabled bool `json:"emailsDisabled"` - EndOfLife time.Time `json:"endOfLife"` - Environment string `json:"environment"` - EnvironmentSelectionStrategy string `json:"environmentSelectionStrategy"` - FailedOn interface{} `json:"failedOn"` - FailedUrl interface{} `json:"failedUrl"` - FailureReason interface{} `json:"failureReason"` - Group WalleeGroup `json:"group"` - Id int64 `json:"id"` - InternetProtocolAddress interface{} `json:"internetProtocolAddress"` - InternetProtocolAddressCountry interface{} `json:"internetProtocolAddressCountry"` - InvoiceMerchantReference string `json:"invoiceMerchantReference"` - JavaEnabled interface{} `json:"javaEnabled"` - Language string `json:"language"` - LineItems []WalleeLineItem `json:"lineItems"` - LinkedSpaceId int `json:"linkedSpaceId"` - MerchantReference string `json:"merchantReference"` - MetaData struct{} `json:"metaData"` - Parent interface{} `json:"parent"` - PaymentConnectorConfiguration WalleePaymentConnectorConfiguration `json:"paymentConnectorConfiguration"` - PlannedPurgeDate time.Time `json:"plannedPurgeDate"` - ProcessingOn time.Time `json:"processingOn"` - RefundedAmount float64 `json:"refundedAmount"` - ScreenColorDepth interface{} `json:"screenColorDepth"` - ScreenHeight interface{} `json:"screenHeight"` - ScreenWidth interface{} `json:"screenWidth"` - ShippingAddress interface{} `json:"shippingAddress"` - ShippingMethod interface{} `json:"shippingMethod"` - SpaceViewId interface{} `json:"spaceViewId"` - State string `json:"state"` - SuccessUrl interface{} `json:"successUrl"` - Terminal WalleeTerminal `json:"terminal"` - TimeZone interface{} `json:"timeZone"` - Token interface{} `json:"token"` - TokenizationMode interface{} `json:"tokenizationMode"` - TotalAppliedFees float64 `json:"totalAppliedFees"` - TotalSettledAmount float64 `json:"totalSettledAmount"` - UserAgentHeader interface{} `json:"userAgentHeader"` - UserFailureMessage interface{} `json:"userFailureMessage"` - UserInterfaceType string `json:"userInterfaceType"` - Version int `json:"version"` - WindowHeight interface{} `json:"windowHeight"` - WindowWidth interface{} `json:"windowWidth"` - YearsToKeep int `json:"yearsToKeep"` -} - -type WalleeGroup struct { - BeginDate time.Time `json:"beginDate"` - CustomerId interface{} `json:"customerId"` - EndDate time.Time `json:"endDate"` - Id int `json:"id"` - LinkedSpaceId int `json:"linkedSpaceId"` - PlannedPurgeDate time.Time `json:"plannedPurgeDate"` - State string `json:"state"` - Version int `json:"version"` -} - -type WalleePaymentConnectorConfiguration struct { - ApplicableForTransactionProcessing bool `json:"applicableForTransactionProcessing"` - Conditions []interface{} `json:"conditions"` - Connector int64 `json:"connector"` - EnabledSalesChannels []WalleeEnabledSalesChannels `json:"enabledSalesChannels"` - EnabledSpaceViews []interface{} `json:"enabledSpaceViews"` - Id int `json:"id"` - ImagePath string `json:"imagePath"` - LinkedSpaceId int `json:"linkedSpaceId"` - Name string `json:"name"` - PaymentMethodConfiguration WalleePaymentMethodConfiguration `json:"paymentMethodConfiguration"` - PlannedPurgeDate interface{} `json:"plannedPurgeDate"` - Priority int `json:"priority"` - ProcessorConfiguration WalleeProcessorConfiguration `json:"processorConfiguration"` - State string `json:"state"` - Version int `json:"version"` -} - -type WalleeEnabledSalesChannels struct { - Description WalleeMultilangProperty `json:"description"` - Icon string `json:"icon"` - Id int64 `json:"id"` - Name WalleeMultilangProperty `json:"name"` - SortOrder int `json:"sortOrder"` -} - -type WalleeMultilangProperty struct { - DeDE string `json:"de-DE"` - EnUS string `json:"en-US"` - FrFR string `json:"fr-FR"` - ItIT string `json:"it-IT"` -} - -type WalleePaymentMethodConfiguration struct { - DataCollectionType string `json:"dataCollectionType"` - Description struct{} `json:"description"` - Id int `json:"id"` - ImageResourcePath interface{} `json:"imageResourcePath"` - LinkedSpaceId int `json:"linkedSpaceId"` - Name string `json:"name"` - OneClickPaymentMode string `json:"oneClickPaymentMode"` - PaymentMethod int64 `json:"paymentMethod"` - PlannedPurgeDate interface{} `json:"plannedPurgeDate"` - ResolvedDescription WalleeMultilangProperty `json:"resolvedDescription"` - ResolvedImageUrl string `json:"resolvedImageUrl"` - ResolvedTitle WalleeMultilangProperty `json:"resolvedTitle"` - SortOrder int `json:"sortOrder"` - SpaceId int `json:"spaceId"` - State string `json:"state"` - Title struct{} `json:"title"` - Version int `json:"version"` -} - -type WalleeProcessorConfiguration struct { - ApplicationManaged bool `json:"applicationManaged"` - ContractId interface{} `json:"contractId"` - Id int `json:"id"` - LinkedSpaceId int `json:"linkedSpaceId"` - Name string `json:"name"` - PlannedPurgeDate interface{} `json:"plannedPurgeDate"` - Processor int64 `json:"processor"` - State string `json:"state"` - Version int `json:"version"` -} - -type WalleeTerminal struct { - ConfigurationVersion WalleeConfigurationVersion `json:"configurationVersion"` - DefaultCurrency string `json:"defaultCurrency"` - DeviceName interface{} `json:"deviceName"` - DeviceSerialNumber string `json:"deviceSerialNumber"` - ExternalId string `json:"externalId"` - Id int `json:"id"` - Identifier string `json:"identifier"` - LinkedSpaceId int `json:"linkedSpaceId"` - LocationVersion WalleeLocationVersion `json:"locationVersion"` - Name string `json:"name"` - PlannedPurgeDate interface{} `json:"plannedPurgeDate"` - State string `json:"state"` - Type WalleeType `json:"type"` - Version int `json:"version"` -} - -type WalleeConfigurationVersion struct { - Configuration WalleeConfiguration `json:"configuration"` - ConnectorConfigurations []int `json:"connectorConfigurations"` - CreatedBy int `json:"createdBy"` - CreatedOn time.Time `json:"createdOn"` - DefaultCurrency interface{} `json:"defaultCurrency"` - Id int `json:"id"` - LinkedSpaceId int `json:"linkedSpaceId"` - MaintenanceWindowDuration string `json:"maintenanceWindowDuration"` - MaintenanceWindowStart string `json:"maintenanceWindowStart"` - PlannedPurgeDate interface{} `json:"plannedPurgeDate"` - State string `json:"state"` - TimeZone string `json:"timeZone"` - Version int `json:"version"` - VersionAppliedImmediately bool `json:"versionAppliedImmediately"` -} - -type WalleeConfiguration struct { - Id int `json:"id"` - LinkedSpaceId int `json:"linkedSpaceId"` - Name string `json:"name"` - PlannedPurgeDate interface{} `json:"plannedPurgeDate"` - State string `json:"state"` - Type WalleeType `json:"type"` - Version int `json:"version"` -} - -type WalleeType struct { - Description WalleeMultilangProperty `json:"description"` - Id int64 `json:"id"` - Name WalleeMultilangProperty `json:"name"` -} - -type WalleeLocationVersion struct { - Address WalleeAddress `json:"address"` - ContactAddress interface{} `json:"contactAddress"` - CreatedBy int `json:"createdBy"` - CreatedOn time.Time `json:"createdOn"` - Id int `json:"id"` - LinkedSpaceId int `json:"linkedSpaceId"` - Location WalleeLocation `json:"location"` - PlannedPurgeDate interface{} `json:"plannedPurgeDate"` - State string `json:"state"` - Version int `json:"version"` - VersionAppliedImmediately bool `json:"versionAppliedImmediately"` -} - -type WalleeAddress struct { - City string `json:"city"` - Country string `json:"country"` - DependentLocality string `json:"dependentLocality"` - EmailAddress string `json:"emailAddress"` - FamilyName string `json:"familyName"` - GivenName string `json:"givenName"` - MobilePhoneNumber string `json:"mobilePhoneNumber"` - OrganizationName string `json:"organizationName"` - PhoneNumber string `json:"phoneNumber"` - PostalState interface{} `json:"postalState"` - Postcode string `json:"postcode"` - PostCode string `json:"postCode"` - Salutation string `json:"salutation"` - SortingCode string `json:"sortingCode"` - Street string `json:"street"` -} - -type WalleeLocation struct { - ExternalId string `json:"externalId"` - Id int `json:"id"` - LinkedSpaceId int `json:"linkedSpaceId"` - Name string `json:"name"` - PlannedPurgeDate interface{} `json:"plannedPurgeDate"` - State string `json:"state"` - Version int `json:"version"` -} diff --git a/simulation/c2ec-simulation b/simulation/c2ec-simulation Binary files differ.