Kulcare Docs
DeveloperAuthentication Documentation04 features

Billing Refund Requests

This document outlines the billing refund request system implemented in the KulCare platform, which allows users to request refunds for captured payments.

Overview

The billing refund request system provides a structured workflow for handling payment refunds. It includes request creation, status management, Slack notifications, and proper authorization controls.

Database Schema

BillingRefundRequest Model

CREATE TABLE billing_refund_requests (
  id BIGINT PRIMARY KEY,
  billing_payment_id BIGINT NOT NULL,
  status VARCHAR DEFAULT 'PENDING' NOT NULL,
  created_by_user_id BIGINT NOT NULL,
  reason TEXT,
  processed_at TIMESTAMP,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

-- Indexes
CREATE INDEX idx_billing_refund_requests_status ON billing_refund_requests(status);
CREATE INDEX idx_billing_refund_requests_payment_status ON billing_refund_requests(billing_payment_id, status);

Status States

  • PENDING: Initial state when refund request is created
  • APPROVED: Request has been approved by admin
  • REJECTED: Request has been rejected
  • PROCESSED: Refund has been completed

API Endpoints

Create Refund Request

POST /billing_payments/:id/refund_request

Creates a new refund request for a billing payment.

Request Parameters

ParameterTypeRequiredDescription
idIntegerYesThe billing payment ID
reasonStringYesReason for refund (max 500 characters)

Request Example

curl -X POST "https://api.kulcare.com/billing_payments/123/refund_request" \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Patient cancelled appointment due to emergency"
  }'

Response Example

{
  "success": true,
  "message": "Refund request created successfully",
  "type": "create",
  "data": {
    "id": 456,
    "status": "PENDING",
    "reason": "Patient cancelled appointment due to emergency",
    "processed_at": null,
    "created_at": "2025-09-10T12:30:00Z",
    "updated_at": "2025-09-10T12:30:00Z",
    "billing_payment": {
      "id": 123,
      "razorpay_payment_id": "pay_abc123xyz",
      "amount": 500.0,
      "created_at": "2025-09-10T10:15:00Z"
    }
  }
}

Error Responses

// Payment not found
{
  "success": false,
  "errors": [
        {
            "source": "billing_payment",
            "title": "Billing Payment not found"
        }
    ]
}

// Payment not captured
{
  "success": false,
  "errors": [
        {
            "source": "billing_payment",
            "title": "Billing Payment is not captures"
        }
    ]
}

// Payment already refunded
{
  "success": false,
  "errors": [
        {
            "source": "billing_payment",
            "title": "Billing Payment is already refunded"
        }
    ]
}

// Refund request already exists
{
  "success": false,
  "errors": [
        {
            "source": "refund_request",
            "title": "Refund request already exists for this payment"
        }
    ]
}

// Validation errors
{
  "success": false,
  "errors": {
    "reason": ["can't be blank", "is too long (maximum is 500 characters)"]
  }
}

Business Logic

SaveRefundRequest Interactor

The BillingPayments::SaveRefundRequest interactor handles the refund request creation workflow:

  1. Parameter Validation: Ensures billing_payment_id is provided
  2. Payment Validation:
    • Verifies payment exists
    • Confirms payment is captured
    • Checks payment hasn't been refunded already
  3. Duplicate Check: Ensures no pending/approved refund request exists for the payment
  4. Request Creation: Creates the refund request with PENDING status
  5. Notification: Queues Slack notification for admin review

Model Methods

The BillingRefundRequest model provides status management methods:

# Status transition methods
refund_request.approve!    # Sets status to APPROVED, processed_at to current time
refund_request.reject!     # Sets status to REJECTED, processed_at to current time
refund_request.mark_processed!  # Sets status to PROCESSED, processed_at to current time

Authorization

Policy Requirements

The BillingPaymentsControllerPolicy requires:

  • User must be a clinic owner
  • Verified through user.user_accounts.clinic_accounts&.owner&.present?

Authorization Check

def refund_request?
  user.user_accounts.clinic_accounts&.owner&.present?
end

Notifications

Slack Integration

When a refund request is created, a Slack notification is sent to the appropriate channel:

  • Production: Uses Rails.application.credentials.slack_payment_support
  • Non-Production: Uses Rails.application.credentials.slack_kulcare_bots

