Skip to content

Commit a03baf5

Browse files
committed
Re-implement Trustly refund tracker on top of the ledger
Previously we explicitly called the API about the withdrawal entry, but that API is now restricted. And we don't *really* need it -- refunds show up on the ledger, so just use that. The only case this doesn't work is refunds in a different currency, and in that case we just estimate the fees and send a notice to hope for the best :) It's not a common scenario, and not worth spending too much on (in production it has never happened outside of testing). To do this, we now track refund transactions int he TrustlyWithdrawals table, and also store the orderid on them if we have them. This removes the separate job to match trustly refunds, and handles it all from the fetch withdrawals job. Not fully tested since it needs production data, so expect some follow-up commits..
1 parent 4f87c2c commit a03baf5

File tree

4 files changed

+142
-139
lines changed

4 files changed

+142
-139
lines changed

postgresqleu/trustlypayment/management/commands/trustly_fetch_withdrawals.py

Lines changed: 108 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,26 @@
77

88
from django.core.management.base import BaseCommand
99
from django.db import transaction
10+
from django.conf import settings
1011

11-
from datetime import time, datetime, timedelta
12+
from datetime import datetime, timedelta
1213
from decimal import Decimal
1314

1415
from postgresqleu.accounting.util import create_accounting_entry
1516
from postgresqleu.invoices.util import is_managed_bank_account
1617
from postgresqleu.invoices.util import register_pending_bank_matcher
1718

18-
from postgresqleu.invoices.models import InvoicePaymentMethod
19+
from postgresqleu.invoices.models import InvoicePaymentMethod, Invoice
20+
from postgresqleu.invoices.util import InvoiceManager
1921
from postgresqleu.trustlypayment.util import Trustly
20-
from postgresqleu.trustlypayment.models import TrustlyWithdrawal, TrustlyLog
22+
from postgresqleu.trustlypayment.models import TrustlyWithdrawal, TrustlyLog, TrustlyTransaction
2123

2224

2325
class Command(BaseCommand):
24-
help = 'Fetch Trustly withdrawals'
26+
help = 'Fetch Trustly withdrawals/refunds'
2527

2628
class ScheduledJob:
27-
scheduled_times = [time(22, 00), ]
29+
scheduled_interval = timedelta(hours=6)
2830

2931
@classmethod
3032
def should_run(self):
@@ -43,13 +45,23 @@ def fetch_one_account(self, method):
4345
transactions = trustly.getledgerforrange(datetime.today() - timedelta(days=7), datetime.today())
4446

4547
for t in transactions:
46-
if t['accountname'] == 'BANK_WITHDRAWAL_QUEUED' and not t['orderid']:
47-
# If it has an orderid, it's a refund, but if not, then it's a transfer out (probably)
48+
if t['accountname'] == 'BANK_WITHDRAWAL_QUEUED':
49+
if t['currency'] != settings.CURRENCY_ABBREV:
50+
TrustlyLog(
51+
message="Received Trustly withdrawal with gluepayid {} in currency {}, expected {}.".format(
52+
t['gluepayid'], t['currency'], settings.CURRENCY_ABBREV,
53+
),
54+
error=True,
55+
paymentmethod=method,
56+
).save()
57+
continue
58+
4859
w, created = TrustlyWithdrawal.objects.get_or_create(paymentmethod=method,
4960
gluepayid=t['gluepayid'],
5061
defaults={
5162
'amount': -Decimal(t['amount']),
5263
'message': t['messageid'],
64+
'orderid': t['orderid'],
5365
},
5466
)
5567
w.save()
@@ -58,17 +70,92 @@ def fetch_one_account(self, method):
5870
TrustlyLog(message='New bank withdrawal of {0} found'.format(-Decimal(t['amount'])),
5971
paymentmethod=method).save()
6072

