@@ -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