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:
+
{{ _("Close") }}