diff --git a/emails/charge_failed.spt b/emails/charge_failed.spt new file mode 100644 index 0000000000..192ce2c51c --- /dev/null +++ b/emails/charge_failed.spt @@ -0,0 +1,28 @@ +{{ _("Oh no! A problem supporting {0}!", top_tippee) }} + +[---] text/html +{{ _("We tried to charge your credit card {0} today, to fund your ongoing support for {1}, but the charge failed with this message:", + format_currency(exchange.amount + exchange.fee, 'USD'), + ('{1}'|safe).format( + participant.profile_url+'giving/', + top_tippee if ntippees == 1 else ngettext('{0} and {n} other', + '{0} and {n} others', + ntippees - 1, + top_tippee))) }} + +
{{ exchange.note }}
+ +{{ _("Fix Credit Card") }} + +[---] text/plain +{{ _("We tried to charge your credit card {0} today, to fund your ongoing support for {1}, but the charge failed with this message:", + format_currency(exchange.amount + exchange.fee, 'USD'), + top_tippee if ntippees == 1 else ngettext('{0} and {n} other', + '{0} and {n} others', + ntippees - 1, + top_tippee)) }} + +{{ exchange.note }} + +{{ _("Follow this link to fix your credit card:") }} {{ participant.profile_url+'routes/credit-card.html' }} diff --git a/emails/charge_succeeded.spt b/emails/charge_succeeded.spt new file mode 100644 index 0000000000..601b4df4cc --- /dev/null +++ b/emails/charge_succeeded.spt @@ -0,0 +1,27 @@ +{{ _("Thanks for supporting {0}!", top_tippee) }} + +[---] text/html +{{ _("We charged your credit card {0} today, to fund your ongoing support for {1}. Thanks for using Gratipay!", + format_currency(exchange.amount + exchange.fee, 'USD'), + ('{1}'|safe).format( + participant.profile_url+'giving/', + top_tippee if ntippees == 1 else ngettext('{0} and {n} other', + '{0} and {n} others', + ntippees - 1, + top_tippee))) }} +
+
+{{ _("View Receipt") }} + +[---] text/plain +{{ _("We charged your credit card {0} today, to fund your ongoing support for {1}. Thanks for using Gratipay!", + format_currency(exchange.amount + exchange.fee, 'USD'), + top_tippee if ntippees == 1 else ngettext('{0} and {n} other', + '{0} and {n} others', + ntippees - 1, + top_tippee)) }} + +{{ _("Follow this link to view your receipt:") }} {{ '{}receipts/{}.html'.format(participant.profile_url, exchange.id) }} + +{{ _("Follow this link if you want to view or modify your payments:") }} {{ participant.profile_url+'giving/' }} diff --git a/emails/verification.spt b/emails/verification.spt index 880d38e0ab..c58eccb382 100644 --- a/emails/verification.spt +++ b/emails/verification.spt @@ -6,7 +6,7 @@ ('{0}'|safe).format(username)) }}

