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 }