cashless2ecash

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

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 }