From 19d77718c51603f5bd6b5abcfa22f619a1de3a8a Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Thu, 23 Aug 2012 12:20:32 -0400 Subject: [PATCH] Do some finishing work on bank account flow (#22) I tweaked the UI to hopefully be a little less confusing wrt the identity verification step. I also tweaked the error messages and "account connected" messages a bit. Lastly, I refactored the billing module to avoid duplicate code. --- gittip/__init__.py | 3 + gittip/authentication.py | 1 - gittip/billing/__init__.py | 208 +++++++++++++-------------------- tests/test_billing.py | 6 +- www/%participant_id/index.html | 5 +- www/assets/%version/gittip.css | 6 +- www/assets/%version/gittip.js | 88 ++++++++------ www/bank-account.html | 111 +++++++++++------- www/bank-account.json | 92 +++++++-------- www/credit-card.html | 2 +- www/credit-card.json | 10 +- 11 files changed, 264 insertions(+), 268 deletions(-) diff --git a/gittip/__init__.py b/gittip/__init__.py index 8ac567dbad..f751746986 100644 --- a/gittip/__init__.py +++ b/gittip/__init__.py @@ -12,6 +12,9 @@ BIRTHDAY = datetime.date(2012, 6, 1) CARDINALS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'] +MONTHS = [None, 'January', 'February', 'March', 'April', 'May', 'June', 'July', + 'August', 'September', 'October', 'November', 'December'] + def age(): today = datetime.date.today() nmonths = today.month - BIRTHDAY.month diff --git a/gittip/authentication.py b/gittip/authentication.py index 40ef033bab..472b4f175f 100644 --- a/gittip/authentication.py +++ b/gittip/authentication.py @@ -40,7 +40,6 @@ def load_session(where, val): , p.statement , p.stripe_customer_id , p.balanced_account_uri - , p.balanced_destination_uri , p.last_bill_result , p.last_ach_result , p.session_token diff --git a/gittip/billing/__init__.py b/gittip/billing/__init__.py index 9ee6ceb529..8ba9dc0bca 100644 --- a/gittip/billing/__init__.py +++ b/gittip/billing/__init__.py @@ -19,179 +19,128 @@ from gittip import db -def associate(participant_id, balanced_account_uri, card_uri): - """Given three unicodes, return a dict. - - This function attempts to associate the credit card details referenced by - card_uri with a Balanced Account. If the attempt succeeds we cancel the - transaction. If it fails we log the failure. Even for failure we keep the - balanced_account_uri, we don't reset it to None/NULL. It's useful for - loading the previous (bad) credit card info from Balanced in order to - prepopulate the form. - +def get_balanced_account(participant_id, balanced_account_uri): + """Find or create a balanced.Account. """ typecheck( participant_id, unicode , balanced_account_uri, (unicode, None) - , card_uri, unicode ) - - # Load or create a Balanced Account. - # ================================== + # XXX Balanced requires an email address + # https://github.com/balanced/balanced-api/issues/20 email_address = '{}@gittip.com'.format(participant_id) + if balanced_account_uri is None: - # arg - balanced requires an email address try: - customer = \ + account = \ balanced.Account.query.filter(email_address=email_address).one() except balanced.exc.NoResultFound: - customer = balanced.Account(email_address=email_address).save() - CUSTOMER = """\ + account = balanced.Account(email_address=email_address).save() + BALANCED_ACCOUNT = """\ UPDATE participants SET balanced_account_uri=%s WHERE id=%s """ - db.execute(CUSTOMER, (customer.uri, participant_id)) - customer.meta['participant_id'] = participant_id - customer.save() # HTTP call under here - else: - customer = balanced.Account.find(balanced_account_uri) - - - # Associate the card with the customer. - # ===================================== - # Handle errors. Return a unicode, a simple error message. If empty it - # means there was no error. Yay! Store any error message from the - # Balanced API as a string in last_bill_result. That may be helpful for - # debugging at some point. - - customer.card_uri = card_uri - try: - customer.save() - except balanced.exc.HTTPError as err: - last_bill_result = err.message.decode('UTF-8') # XXX UTF-8? - typecheck(last_bill_result, unicode) - out = last_bill_result + db.execute(BALANCED_ACCOUNT, (account.uri, participant_id)) + account.meta['participant_id'] = participant_id + account.save() # HTTP call under here else: - out = last_bill_result = '' - - STANDING = """\ - - UPDATE participants - SET last_bill_result=%s - WHERE id=%s + account = balanced.Account.find(balanced_account_uri) + return account - """ - db.execute(STANDING, (last_bill_result, participant_id)) - return out +def associate(thing, participant_id, balanced_account_uri, balanced_thing_uri): + """Given four unicodes, return a unicode. -def associate_bank_account(participant_id, balanced_account_uri, - balanced_destination_uri): - """ + This function attempts to associate the credit card or bank account details + referenced by balanced_thing_uri with a Balanced Account. If it fails we + log and return a unicode describing the failure. Even for failure we keep + balanced_account_uri; we don't reset it to None/NULL. It's useful for + loading the previous (bad) info from Balanced in order to prepopulate the + form. """ typecheck( participant_id, unicode - , balanced_account_uri, (unicode, None) - , balanced_destination_uri, unicode + , balanced_account_uri, (unicode, None, balanced.Account) + , balanced_thing_uri, unicode + , thing, unicode ) - account = balanced.Account.find(balanced_account_uri) + if isinstance(balanced_account_uri, balanced.Account): + balanced_account = balanced_account_uri + else: + balanced_account = get_balanced_account( participant_id + , balanced_account_uri + ) + SQL = "UPDATE participants SET last_%s_result=%%s WHERE id=%%s" + + if thing == "credit card": + add = balanced_account.add_card + SQL %= "bill" + else: + assert thing == "bank account", thing # sanity check + add = balanced_account.add_bank_account + SQL %= "ach" + try: - account.add_bank_account(balanced_destination_uri) + add(balanced_thing_uri) except balanced.exc.HTTPError as err: - last_bill_result = err.message.decode('UTF-8') # XXX UTF-8? - typecheck(last_bill_result, unicode) - out = last_bill_result + error = err.message.decode('UTF-8') # XXX UTF-8? else: - out = last_bill_result = '' - - STANDING = """\ - - UPDATE participants - SET last_ach_result = %s, - balanced_account_uri = %s, - balanced_destination_uri = %s - WHERE id = %s - - """ - db.execute(STANDING, (last_bill_result, - balanced_account_uri, - balanced_destination_uri, - participant_id)) - return out - - -def clear_bank_account(participant_id, balanced_account_uri): - typecheck(participant_id, unicode, balanced_account_uri, unicode) + error = '' + typecheck(error, unicode) - # accounts in balanced cannot be deleted at the moment. instead we mark all - # valid cards as invalid which will restrict against anyone being able to - # issue charges against them in the future. - customer = balanced.Account.find(balanced_account_uri) - for bank_account in customer.bank_accounts: - if bank_account.is_valid: - bank_account.is_valid = False - bank_account.save() + db.execute(SQL, (error, participant_id)) + return error - CLEAR = """\ - UPDATE participants - SET balanced_destination_uri = NULL - , last_ach_result = NULL - WHERE id = %s +def clear(thing, participant_id, balanced_account_uri): + typecheck( thing, unicode + , participant_id, unicode + , balanced_account_uri, unicode + ) + assert thing in ("credit card", "bank account"), thing - """ - db.execute(CLEAR, (participant_id,)) + # XXX Things in balanced cannot be deleted at the moment. + # ======================================================= + # Instead we mark all valid cards as invalid which will restrict against + # anyone being able to issue charges against them in the future. + # + # See: https://github.com/balanced/balanced-api/issues/22 -def clear(participant_id, balanced_account_uri): - typecheck(participant_id, unicode, balanced_account_uri, unicode) + account = balanced.Account.find(balanced_account_uri) + things = account.cards if thing == "credit card" else account.bank_accounts - # accounts in balanced cannot be deleted at the moment. instead we mark all - # valid cards as invalid which will restrict against anyone being able to - # issue charges against them in the future. - customer = balanced.Account.find(balanced_account_uri) - for card in customer.cards: - if card.is_valid: - card.is_valid = False - card.save() + for thing in things: + if thing.is_valid: + thing.is_valid = False + thing.save() CLEAR = """\ UPDATE participants SET balanced_account_uri=NULL - , last_bill_result=NULL - WHERE id=%s - - """ - db.execute(CLEAR, (participant_id,)) + , last_%s_result=NULL + WHERE id=%%s + """ % ("bill" if thing == "credit card" else "ach") -def store_error(participant_id, msg): - typecheck(participant_id, unicode, msg, unicode) - ERROR = """\ - - UPDATE participants - SET last_bill_result=%s - WHERE id=%s - - """ - db.execute(ERROR, (msg, participant_id)) + db.execute(CLEAR, (participant_id,)) -def store_ach_error(participant_id, msg): - typecheck(participant_id, unicode, msg, unicode) +def store_error(thing, participant_id, msg): + typecheck(thing, unicode, participant_id, unicode, msg, unicode) ERROR = """\ UPDATE participants - SET last_ach_result=%s - WHERE id=%s + SET last_%s_result=%%s + WHERE id=%%s - """ + """ % "bill" if thing == "credit card" else "ach" db.execute(ERROR, (msg, participant_id)) @@ -200,7 +149,6 @@ def store_ach_error(participant_id, msg): # While we're migrating data we need to support loading data from both Stripe # and Balanced. - class StripeCard(object): """This is a dict-like wrapper around a Stripe PaymentMethod. """ @@ -322,17 +270,17 @@ class BalancedBankAccount(object): _account = None # underlying balanced.Account object _bank_account = None - def __init__(self, balanced_account_uri, balanced_destination_uri): + def __init__(self, balanced_account_uri): """Given a Balanced account_uri, load data from Balanced. """ if not balanced_account_uri: return self._account = balanced.Account.find(balanced_account_uri) - - if balanced_destination_uri: - self._bank_account = balanced.BankAccount.find( - balanced_destination_uri) + try: + self._bank_account = self._account.bank_accounts[-1] + except IndexError: + self._bank_account = None def __getitem__(self, item): mapper = { diff --git a/tests/test_billing.py b/tests/test_billing.py index 6e0ce5a7fd..f757be46c5 100644 --- a/tests/test_billing.py +++ b/tests/test_billing.py @@ -115,7 +115,7 @@ def test_store_error_stores_error(self): self.assertEqual(rec['last_bill_result'], "cheese is yummy") @mock.patch('balanced.Account') - def test_associate_valid(self, ba): + def test_associate_valid_card(self, ba): not_found = balanced.exc.NoResultFound() ba.query.filter.return_value.one.side_effect = not_found ba.return_value.save.return_value.uri = self.balanced_account_uri @@ -193,8 +193,7 @@ def test_clear_bank_account(self, b_account): MURKY = """\ UPDATE participants - SET balanced_destination_uri='not null' - , last_ach_result='ooga booga' + SET last_ach_result='ooga booga' WHERE id=%s """ @@ -208,7 +207,6 @@ def test_clear_bank_account(self, b_account): user = authentication.User.from_id(self.participant_id) self.assertFalse(user.session['last_ach_result']) - self.assertFalse(user.session['balanced_destination_uri']) @mock.patch('gittip.billing.balanced.Account') def test_associate_bank_account_valid(self, b_account): diff --git a/www/%participant_id/index.html b/www/%participant_id/index.html index cbd75444ab..83a0055262 100644 --- a/www/%participant_id/index.html +++ b/www/%participant_id/index.html @@ -343,7 +343,7 @@

What is your personal funding goal on Gittip?

You give ${{ total }} per week. - Credit card

@@ -405,7 +405,8 @@

You receive ${{ backed_amount }} per week.

Your balance is ${{ user.balance }}. - Bank account + Bank account

{% if user.balance > 0 %} diff --git a/www/assets/%version/gittip.css b/www/assets/%version/gittip.css index b8c7a4a41b..d3f211c9d2 100644 --- a/www/assets/%version/gittip.css +++ b/www/assets/%version/gittip.css @@ -203,7 +203,7 @@ FORM.special .right { FORM.special LABEL { display: block; font: normal 10px/14px Monaco, "Lucida Console", monospace; - margin: 5px 0 0; + margin: 8px 0 0; padding: 0; } FORM.special INPUT { @@ -215,6 +215,10 @@ FORM.special INPUT { outline: none; color: #614C3E; } +FORM.special INPUT.disabled { + color: #CCBAAD; +} + FORM.special .half INPUT { width: 137px; } diff --git a/www/assets/%version/gittip.js b/www/assets/%version/gittip.js index f6dd6ccae1..af22628cdd 100644 --- a/www/assets/%version/gittip.js +++ b/www/assets/%version/gittip.js @@ -155,6 +155,7 @@ Gittip.paymentProcessorAttempts = 0; Gittip.submitDeleteForm = function(e) { var item = $("#payout").length ? "bank account" : "credit card"; + var slug = $("#payout").length ? "bank-account" : "credit-card"; var msg = "Really delete your " + item + " details?"; if (!confirm(msg)) { @@ -164,15 +165,15 @@ Gittip.submitDeleteForm = function(e) } jQuery.ajax( - { url: '/credit-card.json' + { url: '/' + slug + '.json' , data: {action: "delete"} , type: "POST" , success: function() { - window.location.href = "/credit-card.html"; + window.location.href = '/' + slug + '.html'; } , error: function(x,y,z) { select(cur); - alert("Sorry, something went wrong deleting your credit card. :("); + alert("Sorry, something went wrong deleting your " + item + ". :("); console.log(x,y,z); } } @@ -208,12 +209,13 @@ Gittip.submitPayoutForm = function (e) { name: $('#name').val() }; var requiredFields = { - name: 'Your legal name is required', - address_1: 'Your street address is required', - zip: 'Your zip code is required', - account_name: 'The name on your bank account is required', - account_number: 'Your bank account number is required', - phone_number: 'A phone number is required' + name: 'Your legal name is required.', + address_1: 'Your street address is required.', + zip: 'Your ZIP or postal code is required.', + phone_number: 'A phone number is required.', + account_name: 'The name on your bank account is required.', + account_number: 'Your bank account number is required.', + routing_number: 'A routing number is required.' }; var errors = []; @@ -224,58 +226,74 @@ Gittip.submitPayoutForm = function (e) { } var value = $f.val(); - if (!value) { + if (!value) + { $f.closest('div').addClass('error'); errors.push(requiredFields[field]); - } else { + } + else + { $f.closest('div').removeClass('error'); } } var $rn = $('#routing_number'); - if (!balanced.bankAccount.validateRoutingNumber(bankAccount.bank_code)) { - $rn.closest('div').addClass('error'); - errors.push("Invalid routing number."); - } else { - $rn.closest('div').removeClass('error'); + if (bankAccount.bank_code) + { + if (!balanced.bankAccount.validateRoutingNumber(bankAccount.bank_code)) + { + $rn.closest('div').addClass('error'); + errors.push("That routing number is invalid."); + } + else + { + $rn.closest('div').removeClass('error'); + } } - try { - new Date(dobs[0], dobs[1] - 1, dobs[2]); // TODO: this is not failing - 1900, 2, 31 gives 3 march :P - } catch (err) { + var d = new Date(dobs[0], dobs[1] - 1, dobs[2]); + // (1900, 2, 31) gives 3 march :P + if (d.getMonth() !== dobs[1] - 1) errors.push('Invalid date of birth.'); - } - if (errors.length) { + + if (errors.length) + { $('BUTTON#save').text('Save'); Gittip.showFeedback(null, errors); - } else { + } + else + { balanced.bankAccount.create(bankAccount, Gittip.bankAccountResponseHandler); } }; Gittip.bankAccountResponseHandler = function (response) { console.log('bank account response', response); - if (response.status != 201) { + if (response.status != 201) + { $('BUTTON#save').text('Save'); var msg = response.status.toString() + " " + response.error.description; jQuery.ajax( { type: "POST" - , url: "/bank-account.json" - , data: {action: 'store-error', msg: msg} - } + , url: "/bank-account.json" + , data: {action: 'store-error', msg: msg} + } ); Gittip.showFeedback(null, [response.error.description]); - } else { + } + else + { /* The request to tokenize the bank account succeeded. Now we need to - * validate the merchant information. We'll submit it to /bank-accounts.json - * and check the response code to see what's going on there. + * validate the merchant information. We'll submit it to + * /bank-accounts.json and check the response code to see what's going + * on there. */ function success() { - $('#status').text('working').addClass('highlight'); + $('#status').text('connected').addClass('highlight'); setTimeout(function() { $('#status').removeClass('highlight'); }, 8000); @@ -283,7 +301,7 @@ Gittip.bankAccountResponseHandler = function (response) { Gittip.clearFeedback(); $('BUTTON#save').text('Save'); setTimeout(function() { - window.location.href = '/bank-account.html'; + window.location.href = '/' + Gittip.participantId + '/'; }, 1000); } @@ -312,10 +330,10 @@ Gittip.bankAccountResponseHandler = function (response) { detailsToSubmit.bank_account_uri = response.data.uri; Gittip.submitForm( "/bank-account.json" - , detailsToSubmit - , success - , detailedFeedback - ); + , detailsToSubmit + , success + , detailedFeedback + ); } }; diff --git a/www/bank-account.html b/www/bank-account.html index fcda02f5a0..174bc4f426 100644 --- a/www/bank-account.html +++ b/www/bank-account.html @@ -3,7 +3,7 @@ import balanced from aspen import json, log, Response -from gittip import billing +from gittip import billing, MONTHS from gittip.networks import github # ========================================================================== ^L @@ -17,21 +17,19 @@ status = "not connected" if balanced_account_uri: - working = user.balanced_destination_uri and not bool(user.last_ach_result) + working = user.last_ach_result == "" status = "connected" if working else "not connected" account = balanced.Account.find(balanced_account_uri) try: - bank_account = billing.BalancedBankAccount(balanced_account_uri, user.balanced_destination_uri) - except balanced.exc.HTTPError: # balanced is unavailable (or balanced_account_uri is bad?) - log(traceback.format_exc()) - except Exception: + bank_account = billing.BalancedBankAccount(balanced_account_uri) + except Exception: # balanced is unavailable (or balanced_account_uri is bad?) log(traceback.format_exc()) payouts_down = True else: if bank_account.is_setup: assert balanced_account_uri == bank_account['account_uri'] - + username = user.id # ========================================================================== ^L @@ -105,41 +103,34 @@

Your bank account is {{ status }}.

Bank account information is stored and processed by Balanced.

- {% if bank_account and bank_account.is_setup %} + {% if account and 'merchant' in account.roles %} -

Your current linked account is a {{ bank_account['bank_name'] }} account ending in {{ bank_account['last_four'] }}.

+

Identity Verification   ✔

-

Change your bank account on file.

- {% end %} +

Routing Information

-
-
{% if user.last_bill_result %} -

Failure

-
-

{{ user.last_bill_result }}

-
- {% end %}
+ {% if bank_account and bank_account.is_setup %} -

Required

-
- - -
+

Your current account is a + {{ bank_account['bank_name'] }} account ending in + {{ bank_account['last_four'] }}.

- + {% end %} + {% end %} -
- - -
+ -
+
{% if user.last_ach_result %} +

Failure

+
+

{{ user.last_ach_result }}

+
+ {% end %}
{% if not account or 'merchant' not in account.roles %} +

Identity Verification

+
@@ -148,8 +139,13 @@

Required

- - + + +
+ +
+ +
@@ -162,21 +158,24 @@

Required

+
+ +
+ + +
+
- - + +
+
+ +

Routing Information

+ {% end %} +
+ + +
+ + + +
+ + +
+
@@ -197,7 +220,7 @@

Required

{% end %} -
+

Disconnect bank account.

Your bank account details will immediately be completely purged from @@ -210,6 +233,8 @@

Disconnect bank account.

- + {% end %} {% end %} diff --git a/www/bank-account.json b/www/bank-account.json index 308b2cd6d5..e1d98292d9 100644 --- a/www/bank-account.json +++ b/www/bank-account.json @@ -1,11 +1,4 @@ -"""Save a payment method token (balanced_account_uri) for a user. - -When the user fills out the payment details form in the UI, we send the new -info to Balanced (using the balanced.js library). Balanced always gives us a -single-use token in return, provided that the bank account info validated. This -present script is called next. It takes the token and tries to associate it with -a Balanced account object (creating one as needed). - +""" """ import balanced import urllib @@ -24,53 +17,56 @@ out = {} redirect_to = 'https://www.gittip.com/bank-account-complete.html' if body.get('action') == 'delete': - billing.clear_bank_account(user.id, user.balanced_account_uri) + billing.clear(u"bank account", user.id, user.balanced_account_uri) elif body.get('action') == 'store-error': - billing.store_error(user.id, body['msg']) + billing.store_error(u"bank account", user.id, body['msg']) else: - # we need to try and create the merchant role for this acccount, let's get to it. - email_address = '{}@gittip.com'.format(user.id) - merchant_keys = ['type', 'street_address', 'postal_code', 'region', 'dob', - 'name', 'phone_number'] - merchant_data = dict((key, body.get(key)) for key in merchant_keys) - bank_account_uri = body.get('bank_account_uri') - - if bank_account_uri is None: - raise Response(400) - - accounts = balanced.Account.query.filter(email_address=email_address) - - # create an empty account with no role, not much can go wrong here. - if not accounts.total: - # we're creating a new account - account = balanced.Account(email_address=email_address, - name=user.id).save() - else: - account = accounts[0] - - out = None - - # add merchant data if supplied, this will possibly fail with 400 if - # formatted badly or 300 if we cannot identify the merchant - if 'merchant' not in account.roles: + + # Get a balanced account. + # ======================= + # This will create one if user.balanced_account_uri is None. + + balanced_account = billing.get_balanced_account( user.id + , user.balanced_account_uri + ) + + + # Ensure the user is a merchant. + # ============================== + # This will possibly fail with 400 if formatted badly, or 300 if we cannot + # identify the merchant. + + out = {} + if 'merchant' not in balanced_account.roles: + merchant_keys = ['type', 'street_address', 'postal_code', 'region', + 'dob', 'name', 'phone_number'] + merchant_data = dict((key, body.get(key)) for key in merchant_keys) + try: - account.add_merchant(merchant_data) - except balanced.exc.MoreInformationRequiredError as mirex: - out = {'problem': 'Escalate', - 'error': 'Unable to verify', - 'redirect_uri': mirex.redirect_uri + '?' + + balanced_account.add_merchant(merchant_data) # HTTP under here + except balanced.exc.MoreInformationRequiredError as mirerr: + out = { 'problem': 'Escalate' + , 'error': 'Unable to verify' + , 'redirect_uri': mirerr.redirect_uri + '?' + urllib.urlencode([('redirect_uri', redirect_to)]) + '&' - } - except balanced.exc.HTTPError as hex: - out = {"problem": "Problem", "error": hex.message} + } + except balanced.exc.HTTPError as err: + out = {"problem": "Problem", "error": err.message} + + + # No errors? Great! Let's add the bank account. + # ============================================= - # no errors? great! let's add the bank account if not out: + bank_account_uri = body['bank_account_uri'] try: - billing.associate_bank_account(user.id, account.uri, - bank_account_uri) - except balanced.exc.HTTPError as ex: - out = {"problem": "Problem", "error": ex.message} + billing.associate( u"bank account" + , user.id + , balanced_account + , bank_account_uri + ) + except balanced.exc.HTTPError as err: + out = {"problem": "Problem", "error": err.message} else: out = {"problem": ""} diff --git a/www/credit-card.html b/www/credit-card.html index ce71747687..0c28d063a5 100644 --- a/www/credit-card.html +++ b/www/credit-card.html @@ -13,7 +13,7 @@ status = "missing" if stripe_customer_id or balanced_account_uri: - working = not bool(user.last_bill_result) + working = user.last_bill_result == "" status = "working" if working else "failing" if balanced_account_uri: diff --git a/www/credit-card.json b/www/credit-card.json index bcd5f7b040..b243238d59 100644 --- a/www/credit-card.json +++ b/www/credit-card.json @@ -19,9 +19,9 @@ request.allow('POST') out = {} if body.get('action') == 'delete': - billing.clear(user.id, user.balanced_account_uri) + billing.clear(u"credit card", user.id, user.balanced_account_uri) elif body.get('action') == 'store-error': - billing.store_error(user.id, body['msg']) + billing.store_error(u"credit card", user.id, body['msg']) else: # Associate the single-use token representing the credit card details (we @@ -33,7 +33,11 @@ else: if card_uri is None: raise Response(400) - error = billing.associate(user.id, user.balanced_account_uri, card_uri) + error = billing.associate( u"bank account" + , user.id + , user.balanced_account_uri + , card_uri + ) if error: out = {"problem": "Problem", "error": error} else: