def webhook(request, methodid): sig = request.META['HTTP_STRIPE_SIGNATURE'] try: payload = json.loads(request.body.decode('utf8', errors='ignore')) except ValueError: return HttpResponse("Invalid JSON", status=400) method = InvoicePaymentMethod.objects.get(pk=methodid, classname="postgresqleu.util.payment.stripe.Stripe") pm = method.get_implementation() sigdata = dict([v.strip().split('=') for v in sig.split(',')]) sigstr = sigdata['t'] + '.' + request.body.decode('utf8', errors='ignore') mac = hmac.new(pm.config('webhook_secret').encode('utf8'), msg=sigstr.encode('utf8'), digestmod=hashlib.sha256) if mac.hexdigest() != sigdata['v1']: return HttpResponse("Invalid signature", status=400) # Signature is OK, figure out what to do if payload['type'] == 'checkout.session.completed': sessionid = payload['data']['object']['id'] try: co = StripeCheckout.objects.get(sessionid=sessionid) except StripeCheckout.DoesNotExist: StripeLog(message="Received completed session event for non-existing sessions {}".format(sessionid), error=True, paymentmethod=method).save() return HttpResponse("OK") # We don't get enough data in the session, unfortunately, so we have to # make some incoming API calls. StripeLog(message="Received Stripe webhook for checkout {}. Processing.".format(co.id), paymentmethod=method).save() process_stripe_checkout(co) StripeLog(message="Completed processing webhook for checkout {}.".format(co.id), paymentmethod=method).save() return HttpResponse("OK") elif payload['type'] == 'charge.refunded': chargeid = payload['data']['object']['id'] # There can be multiple refunds on each charge, so we have to look through all the # possible ones, and compare. Unfortunately, there is no notification available which # tells us *which one* was completed. Luckily there will never be *many* refunds on a # single charge. with transaction.atomic(): for r in payload['data']['object']['refunds']['data']: try: refund = StripeRefund.objects.get(paymentmethod=method, chargeid=chargeid, refundid=r['id']) except StripeRefund.DoesNotExist: StripeLog(message="Received completed refund event for non-existant refund {}".format(r['id']), error=True, paymentmethod=method).save() return HttpResponse("OK") if refund.completedat: # It wasn't this one, as it's already been completed. continue if r['amount'] != refund.amount * 100: StripeLog(message="Received completed refund with amount {0} instead of expected {1} for refund {2}".format(r['amount'] / 100, refund.amount, refund.id), error=True, paymentmethod=method).save() return HttpResponse("OK") # OK, refund looks fine StripeLog(message="Received Stripe webhook for refund {}. Processing.".format(refund.id), paymentmethod=method).save() refund.completedat = datetime.now() refund.save() manager = InvoiceManager() manager.complete_refund( refund.invoicerefundid_id, refund.amount, 0, # Unknown fee pm.config('accounting_income'), pm.config('accounting_fee'), [], method) return HttpResponse("OK") elif payload['type'] == 'payout.paid': # Payout has left Stripe. Should include both automatic and manual ones payoutid = payload['data']['object']['id'] obj = payload['data']['object'] if obj['currency'].lower() != settings.CURRENCY_ISO.lower(): StripeLog(message="Received payout in incorrect currency {}, ignoring".format(obj['currency']), error=True, paymentmethod=method).save() return HttpResponse("OK") with transaction.atomic(): if StripePayout.objects.filter(payoutid=payoutid).exists(): StripeLog(message="Received duplicate notification for payout {}, ignoring".format(payoutid), error=True, paymentmethod=method).save() return HttpResponse("OK") payout = StripePayout(paymentmethod=method, payoutid=payoutid, amount=Decimal(obj['amount']) / 100, sentat=datetime.now(), description=obj['description']) payout.save() acctrows = [ (pm.config('accounting_income'), 'Stripe payout {}'.format(payout.payoutid), -payout.amount, None), (pm.config('accounting_payout'), 'Stripe payout {}'.format(payout.payoutid), payout.amount, None), ] if is_managed_bank_account(pm.config('accounting_payout')): entry = create_accounting_entry(date.today(), acctrows, True) # XXX: we don't know what this looks like at the other end yet, so put a random string in there register_pending_bank_matcher(pm.config('accounting_payout'), '.*STRIPE_PAYOUT_WHAT_GOES_HERE?.*', payout.amount, entry) msg = "A Stripe payout with description {} completed for {}.\n\nAccounting entry {} was created and will automatically be closed once the payout has arrived.".format( payout.description, method.internaldescription, entry, ) else: msg = "A Stripe payout with description {} completed for {}.\n".format(payout.description, method.internaldescription) StripeLog(message=msg, paymentmethod=method).save() send_simple_mail(settings.INVOICE_SENDER_EMAIL, pm.config('notification_receiver'), 'Stripe payout completed', msg, ) return HttpResponse("OK") else: StripeLog(message="Received unknown Stripe event type '{}'".format(payload['type']), error=True, paymentmethod=method).save() # We still flag it as OK to stripe return HttpResponse("OK")
def handle_method(self, method): pm = method.get_implementation() api = pm.get_api() for t in api.get_transactions(): # We will re-fetch most transactions, so only create them if they are not # already there. trans, created = TransferwiseTransaction.objects.get_or_create( paymentmethod=method, twreference=t['referenceNumber'], defaults={ 'datetime': api.parse_datetime(t['date']), 'amount': api.get_structured_amount(t['amount']), 'feeamount': api.get_structured_amount(t['totalFees']), 'transtype': t['details']['type'], 'paymentref': t['details']['paymentReference'][:200], 'fulldescription': t['details']['description'], }) if created: # Set optional fields trans.counterpart_name = t['details'].get('senderName', '') trans.counterpart_account = t['details'].get( 'senderAccount', '').replace(' ', '') # Weird stuff that sometimes shows up if trans.counterpart_account == 'Unknownbankaccount': trans.counterpart_account = '' if trans.counterpart_account: # If account is IBAN, then try to validate it! trans.counterpart_valid_iban = api.validate_iban( trans.counterpart_account) trans.save() # If this is a refund transaction, process it as such if trans.transtype == 'TRANSFER' and trans.paymentref.startswith( '{0} refund'.format(settings.ORG_SHORTNAME)): # Yes, this is one of our refunds. Can we find the corresponding transaction? m = re.match(r'^TRANSFER-(\d+)$', t['referenceNumber']) if not m: raise Exception( "Could not find TRANSFER info in transfer reference {0}" .format(t['referenceNumber'])) transferid = m.groups(1)[0] try: twrefund = TransferwiseRefund.objects.get( transferid=transferid) except TransferwiseRefund.DoesNotExist: print( "Could not find transferwise refund for id {0}, registering as manual bank transaction" .format(transferid)) register_bank_transaction(method, trans.id, trans.amount, trans.paymentref, trans.fulldescription, False) continue if twrefund.refundtransaction or twrefund.completedat: raise Exception( "Transferwise refund for id {0} has already been processed!" .format(transferid)) # Flag this one as done! twrefund.refundtransaction = trans twrefund.completedat = datetime.now() twrefund.save() invoicemanager = InvoiceManager() invoicemanager.complete_refund( twrefund.refundid, trans.amount + trans.feeamount, trans.feeamount, pm.config('bankaccount'), pm.config('feeaccount'), [], # urls method, ) elif trans.transtype == 'TRANSFER' and trans.paymentref.startswith( '{0} returned payment'.format(settings.ORG_SHORTNAME)): # Returned payment. Nothing much to do, but we create an accounting record # for it just to make things nice and clear. But first make sure we can # actually find the original transaction. try: po = TransferwisePayout.objects.get( reference=trans.paymentref) except TransferwisePayout.DoesNotExist: raise Exception( "Could not find transferwise payout object for {0}" .format(trans.paymentref)) po.completedat = datetime.now() po.completedtrans = trans po.save() m = re.match( r'^{0} returned payment (\d+)$'.format( settings.ORG_SHORTNAME), trans.paymentref) if not m: raise Exception( "Could not find returned transaction id in reference '{0}'" .format(trans.paymentref)) twtrans = TransferwiseTransaction.objects.get( pk=m.groups(1)[0]) if twtrans.amount != -trans.amount - trans.feeamount: raise Exception( "Original amount {0} does not match returned amount {1}" .format(twtrans.amount, -trans.amount - trans.feeamount)) accountingtxt = "TransferWise returned payment {0}".format( trans.twreference) accrows = [ (pm.config('bankaccount'), accountingtxt, trans.amount, None), (pm.config('feeaccount'), accountingtxt, trans.feeamount, None), (pm.config('bankaccount'), accountingtxt, -(trans.amount + trans.feeamount), None), ] create_accounting_entry(date.today(), accrows) elif trans.transtype == 'TRANSFER' and trans.paymentref.startswith( 'TW payout'): # Payout. Create an appropriate accounting record and a pending matcher. try: po = TransferwisePayout.objects.get( reference=trans.paymentref) except TransferwisePayout.DoesNotExist: raise Exception( "Could not find transferwise payout object for {0}" .format(trans.paymentref)) refno = int(trans.paymentref[len("TW payout "):]) if po.amount != -(trans.amount + trans.feeamount): raise Exception( "Transferwise payout {0} returned transaction with amount {1} instead of {2}" .format(refno, -(trans.amount + trans.feeamount), po.amount)) po.completedat = datetime.now() po.completedtrans = trans po.save() # Payout exists at TW, so proceed to generate records. If the receiving account # is a managed one, create a bank matcher. Otherwise just close the record # immediately. accrows = [ (pm.config('bankaccount'), trans.paymentref, trans.amount, None), (pm.config('feeaccount'), trans.paymentref, trans.feeamount, None), (pm.config('accounting_payout'), trans.paymentref, -(trans.amount + trans.feeamount), None), ] if is_managed_bank_account(pm.config('accounting_payout')): entry = create_accounting_entry( date.today(), accrows, True) register_pending_bank_matcher( pm.config('accounting_payout'), '.*TW.*payout.*{0}.*'.format(refno), -(trans.amount + trans.feeamount), entry) else: create_accounting_entry(date.today(), accrows) else: # Else register a pending bank transaction. This may immediately match an invoice # if it was an invoice payment, in which case the entire process will copmlete. register_bank_transaction(method, trans.id, trans.amount, trans.paymentref, trans.fulldescription, trans.counterpart_valid_iban)
def process_refund(notification): method = notification.rawnotification.paymentmethod pm = method.get_implementation() # Store the refund, and send an email! if notification.success: try: ts = TransactionStatus.objects.get( pspReference=notification.originalReference, paymentmethod=method) refund = Refund(notification=notification, transaction=ts, refund_amount=notification.amount) refund.save() urls = [ "https://ca-live.adyen.com/ca/ca/accounts/showTx.shtml?pspReference=%s&txType=Payment&accountKey=MerchantAccount.%s" % (notification.pspReference, notification.merchantAccountCode), ] # API generated refund? if notification.merchantReference.startswith( pm.config('merchantref_refund_prefix')): # API generated invoicerefundid = int(notification.merchantReference[ len(pm.config('merchantref_refund_prefix')):]) manager = InvoiceManager() manager.complete_refund( invoicerefundid, refund.refund_amount, 0, # we don't know the fee, it'll be generically booked pm.config('accounting_refunds'), pm.config('accounting_fee'), urls, method) else: # Generate an open accounting record for this refund. # We expect this happens so seldom that we can just deal with # manually finishing off the accounting records. accrows = [ (pm.config('accounting_refunds'), "Refund of %s (transaction %s) " % (ts.notes, ts.pspReference), -refund.refund_amount, None), ] send_simple_mail( settings.INVOICE_SENDER_EMAIL, pm.config('notification_receiver'), 'Adyen refund received', "A refund of %s%s for transaction %s was processed on %s\n\nNOTE! You must complete the accounting system entry manually as it was not API generated!!" % (settings.CURRENCY_ABBREV, notification.amount, method.internaldescription, notification.originalReference)) create_accounting_entry(date.today(), accrows, True, urls) except TransactionStatus.DoesNotExist: send_simple_mail( settings.INVOICE_SENDER_EMAIL, pm.config('notification_receiver'), 'Adyen refund received for nonexisting transaction', "A refund for %s was received on %s, but the transaction does not exist!\n\nYou probably want to investigate this!\n" % (notification.originalReference, method.internaldescription)) else: send_simple_mail( settings.INVOICE_SENDER_EMAIL, pm.config('notification_receiver'), 'Unsuccessful adyen refund received', "A refund for %s has failed on %s.\nThe reason given was:\n%s\n\nYou probably want to investigate this!\n" % (notification.merchantReference, method.internaldescription, notification.reason)) notification.confirmed = True notification.save()
def handle(self, *args, **options): invoicemanager = InvoiceManager() # There may be multiple accounts, so loop over them for method in InvoicePaymentMethod.objects.filter( active=True, classname='postgresqleu.util.payment.paypal.Paypal'): pm = method.get_implementation() translist = TransactionInfo.objects.filter( matched=False, paymentmethod=method).order_by('timestamp') for trans in translist: # URLs for linkback to paypal urls = [ "%s?cmd=_view-a-trans&id=%s" % ( pm.get_baseurl(), trans.paypaltransid, ), ] # Manual handling of some record types # Record type: donation if trans.transtext == pm.config('donation_text'): trans.setmatched( 'Donation, automatically matched by script') # Generate a simple accounting record, that will have to be # manually completed. accstr = "Paypal donation %s" % trans.paypaltransid accrows = [ (pm.config('accounting_income'), accstr, trans.amount - trans.fee, None), (pm.config('accounting_fee'), accstr, trans.fee, None), (settings.ACCOUNTING_DONATIONS_ACCOUNT, accstr, -trans.amount, None), ] create_accounting_entry(trans.timestamp.date(), accrows, True, urls) continue # Record type: payment, but with no notice (auto-generated elsewhere, the text is # hard-coded in paypal_fetch.py if trans.transtext == "Paypal payment with empty note" or trans.transtext == "Recurring paypal payment without note": trans.setmatched( 'Empty payment description, leaving for operator') accstr = "Unlabeled paypal payment from {0}".format( trans.sender) accrows = [ (pm.config('accounting_income'), accstr, trans.amount - trans.fee, None), ] if trans.fee: accrows.append((pm.config('accounting_fee'), accstr, trans.fee, None), ) create_accounting_entry(trans.timestamp.date(), accrows, True, urls) continue # Record type: transfer if trans.amount < 0 and trans.transtext == 'Transfer from Paypal to bank': trans.setmatched( 'Bank transfer, automatically matched by script') # There are no fees on the transfer, and the amount is already # "reversed" and will automatically become a credit entry. accstr = 'Transfer from Paypal to bank' accrows = [ (pm.config('accounting_income'), accstr, trans.amount, None), (pm.config('accounting_transfer'), accstr, -trans.amount, None), ] entry = create_accounting_entry(trans.timestamp.date(), accrows, True, urls) if is_managed_bank_account( pm.config('accounting_transfer')): register_pending_bank_matcher( pm.config('accounting_transfer'), '.*PAYPAL.*', -trans.amount, entry) continue textstart = 'Refund of Paypal payment: {0} refund '.format( settings.ORG_SHORTNAME) if trans.amount < 0 and trans.transtext.startswith(textstart): trans.setmatched('Matched API initiated refund') # API initiated refund, so we should be able to match it invoicemanager.complete_refund( trans.transtext[len(textstart):], -trans.amount, -trans.fee, pm.config('accounting_income'), pm.config('accounting_fee'), urls, method, ) # Accounting record is created by invoice manager continue # Record type: outgoing payment (or manual refund) if trans.amount < 0: trans.setmatched( 'Outgoing payment or manual refund, automatically matched by script' ) # Refunds typically have a fee (a reversed fee), whereas pure # payments don't have one. We don't make a difference of them # though - we leave the record open for manual verification accrows = [ (pm.config('accounting_income'), trans.transtext[:200], trans.amount - trans.fee, None), ] if trans.fee != 0: accrows.append( (pm.config('accounting_fee'), trans.transtext[:200], trans.fee, None), ) create_accounting_entry(trans.timestamp.date(), accrows, True, urls) continue # Otherwise, it's an incoming payment. In this case, we try to # match it to an invoice. # Log things to the db def payment_logger(msg): # Write the log output to somewhere interesting! ErrorLog( timestamp=datetime.now(), sent=False, message='Paypal %s by %s (%s) on %s: %s' % (trans.paypaltransid, trans.sender, trans.sendername, trans.timestamp, msg), paymentmethod=method, ).save() (r, i, p) = invoicemanager.process_incoming_payment( trans.transtext, trans.amount, "Paypal id %s, from %s <%s>" % (trans.paypaltransid, trans.sendername, trans.sender), trans.fee, pm.config('accounting_income'), pm.config('accounting_fee'), urls, payment_logger, method, ) if r == invoicemanager.RESULT_OK: trans.setmatched('Matched standard invoice') elif r == invoicemanager.RESULT_NOTMATCHED: # Could not match this to our pattern of transaction texts. Treat it # the same way as we would treat an empty one -- create an open accounting # entry. trans.setmatched( 'Could not match transaction text, leaving for operator' ) accstr = "Paypal payment '{0}' from {1}".format( trans.transtext, trans.sender) accrows = [ (pm.config('accounting_income'), accstr, trans.amount - trans.fee, None), ] if trans.fee: accrows.append((pm.config('accounting_fee'), accstr, trans.fee, None), ) create_accounting_entry(trans.timestamp.date(), accrows, True, urls) else: # Logging is done by the invoice manager callback pass
def process_one_account(self, method): pm = method.get_implementation() trustly = Trustly(pm) manager = InvoiceManager() refunds = InvoiceRefund.objects.filter(completed__isnull=True, invoice__paidusing=method) for r in refunds: # Find the matching Trustly transaction trustlytransactionlist = list( TrustlyTransaction.objects.filter(invoiceid=r.invoice.pk, paymentmethod=method)) if len(trustlytransactionlist) == 0: raise CommandError( "Could not find trustly transaction for invoice {0}". format(r.invoice.pk)) elif len(trustlytransactionlist) != 1: raise CommandError( "Found {0} trustly transactions for invoice {1}!".format( len(trustlytransactionlist), r.invoice.pk)) trustlytrans = trustlytransactionlist[0] w = trustly.getwithdrawal(trustlytrans.orderid) if not w: # No refund yet continue if w['transferstate'] != 'CONFIRMED': # Still pending continue if w['currency'] != settings.CURRENCY_ABBREV: # If somebody paid in a different currency (and Trustly converted it for us), # the withdrawal entry is specified in the original currency, which is more than # a little annoying. To deal with it, attempt to fetch the ledger for the day # and if we can find it there, use the amount from that one. day = dateutil.parser.parse(w['datestamp']).date() ledgerrows = trustly.getledgerforday(day) for lr in ledgerrows: if int(lr['orderid']) == trustlytrans.orderid and lr[ 'accountname'] == 'BANK_WITHDRAWAL_QUEUED': # We found the corresponding accounting row. So we take the amount from # this and convert the difference to what we expeced into the fee. This # can end up being a negative fee, but it should be small enough that # it's not a real problem. fees = ( r.fullamount + Decimal(lr['amount']).quantize(Decimal('0.01'))) TrustlyLog( message= "Refund for order {0}, invoice {1}, was made as {2} {3} instead of {4} {5}. Using ledger mapped to {6} {7} with difference of {8} {9} booked as fees" .format( trustlytrans.orderid, r.invoice.pk, Decimal(w['amount']), w['currency'], r.fullamount, settings.CURRENCY_ABBREV, Decimal(lr['amount']).quantize( Decimal('0.01')), settings.CURRENCY_ABBREV, fees, settings.CURRENCY_ABBREV, ), error=False, paymentmethod=method, ).save() break else: # Unable to find the refund in the ledger. This could be a matter of timing, # so yell about it but try agian. raise CommandError( "Trustly refund for invoice {0} was made in {1} instead of {2}, but could not be found in ledger." .format(r.invoice.pk, w['currency'], settings.CURRENCY_ABBREV)) else: # Currency is correct, so check that the refunded amount is the same as # the one we expected. if Decimal(w['amount']) != r.fullamount: raise CommandError( "Mismatch in amount on Trustly refund for invoice {0} ({1} vs {2})" .format(r.invoice.pk, Decimal(w['amount']), r.fullamount)) fees = 0 # Ok, things look good! TrustlyLog( message="Refund for order {0}, invoice {1}, completed".format( trustlytrans.orderid, r.invoice.pk), error=False, paymentmethod=method).save() manager.complete_refund(r.id, r.fullamount, fees, pm.config('accounting_income'), pm.config('accounting_fee'), [], method)