61-
accstr = 'Transfer from Trustly to bank'
62-
accrows = [
63-
(pm.config('accounting_income'), accstr, -w.amount, None),
64-
(pm.config('accounting_transfer'), accstr, w.amount, None),
65-
]
66-
entry = create_accounting_entry(accrows,
67-
True,
68-
[],
69-
)
70-
if is_managed_bank_account(pm.config('accounting_transfer')):
71-
register_pending_bank_matcher(pm.config('accounting_transfer'),
72-
'.*TRUSTLY.*{0}.*'.format(w.gluepayid),
73-
w.amount,
74-
entry)
73+
if w.orderid:
74+
# This is either a payout (which we don't support) or a refund (which we do, so track it here)
75+
if not w.message.startswith('Refund '):
76+
TrustlyLog(
77+
message="Received bank withdrawal with orderid {} that does not appear to be a refund. What is it?".format(w.orderid),
78+
error=True,
79+
paymentmethod=method,
80+
).save()
81+
continue
82+
83+
try:
84+
trans = TrustlyTransaction.objects.get(orderid=t['orderid'])
85+
except TrustlyTransaction.DoesNotExist:
86+
TrustlyLog(
87+
message="Received bank withdrawal with orderid {} which does not exist!".format(w.orderid),
88+
error=True,
89+
paymentmethod=method,
90+
).save()
91+
continue
92+
93+
# Do we have a matching refund object?
94+
refundlist = list(Invoice.objects.get(pk=trans.invoiceid).invoicerefund_set.filter(issued__isnull=False, completed__isnull=True))
95+
for r in refundlist:
96+
if r.fullamount == w.amount:
97+
# Found the matching refund!
98+
manager = InvoiceManager()
99+
manager.complete_refund(
100+
r.id,
101+
r.fullamount,
102+
0,
103+
pm.config('accounting_income'),
104+
pm.config('accounting_fee'),
105+
[],
106+
method,
107+
)
108+
w.matched_refund = r
109+
w.save(update_fields=['matched_refund'])
110+
break
111+
else:
112+
# Another option is it's a refund in a different currency and we lost out on some currency conversion.
113+
# If we find a refund that's within 5% of the original value and we didn't find an exact one, then let's assume that's the case.
114+
# (in 99.999% of all cases there will only be one refund pending, so it'll very likely be correct)
115+
for r in refundlist:
116+
if w.amount < r.fullamount and w.amount / r.fullamount > 0.95:
117+
manager = InvoiceManager()
118+
manager.complete_refund(
119+
r.id,
120+
r.fullamount,
121+
r.fullamount - w.amount,
122+
pm.config('accounting_income'),
123+
pm.config('accounting_fee'),
124+
[],
125+
method,
126+
)
127+
w.matched_refund = r
128+
w.save(update_fields=['matched_refund'])
129+
TrustlyLog(
130+
message="Refund for order {}, invoice {}, was made as {} {}. Found no exact match for a refund, but matched to a refund of {} {} with fees of {} {}. Double check!".format(
131+
w.orderid, r.invoice_id,
132+
w.amount, settings.CURRENCY_ABBREV,
133+
r.fullamount, settings.CURRENCY_ABBREV,
134+
r.fullamount - w.amount, settings.CURRENCY_ABBREV,
135+
),
136+
error=False,
137+
paymentmethod=method,
138+
).save()
139+
break
140+
else:
141+
TrustlyLog(
142+
message="Received refund of {} for orderid {}, but could not find a matching refund object.".format(w.amount, w.orderid),
143+
error=True,
144+
paymentmethod=method,
145+
).save()
146+
else:
147+
# No orderid means it's a payout/settlement
148+
accstr = 'Transfer from Trustly to bank'
149+
accrows = [
150+
(pm.config('accounting_income'), accstr, -w.amount, None),
151+
(pm.config('accounting_transfer'), accstr, w.amount, None),
152+
]
153+
entry = create_accounting_entry(accrows,
154+
True,
155+
[],
156+
)
157+
if is_managed_bank_account(pm.config('accounting_transfer')):
158+
register_pending_bank_matcher(pm.config('accounting_transfer'),
159+
'.*TRUSTLY.*{0}.*'.format(w.gluepayid),
160+
w.amount,
161+
entry)

postgresqleu/trustlypayment/management/commands/trustly_match_refunds.py

Lines changed: 0 additions & 116 deletions
This file was deleted.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 4.2.11 on 2025-10-10 11:30
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('invoices', '0021_alter_vatrate_vatpercent'),
11+
('trustlypayment', '0004_trustlywithdrawal'),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name='trustlywithdrawal',
17+
name='matched_refund',
18+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='invoices.invoicerefund'),
19+
),
20+
migrations.AddField(
21+
model_name='trustlywithdrawal',
22+
name='orderid',
23+
field=models.BigIntegerField(blank=True, null=True),
24+
),
25+
migrations.AlterField(
26+
model_name='trustlytransaction',
27+
name='orderid',
28+
field=models.BigIntegerField(unique=True),
29+
),
30+
]

postgresqleu/trustlypayment/models.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.db import models
22

33

4-
from postgresqleu.invoices.models import InvoicePaymentMethod
4+
from postgresqleu.invoices.models import InvoicePaymentMethod, InvoiceRefund
55

66

77
class TrustlyTransaction(models.Model):
@@ -11,7 +11,7 @@ class TrustlyTransaction(models.Model):
1111
amount = models.DecimalField(decimal_places=2, max_digits=20, null=False)
1212
invoiceid = models.IntegerField(null=False, blank=False)
1313
redirecturl = models.CharField(max_length=2000, null=False, blank=False)
14-
orderid = models.BigIntegerField(null=False, blank=False)
14+
orderid = models.BigIntegerField(null=False, blank=False, unique=True)
1515
paymentmethod = models.ForeignKey(InvoicePaymentMethod, blank=False, null=False, on_delete=models.CASCADE)
1616

1717
def __str__(self):
@@ -59,5 +59,7 @@ class ReturnAuthorizationStatus(models.Model):
5959
class TrustlyWithdrawal(models.Model):
6060
paymentmethod = models.ForeignKey(InvoicePaymentMethod, blank=False, null=False, on_delete=models.CASCADE)
6161
gluepayid = models.BigIntegerField(null=False, blank=False)
62+
orderid = models.BigIntegerField(null=True, blank=True)
6263
amount = models.DecimalField(decimal_places=2, max_digits=20, null=False, blank=False)
6364
message = models.CharField(max_length=200, null=False, blank=True)
65+
matched_refund = models.ForeignKey(InvoiceRefund, null=True, blank=True, on_delete=models.SET_NULL)

0 commit comments

Comments
 (0)