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:
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.