cashless2ecash

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

wallee-client.go (12009B)


      1 // This file is part of taler-cashless2ecash.
      2 // Copyright (C) 2024 Joel Häberli
      3 //
      4 // taler-cashless2ecash is free software: you can redistribute it and/or modify it
      5 // under the terms of the GNU Affero General Public License as published
      6 // by the Free Software Foundation, either version 3 of the License,
      7 // or (at your option) any later version.
      8 //
      9 // taler-cashless2ecash is distributed in the hope that it will be useful, but
     10 // WITHOUT ANY WARRANTY; without even the implied warranty of
     11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
     12 // Affero General Public License for more details.
     13 //
     14 // You should have received a copy of the GNU Affero General Public License
     15 // along with this program.  If not, see <http://www.gnu.org/licenses/>.
     16 //
     17 // SPDX-License-Identifier: AGPL3.0-or-later
     18 
     19 package internal_provider_wallee
     20 
     21 import (
     22 	"bytes"
     23 	internal_utils "c2ec/internal/utils"
     24 	"c2ec/pkg/config"
     25 	"c2ec/pkg/db"
     26 	"c2ec/pkg/provider"
     27 	"crypto/hmac"
     28 	"crypto/sha512"
     29 	"encoding/base64"
     30 	"errors"
     31 	"fmt"
     32 	"io"
     33 	"regexp"
     34 	"strconv"
     35 	"strings"
     36 	"time"
     37 	"unicode/utf8"
     38 )
     39 
     40 const WALLEE_AUTH_HEADER_VERSION = "x-mac-version"
     41 const WALLEE_AUTH_HEADER_USERID = "x-mac-userid"
     42 const WALLEE_AUTH_HEADER_TIMESTAMP = "x-mac-timestamp"
     43 const WALLEE_AUTH_HEADER_MAC = "x-mac-value"
     44 
     45 const WALLEE_READ_TRANSACTION_API = "/api/transaction/read"
     46 const WALLEE_SEARCH_TRANSACTION_API = "/api/transaction/search"
     47 const WALLEE_CREATE_REFUND_API = "/api/refund/refund"
     48 
     49 const WALLEE_API_SPACEID_PARAM_NAME = "spaceId"
     50 
     51 type WalleeCredentials struct {
     52 	SpaceId            int    `json:"spaceId"`
     53 	UserId             int    `json:"userId"`
     54 	ApplicationUserKey string `json:"application-user-key"`
     55 }
     56 
     57 type WalleeClient struct {
     58 	provider.ProviderClient
     59 
     60 	name        string
     61 	baseUrl     string
     62 	credentials *WalleeCredentials
     63 }
     64 
     65 func (wt *WalleeTransaction) AllowWithdrawal() bool {
     66 
     67 	return strings.EqualFold(string(wt.State), string(StateFulfill))
     68 }
     69 
     70 func (wt *WalleeTransaction) AbortWithdrawal() bool {
     71 	// guaranteed abortion is given when the state of
     72 	// the transaction is a final state but not the
     73 	// success case (which is FULFILL)
     74 	return strings.EqualFold(string(wt.State), string(StateFailed)) ||
     75 		strings.EqualFold(string(wt.State), string(StateVoided)) ||
     76 		strings.EqualFold(string(wt.State), string(StateDecline))
     77 }
     78 
     79 func (wt *WalleeTransaction) Confirm(w *db.Withdrawal) error {
     80 
     81 	if wt.MerchantReference != *w.ProviderTransactionId {
     82 
     83 		return errors.New("the merchant reference does not match the withdrawal")
     84 	}
     85 
     86 	amountFloatFrmt := strconv.FormatFloat(wt.CompletedAmount, 'f', config.CONFIG.Server.CurrencyFractionDigits, 64)
     87 	internal_utils.LogInfo("wallee-client", fmt.Sprintf("converted %f (float) to %s (string)", wt.CompletedAmount, amountFloatFrmt))
     88 	completedAmountStr := fmt.Sprintf("%s:%s", config.CONFIG.Server.Currency, amountFloatFrmt)
     89 	completedAmount, err := internal_utils.ParseAmount(completedAmountStr, config.CONFIG.Server.CurrencyFractionDigits)
     90 	if err != nil {
     91 		internal_utils.LogError("wallee-client", err)
     92 		return err
     93 	}
     94 
     95 	withdrawAmount, err := internal_utils.ToAmount(w.Amount)
     96 	if err != nil {
     97 		return err
     98 	}
     99 	withdrawFees, err := internal_utils.ToAmount(w.TerminalFees)
    100 	if err != nil {
    101 		return err
    102 	}
    103 	if completedAmountMinusFees, err := completedAmount.Sub(*withdrawFees, config.CONFIG.Server.CurrencyFractionDigits); err == nil {
    104 		if smaller, err := completedAmountMinusFees.IsSmallerThan(*withdrawAmount); smaller || err != nil {
    105 
    106 			if err != nil {
    107 				return err
    108 			}
    109 
    110 			return fmt.Errorf("the confirmed amount (%s) minus the fees (%s) was smaller than the withdraw amount (%s)",
    111 				completedAmountStr,
    112 				withdrawFees.String(config.CONFIG.Server.CurrencyFractionDigits),
    113 				withdrawAmount.String(config.CONFIG.Server.CurrencyFractionDigits),
    114 			)
    115 		}
    116 	}
    117 
    118 	return nil
    119 }
    120 
    121 func (wt *WalleeTransaction) Bytes() []byte {
    122 
    123 	reader, err := internal_utils.NewJsonCodec[WalleeTransaction]().Encode(wt)
    124 	if err != nil {
    125 		internal_utils.LogError("wallee-client", err)
    126 		return make([]byte, 0)
    127 	}
    128 	bytes, err := io.ReadAll(reader)
    129 	if err != nil {
    130 		internal_utils.LogError("wallee-client", err)
    131 		return make([]byte, 0)
    132 	}
    133 	return bytes
    134 }
    135 
    136 func (w *WalleeClient) SetupClient(p *db.Provider) error {
    137 
    138 	cfg, err := config.ConfigForProvider(p.Name)
    139 	if err != nil {
    140 		return err
    141 	}
    142 
    143 	creds, err := parseCredentials(p.BackendCredentials, cfg)
    144 	if err != nil {
    145 		return err
    146 	}
    147 
    148 	w.name = p.Name
    149 	w.baseUrl = p.BackendBaseURL
    150 	w.credentials = creds
    151 
    152 	provider.PROVIDER_CLIENTS[w.name] = w
    153 
    154 	internal_utils.LogInfo("wallee-client", fmt.Sprintf("Wallee client is setup (user=%d, spaceId=%d, backend=%s)", w.credentials.UserId, w.credentials.SpaceId, w.baseUrl))
    155 
    156 	return nil
    157 }
    158 
    159 func (w *WalleeClient) GetTransaction(transactionId string) (provider.ProviderTransaction, error) {
    160 
    161 	if transactionId == "" {
    162 		return nil, errors.New("transaction id must be specified but was blank")
    163 	}
    164 
    165 	call := fmt.Sprintf("%s%s", w.baseUrl, WALLEE_SEARCH_TRANSACTION_API)
    166 	queryParams := map[string]string{
    167 		WALLEE_API_SPACEID_PARAM_NAME: strconv.Itoa(w.credentials.SpaceId),
    168 	}
    169 	url := internal_utils.FormatUrl(call, map[string]string{}, queryParams)
    170 
    171 	hdrs, err := prepareWalleeHeaders(url, internal_utils.HTTP_POST, w.credentials.UserId, w.credentials.ApplicationUserKey)
    172 	if err != nil {
    173 		return nil, err
    174 	}
    175 
    176 	filter := WalleeSearchFilter{
    177 		FieldName: "merchantReference",
    178 		Operator:  EQUALS,
    179 		Type:      LEAF,
    180 		Value:     transactionId,
    181 	}
    182 
    183 	req := WalleeTransactionSearchRequest{
    184 		Filter:           filter,
    185 		Language:         "en",
    186 		NumberOfEntities: 1,
    187 		StartingEntity:   0,
    188 	}
    189 
    190 	t, status, err := internal_utils.HttpPost(
    191 		url,
    192 		hdrs,
    193 		&req,
    194 		internal_utils.NewJsonCodec[WalleeTransactionSearchRequest](),
    195 		internal_utils.NewJsonCodec[[]*WalleeTransaction](),
    196 	)
    197 	if err != nil {
    198 		return nil, err
    199 	}
    200 	if status != internal_utils.HTTP_OK {
    201 		return nil, errors.New("no result")
    202 	}
    203 	if t == nil {
    204 		return nil, errors.New("no such transaction for merchantReference=" + transactionId)
    205 	}
    206 	derefRes := *t
    207 	if len(derefRes) < 1 {
    208 		return nil, errors.New("no such transaction for merchantReference=" + transactionId)
    209 	}
    210 	return derefRes[0], nil
    211 }
    212 
    213 func (sc *WalleeClient) FormatPayto(w *db.Withdrawal) string {
    214 
    215 	if w == nil || w.ProviderTransactionId == nil {
    216 		internal_utils.LogError("wallee-client", errors.New("withdrawal or provider transaction identifier was nil"))
    217 		return ""
    218 	}
    219 	return fmt.Sprintf("payto://wallee-transaction/%s", *w.ProviderTransactionId)
    220 }
    221 
    222 func (w *WalleeClient) Refund(transactionId string) error {
    223 
    224 	internal_utils.LogInfo("wallee-client", "trying to refund provider transaction "+transactionId)
    225 	call := fmt.Sprintf("%s%s", w.baseUrl, WALLEE_CREATE_REFUND_API)
    226 	queryParams := map[string]string{
    227 		WALLEE_API_SPACEID_PARAM_NAME: strconv.Itoa(w.credentials.SpaceId),
    228 	}
    229 	url := internal_utils.FormatUrl(call, map[string]string{}, queryParams)
    230 	internal_utils.LogInfo("wallee-client", "refund url "+url)
    231 
    232 	hdrs, err := prepareWalleeHeaders(url, internal_utils.HTTP_POST, w.credentials.UserId, w.credentials.ApplicationUserKey)
    233 	if err != nil {
    234 		internal_utils.LogError("wallee-client", err)
    235 		return err
    236 	}
    237 
    238 	withdrawal, err := db.DB.GetWithdrawalByProviderTransactionId(transactionId)
    239 	if err != nil {
    240 		err = errors.New("error unable to find withdrawal belonging to transactionId=" + transactionId)
    241 		internal_utils.LogError("wallee-client", err)
    242 		return err
    243 	}
    244 	if withdrawal == nil {
    245 		err = errors.New("withdrawal is nil unable to find withdrawal belonging to transactionId=" + transactionId)
    246 		internal_utils.LogError("wallee-client", err)
    247 		return err
    248 	}
    249 
    250 	decodedWalleeTransaction, err := internal_utils.NewJsonCodec[WalleeTransaction]().Decode(bytes.NewBuffer(withdrawal.CompletionProof))
    251 	if err != nil {
    252 		internal_utils.LogError("wallee-client", err)
    253 		return err
    254 	}
    255 
    256 	refundAmount, err := internal_utils.ToAmount(withdrawal.Amount)
    257 	if err != nil {
    258 		internal_utils.LogError("wallee-client", err)
    259 		return err
    260 	}
    261 
    262 	refundableAmount := refundAmount.String(config.CONFIG.Server.CurrencyFractionDigits)
    263 	refundableAmount, _ = strings.CutPrefix(refundableAmount, config.CONFIG.Server.Currency+":")
    264 	internal_utils.LogInfo("wallee-client", fmt.Sprintf("stripped currency from amount %s", refundableAmount))
    265 	refund := &WalleeRefund{
    266 		Amount:            refundableAmount,
    267 		ExternalID:        internal_utils.TalerBinaryEncode(withdrawal.Wopid),
    268 		MerchantReference: decodedWalleeTransaction.MerchantReference,
    269 		Transaction: WalleeRefundTransaction{
    270 			Id: int64(decodedWalleeTransaction.Id),
    271 		},
    272 		Type: "MERCHANT_INITIATED_ONLINE", // this type will refund the transaction using the responsible processor (e.g. VISA, MasterCard, TWINT, etc.)
    273 	}
    274 
    275 	_, status, err := internal_utils.HttpPost[WalleeRefund, any](
    276 		url,
    277 		hdrs,
    278 		refund,
    279 		internal_utils.NewJsonCodec[WalleeRefund](),
    280 		nil,
    281 	)
    282 	if err != nil {
    283 		internal_utils.LogError("wallee-client", err)
    284 		return err
    285 	}
    286 	if status != internal_utils.HTTP_OK {
    287 		return errors.New("failed refunding the transaction at the wallee-backend. statuscode=" + strconv.Itoa(status))
    288 	}
    289 
    290 	return nil
    291 }
    292 
    293 func prepareWalleeHeaders(
    294 	url string,
    295 	method string,
    296 	userId int,
    297 	applicationUserKey string,
    298 ) (map[string]string, error) {
    299 
    300 	timestamp := time.Time.Unix(time.Now())
    301 
    302 	base64Mac, err := calculateWalleeAuthToken(
    303 		userId,
    304 		timestamp,
    305 		method,
    306 		url,
    307 		applicationUserKey,
    308 	)
    309 	if err != nil {
    310 		return nil, err
    311 	}
    312 
    313 	headers := map[string]string{
    314 		WALLEE_AUTH_HEADER_VERSION:   "1",
    315 		WALLEE_AUTH_HEADER_USERID:    strconv.Itoa(userId),
    316 		WALLEE_AUTH_HEADER_TIMESTAMP: strconv.Itoa(int(timestamp)),
    317 		WALLEE_AUTH_HEADER_MAC:       base64Mac,
    318 	}
    319 
    320 	return headers, nil
    321 }
    322 
    323 func parseCredentials(raw string, cfg *config.C2ECProviderConfig) (*WalleeCredentials, error) {
    324 
    325 	credsJson := make([]byte, len(raw))
    326 	_, err := base64.StdEncoding.Decode(credsJson, []byte(raw))
    327 	if err != nil {
    328 		return nil, err
    329 	}
    330 
    331 	creds, err := internal_utils.NewJsonCodec[WalleeCredentials]().Decode(bytes.NewBuffer(credsJson))
    332 	if err != nil {
    333 		return nil, err
    334 	}
    335 
    336 	if !internal_utils.ValidPassword(cfg.Key, creds.ApplicationUserKey) {
    337 		return nil, errors.New("invalid application user key in wallee client configuration")
    338 	}
    339 
    340 	// correct application user key.
    341 	creds.ApplicationUserKey = cfg.Key
    342 	return creds, nil
    343 }
    344 
    345 // This function calculates the authentication token according
    346 // to the documentation of wallee:
    347 // https://app-wallee.com/en-us/doc/api/web-service#_authentication
    348 // the function returns the token in Base64 format.
    349 func calculateWalleeAuthToken(
    350 	userId int,
    351 	unixTimestamp int64,
    352 	httpMethod string,
    353 	pathWithParams string,
    354 	userKeyBase64 string,
    355 ) (string, error) {
    356 
    357 	// Put together the correct formatted string
    358 	// Version | UserId | Timestamp | Method | Path
    359 	authMsgStr := fmt.Sprintf("%d|%d|%d|%s|%s",
    360 		1, // version is static
    361 		userId,
    362 		unixTimestamp,
    363 		httpMethod,
    364 		cutSchemeAndHost(pathWithParams),
    365 	)
    366 
    367 	authMsg := make([]byte, 0)
    368 	if valid := utf8.ValidString(authMsgStr); !valid {
    369 
    370 		// encode the string using utf8
    371 		for _, r := range authMsgStr {
    372 			rbytes := make([]byte, 4)
    373 			utf8.EncodeRune(rbytes, r)
    374 			authMsg = append(authMsg, rbytes...)
    375 		}
    376 	} else {
    377 		authMsg = bytes.NewBufferString(authMsgStr).Bytes()
    378 	}
    379 
    380 	internal_utils.LogInfo("wallee-client", fmt.Sprintf("authMsg (utf-8 encoded): %s", string(authMsg)))
    381 
    382 	key := make([]byte, 32)
    383 	_, err := base64.StdEncoding.Decode(key, []byte(userKeyBase64))
    384 	if err != nil {
    385 		internal_utils.LogError("wallee-client", err)
    386 		return "", err
    387 	}
    388 
    389 	if len(key) != 32 {
    390 		return "", errors.New("malformed secret")
    391 	}
    392 
    393 	macer := hmac.New(sha512.New, key)
    394 	_, err = macer.Write(authMsg)
    395 	if err != nil {
    396 		internal_utils.LogError("wallee-client", err)
    397 		return "", err
    398 	}
    399 	mac := macer.Sum(make([]byte, 0))
    400 
    401 	return base64.StdEncoding.EncodeToString(mac), nil
    402 }
    403 
    404 func cutSchemeAndHost(url string) string {
    405 
    406 	reg := regexp.MustCompile(`https?:\/\/[\w-\.]{1,}`)
    407 	return reg.ReplaceAllString(url, "")
    408 }