From de5f108701f1c82416836568cf40e01aa5d32a1c Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Fri, 22 May 2026 13:00:55 +0100 Subject: [PATCH 1/6] Align override code confirmation UX Co-authored-by: Khant Zayar --- .../src/components/overflow/spill-over-page.tsx | 2 +- .../src/components/period/period-page.tsx | 2 +- .../components/tickets/ticket-refund-modal.tsx | 15 --------------- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/flowbit-frontend/src/components/overflow/spill-over-page.tsx b/flowbit-frontend/src/components/overflow/spill-over-page.tsx index 3947edc..ae09ca3 100644 --- a/flowbit-frontend/src/components/overflow/spill-over-page.tsx +++ b/flowbit-frontend/src/components/overflow/spill-over-page.tsx @@ -237,7 +237,7 @@ export function SpillOverPage() { } | null>(null); const effectiveUserRole = currentUserState?.user?.role ?? user?.role ?? ""; - const requiresOverride = effectiveUserRole !== "admin"; + const requiresOverride = true; useEffect(() => { setUser(getStoredUser()); diff --git a/flowbit-frontend/src/components/period/period-page.tsx b/flowbit-frontend/src/components/period/period-page.tsx index fe2ab0d..8781fd5 100644 --- a/flowbit-frontend/src/components/period/period-page.tsx +++ b/flowbit-frontend/src/components/period/period-page.tsx @@ -526,7 +526,7 @@ export function PeriodPage() { ? "Delete period" : "Save changes" } - showCodeInput={false} + showCodeInput={true} busy={isSaving} onCodeChange={setOverrideCode} onCancel={() => { diff --git a/flowbit-frontend/src/components/tickets/ticket-refund-modal.tsx b/flowbit-frontend/src/components/tickets/ticket-refund-modal.tsx index d8727ec..982972c 100644 --- a/flowbit-frontend/src/components/tickets/ticket-refund-modal.tsx +++ b/flowbit-frontend/src/components/tickets/ticket-refund-modal.tsx @@ -124,21 +124,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 ? ( @@ -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 ? ( +
setPendingCsoRefundChoice(null)} + > +
event.stopPropagation()} + > +

Approved spill-over

+

{pendingCsoRefundChoice.overflow.identifier_number}

+

+ 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. +

+
+ + +
+
+ +
+
+
+ ) : null} {selectedCollaborator ? (
{ - setRefundPickerTarget(null); - setRefundTarget({ overflow: refundPickerTarget, action: "refund_overflow_only" }); + queueRefundAction(refundPickerTarget, "refund_overflow_only"); }} >
@@ -1285,8 +1394,7 @@ export function SpillOverPage() { type="button" className="flex w-full items-center justify-between rounded-[22px] border border-stone-900/8 bg-stone-50 px-4 py-4 text-left transition hover:border-stone-900/20" onClick={() => { - setRefundPickerTarget(null); - setRefundTarget({ overflow: refundPickerTarget, action: "refund_transaction" }); + queueRefundAction(refundPickerTarget, "refund_transaction"); }} >
@@ -1300,8 +1408,7 @@ export function SpillOverPage() { type="button" className="flex w-full items-center justify-between rounded-[22px] border border-stone-900/8 bg-stone-50 px-4 py-4 text-left transition hover:border-stone-900/20" onClick={() => { - setRefundPickerTarget(null); - setRefundTarget({ overflow: refundPickerTarget, action: "refund_ticket" }); + queueRefundAction(refundPickerTarget, "refund_ticket"); }} >
diff --git a/flowbit-frontend/src/lib/overflow-client.ts b/flowbit-frontend/src/lib/overflow-client.ts index fa1dac6..1b5eb55 100644 --- a/flowbit-frontend/src/lib/overflow-client.ts +++ b/flowbit-frontend/src/lib/overflow-client.ts @@ -233,6 +233,7 @@ export async function resolveOverflowAction(payload: { overflowId: number; action: "refund_overflow_only" | "refund_transaction" | "refund_ticket"; adminOverrideCode?: string; + csoRefundMode?: "return_to_tcso" | "refund_spill_over"; syncRepeatTicket?: boolean; }) { return apiRequest<{ message: string }>(`/overflows/${payload.overflowId}/resolve/`, { @@ -240,6 +241,7 @@ export async function resolveOverflowAction(payload: { headers: authHeaders(), body: JSON.stringify({ action: payload.action, + ...(payload.csoRefundMode ? { cso_refund_mode: payload.csoRefundMode } : {}), ...(payload.syncRepeatTicket ? { sync_repeat_ticket: true } : {}), ...(payload.adminOverrideCode ? { admin_override_code: payload.adminOverrideCode } From 3733ed09474b0795c5e5ed9aa5fa7bbd43e26289 Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Sat, 23 May 2026 03:09:26 +0100 Subject: [PATCH 3/6] Apply CSO refund flow in ticket history Co-authored-by: Khant Zayar --- flowbit-backend/core/serializers.py | 4 + flowbit-backend/core/tests.py | 88 ++++++++++++++ flowbit-backend/core/views.py | 111 ++++++++++++++++++ .../tickets/ticket-history-page.tsx | 20 +++- .../tickets/ticket-refund-modal.tsx | 91 +++++++++++--- flowbit-frontend/src/lib/ticket-client.ts | 4 + 6 files changed, 298 insertions(+), 20 deletions(-) diff --git a/flowbit-backend/core/serializers.py b/flowbit-backend/core/serializers.py index eacc604..d2ab98d 100644 --- a/flowbit-backend/core/serializers.py +++ b/flowbit-backend/core/serializers.py @@ -640,6 +640,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 ef9add9..a9b481b 100644 --- a/flowbit-backend/core/tests.py +++ b/flowbit-backend/core/tests.py @@ -3786,6 +3786,94 @@ 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')) + + 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) diff --git a/flowbit-backend/core/views.py b/flowbit-backend/core/views.py index e97e498..0d43406 100644 --- a/flowbit-backend/core/views.py +++ b/flowbit-backend/core/views.py @@ -5489,6 +5489,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()) @@ -5497,6 +5498,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(): @@ -5552,6 +5608,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], diff --git a/flowbit-frontend/src/components/tickets/ticket-history-page.tsx b/flowbit-frontend/src/components/tickets/ticket-history-page.tsx index ed55911..99c362f 100644 --- a/flowbit-frontend/src/components/tickets/ticket-history-page.tsx +++ b/flowbit-frontend/src/components/tickets/ticket-history-page.tsx @@ -260,7 +260,11 @@ export function TicketHistoryPage() { } } - async function runOverflowRefundAction(overflowId: number, kind: "overflow") { + async function runOverflowRefundAction( + overflowId: number, + kind: "overflow", + csoRefundMode?: "return_to_tcso" | "refund_spill_over", + ) { if (!adminOverrideCode.trim()) { setToast({ type: "error", @@ -275,6 +279,7 @@ export function TicketHistoryPage() { overflowId, action: "refund_overflow_only", adminOverrideCode: adminOverrideCode.trim() || undefined, + csoRefundMode, syncRepeatTicket, }); await refreshTicketHistoryState(); @@ -299,6 +304,7 @@ export function TicketHistoryPage() { action: "refund_ticket" | "refund_transaction", kind: "ticket" | "transaction", transactionId?: number, + csoRefundMode?: "return_to_tcso" | "refund_spill_over", ) { if (!selectedTicketNumber) { return; @@ -320,6 +326,7 @@ export function TicketHistoryPage() { action, transactionId, adminOverrideCode: adminOverrideCode.trim() || undefined, + csoRefundMode, syncRepeatTicket, }); await refreshTicketHistoryState(); @@ -565,16 +572,19 @@ export function TicketHistoryPage() { setAdminOverrideCode(""); setSyncRepeatTicket(false); }} - onRefundTicket={() => runTicketRefundAction("refund_ticket", "ticket")} - onRefundTransaction={(transactionId) => + onRefundTicket={(csoRefundMode) => + runTicketRefundAction("refund_ticket", "ticket", undefined, csoRefundMode) + } + onRefundTransaction={(transactionId, csoRefundMode) => runTicketRefundAction( "refund_transaction", "transaction", transactionId, + csoRefundMode, ) } - onRefundOverflow={(overflowId) => - runOverflowRefundAction(overflowId, "overflow") + onRefundOverflow={(overflowId, csoRefundMode) => + runOverflowRefundAction(overflowId, "overflow", csoRefundMode) } /> diff --git a/flowbit-frontend/src/components/tickets/ticket-refund-modal.tsx b/flowbit-frontend/src/components/tickets/ticket-refund-modal.tsx index 982972c..11304e6 100644 --- a/flowbit-frontend/src/components/tickets/ticket-refund-modal.tsx +++ b/flowbit-frontend/src/components/tickets/ticket-refund-modal.tsx @@ -19,9 +19,15 @@ type TicketRefundModalProps = { onCodeChange: (value: string) => void; onSyncRepeatTicketChange: (value: boolean) => void; onClose: () => void; - onRefundTicket: () => void; - onRefundTransaction: (transactionId: number) => void; - onRefundOverflow: (overflowId: number) => void; + onRefundTicket: (csoRefundMode?: "return_to_tcso" | "refund_spill_over") => void; + onRefundTransaction: ( + transactionId: number, + csoRefundMode?: "return_to_tcso" | "refund_spill_over", + ) => void; + onRefundOverflow: ( + overflowId: number, + csoRefundMode?: "return_to_tcso" | "refund_spill_over", + ) => void; }; function formatAmount(value: string) { @@ -37,9 +43,9 @@ function formatAmount(value: string) { } type ConfirmRefundAction = - | { kind: "ticket"; label: string } - | { kind: "transaction"; id: number; label: string } - | { kind: "overflow"; id: number; label: string }; + | { kind: "ticket"; label: string; requiresCsoChoice?: boolean } + | { kind: "transaction"; id: number; label: string; requiresCsoChoice?: boolean } + | { kind: "overflow"; id: number; label: string; requiresCsoChoice?: boolean }; export function TicketRefundModal({ open, @@ -73,14 +79,19 @@ export function TicketRefundModal({ })), ); const [confirmAction, setConfirmAction] = useState(null); + const [csoRefundMode, setCsoRefundMode] = useState< + "return_to_tcso" | "refund_spill_over" | "" + >(""); function closeModal() { setConfirmAction(null); + setCsoRefundMode(""); onClose(); } function openConfirmation(action: ConfirmRefundAction) { setConfirmAction(action); + setCsoRefundMode(""); } function handleConfirmRefund() { @@ -93,16 +104,16 @@ export function TicketRefundModal({ } if (confirmAction.kind === "ticket") { - onRefundTicket(); + onRefundTicket(csoRefundMode || undefined); return; } if (confirmAction.kind === "transaction") { - onRefundTransaction(confirmAction.id); + onRefundTransaction(confirmAction.id, csoRefundMode || undefined); return; } - onRefundOverflow(confirmAction.id); + onRefundOverflow(confirmAction.id, csoRefundMode || undefined); } return ( @@ -150,11 +161,14 @@ export function TicketRefundModal({ variant="outline" className="rounded-[18px]" onClick={() => - openConfirmation({ - kind: "ticket", - label: `Refund the full ticket ${ticket.ticket_number}`, - }) - } + openConfirmation({ + kind: "ticket", + label: `Refund the full ticket ${ticket.ticket_number}`, + requiresCsoChoice: ticket.transactions.some((transaction) => + transaction.overflows.some((overflow) => overflow.status === "CSO"), + ), + }) + } disabled={Boolean(busyAction)} > {busyAction?.kind === "ticket" ? "Refunding..." : "Refund ticket"} @@ -194,6 +208,9 @@ export function TicketRefundModal({ kind: "transaction", id: transaction.id, label: `Refund transaction ${transaction.order_number}`, + requiresCsoChoice: transaction.overflows.some( + (overflow) => overflow.status === "CSO", + ), }) } disabled={Boolean(busyAction)} @@ -238,6 +255,7 @@ export function TicketRefundModal({ kind: "overflow", id: overflow.id, label: `Refund spill over for ${overflow.identifierNumber}`, + requiresCsoChoice: overflow.status === "CSO", }) } disabled={Boolean(busyAction)} @@ -276,6 +294,45 @@ export function TicketRefundModal({

{confirmAction.label}. This action will reverse the selected ticket records.

+ {confirmAction.requiresCsoChoice ? ( +
+

+ Approved spill-over handling +

+ + +
+ ) : null} {requireOverrideCode ? (