Repository: liberapay/liberapay.com

Issue:
SEPA Direct Debits
Stripe has enabled SEPA Direct Debits on our account, we need to finish the implementation on our side.


Discussion:

Resolving patch:
diff --git a/liberapay/constants.py b/liberapay/constants.py
--- a/liberapay/constants.py
+++ b/liberapay/constants.py
@@ -154,17 +154,14 @@ def __missing__(self, currency):
 SEPA = EUROZONE | set("BG CH CZ DK GB GI HR HU IS LI MC NO PL RO SE".split())
 
 EVENTS = [
-    Event('income', 1, _("When I receive money")),
+    Event('income', 1, _("Every week as long as I am receiving donations")),
     Event('donate_reminder', 2, _("When it's time to renew my donations")),
     Event('withdrawal_created', 4, _("When a transfer to my bank account is initiated")),
     Event('withdrawal_failed', 8, _("When a transfer to my bank account fails")),
     Event('pledgee_joined', 16, _("When someone I pledge to joins Liberapay")),
     Event('team_invite', 32, _("When someone invites me to join a team")),
-    Event('payin_bankwire_failed', 64, _("When a bank wire transfer to my Liberapay wallet fails")),
-    Event('payin_bankwire_succeeded', 128, _("When a bank wire transfer to my Liberapay wallet succeeds")),
-    Event('payin_bankwire_expired', 256, _("When a bank wire transfer to my Liberapay wallet expires")),
-    Event('payin_directdebit_failed', 512, _("When a direct debit from my bank account fails")),
-    Event('payin_directdebit_succeeded', 1024, _("When a direct debit from my bank account succeeds")),
+    Event('payin_failed', 2**11, _("When a payment I initiated fails")),
+    Event('payin_succeeded', 2**12, _("When a payment I initiated succeeds")),
 ]
 check_bits([e.bit for e in EVENTS])
 EVENTS = OrderedDict((e.name, e) for e in EVENTS)
diff --git a/liberapay/payin/stripe.py b/liberapay/payin/stripe.py
--- a/liberapay/payin/stripe.py
+++ b/liberapay/payin/stripe.py
@@ -1,3 +1,5 @@
+# coding: utf8
+
 from __future__ import absolute_import, division, print_function, unicode_literals
 
 from decimal import Decimal
@@ -7,6 +9,7 @@
 
 from ..models.exchange_route import ExchangeRoute
 from ..utils.currencies import Money
+from ..website import website
 from .common import update_payin, update_payin_transfer
 
 
@@ -44,6 +47,10 @@ def repr_charge_error(charge):
     return '%s (code %s)' % (charge.failure_message, charge.failure_code)
 
 