Notification Details

The Slack message includes:

  • Request amount (formatted as ₹XXX.XX)
  • Payment ID (Razorpay ID or internal ID)
  • Requester details (name and phone)
  • Refund reason

Retry Configuration

The SlackRefundRequestNotification job includes:

  • Exponential backoff retry strategy
  • Maximum 3 retry attempts
  • Automatic failure handling with error raising
class SlackRefundRequestNotification < ActiveJob::Base
  queue_as :slack_notifications
  retry_on StandardError, wait: :exponentially_longer, attempts: 3
end

Integration Points

Payment Relationship

# BillingPayment model
has_one :billing_refund_request

# BillingRefundRequest model
belongs_to :billing_payment
belongs_to :created_by_user, class_name: 'User'

Payment Serialization

The BillingPaymentSerializer now includes refund request details:

{
  "payment_status": "captured",
  "billing_refund_request": {
    "id": 456,
    "status": "PENDING",
    "reason": "Patient cancelled appointment",
    "processed_at": null,
    "created_at": "2025-09-10T12:30:00Z"
  }
}

Performance Optimizations

Query Optimization

The TelemedicinePaymentActions::Index interactor has been optimized to prevent N+1 queries:

  1. Pagination First: Apply filters and paginate before loading associations
  2. Selective Loading: Include associations only for paginated results
  3. Eager Loading: Preload related data to minimize database queries
# Optimized query pattern
base_query = BillingPayment
              .joins(:telemedicine_order)
              .where(conditions)

paginated_payments = base_query.paginate(page: page, per_page: per_page)

payments_with_associations = BillingPayment
                              .where(id: paginated_payments.pluck(:id))
                              .includes(:billing_refund_request, ...)

Error Handling

Validation Errors

The system validates:

  • Reason presence: Required field for better UX
  • Reason length: Maximum 500 characters
  • Status values: Must be one of PENDING, APPROVED, REJECTED, PROCESSED

Business Logic Errors

  • Prevents duplicate refund requests for same payment
  • Ensures only captured payments can be refunded
  • Blocks refund requests for already refunded payments

Notification Failures

  • Slack notification failures are logged but don't fail the entire operation
  • Retry mechanism handles transient network issues
  • Error details are preserved for debugging

Security Considerations

  1. Authorization: Only clinic owners can create refund requests
  2. Validation: Input sanitization and length limits prevent abuse
  3. Audit Trail: All refund requests are logged with user and timestamp
  4. Status Management: Controlled state transitions prevent invalid states

Testing

Unit Tests

RSpec.describe BillingPayments::SaveRefundRequest do
  it "creates refund request for valid payment" do
    payment = create(:billing_payment, status: 'captured')

    result = described_class.call(
      id: payment.id,
      reason: "Patient cancelled"
    )

    expect(result).to be_success
    expect(result.refund_request.status).to eq('PENDING')
  end

  it "fails for already refunded payment" do
    payment = create(:billing_payment, status: 'captured')
    create(:billing_refund, billing_payment: payment)

    result = described_class.call(id: payment.id, reason: "Test")

    expect(result).to be_failure
    expect(result.errors['billing_payment']).to include('is already refunded')
  end
end

Integration Tests

RSpec.describe "POST /billing_payments/:id/refund_request" do
  it "creates refund request successfully" do
    payment = create(:billing_payment, status: 'captured')

    post "/billing_payments/#{payment.id}/refund_request",
         params: { reason: "Emergency cancellation" },
         headers: auth_headers

    expect(response).to have_http_status(:success)
    expect(json_response['data']['status']).to eq('PENDING')
  end
end

Monitoring and Alerts

Key Metrics

  • Number of refund requests created per day
  • Average time from request to approval/rejection
  • Refund request success/failure rates
  • Slack notification delivery rates
  • High volume of refund requests in short time period
  • Slack notification failures exceeding threshold
  • Refund requests stuck in PENDING status for extended periods

Future Enhancements

  1. Admin Dashboard: Web interface for managing refund requests
  2. Automated Processing: Integration with payment gateway for automatic refunds
  3. Approval Workflow: Multi-step approval process for large amounts
  4. Reporting: Analytics and reporting on refund patterns
  5. Email Notifications: Complement Slack with email notifications

On this page