Skip to content

Commit 050123f

Browse files
authored
Merge pull request #3211 from ResearchHub/fix-locked-balance-allocation
Fix locked balance allocation
2 parents d98de2c + 85f289e commit 050123f

File tree

13 files changed

+487
-102
lines changed

13 files changed

+487
-102
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 5.2.12 on 2026-03-19 22:02
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("purchase", "0049_grant_add_pending_declined_moderation_fields"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="balance",
16+
name="purchase",
17+
field=models.ForeignKey(
18+
blank=True,
19+
null=True,
20+
on_delete=django.db.models.deletion.SET_NULL,
21+
related_name="balance_records",
22+
to="purchase.purchase",
23+
),
24+
),
25+
]

src/purchase/related_models/balance_model.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ class LockType(models.TextChoices):
1717
object_id = models.PositiveIntegerField(null=True)
1818
source = GenericForeignKey("content_type", "object_id")
1919

20+
# Optional link to the purchase that triggered this balance record.
21+
# Used for contribution refunds.
22+
purchase = models.ForeignKey(
23+
"purchase.Purchase",
24+
on_delete=models.SET_NULL,
25+
null=True,
26+
blank=True,
27+
related_name="balance_records",
28+
)
29+
2030
# TODO: why is this a char field?
2131
amount = models.CharField(max_length=255)
2232
testnet_amount = models.CharField(max_length=255, default=0, null=True, blank=True)

src/purchase/services/fundraise_service.py

