diff --git a/gratipay/models/participant.py b/gratipay/models/participant.py index 650154b50c..ee0dc911a3 100644 --- a/gratipay/models/participant.py +++ b/gratipay/models/participant.py @@ -372,6 +372,7 @@ class NoOneToGiveFinalGiftTo(Exception): pass def distribute_balance_as_final_gift(self, cursor): """Distribute a balance as a final gift. """ + raise NotImplementedError # XXX Bring me back! if self.balance == 0: return @@ -1022,60 +1023,29 @@ def update_giving_and_tippees(self): Participant.from_username(tip.tippee).update_receiving(cursor) def update_giving(self, cursor=None): + updated = [] # Update is_funded on tips if self.get_credit_card_error() == '': updated = (cursor or self.db).all(""" - UPDATE current_tips + UPDATE current_subscriptions SET is_funded = true - WHERE tipper = %s + WHERE subscriber = %s AND is_funded IS NOT true RETURNING * """, (self.username,)) - else: - tips = (cursor or self.db).all(""" - SELECT t.* - FROM current_tips t - JOIN participants p2 ON p2.username = t.tippee - WHERE t.tipper = %s - AND t.amount > 0 - AND p2.is_suspicious IS NOT true - ORDER BY p2.claimed_time IS NULL, t.ctime ASC - """, (self.username,)) - fake_balance = self.balance + self.receiving - updated = [] - for tip in tips: - if tip.amount > fake_balance: - is_funded = False - else: - fake_balance -= tip.amount - is_funded = True - if tip.is_funded == is_funded: - continue - updated.append((cursor or self.db).one(""" - UPDATE tips - SET is_funded = %s - WHERE id = %s - RETURNING * - """, (is_funded, tip.id))) - - # Update giving on participant + giving = (cursor or self.db).one(""" - WITH our_tips AS ( - SELECT amount, p2.claimed_time - FROM current_tips - JOIN participants p2 ON p2.username = tippee - WHERE tipper = %(username)s - AND p2.is_suspicious IS NOT true - AND amount > 0 - AND is_funded - ) UPDATE participants p SET giving = COALESCE(( - SELECT sum(amount) - FROM our_tips - WHERE claimed_time IS NOT NULL + SELECT sum(amount) + FROM current_subscriptions s + JOIN teams t ON t.slug=s.team + WHERE subscriber=%(username)s + AND amount > 0 + AND is_funded + AND t.is_approved ), 0) - WHERE p.username = %(username)s + WHERE p.username=%(username)s RETURNING giving """, dict(username=self.username)) self.set_attributes(giving=giving) @@ -1341,49 +1311,43 @@ def get_tip_distribution(self): return tip_amounts, npatrons, contributed - def get_giving_for_profile(self): - """Given a participant id and a date, return a list and a Decimal. - - This function is used to populate a participant's page for their own - viewing pleasure. - + def get_subscriptions_for_profile(self): + """Return a list and a Decimal. """ - TIPS = """\ + SUBSCRIPTIONS = """\ SELECT * FROM ( - SELECT DISTINCT ON (tippee) - amount - , tippee - , t.ctime - , t.mtime - , p.claimed_time - , p.username_lower - , p.number - FROM tips t - JOIN participants p ON p.username = t.tippee - WHERE tipper = %s - AND p.is_suspicious IS NOT true - AND p.claimed_time IS NOT NULL - ORDER BY tippee - , t.mtime DESC + SELECT DISTINCT ON (s.team) + s.team as team_slug + , s.amount + , s.ctime + , s.mtime + , t.name as team_name + FROM subscriptions s + JOIN teams t ON s.team = t.slug + WHERE subscriber = %s + AND t.is_approved is true + AND t.is_closed is not true + ORDER BY s.team + , s.mtime DESC ) AS foo ORDER BY amount DESC - , username_lower + , team_slug """ - tips = self.db.all(TIPS, (self.username,)) + subscriptions = self.db.all(SUBSCRIPTIONS, (self.username,)) # Compute the total. # ================== - total = sum([t.amount for t in tips]) + total = sum([s.amount for s in subscriptions]) if not total: # If tips is an empty list, total is int 0. We want a Decimal. total = Decimal('0.00') - return tips, total + return subscriptions, total def get_current_tips(self): """Get the tips this participant is currently sending to others. diff --git a/js/gratipay.js b/js/gratipay.js index 86940ac04c..129cd3fc81 100644 --- a/js/gratipay.js +++ b/js/gratipay.js @@ -14,7 +14,7 @@ Gratipay.init = function() { Gratipay.forms.initCSRF(); Gratipay.signIn(); Gratipay.signOut(); - Gratipay.tips.initSupportGratipay(); + Gratipay.payments.initSupportGratipay(); }; Gratipay.error = function(jqXHR, textStatus, errorThrown) { diff --git a/js/gratipay/giving.js b/js/gratipay/giving.js deleted file mode 100644 index 93db2ccd2e..0000000000 --- a/js/gratipay/giving.js +++ /dev/null @@ -1,29 +0,0 @@ -Gratipay.giving = {} - -Gratipay.giving.init = function() { - Gratipay.giving.activateTab('active'); - $('.giving #tab-nav a').on('click', Gratipay.giving.handleClick); -} - -Gratipay.giving.handleClick = function(e) { - e.preventDefault(); - var $target = $(e.target); - Gratipay.giving.activateTab($target.data('tab')); -} - -Gratipay.giving.activateTab = function(tab) { - $.each($('.giving #tab-nav a'), function(i, obj) { - var $obj = $(obj); - if ($obj.data('tab') == tab) { - $obj.addClass('selected'); - } else { - $obj.removeClass('selected'); - } - }) - - $.each($('.giving .tab'), function(i, obj) { - var $obj = $(obj); - if ($obj.data('tab') == tab) { $obj.show(); } else { $obj.hide(); } - }) -} - diff --git a/js/gratipay/payments.js b/js/gratipay/payments.js index 8731e40d6e..b8cf3e5a5c 100644 --- a/js/gratipay/payments.js +++ b/js/gratipay/payments.js @@ -1,324 +1,86 @@ -/* Bank Account and Credit Card forms - * - * These two forms share some common wiring under the Gratipay.payments - * namespace, and each has unique code under the Gratipay.payments.{cc,ba} - * namespaces. Each form gets its own page so we only instantiate one of these - * at a time. - * - */ - Gratipay.payments = {}; - -// Common code -// =========== - Gratipay.payments.init = function() { - $('#delete').submit(Gratipay.payments.deleteRoute); -} - -Gratipay.payments.lazyLoad = function(script_url) { - jQuery.getScript(script_url, function() { - $('input[type!="hidden"]').eq(0).focus(); - }).fail(Gratipay.error); -} - -Gratipay.payments.deleteRoute = function(e) { - e.stopPropagation(); - e.preventDefault(); - - var $this = $(this); - var confirm_msg = $this.data('confirm'); - if (confirm_msg && !confirm(confirm_msg)) { - return false; - } - jQuery.ajax( - { url: "/~" + Gratipay.username + "/routes/delete.json" - , data: {network: $this.data('network'), address: $this.data('address')} - , type: "POST" - , success: function() { window.location.reload(); } - , error: Gratipay.error - } - ); - return false; -}; - -Gratipay.payments.onSuccess = function(data) { - $('button#save').prop('disabled', false); - window.location.reload(); -}; - -Gratipay.payments.associate = function (network, address) { - jQuery.ajax({ - url: "associate.json", - type: "POST", - data: {network: network, address: address}, - dataType: "json", - success: Gratipay.payments.onSuccess, - error: [ - Gratipay.error, - function() { $('button#save').prop('disabled', false); }, - ], - }); -}; - - -// Bank Accounts -// ============= - -Gratipay.payments.ba = {}; - -Gratipay.payments.ba.init = function() { - Gratipay.payments.init(); - - // Lazily depend on Balanced. - Gratipay.payments.lazyLoad("https://js.balancedpayments.com/1.1/balanced.min.js") - - $('form#bank-account').submit(Gratipay.payments.ba.submit); -}; -Gratipay.payments.ba.submit = function (e) { - e.preventDefault(); - - $('button#save').prop('disabled', true); - Gratipay.forms.clearInvalid($(this)); - - var bankAccount = { - name: $('#account_name').val(), - account_number: $('#account_number').val(), - account_type: $('#account_type').val(), - routing_number: $('#routing_number').val() - }; - - // Validate routing number. - if (bankAccount.routing_number) { - if (!balanced.bankAccount.validateRoutingNumber(bankAccount.routing_number)) { - Gratipay.forms.setInvalid($('#routing_number')); - Gratipay.forms.focusInvalid($(this)); - $('button#save').prop('disabled', false); - return false - } - } - - // Okay, send the data to Balanced. - balanced.bankAccount.create(bankAccount, function (response) { - if (response.status_code !== 201) { - return Gratipay.payments.ba.onError(response); + Gratipay.forms.jsEdit({ + confirmBeforeUnload: true, + hideEditButton: true, + root: $('.your-payment.js-edit'), + success: function(data) { + Gratipay.notification(data.msg, 'success'); + Gratipay.payments.afterTipChange(data); } - - /* The request to tokenize the thing succeeded. Now we need to associate it - * to the Customer on Balanced and to the participant in our DB. - */ - Gratipay.payments.associate('balanced-ba', response.bank_accounts[0].href); }); -}; - -Gratipay.payments.ba.onError = function(response) { - $('button#save').prop('disabled', false); - var msg = response.status_code + ": " + - $.map(response.errors, function(obj) { return obj.description }).join(', '); - Gratipay.notification(msg, 'error', -1); - return msg; -}; + $('.your-payment button.edit').click(function() { + $('.your-payment input').focus(); + }); -// Credit Cards -// ============ - -Gratipay.payments.cc = {}; - -Gratipay.payments.cc.init = function() { - Gratipay.payments.init(); + $('.your-payment button.stop').click(function() { + $('.your-payment input').val('0'); + $('.your-payment button.save').click(); + }); - // Lazily depend on Braintree. - Gratipay.payments.lazyLoad("https://js.braintreegateway.com/v2/braintree.js") + $('.your-payment button.cancel').click(function() { + $('.your-payment form').trigger('reset'); + }); - $('form#credit-card').submit(Gratipay.payments.cc.submit); - Gratipay.payments.cc.formatInputs( - $('#card_number'), - $('#expiration_month'), - $('#expiration_year'), - $('#cvv') - ); + // Cancel if the user presses the Escape key + $('.your-payment input').keyup(function(e) { + if (e.keyCode === 27) + $('.your-payment button.cancel').click(); + }); }; -/* Most of the following code is taken from https://github.com/wangjohn/creditly */ - -Gratipay.payments.cc.formatInputs = function (cardNumberInput, expirationMonthInput, expirationYearInput, cvvInput) { - function getInputValue(e, element) { - var inputValue = element.val().trim(); - inputValue = inputValue + String.fromCharCode(e.which); - return inputValue.replace(/[^\d]/g, ""); - } - - function isEscapedKeyStroke(e) { - // Key event is for a browser shortcut - if (e.metaKey || e.ctrlKey) return true; - - // If keycode is a space - if (e.which === 32) return false; +Gratipay.payments.initSupportGratipay = function() { + $('.support-gratipay button').click(function() { + var amount = parseFloat($(this).attr('data-amount'), 10); + Gratipay.payments.set('Gratipay', amount, function(data) { + Gratipay.notification(data.msg, 'success'); + $('.support-gratipay').slideUp(); - // If keycode is a special char (WebKit) - if (e.which === 0) return true; - - // If char is a special char (Firefox) - if (e.which < 33) return true; - - return false; - } - - function isNumberEvent(e) { - return (/^\d+$/.test(String.fromCharCode(e.which))); - } - - function onlyAllowNumeric(e, maximumLength, element) { - e.preventDefault(); - // Ensure that it is a number and stop the keypress - if (!isNumberEvent(e)) { - return false; - } - return true; - } - - var isAmericanExpress = function(number) { - return number.match("^(34|37)"); - }; - - function shouldProcessInput(e, maximumLength, element) { - var target = e.currentTarget; - if (getInputValue(e, element).length > maximumLength) { - e.preventDefault(); - return false; - } - if ((target.selectionStart !== target.value.length)) { - return false; - } - return (!isEscapedKeyStroke(e)) && onlyAllowNumeric(e, maximumLength, element); - } - - function addSpaces(number, spaces) { - var parts = [] - var j = 0; - for (var i=0; i spaces[i]) { - parts.push(number.slice(j, spaces[i])); - j = spaces[i]; - } else { - if (i < spaces.length) { - parts.push(number.slice(j, spaces[i])); - } else { - parts.push(number.slice(j)); - } - break; - } - } - - if (parts.length > 0) { - return parts.join(" "); - } else { - return number; - } - } - - var americanExpressSpaces = [4, 10, 15]; - var defaultSpaces = [4, 8, 12, 16]; - - cardNumberInput.on("keypress", function(e) { - var number = getInputValue(e, cardNumberInput); - var isAmericanExpressCard = isAmericanExpress(number); - var maximumLength = (isAmericanExpressCard ? 15 : 16); - if (shouldProcessInput(e, maximumLength, cardNumberInput)) { - var newInput; - newInput = isAmericanExpressCard ? addSpaces(number, americanExpressSpaces) : addSpaces(number, defaultSpaces); - cardNumberInput.val(newInput); - } - }); - - expirationMonthInput.on("keypress", function(e) { - var maximumLength = 2; - if (shouldProcessInput(e, maximumLength, expirationMonthInput)) { - var newInput = getInputValue(e, expirationMonthInput); - if (newInput < 13) { - expirationMonthInput.val(newInput); - } else { - e.preventDefault(); + // If you're on your own giving page ... + var payment_on_giving = $('.your-payment[data-team="Gratipay"]'); + if (payment_on_giving.length > 0) { + payment_on_giving[0].defaultValue = amount; + payment_on_giving.attr('value', amount.toFixed(2)); } - } - }); - - expirationYearInput.on("keypress", function(e) { - var maximumLength = 2; - if (shouldProcessInput(e, maximumLength, expirationYearInput)) { - var newInput = getInputValue(e, expirationYearInput); - expirationYearInput.val(newInput); - } + }); }); - cvvInput.on("keypress", function(e) { - var number = getInputValue(e, cardNumberInput); - var isAmericanExpressCard = isAmericanExpress(number); - var maximumLength = (isAmericanExpressCard ? 4 : 3); - if (shouldProcessInput(e, maximumLength, cvvInput)) { - var newInput = getInputValue(e, cvvInput); - cvvInput.val(newInput); - } + $('.support-gratipay .no-thanks').click(function(event) { + event.preventDefault(); + jQuery.post('/ride-free.json') + .success(function() { $('.support-gratipay').slideUp(); }) + .fail(Gratipay.error) }); -} - -Gratipay.payments.cc.submit = function(e) { +}; - e.stopPropagation(); - e.preventDefault(); - $('button#save').prop('disabled', true); - Gratipay.forms.clearInvalid($(this)); - // Adapt our form lingo to braintree nomenclature. +Gratipay.payments.afterTipChange = function(data) { + $('.my-total-giving').text(data.total_giving_l); + $('.total-receiving[data-team="'+data.team_id+'"]').text(data.total_receiving_team_l); + $('#payment-prompt').toggleClass('needed', data.amount > 0); + $('.nsupporters[data-team="'+data.team_id+'"]').text(data.nsupporters); - function val(field) { - return $('form#credit-card #'+field).val(); + var $your_payment = $('.your-payment[data-team="'+data.team_id+'"]'); + if ($your_payment) { + var $input = $your_payment.find('input'); + $input[0].defaultValue = $input.val(); + $your_payment.find('span.amount').text(data.amount_l); + $your_payment.find('.edit').toggleClass('not-zero', data.amount > 0); + $your_payment.find('.stop').toggleClass('zero', data.amount === 0); } - - var credit_card = {}; - - credit_card.number = val('card_number').replace(/[^\d]/g, ''); - credit_card.cvv = val('cvv'); - credit_card.cardholderName = val('name'); - credit_card.billingAddress = { 'postalCode': val('zip') }; - credit_card.expirationMonth = val('expiration_month'); - var year = val('expiration_year'); - credit_card.expirationYear = year.length == 2 ? '20' + year : year; - - // TODO: Client Side validation - - var client = new braintree.api.Client({clientToken: val('braintree_token')}); - - client.tokenizeCard(credit_card, function (err, nonce) { - if (err) { - Gratipay.notification(err, 'error') - } else { - Gratipay.payments.associate('braintree-cc', nonce); - } - }); - - return false; }; -// PayPal -// ====== - -Gratipay.payments.pp = {}; -Gratipay.payments.pp.init = function () { - Gratipay.payments.init(); - $('form#paypal').submit(Gratipay.payments.pp.submit); -} +Gratipay.payments.set = function(team, amount, callback) { -Gratipay.payments.pp.submit = function (e) { - e.stopPropagation(); - e.preventDefault(); - $('button#save').prop('disabled', true); - var paypal_email = $('form#paypal #email').val(); - - Gratipay.payments.associate('paypal', paypal_email); -} + // send request to set up a recurring payment + $.post('/' + team + '/subscription.json', { amount: amount }, function(data) { + if (callback) callback(data); + Gratipay.payments.afterTipChange(data); + }) + .fail(Gratipay.error); +}; diff --git a/js/gratipay/routes.js b/js/gratipay/routes.js new file mode 100644 index 0000000000..809a77b493 --- /dev/null +++ b/js/gratipay/routes.js @@ -0,0 +1,324 @@ +/* Payment route forms + * + * These forms share some common wiring under the Gratipay.routes namespace, + * and each has unique code under the Gratipay.routes.{cc,ba,pp} namespaces. + * Each form gets its own page, so we only instantiate one of these at a time. + * + */ + +Gratipay.routes = {}; + + +// Common code +// =========== + +Gratipay.routes.init = function() { + $('#delete').submit(Gratipay.routes.deleteRoute); +} + +Gratipay.routes.lazyLoad = function(script_url) { + jQuery.getScript(script_url, function() { + $('input[type!="hidden"]').eq(0).focus(); + }).fail(Gratipay.error); +} + +Gratipay.routes.deleteRoute = function(e) { + e.stopPropagation(); + e.preventDefault(); + + var $this = $(this); + var confirm_msg = $this.data('confirm'); + if (confirm_msg && !confirm(confirm_msg)) { + return false; + } + jQuery.ajax( + { url: "/~" + Gratipay.username + "/routes/delete.json" + , data: {network: $this.data('network'), address: $this.data('address')} + , type: "POST" + , success: function() { window.location.reload(); } + , error: Gratipay.error + } + ); + return false; +}; + +Gratipay.routes.onSuccess = function(data) { + $('button#save').prop('disabled', false); + window.location.reload(); +}; + +Gratipay.routes.associate = function (network, address) { + jQuery.ajax({ + url: "associate.json", + type: "POST", + data: {network: network, address: address}, + dataType: "json", + success: Gratipay.routes.onSuccess, + error: [ + Gratipay.error, + function() { $('button#save').prop('disabled', false); }, + ], + }); +}; + + +// Bank Accounts +// ============= + +Gratipay.routes.ba = {}; + +Gratipay.routes.ba.init = function() { + Gratipay.routes.init(); + + // Lazily depend on Balanced. + Gratipay.routes.lazyLoad("https://js.balancedroutes.com/1.1/balanced.min.js") + + $('form#bank-account').submit(Gratipay.routes.ba.submit); +}; + +Gratipay.routes.ba.submit = function (e) { + e.preventDefault(); + + $('button#save').prop('disabled', true); + Gratipay.forms.clearInvalid($(this)); + + var bankAccount = { + name: $('#account_name').val(), + account_number: $('#account_number').val(), + account_type: $('#account_type').val(), + routing_number: $('#routing_number').val() + }; + + // Validate routing number. + if (bankAccount.routing_number) { + if (!balanced.bankAccount.validateRoutingNumber(bankAccount.routing_number)) { + Gratipay.forms.setInvalid($('#routing_number')); + Gratipay.forms.focusInvalid($(this)); + $('button#save').prop('disabled', false); + return false + } + } + + // Okay, send the data to Balanced. + balanced.bankAccount.create(bankAccount, function (response) { + if (response.status_code !== 201) { + return Gratipay.routes.ba.onError(response); + } + + /* The request to tokenize the thing succeeded. Now we need to associate it + * to the Customer on Balanced and to the participant in our DB. + */ + Gratipay.routes.associate('balanced-ba', response.bank_accounts[0].href); + }); +}; + +Gratipay.routes.ba.onError = function(response) { + $('button#save').prop('disabled', false); + var msg = response.status_code + ": " + + $.map(response.errors, function(obj) { return obj.description }).join(', '); + Gratipay.notification(msg, 'error', -1); + return msg; +}; + + +// Credit Cards +// ============ + +Gratipay.routes.cc = {}; + +Gratipay.routes.cc.init = function() { + Gratipay.routes.init(); + + // Lazily depend on Braintree. + Gratipay.routes.lazyLoad("https://js.braintreegateway.com/v2/braintree.js") + + $('form#credit-card').submit(Gratipay.routes.cc.submit); + Gratipay.routes.cc.formatInputs( + $('#card_number'), + $('#expiration_month'), + $('#expiration_year'), + $('#cvv') + ); +}; + + +/* Most of the following code is taken from https://github.com/wangjohn/creditly */ + +Gratipay.routes.cc.formatInputs = function (cardNumberInput, expirationMonthInput, expirationYearInput, cvvInput) { + function getInputValue(e, element) { + var inputValue = element.val().trim(); + inputValue = inputValue + String.fromCharCode(e.which); + return inputValue.replace(/[^\d]/g, ""); + } + + function isEscapedKeyStroke(e) { + // Key event is for a browser shortcut + if (e.metaKey || e.ctrlKey) return true; + + // If keycode is a space + if (e.which === 32) return false; + + // If keycode is a special char (WebKit) + if (e.which === 0) return true; + + // If char is a special char (Firefox) + if (e.which < 33) return true; + + return false; + } + + function isNumberEvent(e) { + return (/^\d+$/.test(String.fromCharCode(e.which))); + } + + function onlyAllowNumeric(e, maximumLength, element) { + e.preventDefault(); + // Ensure that it is a number and stop the keypress + if (!isNumberEvent(e)) { + return false; + } + return true; + } + + var isAmericanExpress = function(number) { + return number.match("^(34|37)"); + }; + + function shouldProcessInput(e, maximumLength, element) { + var target = e.currentTarget; + if (getInputValue(e, element).length > maximumLength) { + e.preventDefault(); + return false; + } + if ((target.selectionStart !== target.value.length)) { + return false; + } + return (!isEscapedKeyStroke(e)) && onlyAllowNumeric(e, maximumLength, element); + } + + function addSpaces(number, spaces) { + var parts = [] + var j = 0; + for (var i=0; i spaces[i]) { + parts.push(number.slice(j, spaces[i])); + j = spaces[i]; + } else { + if (i < spaces.length) { + parts.push(number.slice(j, spaces[i])); + } else { + parts.push(number.slice(j)); + } + break; + } + } + + if (parts.length > 0) { + return parts.join(" "); + } else { + return number; + } + } + + var americanExpressSpaces = [4, 10, 15]; + var defaultSpaces = [4, 8, 12, 16]; + + cardNumberInput.on("keypress", function(e) { + var number = getInputValue(e, cardNumberInput); + var isAmericanExpressCard = isAmericanExpress(number); + var maximumLength = (isAmericanExpressCard ? 15 : 16); + if (shouldProcessInput(e, maximumLength, cardNumberInput)) { + var newInput; + newInput = isAmericanExpressCard ? addSpaces(number, americanExpressSpaces) : addSpaces(number, defaultSpaces); + cardNumberInput.val(newInput); + } + }); + + expirationMonthInput.on("keypress", function(e) { + var maximumLength = 2; + if (shouldProcessInput(e, maximumLength, expirationMonthInput)) { + var newInput = getInputValue(e, expirationMonthInput); + if (newInput < 13) { + expirationMonthInput.val(newInput); + } else { + e.preventDefault(); + } + } + }); + + expirationYearInput.on("keypress", function(e) { + var maximumLength = 2; + if (shouldProcessInput(e, maximumLength, expirationYearInput)) { + var newInput = getInputValue(e, expirationYearInput); + expirationYearInput.val(newInput); + } + }); + + cvvInput.on("keypress", function(e) { + var number = getInputValue(e, cardNumberInput); + var isAmericanExpressCard = isAmericanExpress(number); + var maximumLength = (isAmericanExpressCard ? 4 : 3); + if (shouldProcessInput(e, maximumLength, cvvInput)) { + var newInput = getInputValue(e, cvvInput); + cvvInput.val(newInput); + } + }); +} + +Gratipay.routes.cc.submit = function(e) { + + e.stopPropagation(); + e.preventDefault(); + $('button#save').prop('disabled', true); + Gratipay.forms.clearInvalid($(this)); + + // Adapt our form lingo to braintree nomenclature. + + function val(field) { + return $('form#credit-card #'+field).val(); + } + + var credit_card = {}; + + credit_card.number = val('card_number').replace(/[^\d]/g, ''); + credit_card.cvv = val('cvv'); + credit_card.cardholderName = val('name'); + credit_card.billingAddress = { 'postalCode': val('zip') }; + credit_card.expirationMonth = val('expiration_month'); + var year = val('expiration_year'); + credit_card.expirationYear = year.length == 2 ? '20' + year : year; + + // TODO: Client Side validation + + var client = new braintree.api.Client({clientToken: val('braintree_token')}); + + client.tokenizeCard(credit_card, function (err, nonce) { + if (err) { + Gratipay.notification(err, 'error') + } else { + Gratipay.routes.associate('braintree-cc', nonce); + } + }); + + return false; +}; + + +// PayPal +// ====== + +Gratipay.routes.pp = {}; + +Gratipay.routes.pp.init = function () { + Gratipay.routes.init(); + $('form#paypal').submit(Gratipay.routes.pp.submit); +} + +Gratipay.routes.pp.submit = function (e) { + e.stopPropagation(); + e.preventDefault(); + $('button#save').prop('disabled', true); + var paypal_email = $('form#paypal #email').val(); + + Gratipay.routes.associate('paypal', paypal_email); +} diff --git a/js/gratipay/subscriptions.js b/js/gratipay/subscriptions.js new file mode 100644 index 0000000000..2b34562631 --- /dev/null +++ b/js/gratipay/subscriptions.js @@ -0,0 +1,29 @@ +Gratipay.subscriptions = {} + +Gratipay.subscriptions.init = function() { + Gratipay.subscriptions.activateTab('active'); + $('.subscriptions #tab-nav a').on('click', Gratipay.subscriptions.handleClick); +} + +Gratipay.subscriptions.handleClick = function(e) { + e.preventDefault(); + var $target = $(e.target); + Gratipay.subscriptions.activateTab($target.data('tab')); +} + +Gratipay.subscriptions.activateTab = function(tab) { + $.each($('.subscriptions #tab-nav a'), function(i, obj) { + var $obj = $(obj); + if ($obj.data('tab') == tab) { + $obj.addClass('selected'); + } else { + $obj.removeClass('selected'); + } + }) + + $.each($('.subscriptions .tab'), function(i, obj) { + var $obj = $(obj); + if ($obj.data('tab') == tab) { $obj.show(); } else { $obj.hide(); } + }) +} + diff --git a/js/gratipay/tips.js b/js/gratipay/tips.js deleted file mode 100644 index 2d4e2114e2..0000000000 --- a/js/gratipay/tips.js +++ /dev/null @@ -1,86 +0,0 @@ -Gratipay.tips = {}; - -Gratipay.tips.init = function() { - - Gratipay.forms.jsEdit({ - confirmBeforeUnload: true, - hideEditButton: true, - root: $('.your-tip.js-edit'), - success: function(data) { - Gratipay.notification(data.msg, 'success'); - Gratipay.tips.afterTipChange(data); - } - }); - - $('.your-tip button.edit').click(function() { - $('.your-tip input').focus(); - }); - - $('.your-tip button.stop').click(function() { - $('.your-tip input').val('0'); - $('.your-tip button.save').click(); - }); - - $('.your-tip button.cancel').click(function() { - $('.your-tip form').trigger('reset'); - }); - - // Cancel if the user presses the Escape key - $('.your-tip input').keyup(function(e) { - if (e.keyCode === 27) - $('.your-tip button.cancel').click(); - }); -}; - - -Gratipay.tips.initSupportGratipay = function() { - $('.support-gratipay button').click(function() { - var amount = parseFloat($(this).attr('data-amount'), 10); - Gratipay.tips.set('Gratipay', amount, function(data) { - Gratipay.notification(data.msg, 'success'); - $('.support-gratipay').slideUp(); - - // If you're on your own giving page ... - var tip_on_giving = $('.your-tip[data-tippee="Gratipay"]'); - if (tip_on_giving.length > 0) { - tip_on_giving[0].defaultValue = amount; - tip_on_giving.attr('value', amount.toFixed(2)); - } - }); - }); - - $('.support-gratipay .no-thanks').click(function(event) { - event.preventDefault(); - jQuery.post('/ride-free.json') - .success(function() { $('.support-gratipay').slideUp(); }) - .fail(Gratipay.error) - }); -}; - - -Gratipay.tips.afterTipChange = function(data) { - $('.my-total-giving').text(data.total_giving_l); - $('.total-receiving[data-tippee="'+data.tippee_id+'"]').text(data.total_receiving_tippee_l); - $('#payment-prompt').toggleClass('needed', data.amount > 0); - $('.npatrons[data-tippee="'+data.tippee_id+'"]').text(data.npatrons); - - var $your_tip = $('.your-tip[data-tippee="'+data.tippee_id+'"]'); - if ($your_tip) { - var $input = $your_tip.find('input'); - $input[0].defaultValue = $input.val(); - $your_tip.find('span.amount').text(data.amount_l); - $your_tip.find('.edit').toggleClass('not-zero', data.amount > 0); - $your_tip.find('.stop').toggleClass('zero', data.amount === 0); - } -}; - - -Gratipay.tips.set = function(tippee, amount, callback) { - - // send request to change tip - $.post('/' + tippee + '/tip.json', { amount: amount }, function(data) { - if (callback) callback(data); - Gratipay.tips.afterTipChange(data); - }) - .fail(Gratipay.error); -}; diff --git a/scss/components/cta.scss b/scss/components/cta.scss index 1ba64443d4..bc351b5042 100644 --- a/scss/components/cta.scss +++ b/scss/components/cta.scss @@ -8,7 +8,7 @@ color: $white; } - .your-tip .amount { + .your-payment .amount { /* Match content h1 */ font: bold 28px/37px $Ideal; } @@ -17,7 +17,7 @@ font: normal 14px/14px $Ideal; } - .your-tip { + .your-payment { input { width: 120px; height: 33px; diff --git a/scss/gratipay.scss b/scss/gratipay.scss index 8caec97ae2..c2e4c5fe69 100644 --- a/scss/gratipay.scss +++ b/scss/gratipay.scss @@ -57,7 +57,7 @@ @import "pages/history"; @import "pages/team"; @import "pages/profile-edit"; -@import "pages/giving"; +@import "pages/subscriptions"; @import "pages/settings"; @import "pages/cc-ba"; @import "pages/on-confirm"; diff --git a/scss/pages/giving.scss b/scss/pages/subscriptions.scss similarity index 77% rename from scss/pages/giving.scss rename to scss/pages/subscriptions.scss index 2c66bb7328..3508be0c6a 100644 --- a/scss/pages/giving.scss +++ b/scss/pages/subscriptions.scss @@ -1,4 +1,4 @@ -.giving { +.subscriptions { .note { font: italic 12px/14px $Ideal; } diff --git a/templates/profile-subnav.html b/templates/profile-subnav.html index ee069096fa..da0f602d2d 100644 --- a/templates/profile-subnav.html +++ b/templates/profile-subnav.html @@ -6,9 +6,7 @@ {% set u = participant.username %} {% set pages = [ ('/', _('Dashboard'), True, False) , ('/~'+u+'/', _('Profile'), True, show_profile) - , ('/~'+u+'/members/', _('Members'), show_members, show_members) - , ('/~'+u+'/receiving/', _('Receiving'), True, show_receiving) - , ('/~'+u+'/giving/', _('Giving'), True, False) + , ('/~'+u+'/subscriptions/', _('Subscriptions'), True, False) , ('/~'+u+'/history/', _('History'), True, False) , ('/~'+u+'/widgets/', _('Widgets'), True, False) , ('/~'+u+'/identity', _('Identity'), True, False) diff --git a/templates/sign-in-using-to-give.html b/templates/sign-in-using-to-give.html index d06363af1d..2b802a515f 100644 --- a/templates/sign-in-using-to-give.html +++ b/templates/sign-in-using-to-give.html @@ -1,4 +1,4 @@ diff --git a/templates/giving-table.html b/templates/subscriptions-table.html similarity index 54% rename from templates/giving-table.html rename to templates/subscriptions-table.html index 99b9c82de1..7f966f88fb 100644 --- a/templates/giving-table.html +++ b/templates/subscriptions-table.html @@ -1,8 +1,8 @@ -{% macro giving_table(state, tips, total) %} +{% macro subscriptions_table(state, subscriptions, total) %} - + {% if state != 'cancelled' %} {% endif %} @@ -21,21 +21,16 @@ - {% for tip in tips %} + {% for subscription in subscriptions %} - {% if state != 'cancelled' %} - + {% endif %} - - + + {% endfor %} diff --git a/templates/your-payment.html b/templates/your-payment.html new file mode 100644 index 0000000000..74eb6b2955 --- /dev/null +++ b/templates/your-payment.html @@ -0,0 +1,38 @@ +{% if user.ANON %} +
+ {% include "templates/sign-in-using-to-give.html" %} +
+{% else %} +
+ {% set subscription = user.participant.get_subscription_to(team.slug) %} +