-{{ _("Yes, proceed!") }} +{{ _("Yes, proceed!") }} [---] text/plain {{ _("We've received a request to connect {0} to the {1} account on Gratipay. Sound familiar?", diff --git a/gratipay/billing/exchanges.py b/gratipay/billing/exchanges.py index 482fb24450..35775631e0 100644 --- a/gratipay/billing/exchanges.py +++ b/gratipay/billing/exchanges.py @@ -266,7 +266,7 @@ def _prep_hit(unrounded): return cents, amount_str, upcharged, fee -def record_exchange(db, route, amount, fee, participant, status, error=''): +def record_exchange(db, route, amount, fee, participant, status, error=None): """Given a Bunch of Stuff, return an int (exchange_id). Records in the exchanges table have these characteristics: @@ -284,10 +284,10 @@ def record_exchange(db, route, amount, fee, participant, status, error=''): exchange_id = cursor.one(""" INSERT INTO exchanges - (amount, fee, participant, status, route) - VALUES (%s, %s, %s, %s, %s) + (amount, fee, participant, status, route, note) + VALUES (%s, %s, %s, %s, %s, %s) RETURNING id - """, (amount, fee, participant.username, status, route.id)) + """, (amount, fee, participant.username, status, route.id, error)) if status == 'failed': propagate_exchange(cursor, participant, route, error, 0) diff --git a/gratipay/billing/payday.py b/gratipay/billing/payday.py index f0c8bf4d47..07c8babe55 100644 --- a/gratipay/billing/payday.py +++ b/gratipay/billing/payday.py @@ -141,6 +141,7 @@ def run(self): self.mark_stage_done() self.end() + self.notify_participants() _end = aspen.utils.utcnow() _delta = _end - _start @@ -706,6 +707,56 @@ def end(self): """, default=NoPayday).replace(tzinfo=aspen.utils.utc) + def notify_participants(self): + ts_start, ts_end = self.ts_start, self.ts_end + exchanges = self.db.all(""" + SELECT e.id, amount, fee, note, status, p.*::participants AS participant + FROM exchanges e + JOIN participants p ON e.participant = p.username + WHERE "timestamp" >= %(ts_start)s + AND "timestamp" < %(ts_end)s + AND amount > 0 + AND p.notify_charge > 0 + """, locals()) + for e in exchanges: + if e.status not in ('failed', 'succeeded'): + log('exchange %s has an unexpected status: %s' % (e.id, e.status)) + continue + i = 1 if e.status == 'failed' else 2 + p = e.participant + if p.notify_charge & i == 0: + continue + username = p.username + ntippees, top_tippee = self.db.one(""" + WITH tippees AS ( + SELECT p.username, amount + FROM ( SELECT DISTINCT ON (tippee) tippee, amount + FROM tips + WHERE mtime < %(ts_start)s + AND tipper = %(username)s + ORDER BY tippee, mtime DESC + ) t + JOIN participants p ON p.username = t.tippee + WHERE t.amount > 0 + AND (p.goal IS NULL or p.goal >= 0) + AND p.is_suspicious IS NOT true + AND p.claimed_time < %(ts_start)s + ) + SELECT ( SELECT count(*) FROM tippees ) AS ntippees + , ( SELECT username + FROM tippees + ORDER BY amount DESC + LIMIT 1 + ) AS top_tippee + """, locals()) + p.queue_email( + 'charge_'+e.status, + exchange=dict(id=e.id, amount=e.amount, fee=e.fee, note=e.note), + ntippees=ntippees, + top_tippee=top_tippee, + ) + + # Record-keeping. # =============== diff --git a/gratipay/models/participant.py b/gratipay/models/participant.py index 98d06aa711..0b658e0a3f 100644 --- a/gratipay/models/participant.py +++ b/gratipay/models/participant.py @@ -623,7 +623,13 @@ def remove_email(self, address): (self.username, address)) def send_email(self, spt_name, **context): + context['participant'] = self context['username'] = self.username + context['button_style'] = ( + "color: #fff; text-decoration:none; display:inline-block; " + "padding: 0 15px; background: #396; white-space: nowrap; " + "font: normal 14px/40px Arial, sans-serif; border-radius: 3px" + ) context.setdefault('include_unsubscribe', True) email = context.setdefault('email', self.email_address) langs = i18n.parse_accept_lang(self.email_lang or 'en') @@ -779,6 +785,13 @@ def get_cryptocoin_addresses(self): # Random Junk # =========== + @property + def profile_url(self): + scheme = gratipay.canonical_scheme + host = gratipay.canonical_host + username = self.username + return '{scheme}://{host}/{username}/'.format(**locals()) + def get_teams(self): """Return a list of teams this user is a member of. """ diff --git a/js/gratipay/settings.js b/js/gratipay/settings.js index d293a539e9..d5cefc5548 100644 --- a/js/gratipay/settings.js +++ b/js/gratipay/settings.js @@ -112,22 +112,24 @@ Gratipay.settings.init = function() { }); // Wire up notification preferences - // ============================== + // ================================ $('.email-notifications input').click(function(e) { var field = $(e.target).data('field'); + var bits = $(e.target).data('bits') || 1; jQuery.ajax( { url: '../emails/notifications.json' , type: 'POST' - , data: {toggle: field} + , data: {toggle: field, bits: bits} , dataType: 'json' , success: function(data) { - if (data.msg) { - Gratipay.notification(data.msg, 'success'); - } - $(e.target).attr('checked', data[field]); + Gratipay.notification(data.msg, 'success'); + $(e.target).attr('checked', data.new_value & bits) } - , error: Gratipay.error + , error: [ + Gratipay.error, + function(){ $(e.target).attr('checked', !$(e.target).attr('checked')) }, + ] }); }); diff --git a/sql/branch.sql b/sql/branch.sql new file mode 100644 index 0000000000..577a220d79 --- /dev/null +++ b/sql/branch.sql @@ -0,0 +1,7 @@ +BEGIN; + ALTER TABLE participants ADD COLUMN notify_charge int DEFAULT 3; + ALTER TABLE participants + ALTER COLUMN notify_on_opt_in DROP DEFAULT, + ALTER COLUMN notify_on_opt_in TYPE int USING notify_on_opt_in::int, + ALTER COLUMN notify_on_opt_in SET DEFAULT 1; +END; diff --git a/tests/py/test_billing_payday.py b/tests/py/test_billing_payday.py index 89a5f06e0a..5bb4a82730 100644 --- a/tests/py/test_billing_payday.py +++ b/tests/py/test_billing_payday.py @@ -12,6 +12,7 @@ from gratipay.models.participant import Participant from gratipay.testing import Foobar, Harness from gratipay.testing.balanced import BalancedHarness +from gratipay.testing.emails import EmailHarness class TestPayday(BalancedHarness): @@ -245,13 +246,11 @@ def test_end_raises_NoPayday(self): @mock.patch('gratipay.billing.payday.log') @mock.patch('gratipay.billing.payday.Payday.payin') - @mock.patch('gratipay.billing.payday.Payday.end') - def test_payday(self, end, payin, log): + def test_payday(self, payin, log): greeting = 'Greetings, program! It\'s PAYDAY!!!!' Payday.start().run() log.assert_any_call(greeting) assert payin.call_count == 1 - assert end.call_count == 1 class TestPayin(BalancedHarness): @@ -529,3 +528,24 @@ def test_payout_ach_error(self, ach_credit): Payday.start().payout() payday = self.fetch_payday() assert payday['nach_failing'] == 1 + + +class TestNotifyParticipants(EmailHarness): + + def test_it_notifies_participants(self): + kalel = self.make_participant('kalel', claimed_time='now', is_suspicious=False, + email_address='kalel@example.net', notify_charge=3) + lily = self.make_participant('lily', claimed_time='now', is_suspicious=False) + kalel.set_tip_to(lily, 10) + + for status in ('failed', 'succeeded'): + payday = Payday.start() + self.make_exchange('balanced-cc', 10, 0, kalel, status) + payday.end() + payday.notify_participants() + + emails = self.db.one('SELECT * FROM email_queue') + assert emails.spt_name == 'charge_'+status + + Participant.dequeue_emails() + assert self.get_last_email()['to'][0]['email'] == 'kalel@example.net' diff --git a/tests/py/test_email_notifs.py b/tests/py/test_email_notifs.py index 78b1f5f7d8..a49c681922 100644 --- a/tests/py/test_email_notifs.py +++ b/tests/py/test_email_notifs.py @@ -49,7 +49,7 @@ def test_take_over_sends_notifications_to_patrons(self): def test_opt_in_notification_includes_unsubscribe(self): carl_twitter = self.make_elsewhere('twitter', 1, 'carl') - roy = self.make_participant('roy', claimed_time='now', email_address='roy@example.com', notify_on_opt_in=True) + roy = self.make_participant('roy', claimed_time='now', email_address='roy@example.com', notify_on_opt_in=1) roy.set_tip_to(carl_twitter.participant.username, '100') AccountElsewhere.from_user_name('twitter', 'carl').opt_in('carl') diff --git a/www/%username/emails/notifications.json.spt b/www/%username/emails/notifications.json.spt index eff46e23f2..6a35c56dab 100644 --- a/www/%username/emails/notifications.json.spt +++ b/www/%username/emails/notifications.json.spt @@ -6,15 +6,15 @@ request.allow("POST") participant = get_participant(state, restrict=True) field = request.body.get("toggle") -if field not in ["notify_on_opt_in"]: +if field not in ["notify_charge", "notify_on_opt_in"]: raise Response(400, "Invalid notification preference.") new_value = website.db.one(""" UPDATE participants - SET {0}=not {0} - WHERE username=%s + SET {0} = {0} # %s + WHERE id = %s RETURNING {0} -""".format(field), (participant.username,)) +""".format(field), (request.body.get("bits", 1), participant.id)) assert new_value is not None [---] application/json via json_dump diff --git a/www/%username/settings/index.html.spt b/www/%username/settings/index.html.spt index 8a079610db..9b13f5ba43 100644 --- a/www/%username/settings/index.html.spt +++ b/www/%username/settings/index.html.spt @@ -202,15 +202,30 @@ emails = participant.get_emails()

{{ _("Notifications") }}

-

- Send me notifications via email when: +

+ {{ _("Send me notifications via email:") }}
-

+
+ {{ _("When my credit card is charged:") }} +
+ +
+ +
+

{{ _("Close") }}