Lines changed: 79 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,11 @@ def create_rsc_contribution(
217217
with transaction.atomic():
218218
user = User.objects.select_for_update().get(id=user.id)
219219

220-
# Check if user has enough balance in their wallet
221-
# For fundraise contributions, we allow using locked balance
222-
user_balance = user.get_balance(include_locked=True)
223-
if user_balance - (amount + fee) < 0:
220+
# Allocate the total spend (amount + fee) across locked/unlocked
221+
# pools. Fundraise contributions are allowed to consume locked funds.
222+
try:
223+
allocations = user.allocate_spend(amount + fee, allow_locked=True)
224+
except ValueError:
224225
return None, "Insufficient balance"
225226

226227
# Create purchase object
@@ -237,55 +238,39 @@ def create_rsc_contribution(
237238
# Deduct fees
238239
deduct_bounty_fees(user, fee, rh_fee, dao_fee, fee_object)
239240

240-
# Get user's available locked balance
241-
available_locked_balance = user.get_locked_balance()
242-
243-
# Determine how to split the contribution amount
244-
locked_amount_used = min(available_locked_balance, amount)
245-
regular_amount_used = amount - locked_amount_used
246-
247-
# Determine how to split the fees using remaining locked balance
248-
remaining_locked_balance = available_locked_balance - locked_amount_used
249-
locked_fee_used = min(remaining_locked_balance, fee)
250-
regular_fee_used = fee - locked_fee_used
251-
252-
# Create balance records for the contribution amount
253-
if locked_amount_used > 0:
254-
Balance.objects.create(
255-
user=user,
256-
content_type=ContentType.objects.get_for_model(Purchase),
257-
object_id=purchase.id,
258-
amount=f"-{locked_amount_used.to_eng_string()}",
259-
is_locked=True,
260-
lock_type=Balance.LockType.REFERRAL_BONUS,
261-
)
262-
263-
if regular_amount_used > 0:
264-
Balance.objects.create(
265-
user=user,
266-
content_type=ContentType.objects.get_for_model(Purchase),
267-
object_id=purchase.id,
268-
amount=f"-{regular_amount_used.to_eng_string()}",
269-
)
270-
271-
# Create balance records for the fees
272-
if locked_fee_used > 0:
273-
Balance.objects.create(
274-
user=user,
275-
content_type=ContentType.objects.get_for_model(BountyFee),
276-
object_id=fee_object.id,
277-
amount=f"-{locked_fee_used.to_eng_string()}",
278-
is_locked=True,
279-
lock_type=Balance.LockType.REFERRAL_BONUS,
280-
)
281-
282-
if regular_fee_used > 0:
283-
Balance.objects.create(
284-
user=user,
285-
content_type=ContentType.objects.get_for_model(BountyFee),
286-
object_id=fee_object.id,
287-
amount=f"-{regular_fee_used.to_eng_string()}",
288-
)
241+
# Create balance debit records, splitting each allocation across
242+
# the contribution amount and fee.
243+
remaining_amount = amount
244+
245+
for alloc in allocations:
246+
alloc_amount = alloc["amount"]
247+
248+
# Apply as much of this allocation to the contribution first,
249+
# then the remainder to fees.
250+
amount_used = min(alloc_amount, remaining_amount)
251+
fee_used = alloc_amount - amount_used
252+
253+
if amount_used > 0:
254+
Balance.objects.create(
255+
user=user,
256+
content_type=ContentType.objects.get_for_model(Purchase),
257+
object_id=purchase.id,
258+
amount=f"-{amount_used.to_eng_string()}",
259+
is_locked=alloc["is_locked"],
260+
purchase=purchase,
261+
)
262+
263+
if fee_used > 0:
264+
Balance.objects.create(
265+
user=user,
266+
content_type=ContentType.objects.get_for_model(BountyFee),
267+
object_id=fee_object.id,
268+
amount=f"-{fee_used.to_eng_string()}",
269+
is_locked=alloc["is_locked"],
270+
purchase=purchase,
271+
)
272+
273+
remaining_amount -= amount_used
289274

290275
# Track in Amplitude
291276
rh_fee_str = rh_fee.to_eng_string()
@@ -389,47 +374,53 @@ def create_usd_contribution(
389374

390375
return contribution, None
391376

377+
def _refund_contribution_debit(
378+
self, fundraise, user, debit, purchase_ct, bounty_fee_ct
379+
):
380+
"""Refund a single debit entry. Returns True on success, False on failure."""
381+
abs_amount = abs(Decimal(debit.amount))
382+
if abs_amount == 0:
383+
return True
384+
385+
if debit.content_type == purchase_ct:
386+
return fundraise.escrow.refund(user, abs_amount, is_locked=debit.is_locked)
387+
388+
if debit.content_type == bounty_fee_ct:
389+
return self._refund_fee(user, debit, abs_amount)
390+
391+
return True
392+
393+
def _refund_fee(self, user, debit, abs_amount):
394+
"""Refund a fee debit from the revenue account. Returns True on success."""
395+
rh_revenue_account = User.objects.get_revenue_account()
396+
fee_object = BountyFee.objects.get(id=debit.object_id)
397+
distribution = create_bounty_refund_distribution(abs_amount)
398+
distributor = Distributor(
399+
distribution,
400+
user,
401+
fee_object,
402+
time.time(),
403+
giver=rh_revenue_account,
404+
is_locked=debit.is_locked,
405+
)
406+
record = distributor.distribute()
407+
return record.distributed_status != "FAILED"
408+
392409
def refund_rsc_contributions(self, fundraise: Fundraise) -> bool:
393410
"""
394411
Refund all RSC contributions from escrow back to contributors.
395412
Also refunds the fees that were deducted when creating contributions.
413+
Preserves the locked/unlocked status of the original funds.
396414
Returns True if all refunds successful, False if any fail.
397415
"""
398-
# Get all purchases (RSC contributions) for this fundraise
399-
contributions = fundraise.purchases.all()
400-
401-
# Refund each RSC contributor
402-
for contribution in contributions:
403-
user = contribution.user
404-
amount = Decimal(contribution.amount)
405-
406-
# Only refund what's still in escrow
407-
if amount > 0:
408-
success = fundraise.escrow.refund(user, amount)
409-
if not success:
410-
# If a refund fails, we should abort the whole transaction
411-
return False
412-
413-
# Also refund the fees that were deducted when this contribution
414-
# was made. Calculate the fee using the same logic used during
415-
# contribution creation.
416-
fee, _, _, fee_object = calculate_bounty_fees(amount)
417-
418-
if fee > 0:
419-
# Create a refund for the fee
420-
rh_revenue_account = User.objects.get_revenue_account()
421-
distribution = create_bounty_refund_distribution(fee)
422-
distributor = Distributor(
423-
distribution,
424-
user,
425-
fee_object, # The BountyFee object
426-
time.time(),
427-
giver=rh_revenue_account,
428-
)
429-
record = distributor.distribute()
430-
if record.distributed_status == "FAILED":
431-
# If fee refund fails, we should abort the whole
432-
# transaction
416+
purchase_ct = ContentType.objects.get_for_model(Purchase)
417+
bounty_fee_ct = ContentType.objects.get_for_model(BountyFee)
418+
419+
for contribution in fundraise.purchases.all():
420+
for debit in Balance.objects.filter(purchase=contribution):
421+
if not self._refund_contribution_debit(
422+
fundraise, contribution.user, debit, purchase_ct, bounty_fee_ct
423+
):
433424
return False
434425

435426
return True

0 commit comments

Comments
 (0)