Problem
When processing a refund, the admin can only refund back to the original payment method. There is no way to refund to an alternative destination like store credit, a gift card, or a voucher.
This is a common B2B/enterprise requirement — instead of reversing the payment, the merchant issues store credit the customer can use on future purchases. Today this requires manually issuing a store credit outside the refund flow, which:
- Doesn't create a Vendure
Refund entity
- Doesn't appear in the order's refund history
- Doesn't transition the order state
- Has no audit trail linking the refund to the order
Current architecture
Backend
refundOrder mutation → OrderService.refundOrder() → PaymentService.createRefund() → PaymentMethodHandler.createRefund()
The flow always calls the original payment's handler. An invoice payment refunds to invoice, a Stripe payment refunds to Stripe. There's no way to redirect the refund to a different destination.
The Refund entity is created with: total, reason, state, transactionId, payment (FK), method, lines (RefundLine[]), metadata. The refund state machine (Pending → Settled / Failed) handles lifecycle.
Dashboard
The refund dialog (RefundOrderDialog) is hardcoded:
- Line selection + quantity
- Shipping refund checkbox
- Reason selector
- Refund total (auto-calculated or manual override)
- "Select payments to refund" — only option, always refunds to original payment
No extension points exist. The dialog, hook (useRefundOrder), and utilities are all internal.
Proposed solution
Backend: RefundDestinationStrategy
A new strategy interface following Vendure's InjectableStrategy pattern:
interface RefundDestinationStrategy extends InjectableStrategy {
/** Unique code identifying this destination */
code: string;
/** Translated description shown in the admin UI */
description: LocalizedStringArray;
/**
* Whether this destination is available for the given order and payment.
* E.g. store credit refund might only be available for settled, non-store-credit payments.
*/
isAvailable(ctx: RequestContext, order: Order, payment: Payment): Promise<boolean>;
/**
* Execute the refund. Called instead of PaymentMethodHandler.createRefund()
* when this destination is selected.
*
* Parameter order matches the existing CreateRefundFn signature.
* Must return a CreateRefundResult so core can create the Refund entity,
* or throw to signal failure (core will set refund state to 'Failed').
*/
createRefund(
ctx: RequestContext,
input: RefundOrderInput,
amount: number,
order: Order,
payment: Payment,
): Promise<CreateRefundResult>;
}
Register under paymentOptions (where refund-related config already lives):
paymentOptions: {
// ...existing handlers, checkers, process...
refundDestinations: [
new DefaultRefundDestination(), // wraps existing PaymentMethodHandler.createRefund()
new StoreCreditRefundDestination(), // from store-credit plugin
],
}
Extend RefundOrderInput with an optional destination:
input RefundOrderInput {
# ...existing fields...
"""
Optional refund destination code. When omitted, refunds to the original
payment method (current default behavior).
"""
destination: String
}
Add a query to discover available destinations for a given order:
type RefundDestination {
code: String!
description: String!
}
extend type Query {
refundDestinations(orderId: ID!): [RefundDestination!]!
}
Changes to PaymentService.createRefund()
- If
input.destination is provided, look up the matching RefundDestinationStrategy and call its createRefund() instead of PaymentMethodHandler.createRefund()
- The
Refund entity is still created by core with the result — full audit trail, state machine, history entry
Refund.method: set to the strategy's code when a non-default destination is used (e.g. 'store-credit' instead of 'stripe'), so the refund entity accurately reflects where funds went
paymentId remains required — it identifies which payment's refundable balance is being consumed, even when funds are directed elsewhere
- Error handling: if the strategy throws, core catches and sets the refund state to
'Failed' with the error in metadata
- Multi-payment overflow: when a destination is specified, the overflow loop (splitting across payments) should use that same destination for all chunks
The DefaultRefundDestination wraps the existing behavior (calling PaymentMethodHandler.createRefund()), so this is fully backward compatible. Omitting destination preserves current behavior.
Dashboard: Extension point for refund destinations
The refund dialog needs to be aware of available destinations. This requires a new kind of dashboard extension point, registered via the dashboard module:
// In the plugin's dashboard/index.tsx
defineDashboardExtension({
refundDestinations: [
{
/** Must match the backend strategy code */
code: 'store-credit',
/** Display label (resolved client-side) */
description: 'Refund as store credit',
/** Optional icon */
icon: WalletIcon,
/**
* Optional component rendered when this destination is selected,
* for extra configuration (e.g. store credit expiry date).
*/
component: StoreCreditRefundOptions,
},
],
});
Changes to the refund dialog:
- Query
refundDestinations(orderId) to get available backend destinations
- Cross-reference with registered dashboard destinations (for icons/components)
- When destinations are available, show a radio group above "Select payments to refund":
- "Refund to original payment" (default)
- "Refund as store credit" (from plugin)
- When a plugin destination is selected, hide the payment selection section (the destination handles where funds go). The plugin's optional component renders in its place.
- Pass the selected
destination code to the refundOrder mutation
Example: Store credit plugin integration
Backend:
class StoreCreditRefundDestination implements RefundDestinationStrategy {
code = 'store-credit';
description = [{ languageCode: LanguageCode.en, value: 'Refund as store credit' }];
async isAvailable(ctx, order, payment) {
// Don't offer store credit refund for store-credit payments
// (those already restore balance via the payment handler)
return payment.method !== 'store-credit-payment'
&& ['PaymentSettled', 'Delivered', 'PartiallyDelivered'].includes(order.state);
}
async createRefund(ctx, input, amount, order, payment) {
const storeCredit = await this.storeCreditService.issueStoreCredit(ctx, {
value: amount,
customerId: order.customer.id,
note: `Refund for order ${order.code} — ${input.reason}`,
});
return {
state: 'Settled' as const,
transactionId: `store-credit-${storeCredit.id}`,
metadata: { storeCreditId: storeCredit.id, storeCreditCode: storeCredit.code },
};
}
}
Dashboard:
defineDashboardExtension({
refundDestinations: [{
code: 'store-credit',
description: 'Refund as store credit',
icon: Wallet,
}],
});
Known limitations (v1)
- Single destination per refund call: A refund goes entirely to one destination. To split (e.g. $50 to card + $50 to store credit), the admin issues two separate refunds. This could be extended later.
paymentId still required: Even for non-payment destinations, the admin must select which payment's refundable balance to consume. This ensures refund accounting stays correct.
Summary of changes needed
| Layer |
Change |
Scope |
| Core types |
Add RefundDestinationStrategy interface |
New file |
| Core schema |
Add destination field to RefundOrderInput |
Schema change |
| Core config |
Add refundDestinations to paymentOptions |
Config change |
| Core service |
Route PaymentService.createRefund() through destination strategy |
Modify existing |
| Core service |
Set Refund.method to strategy code for non-default destinations |
Modify existing |
| Core schema |
Add refundDestinations(orderId) query |
New query + resolver |
| Dashboard types |
Add refundDestinations to dashboard extension API |
New extension point |
| Dashboard |
Render destination selector in RefundOrderDialog |
Modify existing |
| Dashboard |
Query available destinations and pass to refundOrder mutation |
Modify hook |
Fully backward compatible — omitting destination preserves current behavior via DefaultRefundDestination.
Problem
When processing a refund, the admin can only refund back to the original payment method. There is no way to refund to an alternative destination like store credit, a gift card, or a voucher.
This is a common B2B/enterprise requirement — instead of reversing the payment, the merchant issues store credit the customer can use on future purchases. Today this requires manually issuing a store credit outside the refund flow, which:
RefundentityCurrent architecture
Backend
refundOrdermutation →OrderService.refundOrder()→PaymentService.createRefund()→PaymentMethodHandler.createRefund()The flow always calls the original payment's handler. An invoice payment refunds to invoice, a Stripe payment refunds to Stripe. There's no way to redirect the refund to a different destination.
The
Refundentity is created with:total,reason,state,transactionId,payment(FK),method,lines(RefundLine[]),metadata. The refund state machine (Pending→Settled/Failed) handles lifecycle.Dashboard
The refund dialog (
RefundOrderDialog) is hardcoded:No extension points exist. The dialog, hook (
useRefundOrder), and utilities are all internal.Proposed solution
Backend:
RefundDestinationStrategyA new strategy interface following Vendure's
InjectableStrategypattern:Register under
paymentOptions(where refund-related config already lives):Extend
RefundOrderInputwith an optionaldestination:Add a query to discover available destinations for a given order:
Changes to
PaymentService.createRefund()input.destinationis provided, look up the matchingRefundDestinationStrategyand call itscreateRefund()instead ofPaymentMethodHandler.createRefund()Refundentity is still created by core with the result — full audit trail, state machine, history entryRefund.method: set to the strategy'scodewhen a non-default destination is used (e.g.'store-credit'instead of'stripe'), so the refund entity accurately reflects where funds wentpaymentIdremains required — it identifies which payment's refundable balance is being consumed, even when funds are directed elsewhere'Failed'with the error in metadataThe
DefaultRefundDestinationwraps the existing behavior (callingPaymentMethodHandler.createRefund()), so this is fully backward compatible. Omittingdestinationpreserves current behavior.Dashboard: Extension point for refund destinations
The refund dialog needs to be aware of available destinations. This requires a new kind of dashboard extension point, registered via the dashboard module:
Changes to the refund dialog:
refundDestinations(orderId)to get available backend destinationsdestinationcode to therefundOrdermutationExample: Store credit plugin integration
Backend:
Dashboard:
Known limitations (v1)
paymentIdstill required: Even for non-payment destinations, the admin must select which payment's refundable balance to consume. This ensures refund accounting stays correct.Summary of changes needed
RefundDestinationStrategyinterfacedestinationfield toRefundOrderInputrefundDestinationstopaymentOptionsPaymentService.createRefund()through destination strategyRefund.methodto strategy code for non-default destinationsrefundDestinations(orderId)queryrefundDestinationsto dashboard extension APIRefundOrderDialogrefundOrdermutationFully backward compatible — omitting
destinationpreserves current behavior viaDefaultRefundDestination.