Repository: gratipay/gratipay.com

Issue:
Email me whenever you bill my credit card
I just got charged $10, which is fine, but you really need to send me a receipt via email when this happens.  And for extra credit, warn me the day before in a separate email.

This is why I think you need to do this:
1.  Everybody else sends email receipts.  That's really the main reason.  It's a convention now that if you charge somebody's card, especially in an offline background process, you send an email receipt.
2.  It is way easier for me to forward an email to a bookkeeper than it is to log in to a site, save an HTML page and then forward that.  I consider gittip donations a marketing-related business expense.
3.   If you warn me first, it might reduce chargebacks / fraud complaints / failed charges.  You can put a link in the warning email back to my account so that I can immediately cancel my giving.

Keep up the good work!



Discussion:
This links with #89 

+1

+1

+1

Thanks for the ticket, @mw44118.

+1
Is there an email provider anyone recommends? Mailchimp?

Chad talked about that here: https://github.com/gittip/www.gittip.com/issues/89#issuecomment-17388924

+2 via @iElectric over on #1606:

> As part of funding campain for https://gittip.com/kiberpipa we got two requests to let users know (with email or sms) that funds were deducted from their credit card.
> 
> I told them history is available from "History/Giving" tab in their profile, but they weren't able to figure that part of for themselves.

after reading #1022, i am not sure this would actually solve the use case presented here. At least it wouldn't solve the "we're going to charge you tomorrow" reminder email

+1.

I reticketed the part about the weekly reminder email as #2224, since that can be done independently of this (and will likely be simpler than this one).

I believe a receipt (with both gifts given _and_ received shown) will handle the rest of this issue, as well as all of #1022.

This is currently blocked on [#89 sign up with or link email address](https://github.com/gittip/www.gittip.com/issues/89).

A receipt is important AND a reminder in advance is important.  A receipt
is nice for people that are giving to gittip and want to track that as an
expense.

Matt

On Wed, Apr 2, 2014 at 1:27 AM, Nik Markwell notifications@github.comwrote:

> This is currently blocked on #89 sign up with or link email addresshttps://github.com/gittip/www.gittip.com/issues/89
> .
> 
> ## 
> 
> Reply to this email directly or view it on GitHubhttps://github.com/gittip/www.gittip.com/issues/1124#issuecomment-39290055
> .

## 

W. Matthew Wilson
principal
216 Software, LLC
matt@216software.com
http://216software.com

The email should also be sent if the charge has failed. cf #2666, cc @wrought

Modifying title to reflect expanded focus: not just receipts (success) but also failure notices.

+1

It would also be nice to set a billing email address.

This is currently blocked by #2312.

Implicit +1 from @ded on [Twitter](https://twitter.com/ded/status/503689241071599616). :-(

+1 https://gratipay.freshdesk.com/helpdesk/tickets/1733


Resolving patch:
diff --git a/gratipay/billing/exchanges.py b/gratipay/billing/exchanges.py
--- 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
--- 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
--- 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.
         """


Test patch:
diff --git a/tests/py/test_billing_payday.py b/tests/py/test_billing_payday.py
--- 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
--- 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')