From bda6379eb4ddbb355c8c1452b848049178f87598 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 17 Feb 2020 20:01:29 +0100 Subject: accounts API --- .../app/management/commands/add_bank_account.py | 2 +- .../management/commands/changepassword_unsafe.py | 6 +- talerbank/app/migrations/0001_initial.py | 104 +++++++++++--- talerbank/app/models.py | 3 +- talerbank/app/tests.py | 8 +- talerbank/app/urls.py | 50 ++++++- talerbank/app/views.py | 154 +++++++++++++++++++-- talerbank/settings.py | 4 +- 8 files changed, 283 insertions(+), 48 deletions(-) diff --git a/talerbank/app/management/commands/add_bank_account.py b/talerbank/app/management/commands/add_bank_account.py index b0e6513..cf01162 100644 --- a/talerbank/app/management/commands/add_bank_account.py +++ b/talerbank/app/management/commands/add_bank_account.py @@ -35,6 +35,7 @@ import uuid LOGGER = logging.getLogger(__name__) LOGGER.setLevel(logging.INFO) + class Command(BaseCommand): help = "Add bank accounts." @@ -67,4 +68,3 @@ class Command(BaseCommand): ) else: print(f"Bank account {accountname} already exists.") - diff --git a/talerbank/app/management/commands/changepassword_unsafe.py b/talerbank/app/management/commands/changepassword_unsafe.py index d2f88d1..56a730f 100644 --- a/talerbank/app/management/commands/changepassword_unsafe.py +++ b/talerbank/app/management/commands/changepassword_unsafe.py @@ -35,6 +35,7 @@ import uuid LOGGER = logging.getLogger(__name__) LOGGER.setLevel(logging.INFO) + class Command(BaseCommand): help = "Add bank accounts." @@ -59,10 +60,7 @@ class Command(BaseCommand): existing_user.set_password(password) existing_user.save() except User.DoesNotExist: - print( - f"Account {accountname} does not exist" - ) + print(f"Account {accountname} does not exist") sys.exit(1) else: print(f"Password for {accountname} changed") - diff --git a/talerbank/app/migrations/0001_initial.py b/talerbank/app/migrations/0001_initial.py index 73a5206..a25b205 100644 --- a/talerbank/app/migrations/0001_initial.py +++ b/talerbank/app/migrations/0001_initial.py @@ -17,37 +17,97 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='BankAccount', + name="BankAccount", fields=[ - ('is_public', models.BooleanField(default=False)), - ('account_no', models.AutoField(primary_key=True, serialize=False)), - ('balance', talerbank.app.models.SignedAmountField(default=talerbank.app.models.get_zero_signed_amount)), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("is_public", models.BooleanField(default=False)), + ("account_no", models.AutoField(primary_key=True, serialize=False)), + ( + "balance", + talerbank.app.models.SignedAmountField( + default=talerbank.app.models.get_zero_signed_amount + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.CreateModel( - name='TalerWithdrawOperation', + name="TalerWithdrawOperation", fields=[ - ('withdraw_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('amount', talerbank.app.models.AmountField(default=False)), - ('selection_done', models.BooleanField(default=False)), - ('withdraw_done', models.BooleanField(default=False)), - ('selected_reserve_pub', models.TextField(null=True)), - ('selected_exchange_account', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='selected_exchange_account', to='app.BankAccount')), - ('withdraw_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='withdraw_account', to='app.BankAccount')), + ( + "withdraw_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("amount", talerbank.app.models.AmountField(default=False)), + ("selection_done", models.BooleanField(default=False)), + ("confirmation_done", models.BooleanField(default=False)), + ("aborted", models.BooleanField(default=False)), + ("selected_reserve_pub", models.TextField(null=True)), + ( + "selected_exchange_account", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="selected_exchange_account", + to="app.BankAccount", + ), + ), + ( + "withdraw_account", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="withdraw_account", + to="app.BankAccount", + ), + ), ], ), migrations.CreateModel( - name='BankTransaction', + name="BankTransaction", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('amount', talerbank.app.models.AmountField(default=False)), - ('subject', models.CharField(default='(no subject given)', max_length=200)), - ('date', models.DateTimeField(auto_now=True, db_index=True)), - ('cancelled', models.BooleanField(default=False)), - ('request_uid', models.CharField(max_length=128, unique=True)), - ('credit_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='credit_account', to='app.BankAccount')), - ('debit_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='debit_account', to='app.BankAccount')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("amount", talerbank.app.models.AmountField(default=False)), + ( + "subject", + models.CharField(default="(no subject given)", max_length=200), + ), + ("date", models.DateTimeField(auto_now=True, db_index=True)), + ("cancelled", models.BooleanField(default=False)), + ("request_uid", models.CharField(max_length=128, unique=True)), + ( + "credit_account", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="credit_account", + to="app.BankAccount", + ), + ), + ( + "debit_account", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="debit_account", + to="app.BankAccount", + ), + ), ], ), ] diff --git a/talerbank/app/models.py b/talerbank/app/models.py index 3833c22..5c4c9ac 100644 --- a/talerbank/app/models.py +++ b/talerbank/app/models.py @@ -205,7 +205,8 @@ class TalerWithdrawOperation(models.Model): related_name="withdraw_account", ) selection_done = models.BooleanField(default=False) - withdraw_done = models.BooleanField(default=False) + confirmation_done = models.BooleanField(default=False) + aborted = models.BooleanField(default=False) selected_exchange_account = models.ForeignKey( BankAccount, null=True, diff --git a/talerbank/app/tests.py b/talerbank/app/tests.py index ad792b7..abbf9f3 100644 --- a/talerbank/app/tests.py +++ b/talerbank/app/tests.py @@ -385,9 +385,10 @@ class AddIncomingTestCase(TestCase): def test_add_incoming(self): client = Client() request_body = dict( - reserve_pub="TESTWTID", - amount=f"{settings.TALER_CURRENCY}:1.0", - debit_account="payto://x-taler-bank/bank_user") + reserve_pub="TESTWTID", + amount=f"{settings.TALER_CURRENCY}:1.0", + debit_account="payto://x-taler-bank/bank_user", + ) response = client.post( reverse("twg-add-incoming", urlconf=urls, args=["user_user"]), data=json.dumps(request_body), @@ -430,7 +431,6 @@ class HistoryTestCase(TestCase): clear_db() def test_history(self): - def histquery(**urlargs): response = self.client.get( reverse("history", urlconf=urls), diff --git a/talerbank/app/urls.py b/talerbank/app/urls.py index e929125..c2a524c 100644 --- a/talerbank/app/urls.py +++ b/talerbank/app/urls.py @@ -23,23 +23,63 @@ from django.views.generic.base import RedirectView from django.contrib.auth import views as auth_views from . import views +# These paths are part of the GNU Taler wire gatweay API taler_wire_gateway_patterns = [ path("/", views.twg_base, name="twg-base"), - path("/admin/add-incoming", views.twg_add_incoming, name="twg-add-incoming"), - path("/history/incoming", views.twg_history_incoming, name="twg-history-incoming"), - path("/history/outgoing", views.twg_history_outgoing, name="twg-history-outgoing"), + path( + "/admin/add-incoming", + views.twg_add_incoming, + name="twg-add-incoming", + ), + path( + "/history/incoming", + views.twg_history_incoming, + name="twg-history-incoming", + ), + path( + "/history/outgoing", + views.twg_history_outgoing, + name="twg-history-outgoing", + ), path("/transfer", views.twg_transfer, name="twg-transfer"), ] +# These paths are part of the bank integration API taler_bank_api_patterns = [ + path( + "withdrawal-operation/", + views.register_headless, + name="tba-withdrawal-operation", + ), +] + +taler_bank_accounts_api_patterns = [ + path("accounts//balance", views.bank_accounts_api_balance), + path( + "accounts//withdrawals", views.bank_accounts_api_create_withdrawal + ), + path( + "accounts//withdrawals/", + views.bank_accounts_api_get_withdrawal, + ), + path( + "accounts//withdrawals//confirm", + views.bank_accounts_api_confirm_withdrawal, + ), + path( + "accounts//withdrawals//abort", + views.bank_accounts_api_abort_withdrawal, + ), path("testing/withdraw", views.withdraw_headless, name="testing-withdraw"), - path("testing/withdraw-uri", views.withdraw_headless_uri, name="testing-withdraw-uri"), + path( + "testing/withdraw-uri", views.withdraw_headless_uri, name="testing-withdraw-uri" + ), path("testing/register", views.register_headless, name="testing-withdraw-register"), - path("withdrawal-operation/", views.register_headless, name="tba-withdrawal-operation"), ] urlpatterns = [ path("taler-bank-api/", include(taler_bank_api_patterns)), + path("", include(taler_bank_accounts_api_patterns)), path("taler-wire-gateway/", include(taler_wire_gateway_patterns)), path("", RedirectView.as_view(pattern_name="profile"), name="index"), path("favicon.ico", views.ignore), diff --git a/talerbank/app/views.py b/talerbank/app/views.py index 16a4b06..2e74f8f 100644 --- a/talerbank/app/views.py +++ b/talerbank/app/views.py @@ -408,12 +408,13 @@ def internal_register(request): # Registration goes through. with transaction.atomic(): + bank_internal_account = BankAccount.objects.get(account_no=1) + user = User.objects.create_user(username=username, password=password) user_account = BankAccount(user=user) user_account.save() # Give the user their joining bonus - bank_internal_account = BankAccount.objects.get(account_no=1) wire_transfer( Amount(settings.TALER_CURRENCY, 100, 0), bank_internal_account, @@ -835,7 +836,9 @@ def twg_transfer(request, user_account, acct_id): subject = f"{wtid} {exchange_base_url}" - wtrans = wire_transfer(amount, exchange_account, credit_account, subject, request_uid) + wtrans = wire_transfer( + amount, exchange_account, credit_account, subject, request_uid + ) return JsonResponse( { @@ -1015,7 +1018,7 @@ def api_withdraw_operation(request, withdraw_id): selected_reserve_pub = data.get("reserve_pub") if not isinstance(selected_reserve_pub, str): return JsonResponse(dict(error="reserve_pub must be a string"), status=400) - if op.selection_done or op.withdraw_done: + if op.selection_done or op.confirmation_done: if ( op.selected_exchange_account != exchange_account or op.selected_reserve_pub != selected_reserve_pub @@ -1026,17 +1029,26 @@ def api_withdraw_operation(request, withdraw_id): ) # No conflict, same data! return JsonResponse(dict(), status=200) - op.selected_exchange_account = exchange_account - op.selected_reserve_pub = selected_reserve_pub - op.selection_done = True - op.save() + with transaction.atomic(): + op.selected_exchange_account = exchange_account + op.selected_reserve_pub = selected_reserve_pub + if op.confirmation_done and not op.selection_done: + # Confirmation already happened, we still need to transfer funds! + wire_transfer( + op.amount, + user_account, + op.selected_exchange_account, + op.selected_reserve_pub, + ) + op.selection_done = True + op.save() return JsonResponse(dict(), status=200) elif request.method == "GET": host = request.get_host() return JsonResponse( dict( selection_done=op.selection_done, - transfer_done=op.withdraw_done, + transfer_done=op.confirmation_done, amount=op.amount.stringify(), wire_types=["x-taler-bank"], sender_wire=f"payto://x-taler-bank/{host}/{op.withdraw_account.user.username}", @@ -1106,7 +1118,7 @@ def confirm_withdrawal(request, withdraw_id): op = TalerWithdrawOperation.objects.get(withdraw_id=withdraw_id) if not op.selection_done: raise Exception("invalid state (withdrawal parameter selection not done)") - if op.withdraw_done: + if op.confirmation_done: return redirect("profile") if request.method == "POST": hashed_attempt = hash_answer(request.POST.get("pin_0", "")) @@ -1119,7 +1131,7 @@ def confirm_withdrawal(request, withdraw_id): ) request.session["captcha_failed"] = True, False, "Wrong CAPTCHA answer." return redirect("withdraw-confirm", withdraw_id=withdraw_id) - op.withdraw_done = True + op.confirmation_done = True op.save() wire_transfer( op.amount, @@ -1218,3 +1230,125 @@ def wire_transfer(amount, debit_account, credit_account, subject, request_uid=No transaction_item.save() return transaction_item + + +@csrf_exempt +@require_GET +@login_via_headers +def bank_accounts_api_balance(request, user_account, acct_id): + """ + Query the balance for an account. + """ + acct = user_account.bankaccount + + if acct_id != user_account.username: + # FIXME: respond nicely + raise Exception( + f"credentials do not match URL ('{acct_id}' vs '{user_account.username}')" + ) + + return JsonResponse(dict(balance=acct.balance.stringify())) + + +@csrf_exempt +@require_POST +@login_via_headers +def bank_accounts_api_create_withdrawal(request, user, acct_id): + user_account = BankAccount.objects.get(user=user) + + if acct_id != user_account.user.username: + # FIXME: respond nicely + raise Exception( + f"credentials do not match URL ('{acct_id}' vs '{user_account.username}')" + ) + + data = WithdrawHeadlessUri(json.loads(decode_body(request))) + amount = Amount.parse(data.get("amount")) + withdraw_amount = SignedAmount(True, amount) + debt_threshold = SignedAmount.parse(settings.TALER_MAX_DEBT) + user_balance = user_account.balance + if user_balance - withdraw_amount < -debt_threshold: + raise DebitLimitException( + f"Aborting payment initiated by '{user_account.user.username}', debit limit {debt_threshold} crossed." + ) + op = TalerWithdrawOperation(amount=amount, withdraw_account=user_account) + op.save() + host = request.get_host() + taler_withdraw_uri = f"taler://withdraw/{host}/-/{op.withdraw_id}" + return JsonResponse( + {"taler_withdraw_uri": taler_withdraw_uri, "withdrawal_id": op.withdraw_id} + ) + + +@csrf_exempt +@require_GET +@login_via_headers +def bank_accounts_api_get_withdrawal(request, user, acct_id, wid): + user_account = BankAccount.objects.get(user=user) + if acct_id != user_account.user.username: + # FIXME: respond nicely + raise Exception( + f"credentials do not match URL ('{acct_id}' vs '{user_account.username}')" + ) + op = TalerWithdrawOperation.objects.get(withdraw_id=wid) + selected_exchange_account = None + if op.selected_exchange_account: + selected_exchange_account = op.selected_exchange_account.user.name + return JsonResponse( + { + "amount": op.amount.stringify(), + "selection_done": op.selection_done, + "confirmation_done": op.confirmation_done, + "selected_reserve_pub": op.selected_reserve_pub, + "selected_exchange_account": selected_exchange_account, + "aborted": op.aborted, + } + ) + + +@csrf_exempt +@require_POST +@login_via_headers +def bank_accounts_api_abort_withdrawal(request, user, acct_id, wid): + user_account = BankAccount.objects.get(user=user) + if acct_id != user_account.user.username: + # FIXME: respond nicely + raise Exception( + f"credentials do not match URL ('{acct_id}' vs '{user_account.username}')" + ) + op = TalerWithdrawOperation.objects.get(withdraw_id=wid) + + if op.confirmation_done: + return JsonResponse(dict(hint="can't abort confirmed withdrawal"), status=409) + op.aborted = True + op.save() + return JsonResponse(dict(), status=200) + + +@csrf_exempt +@require_POST +@login_via_headers +def bank_accounts_api_confirm_withdrawal(request, user, acct_id, wid): + user_account = BankAccount.objects.get(user=user) + if acct_id != user_account.user.username: + # FIXME: respond nicely + raise Exception( + f"credentials do not match URL ('{acct_id}' vs '{user_account.username}')" + ) + op = TalerWithdrawOperation.objects.get(withdraw_id=wid) + if op.confirmation_done: + return JsonResponse(dict(), status=200) + if op.aborted: + return JsonResponse(dict(hint="can't confirm aborted withdrawal"), status=409) + + with transaction.atomic(): + if op.selection_done: + wire_transfer( + op.amount, + user_account, + op.selected_exchange_account, + op.selected_reserve_pub, + ) + op.confirmation_done = True + op.save() + return JsonResponse(dict(), status=200) diff --git a/talerbank/settings.py b/talerbank/settings.py index ba880e3..8024907 100644 --- a/talerbank/settings.py +++ b/talerbank/settings.py @@ -217,7 +217,9 @@ else: ALLOW_REGISTRATIONS = False -_show_freeform_withdrawal = TC.value_string("bank", "SHOW_FREEFORM_WITHDRAWAL", default="no") +_show_freeform_withdrawal = TC.value_string( + "bank", "SHOW_FREEFORM_WITHDRAWAL", default="no" +) if _show_freeform_withdrawal.lower() == "yes": SHOW_FREEFORM_WITHDRAWAL = True else: -- cgit v1.2.3