commit 3856219284e7c27ae70ad8ffb96aec38ba19627c
parent 5770318d4a4f7a8e668afa7db088776191ccd191
Author: Joel-Haeberli <haebu@rubigen.ch>
Date: Mon, 1 Apr 2024 12:19:24 +0200
code: add attestor interface, trigger attestation in database
Diffstat:
18 files changed, 1051 insertions(+), 188 deletions(-)
diff --git a/c2ec/amount_test.go b/c2ec/amount_test.go
@@ -61,8 +61,12 @@ func TestAmountSub(t *testing.T) {
func TestAmountLarge(t *testing.T) {
x, err := ParseAmount("EUR:50")
+ if err != nil {
+ fmt.Println(err)
+ t.Errorf("Failed")
+ }
_, err = x.Add(a)
- if nil != err {
+ if err != nil {
fmt.Println(err)
t.Errorf("Failed")
}
diff --git a/c2ec/attestor.go b/c2ec/attestor.go
@@ -0,0 +1,57 @@
+package main
+
+import (
+ "context"
+ "errors"
+)
+
+const PAYMENT_NOTIFICATION_CHANNEL_BUFFER_SIZE = 10
+const PS_PAYMENT_NOTIFICATION_CHANNEL = "payment_notification"
+
+// An attestor attests a withdrawal using the providers
+// backend. Attestations are triggered by a database event.
+// An attestor registers itself to those events using the
+// Listen function. An attestor shall be run on its own.
+// The generic argument T is the type of the channel which
+// is used to specify the expected result type.
+type Attestor[T any] interface {
+ // This will setup the attestor. Don't call this function
+ // on your own. Instead, use RunAttestor which will setup
+ // and run the Attestor for you.
+ Setup(p *Provider, cfg *C2ECDatabseConfig) (chan *T, error)
+ // Listen for database event. If event is catched,
+ // dispatch and kick off attestation. The notifications
+ // will be sent through the notification channel. Since the
+ // process is started in the background, errors can
+ // be retrieved through the supplied error channel.
+ Listen(ctx context.Context, notificationChannel chan *T, errs chan error) error
+ // Attests a single withdrawal.
+ Attest(withdrawalId int, providerTransactionId string, errs chan error) error
+}
+
+// Sets up and runs an attestor in the background. This must be called at startup.
+func RunAttestor[T any](
+ ctx context.Context,
+ attestor Attestor[T],
+ provider *Provider,
+ cfg *C2ECDatabseConfig,
+) (chan error, error) {
+
+ if attestor == nil {
+ return nil, errors.New("the attestor was null")
+ }
+
+ if cfg == nil {
+ return nil, errors.New("the database config was null")
+ }
+
+ notificationChannel, err := attestor.Setup(provider, cfg)
+ if err != nil {
+ return nil, err
+ }
+
+ errs := make(chan error)
+ go attestor.Listen(ctx, notificationChannel, errs)
+
+ return errs, nil
+}
diff --git a/c2ec/bank-integration.go b/c2ec/bank-integration.go
@@ -2,11 +2,11 @@ package main
import (
"bytes"
+ "context"
"fmt"
"log"
http "net/http"
"strconv"
- "strings"
"time"
)
@@ -83,7 +83,7 @@ func bankIntegrationConfig(res http.ResponseWriter, req *http.Request) {
func handleWithdrawalRegistration(res http.ResponseWriter, req *http.Request) {
jsonCodec := NewJsonCodec[C2ECWithdrawRegistration]()
- registration, err := ReadStructFromBody[C2ECWithdrawRegistration](req, jsonCodec)
+ registration, err := ReadStructFromBody(req, jsonCodec)
if err != nil {
err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
@@ -98,7 +98,13 @@ func handleWithdrawalRegistration(res http.ResponseWriter, req *http.Request) {
return
}
- err = DB.RegisterWithdrawal(registration)
+ err = DB.RegisterWithdrawal(
+ registration.Wopid,
+ registration.ReservePubKey,
+ registration.Amount,
+ registration.TerminalId,
+ )
+
if err != nil {
err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
@@ -126,36 +132,115 @@ func handleWithdrawalRegistration(res http.ResponseWriter, req *http.Request) {
// given old_state until responding
// - old_state (optional):
// Default is 'pending'
-// - provider_id (optional, for c2ec use mandatory):
-// The terminal provider requesting for status update.
func handleWithdrawalStatus(res http.ResponseWriter, req *http.Request) {
// read and validate request query parameters
+ shouldStartLongPoll := true
longPollMilli := DEFAULT_LONG_POLL_MS
- longPollMilliPtr, err := OptionalQueryParamOrError("long_poll_ms", req, strconv.Atoi)
- if err != nil {
- err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAWAL_STATUS_INVALID_PARAMETER",
- Title: "invalid request parameter",
- Detail: "the withdrawal status request parameter 'long_poll_ms' is malformed (error: " + err.Error() + ")",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ 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
+ }
+ }
+
+ // read and validate the wopid path parameter
+ wopid := req.PathValue(WOPID_PARAMETER)
+ if _, ok := any(wopid).(WithdrawalIdentifier); !ok {
+
+ if wopid == "" {
+ err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
+ TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_PATH_PARAMETER",
+ Title: "invalid request path parameter",
+ Detail: "the withdrawal status request path parameter 'wopid' is malformed",
+ Instance: req.RequestURI,
+ })
+ if err != nil {
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ }
+ return
+ }
+ }
+
+ if shouldStartLongPoll {
+
+ timeoutCtx, cancelFunc := context.WithTimeout(
+ req.Context(),
+ time.Duration(longPollMilli)*time.Millisecond,
+ )
+ defer cancelFunc()
+
+ statusChannel := make(chan WithdrawalOperationStatus)
+ errChan := make(chan error)
+
+ // listen for status change in goroutine
+ go DB.ListenForWithdrawalStatusChange(timeoutCtx, WithdrawalIdentifier(wopid), statusChannel, errChan)
+
+ for {
+ select {
+ case <-timeoutCtx.Done():
+ err := WriteProblem(res, HTTP_NO_CONTENT, &RFC9457Problem{
+ TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_LONG_POLL_TIME_EXCEEDED",
+ Title: "time exceeded",
+ Detail: fmt.Sprintf("long poll ended due to timeout: %dms", longPollMilli),
+ Instance: req.RequestURI,
+ })
+ if err != nil {
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ }
+ return
+ case err := <-errChan:
+ err = WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
+ TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INTERNAL_SERVER_ERROR",
+ Title: "internal server error",
+ Detail: err.Error(),
+ Instance: req.RequestURI,
+ })
+ if err != nil {
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ }
+ return
+ case <-statusChannel:
+ getWithdrawalOrWriteError(wopid, res, req.RequestURI)
+ }
}
- return
}
- if longPollMilliPtr != nil {
- longPollMilli = *longPollMilliPtr
+
+ getWithdrawalOrWriteError(wopid, res, req.RequestURI)
+}
+
+func handlePaymentNotification(res http.ResponseWriter, req *http.Request) {
+
+ wopid := req.PathValue(WOPID_PARAMETER)
+ if _, ok := any(wopid).(WithdrawalIdentifier); !ok {
+
+ if wopid == "" {
+ err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
+ TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_PATH_PARAMETER",
+ Title: "invalid request path parameter",
+ Detail: "the withdrawal status request path parameter 'wopid' is malformed",
+ Instance: req.RequestURI,
+ })
+ if err != nil {
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ }
+ return
+ }
}
- oldState := DEFAULT_OLD_STATE
- oldStatePtr, err := OptionalQueryParamOrError("old_state", req, ToWithdrawalOpStatus)
+ body := make([]byte, req.ContentLength)
+ _, err := req.Body.Read(body)
if err != nil {
err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAWAL_STATUS_INVALID_PARAMETER",
- Title: "invalid request parameter",
- Detail: "the withdrawal status request parameter 'old_state' is malformed (error: " + err.Error() + ")",
+ TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_BODY",
+ Title: "invalid request body",
+ Detail: "the body of the payment notification request is malformed: " + err.Error(),
Instance: req.RequestURI,
})
if err != nil {
@@ -163,32 +248,13 @@ func handleWithdrawalStatus(res http.ResponseWriter, req *http.Request) {
}
return
}
- if oldStatePtr != nil {
- oldState = *oldStatePtr
- }
-
- // TODO is this needed ? I think there's a better solution
- // providerId, err := OptionalQueryParamOrError("provider_id", req, strconv.Atoi)
- // if err != nil {
- // err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- // TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAWAL_STATUS_INVALID_PARAMETER",
- // Title: "invalid request parameter",
- // Detail: "the withdrawal status request parameter 'provider_id' is malformed (error: " + err.Error() + ")",
- // Instance: req.RequestURI,
- // })
- // if err != nil {
- // res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- // }
- // return
- // }
- // read and validate the wopid path parameter
- wopid := req.PathValue(WOPID_PARAMETER)
- if wopid == "" {
+ paymentNotification, err := NewJsonCodec[C2ECPaymentNotification]().Decode(bytes.NewBuffer(body))
+ if err != nil {
err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAWAL_STATUS_INVALID_PARAMETER",
- Title: "invalid request path parameter",
- Detail: "the withdrawal status request path parameter 'wopid' is malformed (error: " + err.Error() + ")",
+ TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_BODY",
+ Title: "invalid request body",
+ Detail: "the body of the payment notification request is malformed: " + err.Error(),
Instance: req.RequestURI,
})
if err != nil {
@@ -197,15 +263,35 @@ func handleWithdrawalStatus(res http.ResponseWriter, req *http.Request) {
return
}
+ err = DB.NotifyPayment(
+ WithdrawalIdentifier(wopid),
+ paymentNotification.ProviderTransactionId,
+ paymentNotification.Amount,
+ paymentNotification.Amount,
+ )
+
+ res.WriteHeader(HTTP_NO_CONTENT)
+}
+
+func handleWithdrawalAbort(res http.ResponseWriter, req *http.Request) {
+
+ res.WriteHeader(HTTP_OK)
+ res.Write(bytes.NewBufferString("retrieved withdrawal operation abortion request").Bytes())
+}
+
+// Tries to load a WithdrawalOperationStatus from the database. If no
+// entry could been found, it will write the correct error to the response.
+func getWithdrawalOrWriteError(wopid string, res http.ResponseWriter, reqUri string) {
// read the withdrawal from the database
withdrawal, err := DB.GetWithdrawalByWopid(wopid)
if err != nil {
+ // TODO : What, if just no entry has been found -> 404?
err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAWAL_STATUS_DB_FAILURE",
Title: "database failure",
Detail: "the registration of the withdrawal failed due to db failure (error:" + err.Error() + ")",
- Instance: req.RequestURI,
+ Instance: reqUri,
})
if err != nil {
res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
@@ -213,37 +299,13 @@ func handleWithdrawalStatus(res http.ResponseWriter, req *http.Request) {
return
}
- // if the old state was supplied and the current status of withdrawal is
- // different, return the current withdrawal directly.
- if oldStatePtr != nil {
- // Only enter listening mode if the old state did not yet change
- // compared to the current state.
- if strings.EqualFold(string(oldState), string(withdrawal.WithdrawalStatus)) {
- // Listen for change from old_state here for a maximal time of long_poll_ms
- duration := time.Duration(longPollMilli) * time.Millisecond
- withdrawal, err = DB.AwaitWithdrawalStatusChange(wopid, duration, oldState)
- if err != nil {
- err := WriteProblem(res, HTTP_NOT_FOUND, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAWAL_STATUS_LISTEN_FOR_CHANGE_FAILED",
- Title: fmt.Sprintf("failed while listening for change of status '%s' for withdrawal-operation with wopid=%s", oldState, wopid),
- Detail: "listening for C2ECWithdrawalStatus object failed (error:" + err.Error() + ")",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
- }
- }
-
// return the C2ECWithdrawalStatus
if amount, err := ToAmount(withdrawal.Amount); err != nil {
err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAWAL_STATUS_CONVERSION_FAILURE",
Title: "conversion failure",
Detail: "failed converting amount object (error:" + err.Error() + ")",
- Instance: req.RequestURI,
+ Instance: reqUri,
})
if err != nil {
res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
@@ -259,7 +321,7 @@ func handleWithdrawalStatus(res http.ResponseWriter, req *http.Request) {
TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAWAL_STATUS_CONVERSION_FAILURE",
Title: "conversion failure",
Detail: "failed converting C2ECWithdrawalStatus object (error:" + err.Error() + ")",
- Instance: req.RequestURI,
+ Instance: reqUri,
})
if err != nil {
res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
@@ -268,28 +330,9 @@ func handleWithdrawalStatus(res http.ResponseWriter, req *http.Request) {
}
res.WriteHeader(HTTP_OK)
res.Write(withdrawalStatusBytes)
- return
}
}
-func handlePaymentNotification(res http.ResponseWriter, req *http.Request) {
-
- wopid := req.PathValue(WOPID_PARAMETER)
- if wopid == "" {
- res.WriteHeader(HTTP_BAD_REQUEST)
- return
- }
-
- res.WriteHeader(HTTP_OK)
- res.Write(bytes.NewBufferString("retrieved payment notification for wopid=" + wopid).Bytes())
-}
-
-func handleWithdrawalAbort(res http.ResponseWriter, req *http.Request) {
-
- res.WriteHeader(HTTP_OK)
- res.Write(bytes.NewBufferString("retrieved withdrawal operation abortion request").Bytes())
-}
-
// ----------------------
// OFFICIAL MODELS
// ----------------------
diff --git a/c2ec/c2ec-config.yaml b/c2ec/c2ec-config.yaml
@@ -8,3 +8,6 @@ db:
port: 5432
username: "local"
password: "local"
+ database: "postgres"
+providers:
+ - "Wallee"
diff --git a/c2ec/config.go b/c2ec/config.go
@@ -6,12 +6,10 @@ import (
"gopkg.in/yaml.v3"
)
-const POSTGRESQL_SCHEME = "postgres://"
-const NONCE2ECASH_DATABASE = "nonce2ecash"
-
type C2ECConfig struct {
- Server C2ECServerConfig `yaml:"c2ec"`
- Database C2ECDatabseConfig `yaml:"db"`
+ Server C2ECServerConfig `yaml:"c2ec"`
+ Database C2ECDatabseConfig `yaml:"db"`
+ Providers []string `yaml:"providers"`
}
type C2ECServerConfig struct {
@@ -26,6 +24,7 @@ type C2ECDatabseConfig struct {
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
+ Database string `yaml:"database"`
}
func Parse(path string) (*C2ECConfig, error) {
diff --git a/c2ec/db.go b/c2ec/db.go
@@ -1,6 +1,8 @@
package main
-import "time"
+import (
+ "context"
+)
const PROVIDER_TABLE_NAME = "provider"
const PROVIDER_FIELD_NAME_ID = "terminal_id"
@@ -65,12 +67,64 @@ type TalerAmountCurrency struct {
Curr string `db:"curr"`
}
+// C2ECDatabase defines the operations which a
+// C2EC compliant database interface must implement
+// in order to be bound to the c2ec API.
type C2ECDatabase interface {
- RegisterWithdrawal(r *C2ECWithdrawRegistration) error
+ // Registers a wopid and reserve public key.
+ // This initiates the withdrawal.
+ RegisterWithdrawal(
+ wopid WithdrawalIdentifier,
+ resPubKey EddsaPublicKey,
+ amount Amount,
+ terminalId uint64,
+ ) error
+
+ // Get the withdrawal associated with the given wopid.
GetWithdrawalByWopid(wopid string) (*Withdrawal, error)
- ConfirmPayment(c *C2ECPaymentNotification) error
- GetUnconfirmedWithdrawals() ([]*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 WithdrawalIdentifier,
+ providerTransactionId string,
+ amount Amount,
+ 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.
+ GetAttestableWithdrawals() ([]*Withdrawal, error)
+
+ // When an attestation (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
+
+ // Get a provider entry by its identifier
GetTerminalProviderById(id int) (*Provider, error)
+
+ // Get a terminal entry by its identifier
GetTerminalById(id int) (*Terminal, error)
- AwaitWithdrawalStatusChange(wopid string, duration time.Duration, oldState WithdrawalOperationStatus) (*Withdrawal, error)
+
+ // This will listen for on the given channel
+ // and write results to the out channels.
+ // Errors will be propagated through the errs
+ // channel. Supply a context with timeout if
+ // you want to use time limitations.
+ ListenForWithdrawalStatusChange(
+ ctx context.Context,
+ wopid WithdrawalIdentifier,
+ out chan WithdrawalOperationStatus,
+ errs chan error,
+ ) (WithdrawalOperationStatus, error)
}
diff --git a/c2ec/db/0000-c2ec_payment_notification_listener.sql b/c2ec/db/0000-c2ec_payment_notification_listener.sql
@@ -0,0 +1,47 @@
+BEGIN;
+
+SELECT _v.register_patch('0000-c2ec-payment-notification-listener', ARRAY['0000-c2ec-schema'], NULL);
+
+SET search_path TO c2ec;
+
+-- to create a function, the user needs USAGE privilege on arguments and return types
+CREATE OR REPLACE FUNCTION emit_payment_notification()
+RETURNS TRIGGER AS $$
+DECLARE
+ provider_name TEXT;
+BEGIN
+ SELECT p.name INTO provider_name FROM provider AS p
+ LEFT JOIN terminal AS t
+ ON t.provider_id = p.provider_id
+ LEFT JOIN withdrawal AS w
+ ON t.terminal_id = w.terminal_id
+ WHERE w.withdrawal_id = NEW.withdrawal_id;
+ PERFORM pg_notify('payment_notification',
+ provider_name || '|' ||
+ NEW.withdrawal_id || '|' ||
+ NEW.provider_transaction_id
+ );
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql;
+COMMENT ON FUNCTION emit_payment_notification
+ IS 'The function emits the name of the provider, row id of the withdrawal
+ and the provider_transaction_id, on the channel "payment_notification".
+ The format of the payload is as follows:
+ "{PROVIDER_NAME}|{WITHDRAWAL_ID}|{PROVIDER_TRANSACTION_ID}". The subscriber
+ shall decide which attestation process to use, based on the name of
+ the provider.';
+
+-- for creating a trigger the user must have TRIGGER pivilege on the table.
+-- to execute the trigger, the user needs EXECUTE privilege on the trigger function.
+CREATE OR REPLACE TRIGGER c2ec_on_payment_notify
+ AFTER UPDATE OF provider_transaction_id
+ ON withdrawal
+ FOR EACH ROW
+ WHEN (NEW.provider_transaction_id IS NOT NULL)
+ EXECUTE FUNCTION emit_payment_notification();
+COMMENT ON TRIGGER c2ec_on_payment_notify ON withdrawal
+ IS 'After setting the provider transaction id following a payment notification,
+ trigger the emit to the respective channel.';
+
+COMMIT;
diff --git a/c2ec/db/0000-c2ec_status_listener.sql b/c2ec/db/0000-c2ec_status_listener.sql
@@ -8,14 +8,14 @@ SET search_path TO c2ec;
CREATE OR REPLACE FUNCTION emit_withdrawal_status()
RETURNS TRIGGER AS $$
BEGIN
- PERFORM pg_notify('withdrawal_' || NEW.withdrawal_id, NEW.withdrawal_status::TEXT);
+ PERFORM pg_notify('w_' || encode(NEW.wopid::BYTEA, 'base64'), NEW.withdrawal_status::TEXT);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION emit_withdrawal_status
- IS 'The function selects the withdrawal according to the wopid
- of the functions argument and sends a notification on the channel
- "withdrawal-{wopid}" with the status in the payload.';
+ IS 'The function encodes the wopid in base64 and
+ sends a notification on the channel "w_{wopid}"
+ with the status in the payload.';
-- for creating a trigger the user must have TRIGGER pivilege on the table.
-- to execute the trigger, the user needs EXECUTE privilege on the trigger function.
diff --git a/c2ec/go.mod b/c2ec/go.mod
@@ -4,6 +4,7 @@ go 1.22.0
require (
github.com/jackc/pgx/v5 v5.5.5
+ github.com/jackc/pgxlisten v0.0.0-20230728233309-2632bad3185a
gopkg.in/yaml.v3 v3.0.1
gotest.tools/v3 v3.5.1
)
@@ -13,7 +14,6 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
- github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.6.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
diff --git a/c2ec/go.sum b/c2ec/go.sum
@@ -1,4 +1,3 @@
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -10,6 +9,8 @@ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
+github.com/jackc/pgxlisten v0.0.0-20230728233309-2632bad3185a h1:Di9X9kIB5WbWxOJLai5MDj0AmA6bKSM0GsHRCqs41rI=
+github.com/jackc/pgxlisten v0.0.0-20230728233309-2632bad3185a/go.mod h1:EqjCOzkITPCEI0My7BdE2xm3r0fZ7OZycVDP+ki1ASA=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
diff --git a/c2ec/http-util.go b/c2ec/http-util.go
@@ -8,6 +8,9 @@ import (
"strings"
)
+const HTTP_GET = "GET"
+const HTTP_POST = "POST"
+
const HTTP_OK = 200
const HTTP_NO_CONTENT = 204
const HTTP_BAD_REQUEST = 400
@@ -42,14 +45,60 @@ func WriteProblem(res http.ResponseWriter, status int, problem *RFC9457Problem)
return nil
}
+// 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 {
+ err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
+ TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_REQUEST_QUERY_PARAMETER",
+ Title: "invalid request query parameter",
+ Detail: "the withdrawal status request parameter '" + name + "' is malformed (error: " + err.Error() + ")",
+ Instance: req.RequestURI,
+ })
+ if err != nil {
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ }
+ return nil, false
+ }
+
+ obj := *ptr
+ assertedObj, ok := any(obj).(T)
+ if !ok {
+ // this should generally not happen (due to the implementation)
+ err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
+ TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_FATAL_ERROR",
+ Title: "Fatal Error",
+ Detail: "Something strange happened. Probably not your fault.",
+ Instance: req.RequestURI,
+ })
+ if err != nil {
+ 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,
- req *http.Request,
transform func(s string) (T, error),
+ req *http.Request,
) (*T, error) {
paramStr := req.URL.Query().Get(name)
@@ -96,33 +145,26 @@ func ReadBody(req *http.Request) ([]byte, error) {
return body, nil
}
-// execute a GET request and parse body or retrieve error
-func HttpGet2[T any](
- req string,
- codec Codec[T],
-) (*T, int, error) {
-
- return HttpGet(
- req,
- nil,
- nil,
- codec,
- )
-}
-
-// execute a GET request and parse body or retrieve error
-// path- and query-parameters can be set to add query and path parameters
+// 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](
- req string,
- pathParams map[string]string,
- queryParams map[string]string,
+ url string,
+ headers map[string]string,
codec Codec[T],
) (*T, int, error) {
- url := formatUrl(req, pathParams, queryParams)
- fmt.Println("GET:", url)
+ 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.Get(url)
+ res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, -1, err
}
@@ -164,7 +206,7 @@ func HttpPost[T any, R any](
responseCodec Codec[R],
) (*R, int, error) {
- url := formatUrl(req, pathParams, queryParams)
+ url := FormatUrl(req, pathParams, queryParams)
fmt.Println("POST:", url)
var res *http.Response
@@ -220,7 +262,7 @@ func HttpPost[T any, R any](
// builds request URL containing the path and query
// parameters of the respective parameter map.
-func formatUrl(
+func FormatUrl(
req string,
pathParams map[string]string,
queryParams map[string]string,
diff --git a/c2ec/http-util_test.go b/c2ec/http-util_test.go
@@ -17,13 +17,19 @@ type TestStruct struct {
func TestGET(t *testing.T) {
- res, status, err := HttpGet(
+ url := FormatUrl(
URL_GET,
map[string]string{
"id": "1",
},
map[string]string{},
- NewJsonCodec[TestStruct](),
+ )
+
+ codec := NewJsonCodec[TestStruct]()
+ res, status, err := HttpGet(
+ url,
+ map[string]string{},
+ codec,
)
if err != nil {
diff --git a/c2ec/main.go b/c2ec/main.go
@@ -40,7 +40,7 @@ func main() {
panic("unable to load config: " + err.Error())
}
- DB, err = setupDatabase()
+ DB, err = setupDatabase(cfg.Database)
if err != nil {
panic("unable initialize datatbase: " + err.Error())
}
@@ -83,9 +83,9 @@ func main() {
}
}
-func setupDatabase() (C2ECDatabase, error) {
+func setupDatabase(cfg C2ECDatabseConfig) (C2ECDatabase, error) {
- return nil, nil
+ return NewC2ECPostgres(&cfg)
}
func setupBankIntegrationRoutes(router *http.ServeMux) {
diff --git a/c2ec/model.go b/c2ec/model.go
@@ -1,7 +1,6 @@
package main
import (
- "errors"
"fmt"
)
@@ -46,7 +45,7 @@ func ToWithdrawalOpStatus(s string) (WithdrawalOperationStatus, error) {
case string(CONFIRMED):
return CONFIRMED, nil
default:
- return "", errors.New(fmt.Sprintf("unknown withdrawal operation status '%s'", s))
+ return "", fmt.Errorf("unknown withdrawal operation status '%s'", s)
}
}
diff --git a/c2ec/postgres.go b/c2ec/postgres.go
@@ -1,13 +1,17 @@
package main
import (
+ "bytes"
"context"
+ "encoding/base64"
"errors"
+ "fmt"
"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 + " (" +
@@ -20,16 +24,21 @@ const PS_INSERT_WITHDRAWAL = "INSERT INTO " + WITHDRAWAL_TABLE_NAME + " (" +
" VALUES ($1, $2, $3, $4, $5, $6);"
const PS_GET_UNCONFIRMED_WITHDRAWALS = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
- " WHERE " + WITHDRAWAL_FIELD_NAME_STATUS + " != '" + CONFIRMED + "'" +
- " AND " + WITHDRAWAL_FIELD_NAME_STATUS + " != '" + ABORTED + "';"
+ " WHERE " + WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + " IS NOT NULL" +
+ " AND " + WITHDRAWAL_FIELD_NAME_STATUS + " = '" + string(SELECTED) + "'"
-const PS_CONFIRM_WITHDRAWAL = "UPDATE " + WITHDRAWAL_TABLE_NAME + " SET (" +
+const PS_PAYMENT_NOTIFICATION = "UPDATE " + WITHDRAWAL_TABLE_NAME + " SET (" +
WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + "," +
WITHDRAWAL_FIELD_NAME_AMOUNT + "," +
WITHDRAWAL_FIELD_NAME_FEES + "," +
- WITHDRAWAL_FIELD_NAME_COMPLETION_PROOF + ")" +
- " = ($1, $2, $3, $4)" +
- " WHERE " + WITHDRAWAL_FIELD_NAME_WOPID + "=$5"
+ " = ($1, $2, $3)" +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_WOPID + "=$4"
+
+const PS_FINALISE_PAYMENT = "UPDATE " + WITHDRAWAL_TABLE_NAME + " SET (" +
+ WITHDRAWAL_FIELD_NAME_STATUS + "," +
+ WITHDRAWAL_FIELD_NAME_COMPLETION_PROOF + "," +
+ " = ($1, $2)" +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$3"
const PS_GET_WITHDRAWAL_BY_WOPID = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
" WHERE " + WITHDRAWAL_FIELD_NAME_WOPID + "=$1"
@@ -48,35 +57,40 @@ type C2ECPostgres struct {
pool *pgxpool.Pool
}
-func NewC2ECPostgres(cfg C2ECDatabseConfig) (*C2ECPostgres, error) {
+func PostgresConnectionString(cfg *C2ECDatabseConfig) string {
+ return fmt.Sprintf(
+ "postgres://%s:%s@%s:%d/%s",
+ cfg.Username,
+ cfg.Password,
+ cfg.Host,
+ cfg.Port,
+ cfg.Database,
+ )
+}
+
+func NewC2ECPostgres(cfg *C2ECDatabseConfig) (*C2ECPostgres, error) {
ctx := context.Background()
db := new(C2ECPostgres)
- dbCfg := pgxpool.Config{
- ConnConfig: &pgx.ConnConfig{
- Config: pgconn.Config{
- Host: cfg.Host,
- Port: uint16(cfg.Port),
- User: cfg.Username,
- Password: cfg.Password,
- },
- },
- }
+ connectionString := PostgresConnectionString(cfg)
- pool, err := pgxpool.NewWithConfig(ctx, &dbCfg)
+ dbConnCfg, err := pgxpool.ParseConfig(connectionString)
if err != nil {
- return nil, err
+ panic(err.Error())
+ }
+ db.pool, err = pgxpool.NewWithConfig(context.Background(), dbConnCfg)
+ if err != nil {
+ panic(err.Error())
}
db.ctx = ctx
- db.pool = pool
return db, nil
}
func (db *C2ECPostgres) RegisterWithdrawal(
- wopid uint32,
+ wopid WithdrawalIdentifier,
resPubKey EddsaPublicKey,
amount Amount,
terminalId uint64,
@@ -124,22 +138,19 @@ func (db *C2ECPostgres) GetWithdrawalByWopid(wopid string) (*Withdrawal, error)
}
}
-func (db *C2ECPostgres) ConfirmPayment(
+func (db *C2ECPostgres) NotifyPayment(
+ wopid WithdrawalIdentifier,
providerTransactionId string,
amount Amount,
fees Amount,
- completion_proof []byte,
- confirmOrAbort WithdrawalOperationStatus,
) error {
res, err := db.pool.Query(
db.ctx,
- PS_CONFIRM_WITHDRAWAL,
+ PS_PAYMENT_NOTIFICATION,
providerTransactionId,
amount,
fees,
- completion_proof,
- confirmOrAbort,
)
if err != nil {
return err
@@ -148,13 +159,11 @@ func (db *C2ECPostgres) ConfirmPayment(
return nil
}
-// TODO this is probably not needed when using the LISTEN / NOTIFY feature
-func (db *C2ECPostgres) GetUnconfirmedWithdrawals(wopid string) ([]*Withdrawal, error) {
+func (db *C2ECPostgres) GetAttestableWithdrawals() ([]*Withdrawal, error) {
if row, err := db.pool.Query(
db.ctx,
- PS_GET_WITHDRAWAL_BY_WOPID,
- wopid,
+ PS_GET_UNCONFIRMED_WITHDRAWALS,
); err != nil {
if row != nil {
row.Close()
@@ -173,23 +182,28 @@ func (db *C2ECPostgres) GetUnconfirmedWithdrawals(wopid string) ([]*Withdrawal,
}
}
-func (db *C2ECPostgres) AwaitWithdrawalStatusChange(
- wopid string,
- timeout time.Duration,
- oldState WithdrawalOperationStatus,
-) (*Withdrawal, error) {
-
- // TODO ... examples: https://github.com/jackc/pgxlisten/blob/master/pgxlisten_test.go
- // -> Start a handler listening for the respective withdrawal
- limitedTimeCtx, cancel := context.WithTimeout(db.ctx, timeout)
- defer cancel()
- conn, err := db.pool.Acquire(limitedTimeCtx)
+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")
+ }
+
+ res, err := db.pool.Query(
+ db.ctx,
+ PS_FINALISE_PAYMENT,
+ confirmOrAbort,
+ completionProof,
+ withdrawalId,
+ )
if err != nil {
- return nil, err
+ return err
}
- conn.Conn().PgConn()
- conn.Conn().WaitForNotification(limitedTimeCtx)
- return nil, errors.New("not yet implemented")
+ res.Close()
+ return nil
}
func (db *C2ECPostgres) GetTerminalProviderById(id int) (*Provider, error) {
@@ -215,6 +229,7 @@ func (db *C2ECPostgres) GetTerminalProviderById(id int) (*Provider, error) {
return provider[0], nil
}
}
+
func (db *C2ECPostgres) GetTerminalById(id int) (*Terminal, error) {
if row, err := db.pool.Query(
@@ -238,3 +253,58 @@ func (db *C2ECPostgres) GetTerminalById(id int) (*Terminal, error) {
return terminal[0], nil
}
}
+
+func (db *C2ECPostgres) ListenForWithdrawalStatusChange(
+ ctx context.Context,
+ wopid WithdrawalIdentifier,
+ out chan WithdrawalOperationStatus,
+ errs chan error,
+) (WithdrawalOperationStatus, error) {
+
+ pgNotification := make(chan *pgconn.Notification)
+ channel := "w_" + base64.StdEncoding.EncodeToString(bytes.NewBufferString(string(wopid)).Bytes())
+ listener := newChannelListener(db.pool.Config().ConnConfig, channel, pgNotification)
+
+ go func() {
+ if err := listener.Listen(ctx); err != nil {
+ errs <- err
+ }
+ // close the channel we send results, because listener has finished.
+ close(pgNotification)
+ }()
+
+ select {
+ case e := <-errs:
+ return "", e
+ case <-ctx.Done():
+ return "", errors.New("time exceeded")
+ case n := <-pgNotification:
+ // TODO : Centralize Logging somehow
+ fmt.Println("received notification for channel", n.Channel, ":", n.Payload)
+ return WithdrawalOperationStatus(n.Payload), nil
+ }
+}
+
+// Sets up a channel with the given configurations.
+func newChannelListener(
+ cfg *pgx.ConnConfig,
+ cn string,
+ out chan *pgconn.Notification,
+) *pgxlisten.Listener {
+
+ listener := &pgxlisten.Listener{
+ Connect: func(ctx context.Context) (*pgx.Conn, error) {
+ return pgx.ConnectConfig(ctx, cfg)
+ },
+ }
+
+ listener.Handle(cn, pgxlisten.HandlerFunc(func(ctx context.Context, notification *pgconn.Notification, conn *pgx.Conn) error {
+ select {
+ case out <- notification:
+ case <-ctx.Done():
+ }
+ return nil
+ }))
+
+ return listener
+}
diff --git a/c2ec/provider-client.go b/c2ec/provider-client.go
@@ -0,0 +1,12 @@
+package main
+
+type ProviderTransaction interface {
+ AllowWithdrawal() bool
+ Bytes() []byte
+}
+
+type ProviderClient[T any] interface {
+ SetupClient(provider *Provider) error
+ GetTransaction(transactionId string) (ProviderTransaction, error)
+ Refund(transactionId string) error
+}
diff --git a/c2ec/wallee-attestor.go b/c2ec/wallee-attestor.go
@@ -0,0 +1,129 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "strconv"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgxlisten"
+)
+
+type WalleeAttestor struct {
+ listener *pgxlisten.Listener
+ provider *Provider
+ providerClient ProviderClient[WalleeClient]
+}
+
+func (wa *WalleeAttestor) Setup(p *Provider, cfg *C2ECDatabseConfig) (chan *pgconn.Notification, error) {
+
+ connectionString := PostgresConnectionString(cfg)
+
+ dbCfg, err := pgx.ParseConfig(connectionString)
+ if err != nil {
+ panic(err.Error())
+ }
+
+ wa.provider = p
+
+ wa.providerClient = new(WalleeClient)
+ err = wa.providerClient.SetupClient(wa.provider)
+ if err != nil {
+ panic(err.Error())
+ }
+
+ notificationChannel := make(chan *pgconn.Notification, PAYMENT_NOTIFICATION_CHANNEL_BUFFER_SIZE)
+ wa.listener = newChannelListener(dbCfg, PS_PAYMENT_NOTIFICATION_CHANNEL, notificationChannel)
+ return notificationChannel, nil
+}
+
+func (wa *WalleeAttestor) Listen(
+ ctx context.Context,
+ notificationChannel chan *pgconn.Notification,
+ errs chan error,
+) error {
+
+ if wa.listener == nil {
+ return errors.New("attestor needs to be setup first")
+ }
+
+ // we must listen for notifications async
+ go func() {
+ for {
+ select {
+ case notification := <-notificationChannel:
+ // the dispatching can be done asynchronously
+ go wa.dispatch(notification, errs)
+ case <-ctx.Done():
+ close(notificationChannel)
+ close(errs)
+ return
+ }
+ }
+ }()
+
+ go func() {
+ err := wa.listener.Listen(ctx)
+ if err != nil {
+ errs <- err
+ }
+ close(notificationChannel)
+ close(errs)
+ }()
+
+ return nil
+}
+
+func (wa *WalleeAttestor) Attest(withdrawalId int, providerTransactionId string, errs chan error) {
+
+ transaction, err := wa.providerClient.GetTransaction(providerTransactionId)
+ if err != nil {
+ // TODO : do we abort the withdrawal here??
+ errs <- err
+ }
+
+ if transaction.AllowWithdrawal() {
+
+ err = DB.FinaliseWithdrawal(withdrawalId, CONFIRMED, transaction.Bytes())
+ if err != nil {
+ // TODO : do we abort the withdrawal here??
+ errs <- err
+ }
+ } else {
+ // TODO : this might be too early ?! What if the payment was not yet
+ // processed by the Wallee backend? Needs testing.
+ err = DB.FinaliseWithdrawal(withdrawalId, ABORTED, transaction.Bytes())
+ if err != nil {
+ // TODO : do we abort the withdrawal here??
+ errs <- err
+ }
+ }
+}
+
+func (wa *WalleeAttestor) dispatch(notification *pgconn.Notification, errs chan error) {
+
+ // 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
+ }
+
+ provider := payload[0]
+ if provider != "Wallee" {
+ // the Wallee attestor can only handle wallee transactions
+ return
+ }
+ withdrawalRowId, err := strconv.Atoi(payload[1])
+ if err != nil {
+ errs <- errors.New("malformed withdrawal_id: " + err.Error())
+ return
+ }
+ providerTransactionId := payload[2]
+
+ wa.Attest(withdrawalRowId, providerTransactionId, errs)
+}
diff --git a/c2ec/wallee-client.go b/c2ec/wallee-client.go
@@ -0,0 +1,397 @@
+package main
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/sha512"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "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_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[WalleeTransaction]
+
+ name string
+ baseUrl string
+ credentials *WalleeCredentials
+}
+
+func (wt *WalleeTransaction) AllowWithdrawal() bool {
+
+ return strings.EqualFold(string(wt.State), string(StateFulfill))
+}
+
+func (w *WalleeClient) SetupClient(p *Provider) error {
+
+ creds, err := parseCredentials(p.BackendCredentials)
+ if err != nil {
+ return err
+ }
+
+ w.name = p.Name
+ w.baseUrl = p.BackendBaseURL
+ w.credentials = creds
+ return nil
+}
+
+func (w *WalleeClient) GetTransaction(transactionId string) (ProviderTransaction, error) {
+
+ call := WALLEE_READ_TRANSACTION_API
+ queryParams := map[string]string{
+ WALLEE_API_SPACEID_PARAM_NAME: strconv.Itoa(w.credentials.SpaceId),
+ "id": transactionId,
+ }
+ url := FormatUrl(call, map[string]string{}, queryParams)
+
+ hdrs, err := w.prepareWalleeHeaders(url, HTTP_GET)
+ if err != nil {
+ return nil, err
+ }
+
+ t, status, err := HttpGet(url, hdrs, NewJsonCodec[WalleeTransaction]())
+ if err != nil {
+ return nil, err
+ }
+ if status != HTTP_OK {
+ return nil, errors.New("no result")
+ }
+ return t, nil
+}
+
+func (w *WalleeClient) Refund(transactionId string) error {
+ return errors.New("not yet implemented")
+}
+
+func (w *WalleeClient) prepareWalleeHeaders(url string, method string) (map[string]string, error) {
+
+ timestamp := time.Time.Unix(time.Now())
+
+ base64Mac, err := calculateWalleeAuthToken(
+ w.credentials.UserId,
+ timestamp,
+ method,
+ url,
+ w.credentials.ApplicationUserKey,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ headers := map[string]string{
+ WALLEE_AUTH_HEADER_VERSION: "1",
+ WALLEE_AUTH_HEADER_USERID: strconv.Itoa(w.credentials.UserId),
+ WALLEE_AUTH_HEADER_TIMESTAMP: strconv.Itoa(int(timestamp)),
+ WALLEE_AUTH_HEADER_MAC: base64Mac,
+ }
+
+ return headers, nil
+}
+
+func parseCredentials(raw string) (*WalleeCredentials, error) {
+
+ return NewJsonCodec[WalleeCredentials]().Decode(bytes.NewBufferString(raw))
+}
+
+// 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,
+ 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...)
+ }
+ }
+
+ key := make([]byte, base64.StdEncoding.DecodedLen(len(userKeyBase64)))
+ _, err := base64.StdEncoding.Decode(key, []byte(userKeyBase64))
+ if err != nil {
+ return "", err
+ }
+
+ if len(key) != 32 {
+ return "", errors.New("malformed secret")
+ }
+
+ macer := hmac.New(sha512.New, key)
+ _, err = macer.Write(authMsg)
+ if err != nil {
+ return "", err
+ }
+ mac := make([]byte, 64)
+ mac = macer.Sum(mac)
+
+ return base64.StdEncoding.EncodeToString(mac), nil
+}
+
+type TransactionState string
+
+const (
+ StateCreate TransactionState = "CREATE"
+ StatePending TransactionState = "PENDING"
+ StateConfirmed TransactionState = "CONFIRMED"
+ StateProcessing TransactionState = "PROCESSING"
+ StateFailed TransactionState = "FAILED"
+ StateAuthorized TransactionState = "AUTHORIZED"
+ StateCompleted TransactionState = "COMPLETED"
+ StateFulfill TransactionState = "FULFILL"
+ StateDecline TransactionState = "DECLINE"
+ StateVoided TransactionState = "VOIDED"
+)
+
+type WalleeTransaction struct {
+ ProviderTransaction
+
+ // acceptHeader contains the header which indicates the language preferences of the buyer.
+ AcceptHeader string `json:"acceptHeader"`
+
+ // acceptLanguageHeader contains the header which indicates the language preferences of the buyer.
+ AcceptLanguageHeader string `json:"acceptLanguageHeader"`
+
+ // allowedPaymentMethodBrands is a collection of payment method brand IDs.
+ AllowedPaymentMethodBrands []int64 `json:"allowedPaymentMethodBrands"`
+
+ // allowedPaymentMethodConfigurations is a collection of payment method configuration IDs.
+ AllowedPaymentMethodConfigurations []int64 `json:"allowedPaymentMethodConfigurations"`
+
+ // authorizationAmount is the amount authorized for the transaction.
+ AuthorizationAmount float64 `json:"authorizationAmount"`
+
+ // authorizationEnvironment is the environment in which this transaction was successfully authorized.
+ AuthorizationEnvironment string `json:"authorizationEnvironment"`
+
+ // authorizationSalesChannel is the sales channel through which the transaction was placed.
+ AuthorizationSalesChannel int64 `json:"authorizationSalesChannel"`
+
+ // authorizationTimeoutOn is the time on which the transaction will be timed out when it is not at least authorized.
+ AuthorizationTimeoutOn time.Time `json:"authorizationTimeoutOn"`
+
+ // authorizedOn is the timestamp when the transaction was authorized.
+ AuthorizedOn time.Time `json:"authorizedOn"`
+
+ // autoConfirmationEnabled indicates whether auto confirmation is enabled for the transaction.
+ AutoConfirmationEnabled bool `json:"autoConfirmationEnabled"`
+
+ // billingAddress is the address associated with the transaction.
+ BillingAddress string `json:"-"`
+
+ // chargeRetryEnabled indicates whether charging retry is enabled for the transaction.
+ ChargeRetryEnabled bool `json:"chargeRetryEnabled"`
+
+ // completedAmount is the total amount which has been captured so far.
+ CompletedAmount float64 `json:"completedAmount"`
+
+ // completedOn is the timestamp when the transaction was completed.
+ CompletedOn time.Time `json:"completedOn"`
+
+ // completionBehavior controls when the transaction is completed.
+ CompletionBehavior string `json:"completionBehavior"`
+
+ // completionTimeoutOn is the timestamp when the transaction completion will time out.
+ CompletionTimeoutOn time.Time `json:"completionTimeoutOn"`
+
+ // confirmedBy is the user ID who confirmed the transaction.
+ ConfirmedBy int64 `json:"confirmedBy"`
+
+ // confirmedOn is the timestamp when the transaction was confirmed.
+ ConfirmedOn time.Time `json:"confirmedOn"`
+
+ // createdBy is the user ID who created the transaction.
+ CreatedBy int64 `json:"createdBy"`
+
+ // createdOn is the timestamp when the transaction was created.
+ CreatedOn time.Time `json:"createdOn"`
+
+ // currency is the currency code associated with the transaction.
+ Currency string `json:"currency"`
+
+ // customerEmailAddress is the email address of the customer.
+ CustomerEmailAddress string `json:"customerEmailAddress"`
+
+ // customerId is the ID of the customer associated with the transaction.
+ CustomerID string `json:"customerId"`
+
+ // customersPresence indicates what kind of authentication method was used during authorization.
+ CustomersPresence string `json:"customersPresence"`
+
+ // deliveryDecisionMadeOn is the timestamp when the decision has been made if a transaction should be delivered or not.
+ DeliveryDecisionMadeOn time.Time `json:"deliveryDecisionMadeOn"`
+
+ // deviceSessionIdentifier links the transaction with the session identifier provided in the URL of the device data JavaScript.
+ DeviceSessionIdentifier string `json:"deviceSessionIdentifier"`
+
+ // emailsDisabled indicates whether email sending is disabled for this particular transaction.
+ EmailsDisabled bool `json:"emailsDisabled"`
+
+ // endOfLife indicates the date from which on no operation can be carried out anymore.
+ EndOfLife time.Time `json:"endOfLife"`
+
+ // environment is the environment in which the transaction is processed.
+ Environment string `json:"environment"`
+
+ // environmentSelectionStrategy determines how the environment (test or production) for processing the transaction is selected.
+ EnvironmentSelectionStrategy string `json:"environmentSelectionStrategy"`
+
+ // failedOn is the timestamp when the transaction failed.
+ FailedOn time.Time `json:"failedOn"`
+
+ // failedUrl is the URL to which the user will be redirected when the transaction fails.
+ FailedURL string `json:"failedUrl"`
+
+ // failureReason describes why the transaction failed.
+ FailureReason string `json:"failureReason"`
+
+ // group is the transaction group associated with the transaction.
+ Group string `json:"-"`
+
+ // id is the unique identifier for the transaction.
+ ID int64 `json:"id"`
+
+ // internetProtocolAddress identifies the device of the buyer.
+ InternetProtocolAddress string `json:"internetProtocolAddress"`
+
+ // internetProtocolAddressCountry is the country associated with the Internet Protocol (IP) address.
+ InternetProtocolAddressCountry string `json:"internetProtocolAddressCountry"`
+
+ // invoiceMerchantReference is the merchant reference associated with the invoice.
+ InvoiceMerchantReference string `json:"invoiceMerchantReference"`
+
+ // javaEnabled indicates whether Java is enabled for the transaction.
+ JavaEnabled bool `json:"javaEnabled"`
+
+ // language is the language linked to the transaction.
+ Language string `json:"language"`
+
+ // lineItems is a collection of line items associated with the transaction.
+ LineItems []string `json:"-"`
+
+ // linkedSpaceId is the ID of the space this transaction belongs to.
+ LinkedSpaceID int64 `json:"linkedSpaceId"`
+
+ // merchantReference is the merchant reference associated with the transaction.
+ MerchantReference string `json:"merchantReference"`
+
+ // metaData allows storing additional information about the transaction.
+ MetaData map[string]string `json:"metaData"`
+
+ // parent is the parent transaction associated with this transaction.
+ Parent int64 `json:"parent"`
+
+ // paymentConnectorConfiguration is the connector configuration associated with the payment.
+ PaymentConnectorConfiguration string `json:"-"`
+
+ // plannedPurgeDate is the date when the transaction is planned to be permanently removed.
+ PlannedPurgeDate time.Time `json:"plannedPurgeDate"`
+
+ // processingOn is the timestamp when the transaction is being processed.
+ ProcessingOn time.Time `json:"processingOn"`
+
+ // refundedAmount is the total amount which has been refunded so far.
+ RefundedAmount float64 `json:"refundedAmount"`
+
+ // screenColorDepth is the color depth of the screen associated with the transaction.
+ ScreenColorDepth string `json:"screenColorDepth"`
+
+ // screenHeight is the height of the screen associated with the transaction.
+ ScreenHeight string `json:"screenHeight"`
+
+ // screenWidth is the width of the screen associated with the transaction.
+ ScreenWidth string `json:"screenWidth"`
+
+ // shippingAddress is the address associated with the shipping of the transaction.
+ ShippingAddress string `json:"-"`
+
+ // shippingMethod is the method used for shipping in the transaction.
+ ShippingMethod string `json:"shippingMethod"`
+
+ // spaceViewId is the ID of the space view associated with the transaction.
+ SpaceViewID int64 `json:"spaceViewId"`
+
+ // state is the current state of the transaction.
+ State TransactionState `json:"state"`
+
+ // successUrl is the URL to which the user will be redirected when the transaction succeeds.
+ SuccessURL string `json:"successUrl"`
+
+ // terminal is the terminal on which the payment was processed.
+ Terminal string `json:"-"`
+
+ // timeZone is the time zone in which the customer is located.
+ TimeZone string `json:"timeZone"`
+
+ // token is the token associated with the transaction.
+ Token string `json:"-"`
+
+ // tokenizationMode controls if and how the tokenization of payment information is applied to the transaction.
+ TokenizationMode string `json:"tokenizationMode"`
+
+ // totalAppliedFees is the sum of all fees that have been applied so far.
+ TotalAppliedFees float64 `json:"totalAppliedFees"`
+
+ // totalSettledAmount is the total amount which has been settled so far.
+ TotalSettledAmount float64 `json:"totalSettledAmount"`
+
+ // userAgentHeader provides the user agent of the buyer.
+ UserAgentHeader string `json:"userAgentHeader"`
+
+ // userFailureMessage describes why the transaction failed for the end user.
+ UserFailureMessage string `json:"userFailureMessage"`
+
+ // userInterfaceType defines through which user interface the transaction has been processed.
+ UserInterfaceType string `json:"userInterfaceType"`
+
+ // version is used for optimistic locking and incremented whenever the object is updated.
+ Version int `json:"version"`
+
+ // windowHeight is the height of the window associated with the transaction.
+ WindowHeight string `json:"windowHeight"`
+
+ // windowWidth is the width of the window associated with the transaction.
+ WindowWidth string `json:"windowWidth"`
+
+ // yearsToKeep is the number of years the transaction will be stored after it has been authorized.
+ YearsToKeep int `json:"yearsToKeep"`
+}