api-bank-integration.go (16551B)
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_api 20 21 import ( 22 internal_utils "c2ec/internal/utils" 23 "c2ec/pkg/config" 24 "c2ec/pkg/db" 25 "c2ec/pkg/provider" 26 "context" 27 "encoding/base64" 28 "errors" 29 "fmt" 30 http "net/http" 31 "strconv" 32 "time" 33 ) 34 35 const DEFAULT_LONG_POLL_MS = 1000 36 const DEFAULT_OLD_STATE = internal_utils.PENDING 37 38 // https://docs.taler.net/core/api-exchange.html#tsref-type-CurrencySpecification 39 type CurrencySpecification struct { 40 Name string `json:"name"` 41 Currency string `json:"currency"` 42 NumFractionalInputDigits int `json:"num_fractional_input_digits"` 43 NumFractionalNormalDigits int `json:"num_fractional_normal_digits"` 44 NumFractionalTrailingZeroDigits int `json:"num_fractional_trailing_zero_digits"` 45 AltUnitNames map[string]string `json:"alt_unit_names"` 46 } 47 48 // https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankIntegrationConfig 49 type BankIntegrationConfig struct { 50 Name string `json:"name"` 51 Version string `json:"version"` 52 Implementation string `json:"implementation"` 53 Currency string `json:"currency"` 54 CurrencySpecification CurrencySpecification `json:"currency_specification"` 55 } 56 57 type BankWithdrawalOperationPostRequest struct { 58 ReservePubKey internal_utils.EddsaPublicKey `json:"reserve_pub"` 59 SelectedExchange string `json:"selected_exchange"` 60 Amount *internal_utils.Amount `json:"amount"` 61 } 62 63 type BankWithdrawalOperationPostResponse struct { 64 Status internal_utils.WithdrawalOperationStatus `json:"status"` 65 ConfirmTransferUrl string `json:"confirm_transfer_url"` 66 TransferDone bool `json:"transfer_done"` 67 } 68 69 type BankWithdrawalOperationStatus struct { 70 Status internal_utils.WithdrawalOperationStatus `json:"status"` 71 Amount string `json:"amount"` 72 CardFees string `json:"card_fees"` 73 SenderWire string `json:"sender_wire"` 74 WireTypes []string `json:"wire_types"` 75 ReservePubKey internal_utils.EddsaPublicKey `json:"selected_reserve_pub"` 76 SuggestedExchange string `json:"suggested_exchange"` 77 RequiredExchange string `json:"required_exchange"` 78 Aborted bool `json:"aborted"` 79 SelectionDone bool `json:"selection_done"` 80 TransferDone bool `json:"transfer_done"` 81 } 82 83 func BankIntegrationConfigApi(res http.ResponseWriter, req *http.Request) { 84 85 internal_utils.LogInfo("bank-integration-api", "reading config") 86 cfg := BankIntegrationConfig{ 87 Name: "taler-bank-integration", 88 Version: "4:8:2", 89 Currency: config.CONFIG.Server.Currency, 90 CurrencySpecification: CurrencySpecification{ 91 Name: config.CONFIG.Server.Currency, 92 Currency: config.CONFIG.Server.Currency, 93 NumFractionalInputDigits: config.CONFIG.Server.CurrencyFractionDigits, 94 NumFractionalNormalDigits: config.CONFIG.Server.CurrencyFractionDigits, 95 NumFractionalTrailingZeroDigits: 0, 96 AltUnitNames: map[string]string{ 97 "0": config.CONFIG.Server.Currency, 98 }, 99 }, 100 } 101 102 encoder := internal_utils.NewJsonCodec[BankIntegrationConfig]() 103 serializedCfg, err := encoder.EncodeToBytes(&cfg) 104 if err != nil { 105 internal_utils.LogInfo("bank-integration-api", fmt.Sprintf("failed serializing config: %s", err.Error())) 106 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 107 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 108 return 109 } 110 111 res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) 112 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_OK) 113 res.WriteHeader(internal_utils.HTTP_OK) 114 res.Write(serializedCfg) 115 } 116 117 func HandleParameterRegistration(res http.ResponseWriter, req *http.Request) { 118 119 jsonCodec := internal_utils.NewJsonCodec[BankWithdrawalOperationPostRequest]() 120 registration, err := internal_utils.ReadStructFromBody[BankWithdrawalOperationPostRequest](req, jsonCodec) 121 if err != nil { 122 internal_utils.LogWarn("bank-integration-api", fmt.Sprintf("invalid body for withdrawal registration error=%s", err.Error())) 123 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) 124 res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) 125 return 126 } 127 128 // read and validate the wopid path parameter 129 wopid := req.PathValue(WOPID_PARAMETER) 130 wpd, err := internal_utils.ParseWopid(wopid) 131 if err != nil { 132 internal_utils.LogWarn("bank-integration-api", "wopid "+wopid+" not valid") 133 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) 134 res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) 135 return 136 } 137 138 if w, err := db.DB.GetWithdrawalByWopid(wpd); err != nil { 139 internal_utils.LogError("bank-integration-api", err) 140 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NOT_FOUND) 141 res.WriteHeader(internal_utils.HTTP_NOT_FOUND) 142 return 143 } else { 144 if w.ReservePubKey != nil || len(w.ReservePubKey) > 0 { 145 internal_utils.LogWarn("bank-integration-api", "tried registering a withdrawal-operation with already existing wopid") 146 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_CONFLICT) 147 res.WriteHeader(internal_utils.HTTP_CONFLICT) 148 return 149 } 150 } 151 152 if err = db.DB.RegisterWithdrawalParameters( 153 wpd, 154 registration.ReservePubKey, 155 ); err != nil { 156 internal_utils.LogError("bank-integration-api", err) 157 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 158 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 159 return 160 } 161 162 withdrawal, err := db.DB.GetWithdrawalByWopid(wpd) 163 if err != nil { 164 internal_utils.LogError("bank-integration-api", err) 165 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 166 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 167 } 168 169 resbody := &BankWithdrawalOperationPostResponse{ 170 Status: withdrawal.WithdrawalStatus, 171 ConfirmTransferUrl: "", // not used in our case 172 TransferDone: withdrawal.WithdrawalStatus == internal_utils.CONFIRMED, 173 } 174 175 encoder := internal_utils.NewJsonCodec[BankWithdrawalOperationPostResponse]() 176 resbyts, err := encoder.EncodeToBytes(resbody) 177 if err != nil { 178 internal_utils.LogError("bank-integration-api", err) 179 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 180 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 181 } 182 183 res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) 184 res.Write(resbyts) 185 } 186 187 // Get status of withdrawal associated with the given WOPID 188 // 189 // Parameters: 190 // - long_poll_ms (optional): 191 // milliseconds to wait for state to change 192 // given old_state until responding 193 // - old_state (optional): 194 // Default is 'pending' 195 func HandleWithdrawalStatus(res http.ResponseWriter, req *http.Request) { 196 197 // read and validate request query parameters 198 shouldStartLongPoll := true 199 longPollMilli := DEFAULT_LONG_POLL_MS 200 oldState := DEFAULT_OLD_STATE 201 if longPollMilliPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( 202 "long_poll_ms", strconv.Atoi, req, res, 203 ); accepted { 204 if longPollMilliPtr != nil { 205 longPollMilli = *longPollMilliPtr 206 if oldStatePtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( 207 "old_state", internal_utils.ToWithdrawalOperationStatus, req, res, 208 ); accepted { 209 if oldStatePtr != nil { 210 oldState = *oldStatePtr 211 } 212 } 213 } else { 214 // this means parameter was not given. 215 // no long polling (simple get) 216 internal_utils.LogInfo("bank-integration-api", "will not start long-polling") 217 shouldStartLongPoll = false 218 } 219 } else { 220 internal_utils.LogInfo("bank-integration-api", "will not start long-polling") 221 shouldStartLongPoll = false 222 } 223 224 // read and validate the wopid path parameter 225 wopid := req.PathValue(WOPID_PARAMETER) 226 wpd, err := internal_utils.ParseWopid(wopid) 227 if err != nil { 228 internal_utils.LogWarn("bank-integration-api", "wopid "+wopid+" not valid") 229 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) 230 res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) 231 return 232 } 233 234 var timeoutCtx context.Context 235 notifications := make(chan *db.Notification) 236 w := make(chan []byte) 237 errStat := make(chan int) 238 if shouldStartLongPoll { 239 240 go func() { 241 // when the current state differs from the old_state 242 // of the request, return immediately. This goroutine 243 // does this check and sends the withdrawal to through 244 // the specified channel, if the withdrawal was already 245 // changed. 246 withdrawal, err := db.DB.GetWithdrawalByWopid(wpd) 247 if err != nil { 248 internal_utils.LogError("bank-integration-api", err) 249 } 250 if withdrawal == nil { 251 // do nothing because other goroutine might deliver result 252 return 253 } 254 if withdrawal.WithdrawalStatus != oldState { 255 byts, status := formatWithdrawalOrErrorStatus(withdrawal) 256 if status != internal_utils.HTTP_OK { 257 errStat <- status 258 } else { 259 w <- byts 260 } 261 } 262 }() 263 264 var cancelFunc context.CancelFunc 265 timeoutCtx, cancelFunc = context.WithTimeout( 266 req.Context(), 267 time.Duration(longPollMilli)*time.Millisecond, 268 ) 269 defer cancelFunc() 270 271 channel := "w_" + base64.StdEncoding.EncodeToString(wpd) 272 273 listenFunc, err := db.DB.NewListener( 274 channel, 275 notifications, 276 ) 277 278 if err != nil { 279 internal_utils.LogError("bank-integration-api", err) 280 errStat <- internal_utils.HTTP_INTERNAL_SERVER_ERROR 281 } else { 282 go listenFunc(timeoutCtx) 283 } 284 } else { 285 wthdrl, stat := getWithdrawalOrError(wpd) 286 internal_utils.LogInfo("bank-integration-api", "loaded withdrawal") 287 if stat != internal_utils.HTTP_OK { 288 internal_utils.LogWarn("bank-integration-api", "tried loading withdrawal but got error") 289 //errStat <- stat 290 internal_utils.SetLastResponseCodeForLogger(stat) 291 res.WriteHeader(stat) 292 return 293 } else { 294 //w <- wthdrl 295 res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, "application/json") 296 res.Write(wthdrl) 297 return 298 } 299 } 300 301 for wait := true; wait; { 302 select { 303 case <-timeoutCtx.Done(): 304 internal_utils.LogInfo("bank-integration-api", "long poll time exceeded") 305 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NO_CONTENT) 306 res.WriteHeader(internal_utils.HTTP_NO_CONTENT) 307 wait = false 308 case <-notifications: 309 wthdrl, stat := getWithdrawalOrError(wpd) 310 if stat != 200 { 311 internal_utils.SetLastResponseCodeForLogger(stat) 312 res.WriteHeader(stat) 313 } else { 314 res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, "application/json") 315 res.Write(wthdrl) 316 } 317 wait = false 318 case wthdrl := <-w: 319 res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, "application/json") 320 res.Write(wthdrl) 321 wait = false 322 case status := <-errStat: 323 internal_utils.LogInfo("bank-integration-api", "got unsucessful state for withdrawal operation request") 324 internal_utils.SetLastResponseCodeForLogger(status) 325 res.WriteHeader(status) 326 wait = false 327 } 328 } 329 internal_utils.LogInfo("bank-integration-api", "withdrawal operation status request finished") 330 } 331 332 func HandleWithdrawalAbort(res http.ResponseWriter, req *http.Request) { 333 334 // read and validate the wopid path parameter 335 wopid := req.PathValue(WOPID_PARAMETER) 336 wpd, err := internal_utils.ParseWopid(wopid) 337 if err != nil { 338 internal_utils.LogWarn("bank-integration-api", "wopid "+wopid+" not valid") 339 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) 340 res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) 341 return 342 } 343 344 withdrawal, err := db.DB.GetWithdrawalByWopid(wpd) 345 if err != nil { 346 internal_utils.LogError("bank-integration-api", err) 347 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NOT_FOUND) 348 res.WriteHeader(internal_utils.HTTP_NOT_FOUND) 349 return 350 } 351 352 if withdrawal.WithdrawalStatus == internal_utils.CONFIRMED { 353 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_CONFLICT) 354 res.WriteHeader(internal_utils.HTTP_CONFLICT) 355 return 356 } 357 358 err = db.DB.FinaliseWithdrawal(int(withdrawal.WithdrawalRowId), internal_utils.ABORTED, make([]byte, 0)) 359 if err != nil { 360 internal_utils.LogError("bank-integration-api", err) 361 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 362 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 363 return 364 } 365 366 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NO_CONTENT) 367 res.WriteHeader(internal_utils.HTTP_NO_CONTENT) 368 } 369 370 // Tries to load a WithdrawalOperationStatus from the database. If no 371 // entry could been found, it will write the correct error to the response. 372 func getWithdrawalOrError(wopid []byte) ([]byte, int) { 373 // read the withdrawal from the database 374 withdrawal, err := db.DB.GetWithdrawalByWopid(wopid) 375 if err != nil { 376 internal_utils.LogError("bank-integration-api", err) 377 return nil, internal_utils.HTTP_NOT_FOUND 378 } 379 380 if withdrawal == nil { 381 // not found -> 404 382 return nil, internal_utils.HTTP_NOT_FOUND 383 } 384 385 // return the C2ECWithdrawalStatus 386 return formatWithdrawalOrErrorStatus(withdrawal) 387 } 388 389 func formatWithdrawalOrErrorStatus(w *db.Withdrawal) ([]byte, int) { 390 391 if w == nil { 392 return nil, internal_utils.HTTP_INTERNAL_SERVER_ERROR 393 } 394 395 operator, err := db.DB.GetProviderByTerminal(w.TerminalId) 396 if err != nil { 397 internal_utils.LogError("bank-integration-api", err) 398 return nil, internal_utils.HTTP_INTERNAL_SERVER_ERROR 399 } 400 401 client := provider.PROVIDER_CLIENTS[operator.Name] 402 if client == nil { 403 internal_utils.LogError("bank-integration-api", errors.New("no provider client registered for provider "+operator.Name)) 404 return nil, internal_utils.HTTP_INTERNAL_SERVER_ERROR 405 } 406 407 if amount, err := internal_utils.ToAmount(w.Amount); err != nil { 408 internal_utils.LogError("bank-integration-api", err) 409 return nil, internal_utils.HTTP_INTERNAL_SERVER_ERROR 410 } else { 411 if fees, err := internal_utils.ToAmount(w.TerminalFees); err != nil { 412 internal_utils.LogError("bank-integration-api", err) 413 return nil, internal_utils.HTTP_INTERNAL_SERVER_ERROR 414 } else { 415 withdrawalStatusBytes, err := internal_utils.NewJsonCodec[BankWithdrawalOperationStatus]().EncodeToBytes(&BankWithdrawalOperationStatus{ 416 Status: w.WithdrawalStatus, 417 Amount: internal_utils.FormatAmount(amount, config.CONFIG.Server.CurrencyFractionDigits), 418 CardFees: internal_utils.FormatAmount(fees, config.CONFIG.Server.CurrencyFractionDigits), 419 SenderWire: client.FormatPayto(w), 420 WireTypes: []string{operator.PaytoTargetType, "iban"}, 421 ReservePubKey: internal_utils.EddsaPublicKey((internal_utils.TalerBinaryEncode(w.ReservePubKey))), 422 SuggestedExchange: config.CONFIG.Server.ExchangeBaseUrl, 423 RequiredExchange: config.CONFIG.Server.ExchangeBaseUrl, 424 Aborted: w.WithdrawalStatus == internal_utils.ABORTED, 425 SelectionDone: w.WithdrawalStatus == internal_utils.SELECTED, 426 TransferDone: w.WithdrawalStatus == internal_utils.CONFIRMED, 427 }) 428 if err != nil { 429 internal_utils.LogError("bank-integration-api", err) 430 return nil, internal_utils.HTTP_INTERNAL_SERVER_ERROR 431 } 432 return withdrawalStatusBytes, internal_utils.HTTP_OK 433 } 434 } 435 }