Uh oh!
There was an error while loading. Please reload this page.
feat(refunds): refund a booking from the Desk - #328
Conversation
Event Booking Refund holds one row per refund raised against a booking: the gateway refund id, its amount, whether the gateway has settled it, the tickets it covers and the cancellation request queued for them. The booking itself carries the summary — refund status and the total refunded so far — in a section that appears only once there is a refund to show. Every field is allow_on_submit, on the child doctype as well as the parent, since refunds always land after the booking is submitted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`EventBooking.refund` is System Manager only. It resolves the received Event Payment, refuses anything that is not Razorpay with a message saying so, and hands the amount to the payments app, which owns the validation against the live payment. `get_refundable_tickets` offers each ticket with the share of the total the buyer actually paid for it. Attendee amounts are pre-tax and pre-discount, so they are scaled by what was charged; the ratio is taken over current attendee amounts rather than net_amount, because a Free Tickets coupon zeroes attendee rows after net_amount is computed and using it would count that discount twice. Tickets picked for a refund get a Ticket Cancellation Request in the usual review queue rather than being cancelled outright. A custom amount cancels nothing, because it maps to no particular ticket. The gateway confirms refunds separately, over the webhook the payments app dispatches. That handler is keyed on the refund id, since the same event arrives more than once, and it returns nothing so that other apps subscribed to the hook still run. A refund the gateway reports as failed is left out of the total and takes its queued cancellation request out of the queue with it — nobody should accept a cancellation for money that never moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
System Managers get a Refund button on a paid, submitted booking. The dialog lists the booking's tickets with what each one cost; ticking them fills in the amount, and the confirmation says exactly what is about to happen — whether tickets are being queued for cancellation or only money is moving. Toasts cover the send and the result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Greptile SummaryThe PR adds System Manager–initiated Razorpay refunds for event bookings and synchronizes their status through gateway notifications.
Confidence Score: 4/5The PR is not yet safe to merge because a cancellation submission failure can leave a processed refund’s tickets active indefinitely. The refund is persisted as Processed before cancellation is attempted, while submission exceptions are caught and logged without an automatic retry, so the previously reported processed-refund-with-active-ticket failure remains outstanding. Files Needing Attention: buzz/ticketing/doctype/event_booking_refund/event_booking_refund.py
|
| Filename | Overview |
|---|---|
| buzz/ticketing/doctype/event_booking/event_booking.py | Adds refund validation, ticket allocation, gateway dispatch, and booking-level refund accounting; the prior foreign-ticket validation issue is fixed. |
| buzz/ticketing/doctype/event_booking_refund/event_booking_refund.py | Applies gateway outcomes and submits ticket cancellation requests, but cancellation submission failures still leave processed refunds without eventual ticket cancellation. |
| buzz/payments.py | Adds validated Razorpay refund webhook handling and idempotent lookup by gateway refund ID. |
| buzz/api/checkin/services.py | Rejects check-in for tickets claimed by initiated or processed refunds. |
| buzz/ticketing/doctype/event_booking/event_booking.js | Adds the Desk refund dialog and refund-history rendering for eligible bookings. |
Reviews (9): Last reviewed commit: "fix(refunds): keep a refunded ticket out..." | Re-trigger Greptile
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
A child table could not answer the question that matters — what is still refundable — without the booking loading every row, and it could not be opened, filtered or linked to on its own. Refunds are now documents, named REF-#####, carrying the booking and payment they belong to, with the tickets as links rather than newline-joined text. The booking keeps its summary — refund status and refunded total — and shows the refunds in an HTML table where the refund id links through to the document and the queued cancellation links to its request. Two holes closed with it: `get_refund_summary` replaces `get_refundable_tickets` and answers with the amount left and the tickets not already spoken for. Both the dialog and `refund` read it, so what an operator is offered cannot drift from what the server accepts. Tickets held by a refund the gateway has not refused are no longer offered a second time; a failed refund releases them again. `validate_refund_amount` refuses anything above what is left. A refund still waiting on the gateway holds its money, so a booking of 2000+3000 cannot give back 3000 twice — previously nothing local stopped it, and the gateway cannot be relied on to reject the second one while the first is unsettled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drops the column break in the refund section, and picks up the type hints Frappe regenerates for the new fields. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cancellation request was raised when the refund went out and has been sitting in review since. Nobody needs to approve what the gateway already settled, so a processed refund accepts and submits it, and a failed one rejects it. Cancelling a ticket emails the attendee, which can fail for reasons that have nothing to do with the money. The attempt runs inside a savepoint, so a cancellation that cannot go through is undone whole and left in review, with the refund still recorded. Ticket Cancellation Request now passes its own ignore_permissions down to the documents it cancels, since the webhook job that submits it runs as Guest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
"Refund" alone read like a status next to the booking's own fields. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
refund() forwarded whatever ticket names it was handed straight into a cancellation request. The dialog only ever offers this booking's tickets, but the method is whitelisted, so a direct call could cancel a stranger's ticket and record the refund here. Tickets are now checked against what the booking actually has left to refund, which also refuses one an earlier refund already claimed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cancellation request was raised the moment a refund went out and sat in review while Razorpay took its time, which can be days. Anyone working that queue could accept it before the answer arrived, and if the refund then failed the tickets were already gone: the failure path skipped a submitted request entirely, so the customer kept neither the money nor the seat. The refund now records which tickets it covers and cancels nothing. The request is raised, and accepted, when the gateway reports the refund processed. A failed refund leaves no request behind at all, so there is nothing for anyone to accept by mistake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The docstrings on the refund helpers had grown into prose that explained the reasoning but never said what the functions hand back. They now open with a one-line summary like the rest of the file, list the keys of the dicts `get_refund_summary` and `get_refundable_tickets` return, and keep the reasoning as comments on the lines it belongs to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The handler dug the refund out of the webhook body with a chain of `get` calls and then read it as a dict, so every field was a string literal and a typo would have read as a missing value. A pydantic model pulls the four fields we care about straight out of `payload.refund.entity`, and owns the conversion from the gateway's minor unit. A payload that carries no refund now raises instead of returning quietly. Only `refund.processed` and `refund.failed` reach this handler, so such a payload is a defect, and the payments app already logs what we raise and marks the Integration Request failed. It used to be marked completed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A refund that settled could leave its ticket active: cancelling the ticket can fail, and the savepoint around it then rolls the cancellation back and leaves the refund standing. Check-in only turned away a cancelled ticket, so the attendee had their money back and a ticket that still scanned. Refunds now refuse a ticket the front desk has already seen, rather than finding out afterwards when cancelling it raises `LinkExistsError`, and the dialog stops offering those tickets. Check-in turns away any ticket a refund holds, settled or not, so a cancellation that never goes through no longer opens the door. A failed refund releases the ticket again. Cancelling a ticket no longer sends its email inline, so a mail server having a bad day cannot roll back the cancellation. Also fixes an unrelated crash it uncovered: Event Payment is named by autoincrement, so `name` reached `PaymentDetails.name` as an int and check-in raised for every ticket with a received payment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
Backport failed for Please cherry-pick the changes locally and resolve any conflicts. git fetch origin main
git worktree add -d .worktree/backport-328-to-main origin/main
cd .worktree/backport-328-to-main
git switch --create backport-328-to-main
git cherry-pick -x f4990fee18bbaac9431196d0d081c028a9df1c1f |
feat(refunds): refund a booking from the Desk (#328) * feat(refunds): add refund fields to Event Booking Event Booking Refund holds one row per refund raised against a booking: the gateway refund id, its amount, whether the gateway has settled it, the tickets it covers and the cancellation request queued for them. The booking itself carries the summary — refund status and the total refunded so far — in a section that appears only once there is a refund to show. Every field is allow_on_submit, on the child doctype as well as the parent, since refunds always land after the booking is submitted. * feat(refunds): refund a booking and apply the gateway's answer `EventBooking.refund` is System Manager only. It resolves the received Event Payment, refuses anything that is not Razorpay with a message saying so, and hands the amount to the payments app, which owns the validation against the live payment. `get_refundable_tickets` offers each ticket with the share of the total the buyer actually paid for it. Attendee amounts are pre-tax and pre-discount, so they are scaled by what was charged; the ratio is taken over current attendee amounts rather than net_amount, because a Free Tickets coupon zeroes attendee rows after net_amount is computed and using it would count that discount twice. Tickets picked for a refund get a Ticket Cancellation Request in the usual review queue rather than being cancelled outright. A custom amount cancels nothing, because it maps to no particular ticket. The gateway confirms refunds separately, over the webhook the payments app dispatches. That handler is keyed on the refund id, since the same event arrives more than once, and it returns nothing so that other apps subscribed to the hook still run. A refund the gateway reports as failed is left out of the total and takes its queued cancellation request out of the queue with it — nobody should accept a cancellation for money that never moved. * feat(refunds): add the Refund dialog to the booking form System Managers get a Refund button on a paid, submitted booking. The dialog lists the booking's tickets with what each one cost; ticking them fills in the amount, and the confirmation says exactly what is about to happen — whether tickets are being queued for cancellation or only money is moving. Toasts cover the send and the result. * refactor(refunds): make Event Booking Refund a document of its own A child table could not answer the question that matters — what is still refundable — without the booking loading every row, and it could not be opened, filtered or linked to on its own. Refunds are now documents, named REF-#####, carrying the booking and payment they belong to, with the tickets as links rather than newline-joined text. The booking keeps its summary — refund status and refunded total — and shows the refunds in an HTML table where the refund id links through to the document and the queued cancellation links to its request. Two holes closed with it: `get_refund_summary` replaces `get_refundable_tickets` and answers with the amount left and the tickets not already spoken for. Both the dialog and `refund` read it, so what an operator is offered cannot drift from what the server accepts. Tickets held by a refund the gateway has not refused are no longer offered a second time; a failed refund releases them again. `validate_refund_amount` refuses anything above what is left. A refund still waiting on the gateway holds its money, so a booking of 2000+3000 cannot give back 3000 twice — previously nothing local stopped it, and the gateway cannot be relied on to reject the second one while the first is unsettled. * feat(refunds): link refunds from the booking's Connections Drops the column break in the refund section, and picks up the type hints Frappe regenerates for the new fields. * feat(refunds): cancel the tickets once the gateway settles the refund The cancellation request was raised when the refund went out and has been sitting in review since. Nobody needs to approve what the gateway already settled, so a processed refund accepts and submits it, and a failed one rejects it. Cancelling a ticket emails the attendee, which can fail for reasons that have nothing to do with the money. The attempt runs inside a savepoint, so a cancellation that cannot go through is undone whole and left in review, with the refund still recorded. Ticket Cancellation Request now passes its own ignore_permissions down to the documents it cancels, since the webhook job that submits it runs as Guest. * feat(refunds): move the refund button under an Actions group "Refund" alone read like a status next to the booking's own fields. * fix(refunds): refuse tickets that belong to another booking refund() forwarded whatever ticket names it was handed straight into a cancellation request. The dialog only ever offers this booking's tickets, but the method is whitelisted, so a direct call could cancel a stranger's ticket and record the refund here. Tickets are now checked against what the booking actually has left to refund, which also refuses one an earlier refund already claimed. * fix(refunds): cancel tickets only once the gateway has settled The cancellation request was raised the moment a refund went out and sat in review while Razorpay took its time, which can be days. Anyone working that queue could accept it before the answer arrived, and if the refund then failed the tickets were already gone: the failure path skipped a submitted request entirely, so the customer kept neither the money nor the seat. The refund now records which tickets it covers and cancels nothing. The request is raised, and accepted, when the gateway reports the refund processed. A failed refund leaves no request behind at all, so there is nothing for anyone to accept by mistake. * docs(refunds): say what the refund helpers return The docstrings on the refund helpers had grown into prose that explained the reasoning but never said what the functions hand back. They now open with a one-line summary like the rest of the file, list the keys of the dicts `get_refund_summary` and `get_refundable_tickets` return, and keep the reasoning as comments on the lines it belongs to. * refactor(refunds): parse the refund webhook into a model The handler dug the refund out of the webhook body with a chain of `get` calls and then read it as a dict, so every field was a string literal and a typo would have read as a missing value. A pydantic model pulls the four fields we care about straight out of `payload.refund.entity`, and owns the conversion from the gateway's minor unit. A payload that carries no refund now raises instead of returning quietly. Only `refund.processed` and `refund.failed` reach this handler, so such a payload is a defect, and the payments app already logs what we raise and marks the Integration Request failed. It used to be marked completed. * fix(refunds): keep a refunded ticket out of the event A refund that settled could leave its ticket active: cancelling the ticket can fail, and the savepoint around it then rolls the cancellation back and leaves the refund standing. Check-in only turned away a cancelled ticket, so the attendee had their money back and a ticket that still scanned. Refunds now refuse a ticket the front desk has already seen, rather than finding out afterwards when cancelling it raises `LinkExistsError`, and the dialog stops offering those tickets. Check-in turns away any ticket a refund holds, settled or not, so a cancellation that never goes through no longer opens the door. A failed refund releases the ticket again. Cancelling a ticket no longer sends its email inline, so a mail server having a bad day cannot roll back the cancellation. Also fixes an unrelated crash it uncovered: Event Payment is named by autoincrement, so `name` reached `PaymentDetails.name` as an int and check-in raised for every ticket with a received payment. --------- (cherry picked from commit f4990fe) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Loom: https://www.loom.com/share/a0bfd08d12c648d3b55adb4144179ac6
Lets a System Manager refund a booking from the Event Booking form, and keeps the booking in step with whatever the gateway settles. Depends on frappe/payments#248, which adds
refund_payment/fetch_refundand the webhook this subscribes to.The flow
Refund button on a paid, submitted booking, System Manager only. The dialog lists the tickets still refundable with what each one cost — ticking them fills in the amount, leaving them unticked refunds money without touching a ticket.
Ticket amounts are what the buyer actually paid: attendee amounts are pre-tax and pre-discount, so each ticket's share is scaled to what was charged. The ratio is taken over current attendee amounts rather than
net_amount, because a Free Tickets coupon zeroes attendee rows afternet_amountis computed, and using it would count the discount twice.Tickets picked for a refund are recorded, not cancelled. Cancelling waits until the gateway settles, because a refund can still fail, and a ticket cancelled against a failed refund leaves the customer with neither money nor a seat. A custom amount cancels nothing — it maps to no particular ticket.
handle_refund_notificationparses the webhook into a pydantic model (payload.refund.entity, flattened withAliasPath), matchespayment_idto an Event Payment and records the outcome. Keyed on the refund id, because the same event arrives more than once. It returns nothing so other subscribers to the hook still run —call_hook_methodstops at the first truthy return. A payload carrying no refund raises instead of returning quietly: onlyrefund.processedandrefund.failedever reach us, so such a payload is a defect, and the payments app already logs it and marks the Integration Request failed.Nothing refunded twice, nothing refunded and used
get_refund_summaryis the single answer to "what is still refundable". Dialog and server both read it, so what an operator is offered cannot drift from what the server accepts. A refund still waiting on the gateway holds its money and its tickets, since the gateway will not reject a second refund while the first is unsettled — a booking of 2000 + 3000 cannot give back 3000 twice,InitiatedorProcessed. A failed refund releases both.A ticket the front desk has already scanned cannot be refunded. That is checked before the money moves, rather than discovered afterwards when cancelling the ticket raises
LinkExistsError. Going the other way, check-in turns away any ticket a refund holds, settled or not: cancelling a ticket can fail, and the savepoint that keeps that failure from dragging the refund down with it leaves the ticket live. A failed refund frees the ticket again.Schema
Event Booking Refund, namedREF-#####: the booking and payment it belongs to, the gateway refund id (unique — this is what makes webhook dedupe safe at the database level), amount, status (Initiated / Processed / Failed), the tickets it covers, and the cancellation request raised for them. Event Booking keeps the summary inrefund_statusandrefunded_amount, bothallow_on_submit, since refunds always land after submission.Tests
29 on the refund path, plus the check-in suite: ticket share maths, the non-Razorpay refusal, the over-refund ceiling settled and unsettled, tickets re-offered after a failure, the checked-in refusals both ways, and the webhook — partial vs full settlement, a repeated event not counting twice, a malformed payload refused, and the job running as Guest. That last one matters: the webhook endpoint is
allow_guest, so its job runs as Guest, who cannot write to Event Booking. Caught in a real run, not by the original tests, which all ran as Administrator.Known limits
namereachedPaymentDetails.nameas an int and check-in raised for every ticket with a received payment.