cashless2ecash

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

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:
Mc2ec/amount_test.go | 6+++++-
Ac2ec/attestor.go | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mc2ec/bank-integration.go | 229+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mc2ec/c2ec-config.yaml | 3+++
Mc2ec/config.go | 9++++-----
Mc2ec/db.go | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Ac2ec/db/0000-c2ec_payment_notification_listener.sql | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mc2ec/db/0000-c2ec_status_listener.sql | 8++++----
Mc2ec/go.mod | 2+-
Mc2ec/go.sum | 3++-
Mc2ec/http-util.go | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mc2ec/http-util_test.go | 10++++++++--
Mc2ec/main.go | 6+++---
Mc2ec/model.go | 3+--
Mc2ec/postgres.go | 162++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Ac2ec/provider-client.go | 12++++++++++++
Ac2ec/wallee-attestor.go | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/wallee-client.go | 397+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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"` +}