libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit f1e1f63691b74e26cbb4605f2e5810e644f45145
parent 571b2f9c1be786dc7ba46cdf589be515db12bb4d
Author: MS <ms@taler.net>
Date:   Thu,  5 Jan 2023 17:02:37 +0100

Adding cash-out operations to the CLI.

Diffstat:
Acli/bin/circuit_test.sh | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcli/bin/libeufin-cli | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
2 files changed, 308 insertions(+), 20 deletions(-)

diff --git a/cli/bin/circuit_test.sh b/cli/bin/circuit_test.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +# Tests successful cases of the CLI acting +# as the client of the Circuit API. + +set -eu + +DB_PATH=/tmp/circuit-test.sqlite3 +export LIBEUFIN_SANDBOX_DB_CONNECTION=jdbc:sqlite:$DB_PATH +export LIBEUFIN_CASHOUT_TEST_TAN=secret-tan + +echo -n Delete previous data.. +rm -f $DB_PATH +echo DONE +echo -n Configure the default demobank... +libeufin-sandbox config default +echo DONE +echo -n Start the bank... +libeufin-sandbox serve &> sandbox.log & +SANDBOX_PID=$! +trap "echo -n 'killing the bank (pid $SANDBOX_PID)...'; kill $SANDBOX_PID; wait; echo DONE" EXIT +echo DONE +echo -n Wait for the bank... +curl --max-time 2 --retry-connrefused --retry-delay 1 --retry 10 http://localhost:5000/ &> /dev/null +echo DONE +echo Ask Circuit API /config... +curl http://localhost:5000/demobanks/default/circuit-api/config &> /dev/null +echo DONE +echo -n "Register new account..." +export LIBEUFIN_SANDBOX_USERNAME=admin +export LIBEUFIN_SANDBOX_PASSWORD=secret +export LIBEUFIN_NEW_CIRCUIT_ACCOUNT_PASSWORD=foo +./libeufin-cli \ + sandbox --sandbox-url http://localhost:5000/ \ + demobank \ + circuit-register --name eee --username www \ + --cashout-address payto://iban/FIAT --internal-iban LOCAL +echo DONE +echo -n Reconfigure account specifying a phone number.. +# Give phone number. +export LIBEUFIN_SANDBOX_USERNAME=www +export LIBEUFIN_SANDBOX_PASSWORD=foo +./libeufin-cli \ + sandbox --sandbox-url http://localhost:5000/ \ + demobank \ + circuit-reconfig --cashout-address payto://iban/WWW --phone +999 +echo DONE +echo -n Create a cash-out operation... +CASHOUT_RESP=$(./libeufin-cli \ + sandbox --sandbox-url http://localhost:5000/ \ + demobank \ + circuit-cashout --amount-debit=EUR:1 --amount-credit=CHF:0.95) +echo DONE +echo -n Extract the cash-out UUID... +CASHOUT_UUID=$(echo ${CASHOUT_RESP} | jq --raw-output '.uuid') +echo DONE +echo -n Get cash-out details... +RESP=$(./libeufin-cli \ + sandbox --sandbox-url http://localhost:5000/ \ + demobank \ + circuit-cashout-details \ + --uuid $CASHOUT_UUID +) +OPERATION_STATUS=$(echo $RESP | jq --raw-output '.status') +if ! test "$OPERATION_STATUS" = "PENDING"; then + echo Unexpected cash-out operation status found: $OPERATION_STATUS + exit 1 +fi +echo DONE +echo -n Delete the cash-out operation... +RESP=$(./libeufin-cli \ + sandbox --sandbox-url http://localhost:5000/ \ + demobank \ + circuit-cashout-abort \ + --uuid $CASHOUT_UUID +) +echo DONE +echo -n Create another cash-out operation... +CASHOUT_RESP=$(./libeufin-cli \ + sandbox --sandbox-url http://localhost:5000/ \ + demobank \ + circuit-cashout --amount-debit=EUR:1 --amount-credit=CHF:0.95) +CASHOUT_UUID=$(echo ${CASHOUT_RESP} | jq --raw-output '.uuid') +echo DONE +echo -n Confirm the last cash-out operation... +./libeufin-cli \ + sandbox --sandbox-url http://localhost:5000/ \ + demobank \ + circuit-cashout-confirm --uuid $CASHOUT_UUID --tan secret-tan +echo DONE +# The user now has -1 balance. Let the bank +# award EUR:1 to them, in order to bring their +# balance to zero. +echo -n Bring the account to 0 balance... +export LIBEUFIN_SANDBOX_USERNAME=admin +export LIBEUFIN_SANDBOX_PASSWORD=secret +./libeufin-cli \ + sandbox --sandbox-url http://localhost:5000/ \ + demobank \ + new-transaction \ + --bank-account admin \ + --payto-with-subject "payto://iban/SANDBOXX/LOCAL?message=bring-to-zero" \ + --amount EUR:1 +echo DONE +echo -n Delete the account... +export LIBEUFIN_SANDBOX_USERNAME=admin +export LIBEUFIN_SANDBOX_PASSWORD=secret +./libeufin-cli \ + sandbox --sandbox-url http://localhost:5000/ \ + demobank \ + circuit-delete-account --username www +echo DONE diff --git a/cli/bin/libeufin-cli b/cli/bin/libeufin-cli @@ -14,16 +14,8 @@ from requests import post, get, auth, delete, patch from urllib.parse import urljoin from getpass import getpass -# Extracts the circuit account username by processing -# the arguments or the environment. It gives precedence -# to the username met on the CLI, defaulting to the environment -# when that is missing. Returns the username, or None if that -# could not be found. This function helps when a username -# is a 'resource name'. Namely, when such username points -# at a customer username; therefore, the value 'admin' is not -# accepted since that's never used in that way. -def get_circuit_username(usernameCli, usernameEnv): - maybeUsername = usernameCli +def get_account_name(accountNameCli, usernameEnv): + maybeUsername = accountNameCli if not maybeUsername: maybeUsername = usernameEnv if not maybeUsername: @@ -42,6 +34,8 @@ def check_response_status(resp, expected_status_code=200): print("Response: {}".format(resp.text), file=sys.stderr) sys.exit(1) +# Prints unexpected responses without exiting +# and optionally prints expected respones. def tell_user(resp, expected_status_code=200, withsuccess=False): if resp.status_code != expected_status_code: print(resp.content.decode("utf-8"), file=sys.stderr) @@ -1536,6 +1530,128 @@ def simulate_incoming_transaction( # The commands below request to the latest CIRCUIT API. @sandbox_demobank.command( + "circuit-cashout-confirm", + help="Confirm a cash-out operation. Only the author is allowed (no admin)." +) +@click.option( + "--tan", + help="TAN that authorizes the cash-out operaion.", + required=True, + prompt=True +) +@click.option( + "--uuid", + help="UUID of the cash-out operation to confirm.", + required=True, + prompt=True +) +@click.pass_obj +def circuit_cashout_confirm(obj, tan, uuid): + cashout_confirm_endpoint = obj.circuit_api_url(f"cashouts/{uuid}/confirm") + req = dict(tan=tan) + try: + resp = post( + cashout_confirm_endpoint, + json=req, + auth=auth.HTTPBasicAuth(obj.username, obj.password) + ) + except Exception as e: + print(e) + print("Could not reach the bank at " + cashout_abort_endpoint) + exit(1) + + check_response_status(resp, 204) + + +@sandbox_demobank.command( + "circuit-cashout-abort", + help="Abort a cash-out operation. Admin and author are allowed to request." +) +@click.option( + "--uuid", + help="UUID of the cash-out operation to abort.", + required=True, + prompt=True +) +@click.pass_obj +def circuit_cashout_abort(obj, uuid): + cashout_abort_endpoint = obj.circuit_api_url(f"cashouts/{uuid}/abort") + try: + resp = post( + cashout_abort_endpoint, + auth=auth.HTTPBasicAuth(obj.username, obj.password) + ) + except Exception as e: + print(e) + print("Could not reach the bank at " + cashout_abort_endpoint) + exit(1) + + check_response_status(resp, 204) + +@sandbox_demobank.command( + "circuit-cashout-details", + help="Retrieve status information about one cash-out operation. Admin and author are allowed to request." +) +@click.option( + "--uuid", + help="UUID of the cash-out operation to retrieve.", + required=True, + prompt=True +) +@click.pass_obj +def circuit_cashout_info(obj, uuid): + cashout_info_endpoint = obj.circuit_api_url(f"cashouts/{uuid}") + try: + resp = get( + cashout_info_endpoint, + auth=auth.HTTPBasicAuth(obj.username, obj.password) + ) + except Exception as e: + print(e) + print("Could not reach the bank at " + cashout_info_endpoint) + exit(1) + + check_response_status(resp) + tell_user(resp, withsuccess=True) + +@sandbox_demobank.command( + "circuit-delete-account", + help="Delete one account. Only available to the administrator and for accounts with zero balance." +) +@click.option( + "--username", + help="account to delete", + required=True, + prompt=True +) +@click.pass_obj +def circuit_delete(obj, username): + + # Check that admin wasn't specified. + # Note: even if 'admin' gets through here, + # the bank can't delete it because its profile + # doesn't get a ordinary entry into the database. + if username == "admin": + print("Won't delete 'admin'", file=sys.stderr) + exit(1) + + # Do not check credentials to let the --no-auth case + # function, in case the bank allows it. + account_deletion_endpoint = obj.circuit_api_url(f"accounts/{username}") + try: + resp = delete( + account_deletion_endpoint, + auth=auth.HTTPBasicAuth(obj.username, obj.password) + ) + except Exception as e: + print(e) + print("Could not reach sandbox at " + account_deletion_endpoint) + exit(1) + + check_response_status(resp, expected_status_code=204) + + +@sandbox_demobank.command( "circuit-register", help="Register a new account with cash-out capabilities. It needs administrator credentials, and the new account password exported in LIBEUFIN_NEW_CIRCUIT_ACCOUNT_PASSWORD." ) @@ -1565,14 +1681,19 @@ def simulate_incoming_transaction( "--email", help="E-mail address where to send the cash-out TAN.", ) +@click.option( + "--internal-iban", + help="Which IBAN to associate to this account. The IBAN participates only in the local currency circuit. If missing, the bank generates one.", +) @click.pass_obj def circuit_register( - obj, - username, - cashout_address, - name, - phone, - email + obj, + username, + cashout_address, + name, + phone, + email, + internal_iban ): # Check admin is requesting. if (obj.username != "admin"): @@ -1588,8 +1709,6 @@ def circuit_register( # Get the bank base URL. registration_endpoint = obj.circuit_api_url("accounts") - # Craft the request. - contact_data = dict() if (phone): contact_data.update(phone=phone) @@ -1602,6 +1721,8 @@ def circuit_register( name=name, cashout_address=cashout_address ) + if internal_iban: + req.update(internal_iban=internal_iban) try: resp = post( registration_endpoint, @@ -1646,7 +1767,7 @@ def circuit_reconfig( cashout_address, username ): - resource_name = get_circuit_username(username, obj.username) + resource_name = get_account_name(username, obj.username) if not resource_name: print("Could not find any username to reconfigure.", file=sys.stderr) reconfig_endpoint = obj.circuit_api_url(f"accounts/{resource_name}") @@ -1682,7 +1803,7 @@ def circuit_reconfig( ) @click.pass_obj def password_reconfig(obj, username): - resource_name = get_circuit_username(username, obj.username) + resource_name = get_account_name(username, obj.username) if not resource_name: print( "Couldn't find the username whose password should change.", @@ -1718,4 +1839,59 @@ def password_reconfig(obj, username): check_response_status(resp, expected_status_code=204) + +@sandbox_demobank.command( + "circuit-cashout", + help="Create a cash-out operation. If successful, the user gets a TAN." +) +@click.option( + "--subject", + help="Payment subject to associate to the outgoing and incoming payments that are associated with this cash-out operation.", + required=False +) +@click.option( + "--amount-debit", + help="Amount that will debited to the local currency account, in the <currency>:X.Y format.", + required=True, + prompt=True +) +@click.option( + "--amount-credit", + help="Amount that will credited to the fiat currency account, in the <currency>:X.Y format.", + required=True, + prompt=True +) +@click.option( + "--tan-channel", + help="Indicates how to send the TAN to the user: only 'sms' or 'email' are valid values. If missing, the bank defaults to SMS", + required=False +) +@click.pass_obj +def circuit_cashout(obj, subject, amount_debit, amount_credit, tan_channel): + # (not) resorting auth credentials, if they're None, request fails at the server. + # Craft the request. + req = dict( + amount_debit=amount_debit, + amount_credit=amount_credit + ) + if subject: + req.update(subject=subject) + if tan_channel: + req.update(tan_channel=tan_channel) + + cashout_creation_endpoint = obj.circuit_api_url("cashouts") + try: + resp = post( + cashout_creation_endpoint, + json=req, + auth=auth.HTTPBasicAuth(obj.username, obj.password) + ) + except Exception as e: + print(e) + print("Could not reach sandbox at " + cashout_creation_endpoint) + exit(1) + + check_response_status(resp, expected_status_code=202) + tell_user(resp, 202, withsuccess=True) # Communicates back the operation UUID. + cli()