Yuno: Refunds and Transactions
Summary
Yuno allows returning a payment fully or partially against the refund endpoint of its API. Farfalla exposes both paths: full refund is initiated from the admin dashboard on a complete UserPlan and is used for both one-off items and subscriptions; partial refund is initiated from the refunds modal in the subscriptions dashboard and returns a specific amount on a recurring payment without cancelling the subscription.
The refund may be confirmed synchronously in the HTTP response or left pending when the actual provider behind Yuno takes time to confirm it. In the pending case, Farfalla does not wait for the webhook: it persists the refund in an intermediate table and a job polls until it is confirmed.
Chargebacks arrive as just another webhook and share a handler with regular payment events. They are mapped to the internal dispute-lost status, with no additional business logic.
Full Refund
For a full refund, Farfalla takes the last approved payment of the UserPlan, queries it against the Yuno API to obtain the current transactions, and selects the first transaction of type PURCHASE with status SUCCEEDED. The refund payload is built from that transaction: full amount of the UserPlan, currency of the original payment, reason mapped to the Yuno vocabulary (REQUESTED_BY_CUSTOMER, DUPLICATE, or FRAUDULENT), and metadata with the UserPlan ID and the order UUID for traceability.
The payload is sent to the Yuno refund endpoint with an idempotency header derived from the UserPlan ID and the timestamp. The response may be a successful refund, a refund pending provider confirmation, or a controlled failure.
If the payment does not exist in Yuno, if there are no transactions with status SUCCEEDED, or if the API responds with an error, the method returns a failure result without throwing an exception and leaves the detail in the yuno_api channel so the admin can inspect it. The subscription is not cancelled in any of these cases.
Partial Refund
The partial refund reuses the same Yuno endpoint but with an amount smaller than the original and a unique merchant_reference per attempt, built from the payment ID and a count of previous refunds on that same cycle. The Yuno API accepts multiple partial refunds on the same payment up to exhausting the balance, and Farfalla tracks this locally: the available balance is the gross payment amount minus the sum of all previous refunds for the same cycle and same UserPlan.
The partial refund service blocks any attempt that exceeds the available balance or that arrives outside the 30-day window, before touching the Yuno API. If it passes validation, it calls the gateway, persists the result, and records the activity with the email of the admin who initiated the refund.
Unlike the full refund, the partial refund does not cancel the subscription: it leaves the UserPlan active and only creates the negative financial record for the returned amount.
Async Flow
When Yuno accepts the refund but the actual provider has not yet processed it, the response arrives with a refund status at the top level and a pending sub_status. Farfalla detects this case, does not yet create a record in payments, and instead persists a row in pending_payment_refunds with the Yuno payment ID, the ID of the refund transaction just issued, and the UserPlan context. The controller returns pending to the frontend so the admin knows the refund is in progress.
A scheduled job running every five minutes iterates the table and, for each pending refund, queries the payment in Yuno and looks inside the transactions array for the REFUND transaction that was saved when the refund was initiated. If that transaction is approved, the job invokes the payments service to create the definitive record in payments with a negative amount and marks the intermediate row as completed. If the transaction was rejected, it marks the row as failed and records the activity. If it is still pending, it increments the attempt counter and leaves the row for the next run.
The job has a ceiling of twelve attempts, equivalent to one hour of polling. After that limit, it stops processing the row and a specific health check marks it as stale so the team can handle it manually.
The payment.refund webhook may also arrive while the refund is pending. When the event handler detects that the payload carries the pending sub_status, it does not process the event and leaves a note in the ipn_records row indicating that the verification job will resolve the case. This prevents both the webhook and the job from processing the same refund twice.
If the webhook arrives between the moment Farfalla sends the refund and the HTTP response from Yuno, the webhook handler creates the record in payments on its own. When the synchronous response later arrives, the payments service uses firstOrCreate with the combination of gateway, transaction, status, and order, so the second processing does not create duplicates.
Refund Statuses
Yuno exposes several statuses that Farfalla maps to the status field of the payments table:
| Status in Yuno | Status in payments |
|---|---|
refunded, partially_refunded | refunded |
dispute_lost, chargeback | dispute_lost |
pending, processing, in_progress | pending |
approved, succeeded, active, completed | approved |
failed, rejected, error | error |
cancelled, canceled | cancelled |
There is a particularity in how a successful refund is determined. The Yuno API responds to the refund endpoint with the full payment object, not the refund object: the top-level status reflects the state of the original payment, not the newly created refund transaction. For this reason the refund entity first looks for the transaction of type REFUND inside the transactions array and evaluates its status; the top-level field is used only as a fallback when it is explicitly refunded or partially_refunded.
The complete status mapping detail, including the events each one triggers, lives in the IPN doc.
Persistence in payments
Each confirmed refund generates a new record in payments, without touching the original approved payment. The gateway_transaction_id of the new record points to the ID of the REFUND transaction inside the Yuno payment, not to the payment ID. This allows the same payment to have multiple partial refunds recorded without conflict and maintains a one-to-one traceability with the Yuno API.
The amount is stored as negative, both gross and net. The refund transaction fee is persisted as zero because fees are not returned. The conversion to cents respects the currency of the original payment.
Persistence idempotency is based on firstOrCreate with the tuple of gateway, tenant, transaction, gateway key, status, order, and UserPlan. The webhook handler also performs an earlier check with the combination of transaction, status, and order to detect the duplicate early and leave the trace in the log before delegating.
The behavior of "new record, negative amount, original payment untouched" matches that of Stripe and MercadoPago. The Yuno-specific difference is the intermediate pending state layer, which the other gateways do not use in their current flow.
Events and Notifications
When the refund is persisted (synchronous or via job), the payments service updates the order status accordingly — for example, moving it to refunded if all items were returned. For a successful full refund on a subscription, the checkout layer also invokes the refund cancellation handler that revokes the user's access to the product. That handler is not invoked when the refund is pending: the decision to revoke access is made only when the job confirms the result.
There is no dedicated PHP domain event for Yuno refunds. The trace remains in the payments table with a negative amount, in yuno_api with the request and response detail, and in pending_payment_refunds when the flow went through the async path.
Chargebacks
Chargebacks arrive as a payment.chargeback webhook. The Yuno event router sends them to the same handler that processes any payment.*, which reuses all the payment construction logic, transaction normalization, and persistence. The chargeback status is mapped to dispute_lost in the local table and is recorded with a negative amount, just like a refund.
There is no additional chargeback-specific logic: access is not automatically revoked, and the tenant is not notified. The trace remains in payments and in activity_log (the channel shared between yuno_api and payment_event depending on the origin) for the support team to process manually if applicable.
Transaction Particulars
See Response structure: payments and transactions. The Yuno transaction model applies to payments, refunds, and webhooks alike.
Edge Cases
Refund on a payment already partially returned. The partial refund service calculates the available balance by summing all previous refunds for the same cycle and same UserPlan, and rejects the attempt if the requested amount exceeds that balance. The full refund does not explicitly recalculate the balance: if the payment already had refunds, the Yuno API rejects the request for excess amount and the result is reported as a failure.
Refund on a payment without a confirmed charge. The full refund verifies that transactions with status SUCCEEDED exist before building the payload. If the payment is in PENDING or PROCESSING, there are no approved transactions and the method returns a controlled failure result, without touching the API.
Full refund on a recurring subscription. Takes the last approved payment of the UserPlan and returns userPlan.amount. If the result is success, the checkout layer invokes the refund cancellation that revokes user access. If the result is pending, access remains intact until the job confirms the refund. The cancellation of the subscription in Yuno is not triggered by the refund: if the subscription also needs to be cancelled, that cancellation is done separately through the cancellation flow.
Partial refund on a subscription. By design it does not cancel the subscription or revoke access. It only creates the negative financial record. The subscription remains active and the next scheduled charge is processed normally.
Cross-currency refund. The refund amount is sent in the same currency as the original payment, without re-querying the exchange rate or including a currency conversion block. Yuno handles internally the reversal of the cross-border conversion when the original charge went through that flow. There are no specific tests for this scenario, so any discrepancy between what was charged and what was returned in local currency would only be visible in the reconciliation against Yuno.
Retroactive synchronization. Yuno does not have a job analogous to those that synchronize Stripe or MercadoPago history. Refunds are recorded only through the three active paths: the synchronous flow when executing the refund, the Yuno webhook, and the pending verification job. If a historical refund did not enter through any of those paths (for example, an operation done directly in the Yuno dashboard), it does not appear in payments and requires manual reconciliation.
Code References
| File | Responsibility |
|---|---|
app/Http/Controllers/Dashboard/RefundHandler.php | HTTP entry point for the full refund from the dashboard |
app/Http/Livewire/Dashboard/Subscriptions/RefundablePaymentsModal.php | Partial refund modal on subscriptions |
app/Domains/Commerce/Services/PartialRefundService.php | Balance validation, 30-day window, partial refund persistence |
app/Domains/Commerce/Gateways/Yuno/YunoOneOffPaymentService.php | Full refund on UserPlan and pending record creation |
app/Domains/Commerce/Gateways/Yuno/YunoService.php | Partial refund, transaction normalization, pending-provider detection |
app/Domains/Commerce/Gateways/Yuno/YunoPaymentStatus.php | Status enum and grouping helpers |
app/Domains/Commerce/Gateways/Yuno/Events/Payment.php | Handler for payment.* webhooks, including chargebacks and pending refund skip |
app/Domains/Commerce/Support/Yuno/YunoConnector.php | HTTP client, refund and payment query endpoints |
app/Domains/Commerce/Support/Yuno/YunoRefund.php | Refund entity, refund transaction lookup, success criterion |
app/Domains/Commerce/Support/Yuno/YunoPayment.php | Payment entity, transaction reading |
app/Domains/Commerce/Models/PendingPaymentRefund.php | Model for the pending confirmation refund |
app/Domains/Commerce/Jobs/VerifyPendingRefunds.php | Polling every 5 minutes, maximum 12 attempts |
app/Domains/Commerce/HealthChecks/StalePendingRefundsCheck.php | Detection of refunds that exhausted retries |
app/Managers/YunoEventManager.php | Mapping of payment.* and payment.chargeback events to the shared handler |
routes/console.php | Verification job schedule |