Skip to content

feat: Support pluggable refund destinations (e.g. refund to store credit) #4563

@oliverstreissi

Description

@oliverstreissi

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 (PendingSettled / 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:

  1. Query refundDestinations(orderId) to get available backend destinations
  2. Cross-reference with registered dashboard destinations (for icons/components)
  3. When destinations are available, show a radio group above "Select payments to refund":
    • "Refund to original payment" (default)
    • "Refund as store credit" (from plugin)
  4. 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.
  5. 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.

Metadata

Metadata

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions