def fetch_one_account(self, method): pm = method.get_implementation() trustly = Trustly(pm) transactions = trustly.getledgerforrange( datetime.today() - timedelta(days=7), datetime.today()) for t in transactions: if t['accountname'] == 'BANK_WITHDRAWAL_QUEUED' and not t[ 'orderid']: # If it has an orderid, it's a refund, but if not, then it's a transfer out (probably) w, created = TrustlyWithdrawal.objects.get_or_create( paymentmethod=method, gluepayid=t['gluepayid'], defaults={ 'amount': -Decimal(t['amount']), 'message': t['messageid'], }, ) w.save() if created: TrustlyLog( message='New bank withdrawal of {0} found'.format( -Decimal(t['amount'])), paymentmethod=method).save() accstr = 'Transfer from Trustly to bank' accrows = [ (pm.config('accounting_income'), accstr, -w.amount, None), (pm.config('accounting_transfer'), accstr, w.amount, None), ] entry = create_accounting_entry( dateutil.parser.parse(t['datestamp']).date(), accrows, True, [], ) if is_managed_bank_account( pm.config('accounting_transfer')): register_pending_bank_matcher( pm.config('accounting_transfer'), '.*TRUSTLY.*{0}.*'.format(w.gluepayid), w.amount, entry)
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 process_settlement_detail_report_batch(self, report): # Summarize the settlement detail report in an email to to treasurer@, so they # can keep track of what's going on. method = report.paymentmethod pm = method.get_implementation() # Get the batch number from the url batchnum = re.search('settlement_detail_report_batch_(\d+).csv$', report.url).groups(1)[0] # Now summarize the contents sio = io.StringIO(report.contents) reader = csv.DictReader(sio, delimiter=',') types = {} for l in reader: t = l['Type'] if t == 'Balancetransfer': # Balance transfer is special -- we can have two of them that evens out, # but we need to separate in and out if Decimal(l['Net Debit (NC)'] or 0) > 0: t = "Balancetransfer2" lamount = Decimal(l['Net Credit (NC)'] or 0) - Decimal( l['Net Debit (NC)'] or 0) if t in types: types[t] += lamount else: types[t] = lamount def sort_types(a): # Special sort method that just ensures that Settled always ends up at the top # and the rest is just alphabetically sorted. (And yes, this is ugly code :P) if a[0] == 'Settled' or a[0] == 'SettledBulk': return 'AAA' return a[0] msg = "\n".join([ "%-20s: %s" % (k, v) for k, v in sorted(iter(types.items()), key=sort_types) ]) acct = report.notification.merchantAccountCode # Generate an accounting record, iff we know what every row on the # statement actually is. acctrows = [] accstr = "Adyen settlement batch %s for %s" % (batchnum, acct) payout_amount = 0 for t, amount in list(types.items()): if t == 'Settled' or t == 'SettledBulk': # Settled means we took it out of the payable balance acctrows.append( (pm.config('accounting_payable'), accstr, -amount, None)) elif t == 'MerchantPayout': # Amount directly into our checking account acctrows.append( (pm.config('accounting_payout'), accstr, -amount, None)) # Payouts show up as negative, so we have to reverse the sign payout_amount -= amount elif t in ('DepositCorrection', 'Balancetransfer', 'Balancetransfer2', 'ReserveAdjustment'): # Modification of our deposit account - in either direction! acctrows.append( (pm.config('accounting_merchant'), accstr, -amount, None)) elif t == 'InvoiceDeduction': # Adjustment of the invoiced costs. So adjust the payment fees! acctrows.append( (pm.config('accounting_fee'), accstr, -amount, None)) elif t == 'Refunded' or t == 'RefundedBulk': # Refunded - should already be booked against the refunding account acctrows.append( (pm.config('accounting_refunds'), accstr, -amount, None)) else: # Other rows that we don't know about will generate an open accounting entry # for manual fixing. pass if len(acctrows) == len(types): # If all entries were processed, the accounting entry should # automatically be balanced by now, so we can safely just complete it. # If payout is to a managed bank account (and there is a payout), we register # the payout for processing there and leave the entry open. If not, then we # just close it right away. is_managed = is_managed_bank_account( pm.config('accounting_payout')) if is_managed and payout_amount > 0: entry = create_accounting_entry(date.today(), acctrows, True) # Register a pending bank transfer using the syntax that Adyen are # currently using. We only match the most important keywords, just # but it should still be safe against most other possibilities. register_pending_bank_matcher( pm.config('accounting_payout'), '.*ADYEN.*BATCH {0} .*'.format(batchnum), payout_amount, entry) msg = "A settlement batch with Adyen has completed for merchant account %s. A summary of the entries are:\n\n%s\n\nAccounting entry %s was created and will automatically be closed once the payout has arrived." % ( acct, msg, entry) else: # Close immediately create_accounting_entry(date.today(), acctrows, False) msg = "A settlement batch with Adyen has completed for merchant account %s. A summary of the entries are:\n\n%s\n\n" % ( acct, msg) else: # All entries were not processed, so we write what we know to the # db, and then just leave the entry open. create_accounting_entry(date.today(), acctrows, True) msg = "A settlement batch with Adyen has completed for merchant account %s. At least one entry in this was UNKNOWN, and therefor the accounting record has been left open, and needs to be adjusted manually!\nA summary of the entries are:\n\n%s\n\n" % ( acct, msg) send_simple_mail(settings.INVOICE_SENDER_EMAIL, pm.config('notification_receiver'), 'Adyen settlement batch %s completed' % batchnum, msg)
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 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