diff --git a/flowbit-backend/core/models.py b/flowbit-backend/core/models.py index 2dcdacd..45ddd19 100644 --- a/flowbit-backend/core/models.py +++ b/flowbit-backend/core/models.py @@ -5,7 +5,8 @@ from django.db import models, transaction from django.contrib.auth.models import User from django.contrib.auth.hashers import check_password, identify_hasher, make_password -from django.db.models import Q, Sum +from django.db.models import Case, DecimalField, F, Q, Sum, When +from django.db.models.functions import Coalesce from decimal import Decimal from django.core.exceptions import ValidationError from django.utils import timezone @@ -1106,17 +1107,8 @@ def total_amount(self): transaction__is_refunded=False, status=Overflow.STATUS_REFUNDED, ).aggregate(total=Sum('refund_amount'))['total'] or Decimal('0.00') - returned_overflow_total = Overflow.objects.filter( - transaction__ticket=self, - transaction__is_refunded=False, - status=Overflow.STATUS_TCSO, - refunded_at__isnull=False, - resolution_type=Overflow.RESOLUTION_REFUND_OVERFLOW, - ).aggregate(total=Sum('refund_amount'))['total'] or Decimal('0.00') - active_total = visible_total - _from_allocation_basis_amount( - refunded_overflow_total + returned_overflow_total - ) + active_total = visible_total - _from_allocation_basis_amount(refunded_overflow_total) if active_total < Decimal('0.00'): return Decimal('0.00') return active_total @@ -1929,7 +1921,123 @@ def _return_reserve_consumed_overflow(overflow, helper_name, refunded_at=None, r return matching_overkill -def refund_overflow(overflow, helper_name, resolution_type, refunded_at=None): +def _reduce_transaction_total_for_overflow_refund(overflow): + if not overflow.transaction_id: + return + + refund_amount = overflow.amount_to_approve or overflow.excess_amount or Decimal('0.00') + reduction = _from_allocation_basis_amount(refund_amount) + transaction_obj = overflow.transaction + next_total = transaction_obj.total_amount - reduction + if next_total < Decimal('0.00'): + next_total = Decimal('0.00') + + transaction_obj.total_amount = next_total + transaction_obj.save(update_fields=['total_amount']) + if transaction_obj.ticket_id: + transaction_obj.ticket.refresh_refund_state() + + +def _restore_transaction_total_from_active_amounts(transaction_obj): + if transaction_obj is None: + return + + allocated_total = transaction_obj.allocations.aggregate( + total=Sum('amount') + )['total'] or Decimal('0.00') + active_overflow_total = transaction_obj.overflows.exclude( + status=Overflow.STATUS_REFUNDED + ).aggregate( + total=Sum( + Case( + When(status=Overflow.STATUS_TCSO, then=F('excess_amount')), + default=Coalesce(F('amount_to_approve'), F('excess_amount')), + output_field=DecimalField(max_digits=12, decimal_places=2), + ) + ) + )['total'] or Decimal('0.00') + + restored_total = _from_allocation_basis_amount(allocated_total + active_overflow_total) + transaction_obj.total_amount = restored_total + transaction_obj.save(update_fields=['total_amount']) + if transaction_obj.ticket_id: + transaction_obj.ticket.refresh_refund_state() + + +def _move_approved_overflow_to_overkill( + overflow, + helper_name, + refunded_at=None, + restore_capacity_adjustment=True, + reduce_transaction_total=True, +): + refunded_at = refunded_at or timezone.now() + refund_amount = overflow.amount_to_approve or overflow.excess_amount or Decimal('0.00') + collaborator_ids = list(overflow.collaborators.values_list('id', flat=True)) + + if restore_capacity_adjustment: + capacity_refund_amount = ( + refund_amount + if overflow.resolution_type == Overflow.RESOLUTION_RESERVE_CONSUMED + else _refund_capacity_amount_for_overflow(overflow) + ) + if capacity_refund_amount > 0: + _grant_capacity_adjustment( + overflow, + capacity_refund_amount, + IdentifierCapacityAdjustment.TYPE_REFUND_CSO, + helper_name, + ) + + if reduce_transaction_total: + _reduce_transaction_total_for_overflow_refund(overflow) + + matching_overkill = ( + Overflow.objects.filter( + identifier=overflow.identifier, + owner=overflow.owner, + period=overflow.period, + status=Overflow.STATUS_OVERKILL, + ) + .order_by('approved_at', 'id') + .first() + ) + + if matching_overkill: + matching_overkill.excess_amount = (matching_overkill.excess_amount or Decimal('0.00')) + refund_amount + matching_overkill.amount_to_approve = (matching_overkill.amount_to_approve or Decimal('0.00')) + refund_amount + matching_overkill.refunded_at = None + matching_overkill.refund_amount = None + matching_overkill.transaction = None + matching_overkill.save(update_fields=[ + 'excess_amount', + 'amount_to_approve', + 'refunded_at', + 'refund_amount', + 'transaction', + ]) + else: + matching_overkill = Overflow.objects.create( + transaction=None, + identifier=overflow.identifier, + owner=overflow.owner, + period=overflow.period, + excess_amount=refund_amount, + status=Overflow.STATUS_OVERKILL, + amount_to_approve=refund_amount, + approved_at=overflow.approved_at or refunded_at, + helper_name=overflow.helper_name or helper_name, + resolution_type=Overflow.RESOLUTION_APPROVE, + ) + + if collaborator_ids: + matching_overkill.collaborators.set(collaborator_ids) + + overflow.delete() + return matching_overkill + + +def refund_overflow(overflow, helper_name, resolution_type, refunded_at=None, cso_refund_mode=None): refunded_at = refunded_at or timezone.now() helper_name = helper_name or DEFAULT_HELPER_NAME period = overflow.period @@ -1946,11 +2054,12 @@ def refund_overflow(overflow, helper_name, resolution_type, refunded_at=None): Overflow.RESOLUTION_REFUND_TICKET, } ): - restored_overkill = _return_reserve_consumed_overflow( + restored_overkill = _move_approved_overflow_to_overkill( overflow, helper_name=helper_name, refunded_at=refunded_at, - restore_capacity_adjustment=resolution_type == Overflow.RESOLUTION_REFUND_OVERFLOW, + restore_capacity_adjustment=True, + reduce_transaction_total=True, ) if period: _retry_pending_overflows(period, restored_overkill.identifier) @@ -1958,13 +2067,25 @@ def refund_overflow(overflow, helper_name, resolution_type, refunded_at=None): if resolution_type == Overflow.RESOLUTION_REFUND_OVERFLOW: if overflow.status == Overflow.STATUS_CSO: + if cso_refund_mode == 'refund_spill_over': + restored_overkill = _move_approved_overflow_to_overkill( + overflow, + helper_name=helper_name, + refunded_at=refunded_at, + restore_capacity_adjustment=True, + reduce_transaction_total=True, + ) + if period: + _retry_pending_overflows(period, restored_overkill.identifier) + return restored_overkill + overflow.status = Overflow.STATUS_TCSO overflow.approved_at = None - overflow.refunded_at = refunded_at - overflow.refund_amount = overflow.amount_to_approve or overflow.excess_amount + overflow.refunded_at = None + overflow.refund_amount = None overflow.amount_to_approve = None overflow.helper_name = helper_name - overflow.resolution_type = resolution_type + overflow.resolution_type = '' overflow.save(update_fields=[ 'status', 'approved_at', @@ -1975,6 +2096,8 @@ def refund_overflow(overflow, helper_name, resolution_type, refunded_at=None): 'resolution_type', ]) overflow.collaborators.clear() + if overflow.transaction_id: + _restore_transaction_total_from_active_amounts(overflow.transaction) if period: _retry_pending_overflows(period, overflow.transaction.identifier) return overflow diff --git a/flowbit-backend/core/serializers.py b/flowbit-backend/core/serializers.py index eacc604..de9b30e 100644 --- a/flowbit-backend/core/serializers.py +++ b/flowbit-backend/core/serializers.py @@ -361,7 +361,6 @@ def _get_overflows(self, obj): def get_total_amount(self, obj): visible_total = Decimal('0.00') refunded_overflow_total = Decimal('0.00') - returned_overflow_total = Decimal('0.00') transactions = self._get_transactions(obj) for transaction in transactions: @@ -375,16 +374,8 @@ def get_total_amount(self, obj): continue if overflow.status == Overflow.STATUS_REFUNDED: refunded_overflow_total += overflow.refund_amount or Decimal('0.00') - elif ( - overflow.status == Overflow.STATUS_TCSO - and overflow.refunded_at is not None - and overflow.resolution_type == Overflow.RESOLUTION_REFUND_OVERFLOW - ): - returned_overflow_total += overflow.refund_amount or Decimal('0.00') - - active_total = visible_total - _from_allocation_basis_amount( - refunded_overflow_total + returned_overflow_total - ) + + active_total = visible_total - _from_allocation_basis_amount(refunded_overflow_total) if active_total < Decimal('0.00'): return Decimal('0.00') return active_total @@ -640,6 +631,10 @@ class TicketRefundActionSerializer(serializers.Serializer): action = serializers.ChoiceField(choices=['refund_ticket', 'refund_transaction']) transaction_id = serializers.IntegerField(required=False) sync_repeat_ticket = serializers.BooleanField(required=False, default=False) + cso_refund_mode = serializers.ChoiceField( + choices=['return_to_tcso', 'refund_spill_over'], + required=False, + ) admin_override_code = serializers.CharField( write_only=True, required=False, diff --git a/flowbit-backend/core/tests.py b/flowbit-backend/core/tests.py index ad20fc1..e715367 100644 --- a/flowbit-backend/core/tests.py +++ b/flowbit-backend/core/tests.py @@ -3786,6 +3786,140 @@ def test_ticket_total_amount_updates_after_overflow_only_refund(self): ).exists() ) + def test_ticket_refund_with_cso_can_change_back_to_tcso_without_reducing_total(self): + refund_ticket = Ticket.objects.create( + customer_name='Ticket CSO Return', + created_by=self.approver, + ) + refund_transaction = Transaction.objects.create( + ticket=refund_ticket, + identifier=self.second_identifier, + total_amount=Decimal('400.00'), + created_by=self.approver, + ) + overflow = Overflow.objects.get(transaction=refund_transaction, status=Overflow.STATUS_TCSO) + self.client.post( + f'/api/overflows/{overflow.id}/approve/', + { + 'amount_to_approve': '300.00', + 'collaborator_ids': [self.collaborator.id], + }, + format='json', + ) + + response = self.client.post( + f'/api/tickets/{refund_ticket.ticket_number}/refund/', + { + 'action': 'refund_ticket', + 'admin_override_code': 'override-123', + 'cso_refund_mode': 'return_to_tcso', + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + refund_ticket.refresh_from_db() + refund_transaction.refresh_from_db() + overflow.refresh_from_db() + self.assertFalse(refund_ticket.is_refunded) + self.assertFalse(refund_transaction.is_refunded) + self.assertEqual(overflow.status, Overflow.STATUS_TCSO) + self.assertEqual(refund_transaction.total_amount, Decimal('400.00')) + self.assertEqual(refund_ticket.total_amount, Decimal('400.00')) + + detail_response = self.client.get(f'/api/tickets/{refund_ticket.ticket_number}/') + self.assertEqual(detail_response.status_code, status.HTTP_200_OK) + self.assertEqual(Decimal(detail_response.data['total_amount']), Decimal('400.00')) + + def test_return_to_tcso_restores_transaction_total_if_old_bug_already_reduced_it(self): + refund_ticket = Ticket.objects.create( + customer_name='Ticket CSO Restore', + created_by=self.approver, + ) + refund_transaction = Transaction.objects.create( + ticket=refund_ticket, + identifier=self.second_identifier, + total_amount=Decimal('400.00'), + created_by=self.approver, + ) + overflow = Overflow.objects.get(transaction=refund_transaction, status=Overflow.STATUS_TCSO) + self.client.post( + f'/api/overflows/{overflow.id}/approve/', + { + 'amount_to_approve': '300.00', + 'collaborator_ids': [self.collaborator.id], + }, + format='json', + ) + + refund_transaction.total_amount = Decimal('160.00') + refund_transaction.save(update_fields=['total_amount']) + + response = self.client.post( + f'/api/tickets/{refund_ticket.ticket_number}/refund/', + { + 'action': 'refund_ticket', + 'admin_override_code': 'override-123', + 'cso_refund_mode': 'return_to_tcso', + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + refund_transaction.refresh_from_db() + refund_ticket.refresh_from_db() + detail_response = self.client.get(f'/api/tickets/{refund_ticket.ticket_number}/') + self.assertEqual(detail_response.status_code, status.HTTP_200_OK) + self.assertEqual(refund_transaction.total_amount, Decimal('400.00')) + self.assertEqual(Decimal(detail_response.data['total_amount']), Decimal('400.00')) + + def test_ticket_transaction_refund_with_cso_can_refund_spill_over_into_overkill(self): + refund_ticket = Ticket.objects.create( + customer_name='Transaction CSO Refund', + created_by=self.approver, + ) + refund_transaction = Transaction.objects.create( + ticket=refund_ticket, + identifier=self.second_identifier, + total_amount=Decimal('400.00'), + created_by=self.approver, + ) + overflow = Overflow.objects.get(transaction=refund_transaction, status=Overflow.STATUS_TCSO) + self.client.post( + f'/api/overflows/{overflow.id}/approve/', + { + 'amount_to_approve': '300.00', + 'collaborator_ids': [self.collaborator.id], + }, + format='json', + ) + + response = self.client.post( + f'/api/tickets/{refund_ticket.ticket_number}/refund/', + { + 'action': 'refund_transaction', + 'transaction_id': refund_transaction.id, + 'admin_override_code': 'override-123', + 'cso_refund_mode': 'refund_spill_over', + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + refund_ticket.refresh_from_db() + refund_transaction.refresh_from_db() + self.assertFalse(refund_ticket.is_refunded) + self.assertFalse(refund_transaction.is_refunded) + self.assertEqual(refund_transaction.total_amount, Decimal('160.00')) + self.assertEqual(refund_ticket.total_amount, Decimal('160.00')) + overkill = Overflow.objects.get( + identifier=self.second_identifier, + owner=self.approver, + period=self.active_period, + status=Overflow.STATUS_OVERKILL, + ) + self.assertEqual(overkill.amount_to_approve, Decimal('300.00')) + def test_ticket_refunds_are_blocked_after_period_pre_close(self): self.active_period.apply_pre_close(triggered_at=timezone.now(), acting_user=self.approver) @@ -4127,7 +4261,7 @@ def test_direct_overkill_creation_is_blocked_after_period_pre_close(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.data['detail'], 'Ticket creation is locked after the pre-close time is reached.') - def test_returning_cso_overflow_moves_it_back_to_tcso(self): + def test_returning_cso_overflow_moves_it_back_to_tcso_without_reducing_total(self): tx = Transaction.objects.create( ticket=Ticket.objects.create(customer_name='Return CSO Ticket', created_by=self.approver), identifier=self.second_identifier, @@ -4148,18 +4282,25 @@ def test_returning_cso_overflow_moves_it_back_to_tcso(self): return_response = self.client.post( f'/api/overflows/{overflow.id}/resolve/', - {'action': 'refund_overflow_only', 'admin_override_code': 'override-123'}, + { + 'action': 'refund_overflow_only', + 'admin_override_code': 'override-123', + 'cso_refund_mode': 'return_to_tcso', + }, format='json', ) self.assertEqual(return_response.status_code, status.HTTP_200_OK) overflow.refresh_from_db() + tx.refresh_from_db() tx.ticket.refresh_from_db() self.assertEqual(overflow.status, Overflow.STATUS_TCSO) self.assertIsNone(overflow.approved_at) self.assertIsNone(overflow.amount_to_approve) - self.assertEqual(overflow.refund_amount, Decimal('300.00')) - self.assertEqual(tx.ticket.total_amount, Decimal('160.00')) + self.assertIsNone(overflow.refunded_at) + self.assertIsNone(overflow.refund_amount) + self.assertEqual(tx.total_amount, Decimal('400.00')) + self.assertEqual(tx.ticket.total_amount, Decimal('400.00')) self.assertFalse( IdentifierCapacityAdjustment.objects.filter( overflow=overflow, @@ -4167,6 +4308,48 @@ def test_returning_cso_overflow_moves_it_back_to_tcso(self): ).exists() ) + def test_refunding_cso_spill_over_moves_it_to_overkill_and_reduces_total(self): + tx = Transaction.objects.create( + ticket=Ticket.objects.create(customer_name='Refund CSO Ticket', created_by=self.approver), + identifier=self.second_identifier, + total_amount=Decimal('400.00'), + created_by=self.approver, + ) + overflow = Overflow.objects.get(transaction=tx, status=Overflow.STATUS_TCSO) + + self.client.post( + f'/api/overflows/{overflow.id}/approve/', + { + 'amount_to_approve': '300.00', + 'collaborator_ids': [self.collaborator.id], + }, + format='json', + ) + + refund_response = self.client.post( + f'/api/overflows/{overflow.id}/resolve/', + { + 'action': 'refund_overflow_only', + 'admin_override_code': 'override-123', + 'cso_refund_mode': 'refund_spill_over', + }, + format='json', + ) + + self.assertEqual(refund_response.status_code, status.HTTP_200_OK) + tx.refresh_from_db() + tx.ticket.refresh_from_db() + self.assertEqual(tx.total_amount, Decimal('160.00')) + self.assertEqual(tx.ticket.total_amount, Decimal('160.00')) + overkill = Overflow.objects.get( + identifier=self.second_identifier, + owner=self.approver, + period=self.active_period, + status=Overflow.STATUS_OVERKILL, + ) + self.assertEqual(overkill.amount_to_approve, Decimal('300.00')) + self.assertEqual(list(overkill.collaborators.values_list('id', flat=True)), [self.collaborator.id]) + def test_reapproving_returned_cso_restores_active_total(self): tx = Transaction.objects.create( ticket=Ticket.objects.create(customer_name='Reapprove CSO Ticket', created_by=self.approver), @@ -4186,7 +4369,11 @@ def test_reapproving_returned_cso_restores_active_total(self): ) self.client.post( f'/api/overflows/{overflow.id}/resolve/', - {'action': 'refund_overflow_only', 'admin_override_code': 'override-123'}, + { + 'action': 'refund_overflow_only', + 'admin_override_code': 'override-123', + 'cso_refund_mode': 'return_to_tcso', + }, format='json', ) @@ -4201,12 +4388,82 @@ def test_reapproving_returned_cso_restores_active_total(self): self.assertEqual(reapprove_response.status_code, status.HTTP_200_OK) overflow.refresh_from_db() + tx.refresh_from_db() tx.ticket.refresh_from_db() self.assertEqual(overflow.status, Overflow.STATUS_CSO) self.assertIsNone(overflow.refunded_at) self.assertIsNone(overflow.refund_amount) + self.assertEqual(tx.total_amount, Decimal('400.00')) self.assertEqual(tx.ticket.total_amount, Decimal('400.00')) + def test_refund_transaction_on_cso_can_change_back_to_tcso(self): + tx = Transaction.objects.create( + ticket=Ticket.objects.create(customer_name='Refund Tx CSO Ticket', created_by=self.approver), + identifier=self.second_identifier, + total_amount=Decimal('400.00'), + created_by=self.approver, + ) + overflow = Overflow.objects.get(transaction=tx, status=Overflow.STATUS_TCSO) + self.client.post( + f'/api/overflows/{overflow.id}/approve/', + { + 'amount_to_approve': '300.00', + 'collaborator_ids': [self.collaborator.id], + }, + format='json', + ) + + response = self.client.post( + f'/api/overflows/{overflow.id}/resolve/', + { + 'action': 'refund_transaction', + 'admin_override_code': 'override-123', + 'cso_refund_mode': 'return_to_tcso', + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + overflow.refresh_from_db() + tx.refresh_from_db() + self.assertFalse(tx.is_refunded) + self.assertEqual(overflow.status, Overflow.STATUS_TCSO) + self.assertEqual(tx.total_amount, Decimal('400.00')) + + def test_refund_ticket_on_cso_can_refund_spill_over_into_overkill(self): + tx = Transaction.objects.create( + ticket=Ticket.objects.create(customer_name='Refund Ticket CSO Ticket', created_by=self.approver), + identifier=self.second_identifier, + total_amount=Decimal('400.00'), + created_by=self.approver, + ) + overflow = Overflow.objects.get(transaction=tx, status=Overflow.STATUS_TCSO) + self.client.post( + f'/api/overflows/{overflow.id}/approve/', + { + 'amount_to_approve': '300.00', + 'collaborator_ids': [self.collaborator.id], + }, + format='json', + ) + + response = self.client.post( + f'/api/overflows/{overflow.id}/resolve/', + { + 'action': 'refund_ticket', + 'admin_override_code': 'override-123', + 'cso_refund_mode': 'refund_spill_over', + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + tx.refresh_from_db() + tx.ticket.refresh_from_db() + self.assertFalse(tx.is_refunded) + self.assertEqual(tx.total_amount, Decimal('160.00')) + self.assertEqual(tx.ticket.total_amount, Decimal('160.00')) + def test_returning_overkill_removes_it_and_clears_reserve_capacity(self): tx = Transaction.objects.create( ticket=Ticket.objects.create(customer_name='Return Overkill Ticket', created_by=self.approver), diff --git a/flowbit-backend/core/views.py b/flowbit-backend/core/views.py index 990e767..256265c 100644 --- a/flowbit-backend/core/views.py +++ b/flowbit-backend/core/views.py @@ -53,7 +53,6 @@ LedgerAllocation, IdentifierCapacityAdjustment, IdentifierLedgerFreeze, - _is_returned_pending_overflow, _announce_pending_overflows, _notify_remaining_overkill_for_lucky_draw, _retry_pending_overflows, @@ -3089,6 +3088,8 @@ def resolve_overflow(self, request, pk=None): if action_name in {'', 'approve'}: return self._approve_overflow(overflow, request) + cso_refund_mode = request.data.get('cso_refund_mode') + if action_name in {'refund_overflow_only', 'refund_transaction', 'refund_ticket'}: if period_locked_after_lucky_draw(overflow.period): return Response( @@ -3125,6 +3126,7 @@ def resolve_overflow(self, request, pk=None): overflow, helper_name=helper_name, resolution_type=Overflow.RESOLUTION_REFUND_OVERFLOW, + cso_refund_mode=cso_refund_mode, ) serializer_data = self.get_serializer(resolved_overflow).data if resolved_overflow is not None else None record_audit_log( @@ -3138,6 +3140,7 @@ def resolve_overflow(self, request, pk=None): ), changes={ 'resolution_type': Overflow.RESOLUTION_REFUND_OVERFLOW, + 'cso_refund_mode': cso_refund_mode or '', 'ticket_number': overflow_ticket_number, 'transaction_id': overflow_transaction_id, 'order_number': overflow_order_number, @@ -3171,11 +3174,66 @@ def resolve_overflow(self, request, pk=None): generation=repeat_generation, ) return Response({ - "message": "Overflow refunded successfully", + "message": ( + "Overflow changed back to pending successfully" + if cso_refund_mode == 'return_to_tcso' + else "Overflow refunded successfully" + ), "overflow": serializer_data, }, status=status.HTTP_200_OK) if action_name == 'refund_transaction': + if overflow.status == Overflow.STATUS_CSO and cso_refund_mode in {'return_to_tcso', 'refund_spill_over'}: + related_ticket = overflow.transaction.ticket if overflow.transaction_id and overflow.transaction.ticket_id else None + with db_transaction.atomic(): + refund_overflow( + overflow, + helper_name=helper_name, + resolution_type=Overflow.RESOLUTION_REFUND_OVERFLOW, + cso_refund_mode=cso_refund_mode, + ) + if sync_repeat_ticket and related_ticket is not None: + repeat_generation = RepeatTicketGeneration.objects.select_related('repeat_ticket').filter( + ticket=related_ticket + ).first() + if repeat_generation is not None: + _sync_repeat_ticket_from_ticket( + repeat_ticket=repeat_generation.repeat_ticket, + ticket=related_ticket, + generation=repeat_generation, + ) + record_audit_log( + request, + 'overflow.refunded', + target=overflow, + details=f"Resolved CSO refund flow for transaction '{overflow.transaction.order_number}'", + changes={ + 'resolution_type': Overflow.RESOLUTION_REFUND_TRANSACTION, + 'cso_refund_mode': cso_refund_mode, + 'ticket_number': overflow.transaction.ticket.ticket_number if overflow.transaction.ticket_id else '', + 'transaction_id': overflow.transaction.id, + 'order_number': overflow.transaction.order_number, + 'identifier_number': overflow.transaction.identifier.number, + }, + ) + notify_refund_change( + recipient=overflow.transaction.created_by, + title='Spill over refunded', + message=f"Approved spill over for transaction {overflow.transaction.order_number} was updated.", + request_user=request.user, + action_href='/spill-over', + source_key=f'refund:transaction-cso:{overflow.transaction.id}:{timezone.now().isoformat()}', + period=overflow.period, + ) + refresh_dashboard_for_user(overflow.transaction.created_by) + return Response({ + "message": ( + "Approved spill over moved back to pending successfully" + if cso_refund_mode == 'return_to_tcso' + else "Approved spill over refunded successfully" + ), + }, status=status.HTTP_200_OK) + refund_amount = overflow.transaction.total_amount related_ticket = overflow.transaction.ticket if overflow.transaction_id and overflow.transaction.ticket_id else None with db_transaction.atomic(): @@ -3229,6 +3287,56 @@ def resolve_overflow(self, request, pk=None): status=status.HTTP_400_BAD_REQUEST, ) ticket = overflow.transaction.ticket + if overflow.status == Overflow.STATUS_CSO and cso_refund_mode in {'return_to_tcso', 'refund_spill_over'}: + with db_transaction.atomic(): + refund_overflow( + overflow, + helper_name=helper_name, + resolution_type=Overflow.RESOLUTION_REFUND_OVERFLOW, + cso_refund_mode=cso_refund_mode, + ) + if sync_repeat_ticket: + repeat_generation = RepeatTicketGeneration.objects.select_related('repeat_ticket').filter( + ticket=ticket + ).first() + if repeat_generation is not None: + _sync_repeat_ticket_from_ticket( + repeat_ticket=repeat_generation.repeat_ticket, + ticket=ticket, + generation=repeat_generation, + ) + record_audit_log( + request, + 'overflow.refunded', + target=overflow, + details=f"Resolved CSO refund flow for ticket '{ticket.ticket_number}'", + changes={ + 'resolution_type': Overflow.RESOLUTION_REFUND_TICKET, + 'cso_refund_mode': cso_refund_mode, + 'ticket_number': ticket.ticket_number, + 'transaction_id': overflow.transaction.id, + 'order_number': overflow.transaction.order_number, + 'identifier_number': overflow.transaction.identifier.number, + }, + ) + notify_refund_change( + recipient=ticket.created_by, + title='Spill over refunded', + message=f"Approved spill over on ticket {ticket.ticket_number} was updated.", + request_user=request.user, + action_href='/spill-over', + source_key=f'refund:ticket-cso:{ticket.id}:{timezone.now().isoformat()}', + period=overflow.period, + ) + refresh_dashboard_for_user(ticket.created_by) + return Response({ + "message": ( + "Approved spill over moved back to pending successfully" + if cso_refund_mode == 'return_to_tcso' + else "Approved spill over refunded successfully" + ), + }, status=status.HTTP_200_OK) + refund_summary = _ticket_refund_summary(ticket) with db_transaction.atomic(): @@ -5214,16 +5322,6 @@ def get_queryset(self): total=Sum('refund_amount'), ).values('total')[:1] - returned_overflow_total_subquery = Overflow.objects.filter( - transaction__ticket=OuterRef('pk'), - transaction__is_refunded=False, - status=Overflow.STATUS_TCSO, - refunded_at__isnull=False, - resolution_type=Overflow.RESOLUTION_REFUND_OVERFLOW, - ).values('transaction__ticket').annotate( - total=Sum('refund_amount'), - ).values('total')[:1] - queryset = queryset.annotate( ticket_transaction_count=Count('transactions', distinct=True), active_spill_over_count_annotated=Count( @@ -5251,11 +5349,6 @@ def get_queryset(self): Value(Decimal('0.00')), output_field=DecimalField(max_digits=14, decimal_places=2), ), - returned_overflow_total_annotated=Coalesce( - Subquery(returned_overflow_total_subquery), - Value(Decimal('0.00')), - output_field=DecimalField(max_digits=14, decimal_places=2), - ), ).prefetch_related(ticket_transactions).distinct() queryset = queryset.annotate( @@ -5263,7 +5356,7 @@ def get_queryset(self): Value(Decimal('0.00')), ExpressionWrapper( F('visible_total_amount_annotated') - ExpressionWrapper( - (F('refunded_overflow_total_annotated') + F('returned_overflow_total_annotated')) / Value(Decimal('1.25')), + F('refunded_overflow_total_annotated') / Value(Decimal('1.25')), output_field=DecimalField(max_digits=14, decimal_places=2), ), output_field=DecimalField(max_digits=14, decimal_places=2), @@ -5380,6 +5473,7 @@ def post(self, request, ticket_number): helper_name = helper_name_from_request(request) action_name = validated['action'] sync_repeat_ticket = validated.get('sync_repeat_ticket', False) + cso_refund_mode = validated.get('cso_refund_mode') if action_name == 'refund_ticket': transactions = list(ticket.transactions.all()) @@ -5388,6 +5482,61 @@ def post(self, request, ticket_number): {"detail": "This ticket has already been refunded."}, status=status.HTTP_400_BAD_REQUEST, ) + + cso_overflows = [ + overflow + for transaction in transactions + for overflow in transaction.overflows.all() + if overflow.status == Overflow.STATUS_CSO + ] + if cso_overflows and cso_refund_mode in {'return_to_tcso', 'refund_spill_over'}: + with db_transaction.atomic(): + for overflow in cso_overflows: + refund_overflow( + overflow, + helper_name=helper_name, + resolution_type=Overflow.RESOLUTION_REFUND_OVERFLOW, + cso_refund_mode=cso_refund_mode, + ) + if sync_repeat_ticket: + repeat_generation = RepeatTicketGeneration.objects.select_related('repeat_ticket').filter( + ticket=ticket + ).first() + if repeat_generation is not None: + _sync_repeat_ticket_from_ticket( + repeat_ticket=repeat_generation.repeat_ticket, + ticket=ticket, + generation=repeat_generation, + ) + + record_audit_log( + request, + 'ticket.refunded', + target=ticket, + details=f"Resolved CSO refund flow for ticket '{ticket.ticket_number}'", + changes={ + 'resolution_type': Overflow.RESOLUTION_REFUND_TICKET, + 'cso_refund_mode': cso_refund_mode, + }, + ) + notify_refund_change( + recipient=ticket.created_by, + title='Spill over refunded', + message=f"Approved spill over on ticket {ticket.ticket_number} was updated.", + request_user=request.user, + action_href='/tickets', + source_key=f'ticket-cso-refund:{ticket.id}:{timezone.now().isoformat()}', + period=ticket.transactions.first().period if ticket.transactions.exists() else None, + ) + refresh_dashboard_for_user(ticket.created_by) + return Response({ + "message": ( + f"Approved spill over on ticket '{ticket.ticket_number}' moved back to pending successfully" + if cso_refund_mode == 'return_to_tcso' + else f"Approved spill over on ticket '{ticket.ticket_number}' refunded successfully" + ), + }, status=status.HTTP_200_OK) + refund_summary = _ticket_refund_summary(ticket) with db_transaction.atomic(): @@ -5443,6 +5592,61 @@ def post(self, request, ticket_number): status=status.HTTP_400_BAD_REQUEST, ) + cso_overflows = [ + overflow for overflow in transaction_obj.overflows.all() if overflow.status == Overflow.STATUS_CSO + ] + if cso_overflows and cso_refund_mode in {'return_to_tcso', 'refund_spill_over'}: + with db_transaction.atomic(): + for overflow in cso_overflows: + refund_overflow( + overflow, + helper_name=helper_name, + resolution_type=Overflow.RESOLUTION_REFUND_OVERFLOW, + cso_refund_mode=cso_refund_mode, + ) + if sync_repeat_ticket: + repeat_generation = RepeatTicketGeneration.objects.select_related('repeat_ticket').filter( + ticket=ticket + ).first() + if repeat_generation is not None: + _sync_repeat_ticket_from_ticket( + repeat_ticket=repeat_generation.repeat_ticket, + ticket=ticket, + generation=repeat_generation, + ) + + record_audit_log( + request, + 'transaction.refunded', + target=transaction_obj, + details=f"Resolved CSO refund flow for transaction '{transaction_obj.order_number}'", + changes={ + 'resolution_type': Overflow.RESOLUTION_REFUND_TRANSACTION, + 'cso_refund_mode': cso_refund_mode, + 'ticket_number': ticket.ticket_number, + 'transaction_id': transaction_obj.id, + 'order_number': transaction_obj.order_number, + 'identifier_number': transaction_obj.identifier.number, + }, + ) + notify_refund_change( + recipient=ticket.created_by, + title='Spill over refunded', + message=f"Approved spill over for transaction {transaction_obj.order_number} on ticket {ticket.ticket_number} was updated.", + request_user=request.user, + action_href='/tickets', + source_key=f'ticket-transaction-cso-refund:{transaction_obj.id}:{timezone.now().isoformat()}', + period=transaction_obj.period, + ) + refresh_dashboard_for_user(ticket.created_by) + return Response({ + "message": ( + f"Approved spill over for transaction '{transaction_obj.order_number}' moved back to pending successfully" + if cso_refund_mode == 'return_to_tcso' + else f"Approved spill over for transaction '{transaction_obj.order_number}' refunded successfully" + ), + }, status=status.HTTP_200_OK) + with db_transaction.atomic(): refund_transactions( [transaction_obj], @@ -5499,21 +5703,12 @@ def _ticket_visible_total(ticket): def _transaction_visible_request_amount(transaction_obj): refunded_overflow_total = Decimal('0.00') - returned_overflow_total = Decimal('0.00') for overflow in transaction_obj.overflows.all(): if overflow.status == Overflow.STATUS_REFUNDED: refunded_overflow_total += overflow.refund_amount or Decimal('0.00') - elif ( - overflow.status == Overflow.STATUS_TCSO - and overflow.refunded_at is not None - and overflow.resolution_type == Overflow.RESOLUTION_REFUND_OVERFLOW - ): - returned_overflow_total += overflow.refund_amount or Decimal('0.00') - active_total = transaction_obj.total_amount - _from_allocation_basis_amount( - refunded_overflow_total + returned_overflow_total - ) + active_total = transaction_obj.total_amount - _from_allocation_basis_amount(refunded_overflow_total) if active_total < Decimal('0.00'): return Decimal('0.00') return active_total @@ -5531,7 +5726,7 @@ def _ticket_visible_line_amount(transaction_obj): active_overflows = [ overflow for overflow in transaction_obj.overflows.all() - if overflow.status != Overflow.STATUS_REFUNDED and not _is_returned_pending_overflow(overflow) + if overflow.status != Overflow.STATUS_REFUNDED ] overflow_total = sum( ( diff --git a/flowbit-frontend/src/components/overflow/spill-over-page.tsx b/flowbit-frontend/src/components/overflow/spill-over-page.tsx index 3947edc..0f56f2f 100644 --- a/flowbit-frontend/src/components/overflow/spill-over-page.tsx +++ b/flowbit-frontend/src/components/overflow/spill-over-page.tsx @@ -99,6 +99,14 @@ function sanitizeWholeNumberInput(value: string) { return value.replace(/[^\d]/g, ""); } +function parseWholeNumberInput(value: string) { + const normalized = sanitizeWholeNumberInput(value); + if (!normalized) { + return NaN; + } + return Number(normalized); +} + function normalizeIdentifierNumber(value: string) { const digits = value.replace(/\D/g, ""); if (!digits) { @@ -211,6 +219,11 @@ export function SpillOverPage() { const [refundTarget, setRefundTarget] = useState<{ overflow: FlowBitOverflow; action: RefundAction; + csoRefundMode?: "return_to_tcso" | "refund_spill_over"; + } | null>(null); + const [pendingCsoRefundChoice, setPendingCsoRefundChoice] = useState<{ + overflow: FlowBitOverflow; + action: RefundAction; } | null>(null); const [pendingExtraApproval, setPendingExtraApproval] = useState<{ overflowAmount: number; @@ -237,7 +250,7 @@ export function SpillOverPage() { } | null>(null); const effectiveUserRole = currentUserState?.user?.role ?? user?.role ?? ""; - const requiresOverride = effectiveUserRole !== "admin"; + const requiresOverride = true; useEffect(() => { setUser(getStoredUser()); @@ -334,7 +347,13 @@ export function SpillOverPage() { function openApproveModal(overflow: FlowBitOverflow) { setApproveTarget(overflow); - setApproveAmount(formatWholeAmount(overflow.amount_to_approve || overflow.excess_amount || "")); + const rawApproveAmount = overflow.amount_to_approve || overflow.excess_amount || ""; + const parsedApproveAmount = Number(String(rawApproveAmount).replace(/,/g, "")); + setApproveAmount( + Number.isNaN(parsedApproveAmount) || parsedApproveAmount <= 0 + ? "" + : formatWholeAmount(String(parsedApproveAmount)), + ); const initialCollaboratorId = overflow.collaborators[0]; setSelectedCollaboratorIds(initialCollaboratorId ? [initialCollaboratorId] : []); setEditingCollaboratorId(null); @@ -376,7 +395,10 @@ export function SpillOverPage() { } const overflowAmount = Number(approveTarget.excess_amount || "0"); - const nextApproveAmount = Number(approveAmount.trim() || overflowAmount); + const parsedApproveAmount = parseWholeNumberInput(approveAmount); + const nextApproveAmount = Number.isNaN(parsedApproveAmount) + ? overflowAmount + : parsedApproveAmount; if (nextApproveAmount > overflowAmount) { setPendingExtraApproval({ overflowAmount, @@ -473,7 +495,7 @@ export function SpillOverPage() { try { const response = await approveOverflow({ overflowId: approveTarget.id, - amountToApprove: approveAmount.trim() || undefined, + amountToApprove: sanitizeWholeNumberInput(approveAmount) || undefined, collaboratorIds: selectedCollaboratorIds, }); setToast({ type: "success", message: response.message }); @@ -531,19 +553,29 @@ export function SpillOverPage() { return; } + const relatedTicketNumber = refundTarget.overflow.ticket_number; setBusyLabel("Processing refund"); try { const response = await resolveOverflowAction({ overflowId: refundTarget.overflow.id, action: refundTarget.action, adminOverrideCode: requiresOverride ? overrideCode : undefined, + csoRefundMode: refundTarget.csoRefundMode, syncRepeatTicket: syncRepeatTicketRefund, }); setToast({ type: "success", message: response.message }); setRefundTarget(null); + setPendingCsoRefundChoice(null); setOverrideCode(""); setSyncRepeatTicketRefund(false); await loadPageData(); + if ( + relatedTicketNumber && + selectedTicketDetail?.ticket_number === relatedTicketNumber + ) { + const refreshedTicketDetail = await fetchTicketDetail(relatedTicketNumber); + setSelectedTicketDetail(refreshedTicketDetail); + } notifyDashboardUpdated(); } catch (error) { const message = error instanceof Error ? error.message : "Request failed."; @@ -553,6 +585,17 @@ export function SpillOverPage() { } } + function queueRefundAction(overflow: FlowBitOverflow, action: RefundAction) { + if (overflow.status === "CSO") { + setRefundPickerTarget(null); + setPendingCsoRefundChoice({ overflow, action }); + return; + } + + setRefundPickerTarget(null); + setRefundTarget({ overflow, action }); + } + async function openTicketView(ticketNumber: string | null) { if (!ticketNumber) { return; @@ -859,7 +902,10 @@ export function SpillOverPage() { inputMode="numeric" pattern="[0-9]*" value={approveAmount} - onChange={(event) => setApproveAmount(sanitizeWholeNumberInput(event.target.value))} + onChange={(event) => { + const normalized = sanitizeWholeNumberInput(event.target.value); + setApproveAmount(normalized ? formatWholeAmount(normalized) : ""); + }} placeholder={formatAmount(approveTarget.excess_amount)} /> @@ -1035,6 +1081,7 @@ export function SpillOverPage() { onCodeChange={setOverrideCode} onCancel={() => { setRefundTarget(null); + setPendingCsoRefundChoice(null); setOverrideCode(""); setSyncRepeatTicketRefund(false); }} @@ -1053,6 +1100,69 @@ export function SpillOverPage() { ) : null} + + {pendingCsoRefundChoice ? ( +
Approved spill-over
++ This spill-over is currently approved. Choose whether you want to move it back to pending `TCSO` or refund the approved spill-over amount into overkill. +
+Refund
@@ -124,21 +136,6 @@ export function TicketRefundModal({ Choose whether to refund the full ticket, a transaction, or a single spill-over item. - {requireOverrideCode ? ( - - ) : null} - {ticket.repeat_ticket_id ? (- Confirmation -
-- {confirmAction.label}. This action will reverse the selected ticket records. -
- {requireOverrideCode ? ( - - ) : null} -Refund spill-over
++ Move approved spill-over into overkill and reduce the active transaction amount. +
+