+def get_partial_iban(sepa_debit):
+    return '%s⋯%s' % (sepa_debit.country, sepa_debit.last4)
+
+
 def destination_charge(db, payin, payer, statement_descriptor):
     """Create a Destination Charge.
 
@@ -79,23 +86,44 @@ def destination_charge(db, payin, payer, statement_descriptor):
     except stripe.error.StripeError as e:
         return update_payin(db, payin.id, '', 'failed', repr_stripe_error(e))
     except Exception as e:
-        from liberapay.website import website
         website.tell_sentry(e, {})
         return update_payin(db, payin.id, '', 'failed', str(e))
+    if route.network == 'stripe-sdd' and payin.status == 'pending':
+        sepa_debit = stripe.Source.retrieve(route.address).sepa_debit
+        payer.notify(
+            'payin_sdd_created',
+            payin_amount=payin.amount,
+            bank_name=sepa_debit.bank_name,
+            partial_bank_account_number=get_partial_iban(sepa_debit),
+            mandate_url=sepa_debit.mandate_url,
+            mandate_id=sepa_debit.mandate_reference,
+            mandate_creation_date=route.ctime.date(),
+            creditor_identifier=website.app_conf.sepa_creditor_identifier,
+            statement_descriptor=charge.statement_descriptor,
+        )
+    return settle_destination_charge(db, payin, charge, pt)
 
-    bt = charge.balance_transaction
-    amount_settled = int_to_Money(bt.amount, bt.currency)
-    fee = int_to_Money(bt.fee, bt.currency)
-    net_amount = amount_settled - fee
 
-    if destination:
-        tr = stripe.Transfer.retrieve(charge.transfer)
-        tr.reversals.create(
-            amount=bt.fee,
-            description="Stripe fee",
-            metadata={'payin_id': payin.id},
-            idempotency_key='payin_fee_%i' % payin.id,
-        )
+def settle_destination_charge(db, payin, charge, pt):
+    """Record the result of a charge, and recover the fee.
+    """
+    if charge.balance_transaction:
+        bt = charge.balance_transaction
+        amount_settled = int_to_Money(bt.amount, bt.currency)
+        fee = int_to_Money(bt.fee, bt.currency)
+        net_amount = amount_settled - fee
+
+        if charge.transfer:
+            tr = stripe.Transfer.retrieve(charge.transfer)
+            if tr.amount_reversed == 0:
+                tr.reversals.create(
+                    amount=bt.fee,
+                    description="Stripe fee",
+                    metadata={'payin_id': payin.id},
+                    idempotency_key='payin_fee_%i' % payin.id,
+                )
+    else:
+        amount_settled, fee, net_amount = None, None, payin.amount
 
     status = charge.status
     error = repr_charge_error(charge)
diff --git a/liberapay/wireup.py b/liberapay/wireup.py
--- a/liberapay/wireup.py
+++ b/liberapay/wireup.py
@@ -254,6 +254,7 @@ class AppConf(object):
         ses_feedback_queue_url=str,
         ses_region=str,
         send_newsletters_every=int,
+        sepa_creditor_identifier=str,
         show_sandbox_warning=bool,
         socket_timeout=float,
         smtp_host=str,
@@ -261,6 +262,7 @@ class AppConf(object):
         smtp_username=str,
         smtp_password=str,
         smtp_use_tls=bool,
+        stripe_callback_secret=str,
         stripe_connect_id=str,
         stripe_publishable_key=str,
         stripe_secret_key=str,


Test patch:
diff --git a/tests/py/test_callbacks.py b/tests/py/test_callbacks.py
--- a/tests/py/test_callbacks.py
+++ b/tests/py/test_callbacks.py
@@ -2,11 +2,10 @@
 
 from mock import patch
 
-from mangopay.resources import BankWirePayOut, BankWirePayIn, Dispute, PayIn, Refund
+from mangopay.resources import BankWirePayOut, Dispute, PayIn, Refund
 from mangopay.utils import Reason
 
 from liberapay.billing.transactions import Money, record_exchange, transfer
-from liberapay.models.exchange_route import ExchangeRoute
 from liberapay.security.csrf import CSRF_TOKEN
 from liberapay.testing import EUR
 from liberapay.testing.emails import EmailHarness
@@ -217,141 +216,3 @@ def test_payout_refund_callback(self, R_Get, PO_Get):
                 assert 'BECAUSE 42' in emails[0]['text']
             self.db.self_check()
             homer.update_status('active')  # reset for next loop run
-
-    @patch('mangopay.resources.PayIn.get')
-    def test_payin_bank_wire_callback(self, Get):
-        homer = self.homer
-        route = ExchangeRoute.insert(homer, 'mango-bw', 'x', 'chargeable')
-        cases = (
-            ('failed', '000001', 'FOO'),
-            ('failed', '101109', 'The payment period has expired'),
-            ('succeeded', '000000', None),
-        )
-        for status, result_code, error in cases:
-            status_up = status.upper()
-            e_id = record_exchange(self.db, route, EUR(11), EUR(0), EUR(0), homer, 'pre').id
-            assert homer.balance == 0
-            homer.close(None)
-            assert homer.status == 'closed'
-            qs = "EventType=PAYIN_NORMAL_"+status_up+"&RessourceId=123456790"
-            payin = BankWirePayIn(Id=-1)
-            payin.Status = status_up
-            payin.ResultCode = result_code
-            payin.ResultMessage = error
-            payin.AuthorId = homer.mangopay_user_id
-            payin.PaymentType = 'BANK_WIRE'
-            payin.DeclaredDebitedFunds = Money(1100, 'EUR')
-            payin.DeclaredFees = Money(0, 'EUR')
-            payin.CreditedFunds = Money(0, 'XXX') if error else Money(1100, 'EUR')
-            payin.Tag = str(e_id)
-            Get.return_value = payin
-            r = self.callback(qs)
-            assert r.code == 200, r.text
-            homer = homer.refetch()
-            if status == 'succeeded':
-                assert homer.balance == 11
-                assert homer.status == 'active'
-            else:
-                assert homer.balance == 0
-                assert homer.status == 'closed'
-            emails = self.get_emails()
-            assert len(emails) == 1
-            assert emails[0]['to'][0] == 'homer <%s>' % homer.email
-            expected = 'expired' if result_code == '101109' else status[:4]
-            assert expected in emails[0]['subject']
-            self.db.self_check()
-            homer.update_status('active')  # reset for next loop run
-
-    @patch('mangopay.resources.PayIn.get')
-    def test_payin_bank_wire_callback_unexpected(self, Get):
-        homer = self.homer
-        cases = (
-            ('failed', '000001', 'FOO', 0),
-            ('succeeded', '000000', None, 5),
-            ('succeeded', '000000', None, 2),
-        )
-        for status, result_code, error, fee in cases:
-            status_up = status.upper()
-            homer.set_tip_to(self.janet, EUR('1.00'))
-            homer.close('downstream')
-            assert homer.balance == 0
-            assert homer.status == 'closed'
-            qs = "EventType=PAYIN_NORMAL_"+status_up+"&RessourceId=123456790"
-            payin = BankWirePayIn(Id=-1)
-            payin.Status = status_up
-            payin.ResultCode = result_code
-            payin.ResultMessage = error
-            payin.AuthorId = homer.mangopay_user_id
-            payin.PaymentType = 'BANK_WIRE'
-            payin.DebitedFunds = Money(242, 'EUR')
-            payin.DeclaredDebitedFunds = payin.DebitedFunds
-            payin.DeclaredFees = Money(fee, 'EUR')
-            payin.Fees = Money(fee, 'EUR')
-            payin.CreditedFunds = Money(0, 'XXX') if error else Money(242 - fee, 'EUR')
-            payin.CreditedWalletId = self.homer_wallet_id
-            Get.return_value = payin
-            r = self.callback(qs)
-            assert r.code == 200, r.text
-            amount = EUR(242 - fee) / 100
-            e = self.db.one("SELECT * FROM exchanges ORDER BY timestamp DESC lIMIT 1")
-            assert e.status == status
-            assert e.amount == amount
-            assert e.fee == EUR(fee) / 100
-            homer = homer.refetch()
-            if status == 'succeeded':
-                assert homer.balance == amount
-                assert homer.status == 'active'
-            else:
-                assert homer.balance == 0
-                assert homer.status == 'closed'
-            emails = self.get_emails()
-            assert len(emails) == 1
-            assert emails[0]['to'][0] == 'homer <%s>' % homer.email
-            assert status[:4] in emails[0]['subject']
-            self.db.self_check()
-            homer.update_status('active')  # reset for next loop run
-
-    @patch('mangopay.resources.PayIn.get')
-    def test_payin_bank_wire_callback_amount_mismatch(self, Get):
-        self._test_payin_bank_wire_callback_amount_mismatch(Get, 2)
-
-    @patch('mangopay.resources.PayIn.get')
-    def test_payin_bank_wire_callback_amount_and_fee_mismatch(self, Get):
-        self._test_payin_bank_wire_callback_amount_mismatch(Get, 50)
-
-    def _test_payin_bank_wire_callback_amount_mismatch(self, Get, fee):
-        homer = self.homer
-        route = ExchangeRoute.insert(homer, 'mango-bw', 'x', 'chargeable')
-        e_id = record_exchange(self.db, route, EUR(11), EUR(0), EUR(0), homer, 'pre').id
-        assert homer.balance == 0
-        homer.close(None)
-        assert homer.status == 'closed'
-        qs = "EventType=PAYIN_NORMAL_SUCCEEDED&RessourceId=123456790"
-        payin = BankWirePayIn(Id=-1)
-        payin.Status = 'SUCCEEDED'
-        payin.ResultCode = '000000'
-        payin.ResultMessage = None
-        payin.AuthorId = homer.mangopay_user_id
-        payin.PaymentType = 'BANK_WIRE'
-        payin.DeclaredDebitedFunds = Money(4500, 'EUR')
-        payin.DeclaredFees = Money(100, 'EUR')
-        payin.DebitedFunds = Money(302, 'EUR')
-        payin.Fees = Money(fee, 'EUR')
-        payin.CreditedFunds = Money(302 - fee, 'EUR')
-        payin.Tag = str(e_id)
-        Get.return_value = payin
-        r = self.callback(qs)
-        assert r.code == 200, r.text
-        e = self.db.one("SELECT * FROM exchanges WHERE id = %s", (e_id,))
-        assert e.amount == payin.CreditedFunds / 100
-        assert e.fee == EUR(fee) / 100
-        assert e.vat == EUR('0.01')
-        assert e.status == 'succeeded'
-        homer = homer.refetch()
-        assert homer.balance == e.amount
-        assert homer.status == 'active'
-        emails = self.get_emails()
-        assert len(emails) == 1
-        assert emails[0]['to'][0] == 'homer <%s>' % homer.email
-        assert 'succ' in emails[0]['subject']
-        self.db.self_check()