{{ _('Your Payment') }}

+
+
+ {{ format_currency(subscription.amount, 'USD') }} +
{{ _("per week") }}
+ +
+
+ $ + +
{{ _("per week") }}
+ + + + +
+ + {% if not subscription.is_funded %} +
+ {{ _("Back your payment with a {0}credit card{1} to make sure it goes through!", + ""|safe % user.participant.username, + ""|safe) }} +
+ {% endif %} +
+{% endif %} diff --git a/templates/your-tip.html b/templates/your-tip.html deleted file mode 100644 index df29622b00..0000000000 --- a/templates/your-tip.html +++ /dev/null @@ -1,39 +0,0 @@ -{% if user.ANON %} -
- {% include "templates/sign-in-using-to-give.html" %} -
-{% else %} -
- {% set tippee = participant.username %} - {% set tip = user.participant.get_tip_to(tippee) %} -

{{ _('Your Tip') }}

-
-
- {{ format_currency(tip.amount, 'USD') }} -
{{ _("per week") }}
- -
-
- $ - -
{{ _("per week") }}
- - - - -
- - {% if not tip.is_funded %} -
- {{ _("Back your payment with a {0}credit card{1} to make sure it goes through!", - ""|safe % user.participant.username, - ""|safe) }} -
- {% endif %} -
-{% endif %} diff --git a/tests/py/test_billing_payday.py b/tests/py/test_billing_payday.py index 446841c418..efc9723a5f 100644 --- a/tests/py/test_billing_payday.py +++ b/tests/py/test_billing_payday.py @@ -107,6 +107,7 @@ def test_ncc_failing(self, cch, fch): after = self.fetch_payday() assert after['ncc_failing'] == 1 + @pytest.mark.xfail(reason="#3399") def test_update_cached_amounts(self): team = self.make_participant('team', claimed_time='now', number='plural') alice = self.make_participant('alice', claimed_time='now', last_bill_result='') @@ -164,6 +165,7 @@ def check(): Payday.start().update_cached_amounts() check() + @pytest.mark.xfail(reason="#3399") def test_update_cached_amounts_depth(self): alice = self.make_participant('alice', claimed_time='now', last_bill_result='') usernames = ('bob', 'carl', 'dana', 'emma', 'fred', 'greg') diff --git a/tests/py/test_close.py b/tests/py/test_close.py index 49080cd35b..ee94288284 100644 --- a/tests/py/test_close.py +++ b/tests/py/test_close.py @@ -109,6 +109,7 @@ def test_wbtba_raises_NotWhitelisted_if_blacklisted(self): # dbafg - distribute_balance_as_final_gift + @pytest.mark.xfail(reason='https://github.com/gratipay/gratipay.com/pull/3467') def test_dbafg_distributes_balance_as_final_gift(self): alice = self.make_participant('alice', claimed_time='now', balance=D('10.00')) bob = self.make_participant('bob', claimed_time='now') @@ -121,6 +122,7 @@ def test_dbafg_distributes_balance_as_final_gift(self): assert Participant.from_username('carl').balance == D('4.00') assert Participant.from_username('alice').balance == D('0.00') + @pytest.mark.xfail(reason='https://github.com/gratipay/gratipay.com/pull/3467') def test_dbafg_needs_claimed_tips(self): alice = self.make_participant('alice', claimed_time='now', balance=D('10.00')) bob = self.make_participant('bob') @@ -134,6 +136,7 @@ def test_dbafg_needs_claimed_tips(self): assert Participant.from_username('carl').balance == D('0.00') assert Participant.from_username('alice').balance == D('10.00') + @pytest.mark.xfail(reason='https://github.com/gratipay/gratipay.com/pull/3467') def test_dbafg_gives_all_to_claimed(self): alice = self.make_participant('alice', claimed_time='now', balance=D('10.00')) bob = self.make_participant('bob', claimed_time='now') @@ -146,6 +149,7 @@ def test_dbafg_gives_all_to_claimed(self): assert Participant.from_username('carl').balance == D('0.00') assert Participant.from_username('alice').balance == D('0.00') + @pytest.mark.xfail(reason='https://github.com/gratipay/gratipay.com/pull/3467') def test_dbafg_skips_zero_tips(self): alice = self.make_participant('alice', claimed_time='now', balance=D('10.00')) bob = self.make_participant('bob', claimed_time='now') @@ -159,6 +163,7 @@ def test_dbafg_skips_zero_tips(self): assert Participant.from_username('carl').balance == D('10.00') assert Participant.from_username('alice').balance == D('0.00') + @pytest.mark.xfail(reason='https://github.com/gratipay/gratipay.com/pull/3467') def test_dbafg_favors_highest_tippee_in_rounding_errors(self): alice = self.make_participant('alice', claimed_time='now', balance=D('10.00')) bob = self.make_participant('bob', claimed_time='now') @@ -171,6 +176,7 @@ def test_dbafg_favors_highest_tippee_in_rounding_errors(self): assert Participant.from_username('carl').balance == D('6.67') assert Participant.from_username('alice').balance == D('0.00') + @pytest.mark.xfail(reason='https://github.com/gratipay/gratipay.com/pull/3467') def test_dbafg_with_zero_balance_is_a_noop(self): alice = self.make_participant('alice', claimed_time='now', balance=D('0.00')) bob = self.make_participant('bob', claimed_time='now') diff --git a/tests/py/test_communities.py b/tests/py/test_communities.py index a123565e0a..7c5b4d34e2 100644 --- a/tests/py/test_communities.py +++ b/tests/py/test_communities.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, division, print_function, unicode_literals +import pytest from gratipay.testing import Harness @@ -19,6 +20,7 @@ def test_community_member_shows_up_on_community_listing(self): html = self.client.GET('/for/something/', want='response.body') assert html.count('alice') == 2 # entry in New Participants + @pytest.mark.xfail(reason="#3399") def test_givers_show_up_on_community_page(self): # Alice tips bob. @@ -40,6 +42,7 @@ def test_givers_dont_show_up_if_they_give_zero(self): assert html.count('alice') == 2 # entry in New Participants only assert 'bob' not in html + @pytest.mark.xfail(reason="#3399") def test_receivers_show_up_on_community_page(self): # Bob tips alice. diff --git a/tests/py/test_pages.py b/tests/py/test_pages.py index 09bb3d00be..07e13996c1 100644 --- a/tests/py/test_pages.py +++ b/tests/py/test_pages.py @@ -162,22 +162,18 @@ def test_settings_page_available_balance(self): expected = "123" assert expected in actual - def test_giving_page(self): + def test_subscriptions_page(self): + self.make_team(is_approved=True) alice = self.make_participant('alice', claimed_time='now') - bob = self.make_participant('bob', claimed_time='now') - alice.set_tip_to(bob, "1.00") - actual = self.client.GET("/~alice/giving/", auth_as="alice").body - expected = "bob" - assert expected in actual + alice.set_subscription_to('TheATeam', "1.00") + assert "The A Team" in self.client.GET("/~alice/subscriptions/", auth_as="alice").body def test_giving_page_shows_cancelled(self): + self.make_team(is_approved=True) alice = self.make_participant('alice', claimed_time='now') - bob = self.make_participant('bob', claimed_time='now') - alice.set_tip_to(bob, "1.00") - alice.set_tip_to(bob, "0.00") - actual = self.client.GET("/~alice/giving/", auth_as="alice").body - assert "bob" in actual - assert "Cancelled" in actual + alice.set_subscription_to('TheATeam', "1.00") + alice.set_subscription_to('TheATeam', "0.00") + assert "Cancelled" in self.client.GET("/~alice/subscriptions/", auth_as="alice").body def test_new_participant_can_edit_profile(self): self.make_participant('alice', claimed_time='now') diff --git a/tests/py/test_participant.py b/tests/py/test_participant.py index f7c0c6e5f0..628e424bf1 100644 --- a/tests/py/test_participant.py +++ b/tests/py/test_participant.py @@ -84,6 +84,7 @@ def test_participant_can_be_instantiated(self): actual = Participant.from_username('alice').__class__ assert actual is expected + @pytest.mark.xfail(reason="#3399") def test_bob_has_two_dollars_in_tips(self): expected = Decimal('2.00') actual = self.bob.receiving @@ -602,38 +603,34 @@ def test_only_funded_tips_count(self): alice = self.make_participant('alice', claimed_time='now', last_bill_result='') bob = self.make_participant('bob', claimed_time='now') carl = self.make_participant('carl', claimed_time='now', last_bill_result="Fail!") - dana = self.make_participant('dana', claimed_time='now') - alice.set_tip_to(dana, '3.00') - alice.set_tip_to(bob, '6.00') - bob.set_tip_to(alice, '5.00') - bob.set_tip_to(dana, '2.00') - carl.set_tip_to(dana, '2.08') - - assert alice.giving == Decimal('9.00') - assert alice.receiving == Decimal('5.00') - assert bob.giving == Decimal('5.00') - assert bob.receiving == Decimal('6.00') + team = self.make_team(is_approved=True) + + alice.set_subscription_to(team, '3.00') # The only funded tip + bob.set_subscription_to(team, '5.00') + carl.set_subscription_to(team, '7.00') + + # TODO - Add team payroll and check receiving values + + assert alice.giving == Decimal('3.00') + assert bob.giving == Decimal('0.00') assert carl.giving == Decimal('0.00') - assert carl.receiving == Decimal('0.00') - assert dana.receiving == Decimal('3.00') - assert dana.npatrons == 1 - funded_tips = self.db.all("SELECT amount FROM tips WHERE is_funded ORDER BY id") - assert funded_tips == [3, 6, 5] + funded_tip = self.db.one("SELECT * FROM subscriptions WHERE is_funded ORDER BY id") + assert funded_tip.subscriber == alice.username def test_only_latest_tip_counts(self): alice = self.make_participant('alice', claimed_time='now', last_bill_result='') - bob = self.make_participant('bob', claimed_time='now', last_bill_result='') - carl = self.make_participant('carl', claimed_time='now') - alice.set_tip_to(carl, '12.00') - alice.set_tip_to(carl, '3.00') - bob.set_tip_to(carl, '2.00') - bob.set_tip_to(carl, '0.00') - assert alice.giving == Decimal('3.00') - assert bob.giving == Decimal('0.00') - assert carl.receiving == Decimal('3.00') - assert carl.npatrons == 1 + team = self.make_team(is_approved=True) + + alice.set_subscription_to(team, '12.00') + alice.set_subscription_to(team, '4.00') + + # TODO - Add team payroll and check receiving values + + assert alice.giving == Decimal('4.00') + + @pytest.mark.xfail(reason="#3399") def test_receiving_includes_tips_from_whitelisted_accounts(self): alice = self.make_participant( 'alice' , claimed_time='now' @@ -646,6 +643,7 @@ def test_receiving_includes_tips_from_whitelisted_accounts(self): assert bob.receiving == Decimal('3.00') assert bob.npatrons == 1 + @pytest.mark.xfail(reason="#3399") def test_receiving_includes_tips_from_unreviewed_accounts(self): alice = self.make_participant( 'alice' , claimed_time='now' @@ -670,6 +668,7 @@ def test_receiving_ignores_tips_from_blacklisted_accounts(self): assert bob.receiving == Decimal('0.00') assert bob.npatrons == 0 + @pytest.mark.xfail(reason="#3399") def test_receiving_includes_taking_when_updated_from_set_tip_to(self): alice = self.make_participant('alice', claimed_time='now', last_bill_result='') bob = self.make_participant('bob', claimed_time='now', taking=Decimal('42.00')) diff --git a/tests/py/test_public_json.py b/tests/py/test_public_json.py index 9e54a73ebe..bf428e7005 100644 --- a/tests/py/test_public_json.py +++ b/tests/py/test_public_json.py @@ -2,6 +2,7 @@ import json +import pytest from aspen.utils import utcnow from gratipay.testing import Harness @@ -18,6 +19,7 @@ def test_on_key_gives_gratipay(self): assert data['on'] == 'gratipay' + @pytest.mark.xfail(reason="#3399") def test_anonymous_gets_receiving(self): alice = self.make_participant('alice', last_bill_result='') bob = self.make_participant('bob') @@ -38,6 +40,7 @@ def test_anonymous_does_not_get_my_tip(self): assert data.has_key('my_tip') == False + @pytest.mark.xfail(reason="#3399") def test_anonymous_gets_giving(self): alice = self.make_participant('alice', last_bill_result='') bob = self.make_participant('bob') @@ -70,6 +73,7 @@ def test_anonymous_gets_null_receiving_if_user_anonymous(self): assert data['receiving'] == None + @pytest.mark.xfail(reason="#3399") def test_authenticated_user_gets_their_tip(self): alice = self.make_participant('alice', last_bill_result='') bob = self.make_participant('bob') @@ -83,6 +87,7 @@ def test_authenticated_user_gets_their_tip(self): assert data['receiving'] == '1.00' assert data['my_tip'] == '1.00' + @pytest.mark.xfail(reason="#3399") def test_authenticated_user_doesnt_get_other_peoples_tips(self): alice = self.make_participant('alice', last_bill_result='') bob = self.make_participant('bob', last_bill_result='') @@ -100,6 +105,7 @@ def test_authenticated_user_doesnt_get_other_peoples_tips(self): assert data['receiving'] == '16.00' assert data['my_tip'] == '1.00' + @pytest.mark.xfail(reason="#3399") def test_authenticated_user_gets_zero_if_they_dont_tip(self): self.make_participant('alice', last_bill_result='') bob = self.make_participant('bob', last_bill_result='') @@ -114,6 +120,7 @@ def test_authenticated_user_gets_zero_if_they_dont_tip(self): assert data['receiving'] == '3.00' assert data['my_tip'] == '0.00' + @pytest.mark.xfail(reason="#3399") def test_authenticated_user_gets_self_for_self(self): alice = self.make_participant('alice', last_bill_result='') bob = self.make_participant('bob') @@ -133,6 +140,7 @@ def test_access_control_allow_origin_header_is_asterisk(self): assert response.headers['Access-Control-Allow-Origin'] == '*' + @pytest.mark.xfail(reason="#3399") def test_jsonp_works(self): alice = self.make_participant('alice', last_bill_result='') bob = self.make_participant('bob') diff --git a/tests/py/test_stats.py b/tests/py/test_stats.py index 9a152e6b08..b60299fef0 100644 --- a/tests/py/test_stats.py +++ b/tests/py/test_stats.py @@ -4,6 +4,7 @@ from decimal import Decimal import json +import pytest from mock import patch from gratipay import wireup @@ -38,6 +39,7 @@ def setUp(self): p = self.make_participant(participant, claimed_time='now', last_bill_result='') setattr(self, participant, p) + @pytest.mark.xfail(reason="#3399") def test_get_tip_distribution_handles_a_tip(self): self.alice.set_tip_to(self.bob, '3.00') expected = ([[Decimal('3.00'), 1, Decimal('3.00'), 1.0, Decimal('1')]], @@ -50,6 +52,7 @@ def test_get_tip_distribution_handles_no_tips(self): actual = self.alice.get_tip_distribution() assert actual == expected + @pytest.mark.xfail(reason="#3399") def test_get_tip_distribution_handles_multiple_tips(self): carl = self.make_participant('carl', claimed_time='now', last_bill_result='') self.alice.set_tip_to(self.bob, '1.00') @@ -61,6 +64,7 @@ def test_get_tip_distribution_handles_multiple_tips(self): actual = self.bob.get_tip_distribution() assert actual == expected + @pytest.mark.xfail(reason="#3399") def test_get_tip_distribution_handles_big_tips(self): self.bob.update_number('plural') carl = self.make_participant('carl', claimed_time='now', last_bill_result='') @@ -73,6 +77,7 @@ def test_get_tip_distribution_handles_big_tips(self): actual = self.bob.get_tip_distribution() assert actual == expected + @pytest.mark.xfail(reason="#3399") def test_get_tip_distribution_ignores_bad_cc(self): bad_cc = self.make_participant('bad_cc', claimed_time='now', last_bill_result='Failure!') self.alice.set_tip_to(self.bob, '1.00') @@ -82,6 +87,7 @@ def test_get_tip_distribution_ignores_bad_cc(self): actual = self.bob.get_tip_distribution() assert actual == expected + @pytest.mark.xfail(reason="#3399") def test_get_tip_distribution_ignores_missing_cc(self): missing_cc = self.make_participant('missing_cc', claimed_time='now') self.alice.set_tip_to(self.bob, '1.00') diff --git a/tests/py/test_subscription_json.py b/tests/py/test_subscription_json.py new file mode 100644 index 0000000000..39b8a407a5 --- /dev/null +++ b/tests/py/test_subscription_json.py @@ -0,0 +1,68 @@ +from __future__ import print_function, unicode_literals + +import json + +from aspen.utils import utcnow +from gratipay.testing import Harness + + +class TestTipJson(Harness): + + def test_api_returns_amount_and_totals(self): + "Test that we get correct amounts and totals back on POSTs to subscription.json" + + # First, create some test data + # We need accounts + now = utcnow() + self.make_team("A", is_approved=True) + self.make_team("B", is_approved=True) + self.make_participant("alice", claimed_time=now, last_bill_result='') + + # Then, add a $1.50 and $3.00 subscription + response1 = self.client.POST( "/A/subscription.json" + , {'amount': "1.50"} + , auth_as='alice' + ) + + response2 = self.client.POST( "/B/subscription.json" + , {'amount': "3.00"} + , auth_as='alice' + ) + + # Confirm we get back the right amounts. + first_data = json.loads(response1.body) + second_data = json.loads(response2.body) + assert first_data['amount'] == "1.50" + assert first_data['total_giving'] == "1.50" + assert second_data['amount'] == "3.00" + assert second_data['total_giving'] == "4.50" + + + def test_setting_subscription_out_of_range_gets_bad_amount(self): + self.make_team(is_approved=True) + self.make_participant("alice", claimed_time='now', last_bill_result='') + + response = self.client.PxST( "/TheATeam/subscription.json" + , {'amount': "1010.00"} + , auth_as='alice' + ) + assert "bad amount" in response.body + assert response.code == 400 + + response = self.client.PxST( "/TheATeam/subscription.json" + , {'amount': "-1.00"} + , auth_as='alice' + ) + assert "bad amount" in response.body + assert response.code == 400 + + + def test_subscribing_to_rejected_team_fails(self): + self.make_team(is_approved=False) + self.make_participant("alice", claimed_time='now', last_bill_result='') + response = self.client.PxST( "/TheATeam/subscription.json" + , {'amount': "10.00"} + , auth_as='alice' + ) + assert "unapproved team" in response.body + assert response.code == 400 diff --git a/tests/py/test_take.py b/tests/py/test_take.py index a079299ff7..940701795d 100644 --- a/tests/py/test_take.py +++ b/tests/py/test_take.py @@ -2,6 +2,7 @@ from decimal import Decimal as D +import pytest from gratipay.testing import Harness from gratipay.models.participant import Participant @@ -81,6 +82,7 @@ def test_if_last_week_is_less_than_a_dollar_can_increase_to_a_dollar(self): actual = team.set_take_for(alice, D('42.00'), team) assert actual == 1 + @pytest.mark.xfail(reason="#3399") def test_get_members(self): team = self.make_team() alice = self.make_participant('alice', claimed_time='now') @@ -92,6 +94,7 @@ def test_get_members(self): assert members[0]['take'] == 42 assert members[0]['balance'] == 58 + @pytest.mark.xfail(reason="#3399") def test_compute_actual_takes_counts_the_team_balance(self): team = self.make_team(balance=D('59.46'), giving=D('7.15')) alice = self.make_participant('alice', claimed_time='now') @@ -106,6 +109,7 @@ def test_compute_actual_takes_counts_the_team_balance(self): assert takes[1]['actual_amount'] == 0 assert takes[1]['balance'] == D('10.31') + @pytest.mark.xfail(reason="#3399") def test_compute_actual_takes_gives_correct_final_balance(self): team = self.make_team(balance=D('53.72')) alice = self.make_participant('alice', claimed_time='now') @@ -120,6 +124,7 @@ def test_compute_actual_takes_gives_correct_final_balance(self): assert takes[1]['actual_amount'] == 14 assert takes[1]['balance'] == D('67.72') + @pytest.mark.xfail(reason="#3399") def test_taking_and_receiving_are_updated_correctly(self): team = self.make_team() alice = self.make_participant('alice', claimed_time='now') @@ -134,6 +139,7 @@ def test_taking_and_receiving_are_updated_correctly(self): assert alice.taking == 50 assert alice.receiving == 60 + @pytest.mark.xfail(reason="#3399") def test_taking_is_zero_for_team(self): team = self.make_team() alice = self.make_participant('alice', claimed_time='now') @@ -142,6 +148,7 @@ def test_taking_is_zero_for_team(self): assert team.taking == 0 assert team.receiving == 100 + @pytest.mark.xfail(reason="#3399") def test_but_team_can_take_from_other_team(self): a_team = self.make_team('A Team', claimed_time='now') b_team = self.make_team('B Team', claimed_time='now') @@ -152,6 +159,7 @@ def test_but_team_can_take_from_other_team(self): assert b_team.taking == 1 assert b_team.receiving == 101 + @pytest.mark.xfail(reason="#3399") def test_changes_to_team_receiving_affect_members_take(self): team = self.make_team() alice = self.make_participant('alice', claimed_time='now') @@ -162,6 +170,7 @@ def test_changes_to_team_receiving_affect_members_take(self): alice = Participant.from_username('alice') assert alice.receiving == alice.taking == 10 + @pytest.mark.xfail(reason="#3399") def test_changes_to_others_take_affects_members_take(self): team = self.make_team() @@ -179,6 +188,7 @@ def test_changes_to_others_take_affects_members_take(self): # But get_members still uses nominal amount assert [m['take'] for m in team.get_members(alice)] == [60, 42, 0] + @pytest.mark.xfail(reason="#3399") def test_changes_to_others_take_can_increase_members_take(self): team = self.make_team() diff --git a/tests/py/test_tip_distribution_json.py b/tests/py/test_tip_distribution_json.py index 485f2bc251..628bd25058 100644 --- a/tests/py/test_tip_distribution_json.py +++ b/tests/py/test_tip_distribution_json.py @@ -2,11 +2,13 @@ import json +import pytest from gratipay.testing import Harness class Tests(Harness): + @pytest.mark.xfail(reason="#3399") def test_tip_distribution_json_gives_tip_distribution(self): alice = self.make_participant('alice', claimed_time='now', last_bill_result='') bob = self.make_participant('bob', claimed_time='now', number='plural') diff --git a/tests/py/test_tip_json.py b/tests/py/test_tip_json.py deleted file mode 100644 index c8179e80d9..0000000000 --- a/tests/py/test_tip_json.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import print_function, unicode_literals - -import json - -import pytest -from aspen.utils import utcnow -from gratipay.testing import Harness - - -class TestTipJson(Harness): - - @pytest.mark.xfail(reason="migrating to Teams; #3399") - def test_get_amount_and_total_back_from_api(self): - "Test that we get correct amounts and totals back on POSTs to tip.json" - - # First, create some test data - # We need accounts - now = utcnow() - self.make_participant("test_tippee1", claimed_time=now) - self.make_participant("test_tippee2", claimed_time=now) - self.make_participant("test_tipper", claimed_time=now, last_bill_result='') - - # Then, add a $1.50 and $3.00 tip - response1 = self.client.POST( "/~test_tippee1/tip.json" - , {'amount': "1.00"} - , auth_as='test_tipper' - ) - - response2 = self.client.POST( "/~test_tippee2/tip.json" - , {'amount': "3.00"} - , auth_as='test_tipper' - ) - - # Confirm we get back the right amounts. - first_data = json.loads(response1.body) - second_data = json.loads(response2.body) - assert first_data['amount'] == "1.00" - assert first_data['total_giving'] == "1.00" - assert second_data['amount'] == "3.00" - assert second_data['total_giving'] == "4.00" - - @pytest.mark.xfail(reason="migrating to Teams; #3399") - def test_set_tip_out_of_range(self): - now = utcnow() - self.make_participant("alice", claimed_time=now) - self.make_participant("bob", claimed_time=now) - - response = self.client.PxST( "/~alice/tip.json" - , {'amount': "110.00"} - , auth_as='bob' - ) - assert "bad amount" in response.body - assert response.code == 400 - - response = self.client.PxST( "/~alice/tip.json" - , {'amount': "-1.00"} - , auth_as='bob' - ) - assert "bad amount" in response.body - assert response.code == 400 - - - @pytest.mark.xfail(reason="migrating to Teams; #3399") - def test_tip_to_unclaimed(self): - now = utcnow() - alice = self.make_elsewhere('twitter', 1, 'alice') - self.make_participant("bob", claimed_time=now) - response = self.client.POST( "/~%s/tip.json" % alice.participant.username - , {'amount': "10.00"} - , auth_as='bob' - ) - data = json.loads(response.body) - assert response.code == 200 - assert data['amount'] == "10.00" diff --git a/www/%team/index.html.spt b/www/%team/index.html.spt index 6ca978b64f..2ef53576e4 100644 --- a/www/%team/index.html.spt +++ b/www/%team/index.html.spt @@ -29,6 +29,10 @@ title = name = team.name {% endblock %} +{% block sidebar %} +{% include "templates/your-payment.html" %} +{% endblock %} + {% block content %}
{% if team.is_approved in (None, False) %} @@ -58,8 +62,6 @@ title = name = team.name {% endblock %} {% block scripts %} -{% if user.participant == team.owner %} - -{% endif %} + {{ super() }} {% endblock %} diff --git a/www/%team/subscription.json.spt b/www/%team/subscription.json.spt new file mode 100644 index 0000000000..088b34fcb6 --- /dev/null +++ b/www/%team/subscription.json.spt @@ -0,0 +1,63 @@ +"""Get or change the authenticated user's subscription to this team. +""" +from decimal import InvalidOperation + +from aspen import Response +from babel.numbers import NumberFormatError +from gratipay.exceptions import BadAmount +from gratipay.utils import get_team + +[-----------------------------------------------------------------------------] + +if user.ANON: + raise Response(403, _("Please sign in first")) + +else: + out = {} + + # Get team. + # ========= + + team = get_team(state) + if team.is_closed or not team.is_approved: + raise Response(400, "unapproved team") + + + # Get and maybe set amount. + # ========================= + + if request.method == 'POST' and 'amount' in request.body: + try: + out = user.participant.set_subscription_to(team, parse_decimal(request.body['amount'])) + except (InvalidOperation, ValueError, BadAmount, NumberFormatError): + raise Response(400, "bad amount") + else: + out = user.participant.get_subscription_to(team) + + amount = out['amount'] + total_giving = user.participant.giving + total_receiving = user.participant.receiving + + out["amount"] = str(amount) + out["amount_l"] = format_currency(amount, 'USD') + out["msg"] = _("Payment changed to {0} per week. ", out["amount_l"]) + out["msg"] += _("Thank you so much for supporting {0}!", team.name) + out["nsupporters"] = team.nsupporters + out["team_id"] = team.id + out["total_giving"] = str(total_giving) + out["total_giving_l"] = format_currency(total_giving, 'USD') + out["total_receiving"] = str(total_receiving) + out["total_receiving_l"] = format_currency(total_receiving, 'USD') + + total_receiving_team = team.receiving + out["total_receiving_team"] = str(total_receiving_team) + out["total_receiving_team_l"] = format_currency(total_receiving_team, 'USD') + + if 'ctime' in out: + out["ctime"] = str(out['ctime']) + out["mtime"] = str(out['mtime']) + else: + out["ctime"] = out["mtime"] = None + +[---] application/json via json_dump +out diff --git a/www/~/%username/routes/bank-account.html.spt b/www/~/%username/routes/bank-account.html.spt index 42baaa4ec8..3be4475610 100644 --- a/www/~/%username/routes/bank-account.html.spt +++ b/www/~/%username/routes/bank-account.html.spt @@ -45,7 +45,7 @@ title = _("Bank Account") {% if not user.ANON %} {% endif %} diff --git a/www/~/%username/routes/credit-card.html.spt b/www/~/%username/routes/credit-card.html.spt index dbc1be9584..c163256a28 100644 --- a/www/~/%username/routes/credit-card.html.spt +++ b/www/~/%username/routes/credit-card.html.spt @@ -55,7 +55,7 @@ title = _("Credit Card") {% if not user.ANON %} diff --git a/www/~/%username/routes/paypal.html.spt b/www/~/%username/routes/paypal.html.spt index e7770e24b2..2af0a3e87f 100644 --- a/www/~/%username/routes/paypal.html.spt +++ b/www/~/%username/routes/paypal.html.spt @@ -39,7 +39,7 @@ title = _("PayPal Account") {% if not user.ANON %} {% endif %} diff --git a/www/~/%username/giving/index.html.spt b/www/~/%username/subscriptions/index.html.spt similarity index 54% rename from www/~/%username/giving/index.html.spt rename to www/~/%username/subscriptions/index.html.spt index 8da63e02a2..51867af7f2 100644 --- a/www/~/%username/giving/index.html.spt +++ b/www/~/%username/subscriptions/index.html.spt @@ -5,50 +5,50 @@ from datetime import timedelta [-----------------------------------------------------------------------------] participant = get_participant(state, restrict=True) -tips, total = participant.get_giving_for_profile() +subscriptions, total = participant.get_subscriptions_for_profile() title = participant.username -subhead = _("Giving") +subhead = _("Subscriptions") recently = utcnow() - timedelta(days=30) -cancelled_tips = [x for x in tips if x.amount == 0 and x.mtime >= recently] +cancelled_subscriptions = [x for x in subscriptions if x.amount == 0 and x.mtime >= recently] # don't filter until after cancelled are looked at -tips = [t for t in tips if t.amount > 0] +subscriptions = [s for s in subscriptions if s.amount > 0] tabs = { 'active': { - 'tips': tips, - 'ntips': len(tips), + 'subscriptions': subscriptions, + 'nsubscriptions': len(subscriptions), 'name': _("Active"), 'note': None, 'total': total }, 'cancelled': { - 'tips': cancelled_tips, - 'ntips': len(cancelled_tips), + 'subscriptions': cancelled_subscriptions, + 'nsubscriptions': len(cancelled_subscriptions), 'name': _("Cancelled"), - 'note': _("These are tips that you recently cancelled."), + 'note': _("These are subscriptions that you recently cancelled."), 'total': 0 } } [-----------------------------------------------------------------------------] -{% from 'templates/giving-table.html' import giving_table with context %} +{% from 'templates/subscriptions-table.html' import subscriptions_table with context %} {% extends "templates/profile.html" %} {% block scripts %} - + {{ super() }} {% endblock %} {% block content %} -
+
-

{{ _("You give {0} every week.", format_currency(participant.giving, "USD")) }}

+

{{ _("You pay {0} every week.", format_currency(participant.giving, "USD")) }}

-

{{ _("Tips") }}

+

{{ _("Subscriptions") }}

{{ _("Recipient") }}{{ _("Team") }}{{ _("Amount ($)") }}
- {% if state == 'unclaimed' %} - - {{ tip.user_name }} - {% else %} - {{ tip.tippee }} - {% endif %} + + {{ subscription.team_name }} {{ tip.amount }}{{ subscription.amount }}{{ to_age(tip.mtime) }}{{ to_age(tip.ctime) }}{{ to_age(subscription.mtime) }}{{ to_age(subscription.ctime) }}