Kulcare Docs
DeveloperBilling

Vendor payments and refunds

Status: Built on feat/connect-practice-phase2 (PR #2636), with PR #2653 (the marketplace gateway contracts, which also holds the #2650 lanes and the #2645 gateway fixes) merged in (B0). Code: backend/domains/billing/services/marketplace/, backend/domains/connect/services/refund_requests/, backend/domains/connect/services/fulfilments/ Plans: docs/plans/2026-10-08-feat-connect-practice-phase2-plan.md, sections 6, 12, 13 and "As built"; docs/plans/2026-10-12-fix-marketplace-gateway-contracts-plan.md, the "As built" sections and section 5 Related: ../marketplace/marketplace-multi-processor.md (the first architecture of the gateway layer) Follow-ups: #2650, #2564, #2563

This guide tells how a patient pays a vendor through Kulcare, how the vendor gets paid, and how money goes back to the patient. It is for developers, support and operations. Each statement comes from the code on the branch above. Where the code is not clear, the guide says so.

Amounts in this guide are in minor units (paise or cents) unless the text says rupees.

1. Overview

Who pays whom

PartyRoleRecord
Buyer (patient)Pays for an offering. Gets the refunds.Identity::User, billing.marketplace_order_intents.user_id
AttendeeThe person who gets the service. The buyer, or a dependent that the buyer manages.attendee_user_id in the fulfilment params and the Connect rows
VendorSells the offering. Gets the payment less the commission, into a linked account at the gateway.Identity::Vendor (payment_account_id, payment_account_provider, payment_account_status)
KulcareRuns the checkout. Keeps the commission.The platform account at each gateway

The vendor is the merchant of the sale. Kulcare takes a commission. The commission is 5% by default (Billing::Marketplace::Config::DEFAULT_COMMISSION_RATE, config.rb:24). The environment variable BILLING_MARKETPLACE_COMMISSION_RATE changes it. The rate must be from 0.0 to 0.5, else the code raises (config.rb:48-74). InitiateOrderService stores the rate on the order as commission_bps. The model and a database trigger refuse a later change.

Gateways

The vendor's country selects the gateway (Identity::Vendor#expected_provider, vendor.rb:271-277).

CountryGatewayProductNotes
INCashfreeEasy SplitWhen the BILLING_CASHFREE_ENABLED feature flag is on. available_providers is then ['cashfree'] (vendor.rb:286-292), so the vendor has no other choice.
INRazorpayRouteOnly when the flag is off: available_providers is then ['razorpay']. The Razorpay code stays, but Kulcare no longer uses Razorpay (plan decision 2026-10-12).
US, AEStripeConnect ExpressThe backend exists. The Connect and patient apps show "coming soon" (#2564).

GatewayFactory.for_vendor (gateway_factory.rb:34-48) routes every order and refund. It uses payment_account_provider when the vendor has a payment_account_id, else expected_provider. The BILLING_MARKETPLACE_PROVIDER setting feeds only GatewayFactory.current, which checkout and refunds do not use.

The lifecycle in one paragraph

The vendor links a payout account at the gateway (KYC). When the gateway marks the account active, the vendor can take payments, and the Connect listing goes public. A patient opens an offering in the patient app and clicks Buy. POST /billing/marketplace/orders checks the offering, computes the split, creates the gateway order and stores a MarketplaceOrderIntent. The patient pays in the gateway checkout. The gateway webhook (and, for Razorpay only, the client verify call) confirms the payment, grants a package subscription, and runs the fulfilment handler, which makes the class seat, the visit series or the meal plan. A failed fulfilment gets up to 3 more tries. Later, money can go back: the vendor removes a paid seat or cancels a batch, a visit request is declined, expires or is cancelled, the vendor or staff approve a refund request, or Kulcare support refunds the order. Each path calls Billing::Marketplace::RefundService, and each refund gets a row in the refund ledger.

Code map

ConcernFile
Checkout startbackend/domains/billing/services/marketplace/initiate_order_service.rb
Client verifybackend/domains/billing/services/marketplace/verify_order_service.rb
Webhook effectsbackend/domains/billing/services/marketplace/apply_event_service.rb
Subscription grantbackend/domains/billing/services/marketplace/subscription_creator.rb
Fulfilment seambackend/domains/billing/services/marketplace/fulfilment.rb, handlers in backend/domains/connect/services/fulfilments/
Refund corebackend/domains/billing/services/marketplace/refund_service.rb
Refund ledger, markers and errorsrefund_ledger.rb, refund_marker.rb, refund_failure.rb, refund_split.rb, apply_gateway_refund.rb in the same folder; models billing/models/marketplace_refund.rb, marketplace_vendor_owed_amount.rb
Support refundbackend/domains/billing/controllers/marketplace/refunds_controller.rb
Fulfilment retryfulfilment_retry.rb, backend/domains/billing/jobs/marketplace/retry_failed_fulfilments_job.rb
Gatewayscashfree_gateway.rb, razorpay_gateway.rb, stripe_gateway.rb in the same folder
Webhook translationcashfree/event_translator.rb, razorpay/event_translator.rb, stripe/event_translator.rb
Webhook endpointsbackend/domains/billing/controllers/marketplace/*_webhooks_controller.rb; Cashfree intake cashfree_webhook_intake.rb
Webhook jobbackend/domains/billing/jobs/marketplace/process_webhook_event_job.rb
Vendor status pollbackend/domains/billing/jobs/marketplace/sync_vendor_accounts_job.rb
Reconcile taskbackend/lib/tasks/billing_marketplace_reconcile.rake, reconcile_orders.rb
Order recordbackend/domains/billing/models/marketplace_order_intent.rb
Vendor onboardingbackend/domains/billing/controllers/marketplace/vendor_onboardings_controller.rb, services/marketplace/onboarding/
Refund requestsbackend/domains/connect/services/refund_requests/, connect/models/vendor_refund_request.rb
Seat refundsbackend/domains/connect/services/classes/seat_refund.rb, batch_service.rb, enrolment_service.rb
Visit refundsbackend/domains/connect/services/visits/full_refund.rb, close_request.rb, respond.rb, connect/jobs/visit_request_sweep_job.rb
Vendor Payments pagefrontend/apps/connect/src/pages/VendorPaymentsPage.tsx; owed amounts components/refunds/OwedToKulcareSection.tsx, backend/domains/connect/controllers/me/owed_amounts_controller.rb
Patient checkoutpatient/src/features/marketplace/components/MarketplaceCheckoutButton.tsx, pages/CheckoutSuccessPage.tsx

2. Vendor payout setup

Endpoints

Method and pathWhoWhat it does
POST /billing/marketplace/vendors/:vendor_id/onboardingThe vendor's own user, or an admin of a partner tenant (Identity::VendorPolicy#onboard?)Body { provider, kyc: { ... } }. Creates the linked account. 201 on success.
GET /billing/marketplace/vendors/:vendor_id/onboardingSame, with show?Returns account_id, provider, status, country, available_providers, expected_provider and requirements.
POST /billing/marketplace/vendors/:vendor_id/onboarding/refreshSameStripe only: makes a new Account Link URL. Other gateways get 422.

The controller is VendorOnboardingsController (vendor_onboardings_controller.rb). It stays open on a suspended practice when the user owns the vendor outside that tenant.

Steps for each gateway

Onboarding::CreateService (onboarding/create_service.rb) does these steps:

  1. It checks the policy and the vendor country.
  2. It selects the provider: the provider parameter, else expected_provider. The provider must be in available_providers.
  3. A payload builder checks the KYC form. A bad field raises ArgumentError, and the endpoint answers 422.
  4. Cashfree only: it reads the vendor at Cashfree first (existing_cashfree_account). When Cashfree has the vendor, the service does not create it again (see the table below).
  5. The gateway creates the linked account.
  6. The service writes only payment_account_id, payment_account_provider, payment_account_status and payment_account_requirements on the vendor (create_service.rb:116-129). Kulcare does not store PAN, bank or tax data. When the gateway answers active at create, the service calls VendorActivation.activate!, so the listing goes live at once.

After step 6 the vendor's country is locked (vendor.rb:390-396). To change the country, an operator clears payment_account_id first.

GatewayKYC fields that Kulcare collectsAPI callsAccount idFirst status
Cashfreelegal_business_name, contact_name, email, phone, pan (required); gstin, business_type, bank account_number and ifsc_code (optional)GET /easy-split/vendors/{id} first. No vendor: POST /easy-split/vendors with status: 'ACTIVE' (cashfree_gateway.rb:114-127). A rejected vendor: one PATCH /easy-split/vendors/{id} with the new bank and KYC details (:147-151). A suspended vendor: refused ("on hold at the payment provider"). Any other status: 409 onboarding_already_started, or a link to the live account when the vendor has no local account id.kulcare_ plus the vendor uuid with _, or the cf_vendor_id that Cashfree returnsFrom STATUS_MAP (below). Cashfree answers IN_BANK_VALIDATION for a new vendor, which is under_review.
RazorpayThe same fieldsAccount.create, Stakeholder.create, Product.requested('route') (razorpay_gateway.rb)acc_...created maps to pending
Stripeemail, country, business_type, optional business_urlaccounts.create (Express), then account_links.create (stripe_gateway.rb:73-108)acct_...From charges_enabled, payouts_enabled and requirements. The answer has an activation_url, and the vendor goes to Stripe for KYC.

The Cashfree validators are in cashfree/onboarding_payload_builder.rb: PAN AAAAA9999A, IFSC AAAA0XXXXXX, a bank account of 9 to 18 digits, a 10-digit Indian mobile (a +91 prefix is removed), and a 15-character GSTIN. The server accepts a blank GSTIN, and it does not check the form for individual. The Payments page labels GSTIN optional for every business type, and checks the form of a typed GSTIN for every business type.

Stripe builds its return and refresh URLs before accounts.create, from config.x.connect_app_url, else the CONNECT_APP_URL variable, else https://connect.kulcare.com (stripe_gateway.rb:476-490). This fixed #2639.

Account statuses

Identity::Vendor#payment_account_status (vendor.rb:65-72):

StatusMeaningCan take payments
noneNo linked accountNo
pendingThe account exists. The vendor must finish steps at the gateway.No
under_reviewThe gateway checks the KYCNo
activeReadyYes, with a payment_account_id (can_receive_payments?, vendor.rb:247-249)
suspendedThe gateway paused the accountNo
rejectedThe gateway refused the accountNo

Webhooks change the status through ApplyEventService#apply_linked_account_status_changed (apply_event_service.rb:74-126):

GatewayEventNew status
CashfreeVENDOR_STATUS_UPDATEThe live status at Cashfree, mapped by STATUS_MAP (below)
Razorpayaccount.under_review, account.needs_clarificationunder_review
Razorpayaccount.activatedactive
Razorpayaccount.suspended, account.rejectedsuspended, rejected
Stripeaccount.updatedFrom Stripe::EventTranslator.map_account_status
Stripeaccount.application.deauthorizedsuspended

A Cashfree vendor event is only a trigger. The service reads the vendor at Cashfree (CashfreeGateway#fetch_linked_account) and applies that live status, so a late ACTIVE event cannot undo BLOCKED or ON_HOLD. A vendor that Cashfree does not know (AccountNotFoundError) changes nothing, and the error goes to Rails.error. Another read error raises, so the job tries again.

The Cashfree status map is CashfreeGateway::STATUS_MAP (cashfree_gateway.rb:29-46):

Cashfree statusKulcare statusRequirements
ACTIVEactivenone
IN_BANK_VALIDATION, IN_BENE_CREATION, IN_KYC_REVIEWunder_reviewnone
ACTION_REQUIREDpendingdisabled_reason: 'action_required', currently_due: the documents that are not VERIFIED
BANK_VALIDATION_FAILED, BENE_CREATION_FAILEDrejecteddisabled_reason: 'bank_validation_failed', currently_due: ['bank_account']
ON_HOLDsuspendeddisabled_reason: 'on_hold'
BLOCKEDsuspendeddisabled_reason: 'blocked'
DELETEDsuspendeddisabled_reason: 'deleted'. A deleted vendor cannot onboard again by itself.

A status that is not in the map changes nothing. map_status reports it as UnknownVendorStatusError.

SyncVendorAccountsJob runs every 30 minutes (config/schedule.rb). For each Cashfree vendor in pending or under_review, with an account id and an update in the last 14 days, it sends the same trigger through ApplyEventService. So a lost VENDOR_STATUS_UPDATE does not leave the vendor waiting. Run the job one time on each environment after the deploy.

The service stores the open requirements (currently_due, disabled_reason) in payment_account_requirements. An empty list clears the column.

When the listing goes live (plan decision A2)

A self-serve vendor's Connect profile starts unlisted. When the account becomes active, VendorActivation.activate! marks it active and calls Connect::PublishVendorListing in its own savepoint (vendor_activation.rb:15-33). The activation webhook and Onboarding::CreateService (an active answer at create time) both use it. PublishVendorListing changes only an unlisted profile to public with accepting_referrals: true. It does not change a profile that is already public, a private or connections_only profile (the vendor turned search off), or a delisted vendor. An error there goes to Rails.error and does not undo the account status.

rake connect:publish_active_vendors (a dry run unless CONFIRM=1) publishes the vendors that were active before this code existed.

Approve KYC on a test environment (#2204)

Sandbox KYC at Cashfree stays pending. On staging the vendor then stays unlisted, and GET /connect/public/vendors/:vendor_id/availability answers 404. To test a booking, a platform admin uses "Mark KYC approved" on the vendor page in kulcare-admin (/vendors/:id).

Method and pathWhat it does
GET /identity/admin/vendorsLists vendors. Filters: search (name, business name, email, phone) and payment_account_status.
GET /identity/admin/vendors/:idThe vendor, with can_mark_kyc_approved. The admin app shows the button only when it is true.
POST /identity/admin/vendors/:id/mark_kyc_approvedCalls Billing::Marketplace::TestKycApproval, which calls VendorActivation.activate!. meta.listing_outcome is the PublishVendorListing outcome. meta.listing_errors gives the reasons for an invalid_profile outcome.
  • The controller is Identity::Admin::VendorsController. It has the platform-admin chain and the Identity::Admin::VendorPolicy#mark_kyc_approved? check.
  • The action works only in the development, test and staging Rails environments (TestKycApproval::ALLOWED_ENVIRONMENTS). The deploy sets the Rails environment, so no flag or credential can turn it on in production. In production the endpoint answers 404 and can_mark_kyc_approved is false.
  • It works only for a Cashfree account, and only when Billing::Gateways::Cashfree::Environment resolves to the sandbox. A staging process that uses the live Cashfree account gets 422 KYC_APPROVAL_GATEWAY_NOT_SANDBOX. An approval there would let real patients pay a vendor that Cashfree did not check.
  • It accepts only a pending or under_review account with a payment_account_id. Other states get 422 with a KYC_APPROVAL_* error code.
  • It calls no gateway and no other external service.
  • The approval and its audit entry commit in one transaction. The Audit::AdminAccessLog row of the request records the admin and the vendor. Its params_redacted.kyc_approval records the environment, the previous status, the outcome and the listing outcome. A refused or failed attempt also has an entry. To find the approvals, filter on controller = 'Identity::Admin::VendorsController' and action = 'mark_kyc_approved'. Do not filter on the kyc_approval key alone: on other admin requests that key holds the request params, which the client sets.
  • A later sandbox webhook sets the status back to the live Cashfree status (pending). The listing stays public. But if the vendor then turns "Show in search" on, publish_visibility_for in ProfessionalProfilesController sets the listing to unlisted, because the payments are not active. Approve the KYC again if that occurs.
  • Cashfree does not know about the approval. A 1:1 slot booking works. A paid package order sends a split to the vendor account, and the Cashfree sandbox can refuse it while the account there is not active.

What the vendor sees on the Payments page

VendorPaymentsPage.tsx (route /connect/payments) reads GET .../onboarding and shows:

StatusBanner and body
none"Your profile is unlisted to patients until you complete payment-account setup." Then the KYC form. With one entry in available_providers (India: Cashfree when the flag is on), the form opens for it at once. A provider selector shows only when there are more. US and AE get a "coming soon" card (VendorPaymentsPage.tsx:396-399).
pending"Awaiting submission." with the next steps, links to the dashboard and the profile, and a document guide.
under_review"KYC review in progress." with the same next steps.
active"Payments active." and "You're live", with the 5% commission text.
rejectedA danger banner, and a "Need a hand?" card with a "Try again" button and support@kulcare.com. "Try again" opens the KYC form for the provider on file, which posts to the same POST .../onboarding. For a Cashfree vendor, the server then sends one PATCH with the new details and makes no second vendor (B3). When requirements.disabled_reason is bank_validation_failed, the banner says "The bank account did not pass the check. Enter the bank details again." and the form opens with the cursor on the bank account field. Kulcare stores no KYC, so the form starts empty.
suspendedA danger banner and the "Need a hand?" card with support@kulcare.com only. This includes a Cashfree vendor that the provider deleted (disabled_reason: 'deleted').

The "How payments work" card says where a refund is made: "Refund a client from the class roster, the visit request or the refund request on this page.", "A refund comes out of your share of the payment." and "Kulcare support handles chargebacks with the payment provider." No refund text names a provider dashboard.

Below the status, the page shows four blocks for all statuses (VendorPaymentsPage.tsx:283-286): FailedOrdersNotice (orders whose fulfilment failed), RefundRequestsSlot (refund requests), OwedToKulcareSection (the amounts that the vendor owes Kulcare after refunds, section 4.3) and RecentPayments (the latest orders from GET /connect/me/earnings).

Failure and rejected states

  • A second POST .../onboarding for a vendor that has a payment_account_id gets 409 onboarding_already_started. The body names refresh_endpoint for Stripe, and nil for the other gateways.
  • A rejected vendor can onboard again: the 409 check skips a rejected account (vendor_onboardings_controller.rb:62), and the Payments page shows "Try again" for a rejected account. For a Cashfree vendor that exists at Cashfree, the service sends one PATCH /easy-split/vendors/{id} with the new bank details and no second create (B3). Cashfree then checks the bank again.
  • A suspended Cashfree vendor (ON_HOLD, BLOCKED, DELETED) cannot onboard again by itself. Only support can help.
  • A suspended or rejected vendor cannot take new orders: InitiateOrderService refuses with "Vendor cannot accept payments yet". The published listing stays as it is.

3. Purchases

Offering kinds

Billing::Marketplace::Fulfilment::TYPE_FOR_KIND (fulfilment.rb:61-65) maps the package offering_kind to a fulfilment type. The handler class has the same name in Connect::Fulfilments.

Offering kindfulfilment_typeHandlerParams at checkoutWhat fulfil! makes
session (1:1 package)nonenonenoneOnly the package subscription. The buyer becomes the vendor's client (VendorClients::Upsert, source purchase).
class (group class seat)class_enrolmentClassEnrolment{ type, enrolment_id } of a held seatThe held pending_payment enrolment becomes enrolled, with the order and the subscription on it. A payment after the hold ended still enrols, above capacity when no seat is free.
visit_planvisit_planVisitPlandays, start_time_local, starts_on, timezone, visit_address, access_notes, contact_phone, attendee_user_idA requested VendorVisitSeries, with respond_by = now plus 72 hours and ends_on from the subscription expiry. The vendor confirms or declines.
meal_planmeal_subscriptionMealSubscriptiondelivery_days, delivery_window, delivery_address, starts_on, dietary_notes, contact_phoneAn active VendorMealSubscription. The subscription expiry moves to the plan's last day.

The order intent

Table billing.marketplace_order_intents, model Billing::MarketplaceOrderIntent. The table has no RLS.

ColumnMeaning
order_idThe gateway order id. Unique. Cashfree: the receipt, MKT- and the first 32 characters of the gateway key (initiate_order_service.rb:103-104). Razorpay: order_.... Stripe: the PaymentIntent id pi_....
user_idThe buyer. NULL after the buyer's account is deleted (section 4.10).
service_package_idThe vendor package. Its owner is the vendor.
statuspending, then verified. A new checkout sets cancelled on the buyer's open intents for the package that are 30 minutes old or more (cancel_stale_open_intents!).
charge_statepending, succeeded, failed, refunded, disputed. No code writes disputed.
payout_statepending, processing, succeeded, failed, reversed. Only a transfer or settlement webhook writes succeeded.
amount_minorThe price in minor units. Nil only on legacy orders.
currencyISO 4217 from the package
commission_bpsThe frozen commission rate (500 = 5%)
refunded_amount_minorThe money refunded so far. Default 0. A copy of the refund ledger total (section 4.2).
metadata{ receipt, gateway, idempotency_key, gateway_key }; paid_at, payment_method and receipt_sent_at after the payment; class_refund_failed after a failed batch cancel refund; admin_refunds (each support refund by its idempotency_key); the open refund markers admin_refund_pending and refund_request_pending (section 4.2); refund_split (the split of the latest refund); fulfilment_attempts and failure_reason (B1, B2)
fulfilment_type, fulfilment_id, fulfilment_stateThe fulfilment seam. The state is pending, fulfilled or failed, or NULL for a 1:1 package.
fulfilment_paramsEncrypted JSON. The fulfilment clears it on success. Account closure clears it too.
share_phoneThe buyer ticked "Share my phone with the vendor"

remainder_minor (marketplace_order_intent.rb:97-102) is amount_minor - refunded_amount_minor, at least 0. It is 0 when charge_state is refunded, and nil for a legacy order with no amount_minor.

Flow

Initiate: POST /billing/marketplace/orders

Body: { service_package_id, receipt?, fulfilment?, share_phone? }. The patient app sends no receipt, so the controller makes one (orders_controller.rb, generate_receipt).

Authenticated, not tenant-scoped. The buyer is usually a patient-app user whose session carries no tenant — they are not a member of the vendor's tenant or of any other — so the controller skips the billing base controller's require_tenant! (as the care-package endpoint does). Both actions run with or without a session tenant (clinic staff buying for themselves arrive under their clinic's RLS context); what makes both work is that nothing behind them reads tenant-scoped: vendor packages carry no tenant_id, the order intent is buyer-keyed, and the subscription is written with none and read unscoped_by_tenant. A tenant-scoped read added to this flow would silently filter for logged-in clinic staff only. Until PR #2786 the guard answered every purchase with 400 TENANT_REQUIRED before #create ran (reported as the "Buying a Vendor Session" QA comment on #2122). The suspended-tenant guard is skipped for the same two actions: a buyer whose session happens to carry a suspended clinic is acting on their own account, and the purchase writes nothing into the clinic. (The care-package endpoint keeps that guard: its purchase writes into the package's clinic.)

InitiateOrderService#call refuses with 422 when:

  • the package is not vendor-owned, not active, or has no positive effective_price;
  • the vendor cannot take payments, or has no payment_account_id;
  • Fulfilment.prepare refuses. The answer has error.code: FULFILMENT_REQUIRED, FULFILMENT_MISMATCH, FULFILMENT_NOT_AVAILABLE, ATTENDEE_NOT_ALLOWED, ENROLMENT_HOLD_EXPIRED, ENROLMENT_NOT_AVAILABLE, REGISTRATION_REQUIRED, VISIT_PLAN_INVALID or MEAL_PLAN_DETAILS_INVALID;
  • the gateway refuses (GatewayError).

The fulfilment check runs before the gateway call, so a refused order leaves no order at the gateway. Then the service creates the intent with status: 'pending', charge_state: 'pending', payout_state: 'pending' and, for a fulfilment, fulfilment_state: 'pending'. The answer is { order: { order_id, checkout_token, env? }, split, gateway, package_id, vendor_id }.

Gateway checkout in the patient app

MarketplaceCheckoutButton.tsx uses a phase lock (idle, initiating, in_gateway, verifying) against a double click.

GatewayClient stepAfter a success
CashfreeOpens Cashfree.js with redirectTarget: '_modal' and the payment_session_id (checkout_token). The env field selects sandbox or production (#2122).Goes to /marketplace/orders/:order_id/success with verificationPending: true. The client does not call verify.
RazorpayOpens Razorpay Checkout with the order id.Calls POST /billing/marketplace/orders/:order_id/verify, then goes to the success page with the subscription. A failed verify also goes to the success page, with verificationPending: true.
StripeShows the toast "Stripe checkout coming soon".Nothing

Verify: POST /billing/marketplace/orders/:id/verify

Body: { service_package_id, payment_id, signature }. VerifyOrderService (verify_order_service.rb):

  1. It finds the intent by order_id. The caller must be the intent's user, and the package must match. This binds the payment to the buyer.
  2. It confirms the payment at the gateway. Razorpay checks the signature. Cashfree reads the order and needs PAID, and checks that the split has the vendor. Stripe reads the PaymentIntent and needs succeeded and the vendor as transfer_data.destination.
  3. Cashfree (webhook_authoritative?): it marks the intent verified and returns pending_webhook: true. It grants nothing (:79-87).
  4. Razorpay and Stripe: in one transaction, SubscriptionCreator grants the subscription keyed on payment_id, the intent becomes verified, and Fulfilment#fulfil! runs (:100-110).

Verify does not set charge_state. It stays pending until the payment.captured (Razorpay) or payment_intent.succeeded (Stripe) webhook. Refunds need succeeded, so a refund of a Razorpay order waits for that webhook.

Failures answer 422 with a fixed text: a signature mismatch, a gateway that is not available, or "We could not confirm this payment yet" for a Cashfree order that is still ACTIVE or PENDING.

Webhooks

GatewayEndpointSignatureReplay windowDedup key
CashfreePOST /billing/marketplace/webhooks/cashfree, and the single-merchant URL for an MKT- order (below)Base64 HMAC-SHA256 of x-webhook-timestamp plus the raw body, with no separator. The secrets, in order: cashfree.webhook_secret_current, webhook_secret, client_secret, webhook_secret_previous (cashfree_gateway.rb:379-397). With no secret, every delivery fails.The x-webhook-timestamp header in milliseconds (13 digits; 10 digits are read as seconds; any other length fails). 25 hours in the past, 1 minute in the future.cf:<type>:<id>:<status>:<event_time>, from signed body fields only (cashfree_webhook_intake.rb:139-162)
RazorpayPOST /billing/marketplace/webhooks/razorpayHMAC-SHA256 of the raw body, razorpay_marketplace.webhook_secret_current or _previousThe payload created_at: 25 hours in the past, 1 minute in the futurerzp:<event>:<entity id>:<created_at>, from the signed body. The X-Razorpay-Event-Id header is not signed, so the key does not use it.
StripePOST /billing/marketplace/webhooks/stripe/connect and /stripe/accountStripe::Webhook::Signature.verify_header, one secret for each endpointThe timestamp in the signature header, 300 s. Stripe signs each delivery again. The body created is not checked.The event id

Each endpoint refuses a body above 256 KB, a bad signature and an event outside the window (400). Then it inserts a row in billing.marketplace_webhook_events. The unique key (provider, external_event_id) makes a second delivery a duplicate, which gets 200 with duplicate: true. Then it enqueues ProcessWebhookEventJob (queue billing, 3 tries for unexpected errors). For Cashfree and Stripe, a failed enqueue removes the new row and answers 503, so the gateway retry is stored again.

Cashfree details (CashfreeWebhookIntake, cashfree_webhook_intake.rb):

  • The dedup key. The id is the first present value of data.payment.cf_payment_id, data.refund.cf_refund_id, data.refund.refund_id, data.merchant_vendor_id, data.settlement.cf_settlement_id, data.settlement.settlement_id and data.order.order_id. The status is the payment, refund or vendor status. A key above 255 characters becomes cf:sha256:<hex>. A body with no type or no id gets 400 missing_event_id. A Cashfree retry keeps its event_time, so a retry has the same key as the first delivery. Cashfree sends one event in several webhook versions: the first delivery wins, and the others are duplicates.
  • x-idempotency-key. This header is not signed. The intake stores it for support only, and it never sets the dedup key. Cashfree sends it only on webhook version 2025-01-01 and later.
  • The stored payload. A VENDOR_STATUS_UPDATE keeps only merchant_vendor_id, past_status, updated_status, updated_by and document_status. A VENDOR_SETTLEMENT_* event keeps an allowlist of data.settlement fields (SETTLEMENT_FIELDS), with no account_number, ifsc or vpa. The unsigned delivery headers (x-idempotency-key, x-webhook-attempt, x-webhook-version) and the endpoint go under payload._delivery. RedactWebhookPayloadsJob (daily at 03:50) clears payload on rows older than 180 days.
  • The dashboard test ping. A signed body with type: 'WEBHOOK' and data.test_object gets 200 { received: true, test: true }, with no row and no job.
  • notify_url. InitiateOrderService sends order_meta.notify_url from CallbackUrls.cashfree_notify_url (initiate_order_service.rb:138), so the payment webhooks of a marketplace order go to the marketplace endpoint. In production the host is the API origin, and MARKETPLACE_WEBHOOK_BASE_URL has no effect. On staging that variable sets the host when it is a usable origin, else the API origin does. In development and test only the variable (the dev tunnel) sets it: with no variable, the order has no notify_url. A URL that is not https is not sent. Outside a local environment, that is reported as NoNotifyUrlError.
  • MKT- events on the single-merchant URL. One Cashfree account serves the single-merchant and the marketplace paths. Cashfree sends refund events, and a copy of other order events, to the dashboard URL (POST /billing/webhooks/cashfree), not to the order's notify_url. That endpoint sends each event whose order id starts with MKT- to the same CashfreeWebhookIntake (Billing::Webhooks::MarketplaceOrder). So the event gets the marketplace signature check, window and dedup row, and an event that reaches both URLs is applied one time. A refusal there answers 200 with marketplace_order: true and reports IntakeRefused. A failed enqueue keeps its 503.

The job translates the payload to an event and calls ApplyEventService:

EventRazorpayCashfreeStripeEffect
OrderPaidpayment.capturedPAYMENT_SUCCESS_WEBHOOKpayment_intent.succeededSee "Apply a payment" below.
OrderPaymentFailedpayment.failedPAYMENT_FAILED_WEBHOOK, PAYMENT_USER_DROPPED_WEBHOOKpayment_intent.payment_failedSets charge_state: 'failed', unless it is failed, succeeded, refunded or disputed
TransferProcessedtransfer.processednonenoneSets payout_state: 'succeeded' when the order id matches
RefundProcessedrefund.processedREFUND_STATUS_WEBHOOKcharge.refunded, charge.refund.updatedApplyGatewayRefund moves the refund ledger row (section 4.8)
LinkedAccountStatusChangedaccount.*VENDOR_STATUS_UPDATEaccount.updated, account.application.deauthorizedSection 2

A VENDOR_SETTLEMENT_* event covers many orders, so it is stored and logged, but not applied. Other Cashfree events (for example PAYMENT_CHARGES_WEBHOOK and disputes) are an UnhandledEvent.

Apply a payment (apply_order_paid, apply_event_service.rb:128-225), in this order:

  1. An unknown order fails. A Cashfree order with no intent whose id does not start with MKT- is a single-merchant order: not_a_marketplace_order, with no alert.
  2. A second payment id on an order that already granted is reported (SecondPaymentError) and not granted.
  3. B1. A paid order whose fulfilment failed runs the fulfilment again (FulfilmentRetry, reason fulfilment_retried). See "The fulfilment seam".
  4. A succeeded order returns already_succeeded. A refunded or disputed order returns already_terminal, so a late payment event cannot bring back a refunded order.
  5. A payment on an intent that a newer checkout cancelled, when the buyer already paid for the package: the charge is recorded, SecondPaymentError is reported, and nothing is granted a second time.
  6. A blank payment id, or an amount or a currency that does not match the intent, is a failure.
  7. B2. The buyer's account was deleted before the webhook (user_id is NULL). The order gets charge_state: 'succeeded', fulfilment_state: 'failed' and metadata.failure_reason: 'buyer_deleted'. Nothing is granted and nothing is refunded. BuyerDeletedError goes to Rails.error, and support decides the refund (section 6).
  8. The grant: the package subscription, charge_state: 'succeeded', status: 'verified', paid_at and payment_method in metadata. The receipt email goes after the commit (OrderReceipt.deliver_after_commit), one time. Then the fulfilment runs.

The webhook row gets processed_outcome: 'applied' only when the apply changed state. Else it gets noop_<reason>, for example noop_already_succeeded, noop_unhandled or noop_not_a_marketplace_order (MarketplaceWebhookEvent.outcome_for). An ApplyEventService failure (for example an amount mismatch or an unknown MKT- order) is reported to Rails.error as ProcessWebhookEventJob::ApplyFailedError, and the row gets failed_apply_failed. The job does not retry it. An unexpected error retries. After the third try the row gets failed_retries_exhausted, and the error is reported.

Idempotency

LayerKeyEffect
InitiateAn advisory lock on the buyer and the package. Open intents 30 minutes old or more are cancelled first. The key is a SHA-256 of marketplace:v3, the user, the package, the fulfilment id or params hash, and the count of the buyer's closed intents for the package (initiate_order_service.rb:217-224). The gateway key is a SHA-256 of that key and the request body.Cashfree gets the gateway key as x-idempotency-key, and Stripe as its idempotency_key, so a double submit gets the first order back. Razorpay returns the open intent with that key.
Webhook row(provider, external_event_id)A second delivery of one event does nothing.
Webhook applycharge_state in succeeded, refunded, disputedA second OrderPaid does nothing, except the B1 retry (apply_event_service.rb:144-151).
Subscriptionexternal_payment_id (partial unique index)Verify and the webhook share one key, so the second one finds the first subscription (subscription_creator.rb:27-53).
FulfilmentThe intent row lock and fulfilment_state == 'fulfilled'A second fulfil! does nothing (fulfilment.rb:181-188).
Receiptmetadata.receipt_sent_atSendOrderReceiptJob claims it before it sends, so the buyer gets one email.

Amounts and the split

amount_minor = (effective_price * 100).round (initiate_order_service.rb:65). Config.commission_split_for(amount) gives commission = (amount * rate).round and vendor_amount = amount - commission (config.rb:83-89).

Example: a package at ₹20.00 with the 5% default.

GatewayOrder requestKulcare getsVendor gets
Cashfreeorder_amount: 20.0, order_splits: [{ vendor_id, amount: 19.0 }], in rupees (cashfree_gateway.rb:124-177)The rest of the order: ₹1.00₹19.00
Razorpayamount: 2000, transfers: [{ account_id, amount: 1900 }], in paise (razorpay_gateway.rb:170-187)The part not in transfers: 100 paise1900 paise
Stripeamount: 2000, application_fee_amount: 100, transfer_data: { destination }, on_behalf_of, card only (stripe_gateway.rb:163-202)The application fee: 100 centsThe rest

Gateway fees are outside this model. SubscriptionCreator#record_pending_commission only logs the split. No Billing::Commission row is written for a marketplace order.

The fulfilment seam

Fulfilment#fulfil! (fulfilment.rb:181-236):

  • An order with no fulfilment_type (a 1:1 package) records the buyer as the vendor's client and returns.
  • Otherwise it locks the intent. A fulfilled order returns at once.
  • The handler runs in its own savepoint. On success the intent gets fulfilment_state: 'fulfilled', the handler's record id in fulfilment_id, and fulfilment_params: nil.
  • On any error the savepoint rolls back the handler's writes. The error goes to Rails.error with the order id and the type, and update_columns sets fulfilment_state: 'failed'.

A failed fulfilment never undoes the payment. The intent stays verified and succeeded, and the package subscription stays. Examples of a failure: the class batch was cancelled before the payment arrived, a second payment for a seat that another order paid, or two paid seats for one person.

B1: the retry of a failed fulfilment (FulfilmentRetry, fulfilment_retry.rb). Two callers run fulfil! again with the subscription that the payment granted:

  • ApplyEventService#apply_order_paid, on a replayed OrderPaid, before the terminal guard;
  • RetryFailedFulfilmentsJob, every 30 minutes (config/schedule.rb). It adds 1 to metadata.fulfilment_attempts before each try, and it stops after 3 tries (MAX_ATTEMPTS). The last failed try is reported as FulfilmentRetriesExhausted, so support can finish or refund the order.

An order is tried again only when charge_state is succeeded, fulfilment_state is failed, fulfilment_params is still present, the buyer account exists, refunded_amount_minor is 0 (a refunded or partly refunded order is not tried), and metadata.failure_reason is not buyer_deleted (B2).

The vendor sees a failed order in FailedOrdersNotice on the Payments page: "The client paid, but the seat or the plan was not made. Kulcare support refunds this order in full." Support refunds it (section 6). An order that is refunded in full (charge_state refunded, or refunded_amount_minor equal to amount_minor) leaves the notice. An order that is refunded in part stays.

What the patient sees

MomentWhat the patient sees
Not signed in"Sign in to buy"
Buy clicked"Processing..." then the gateway window
Razorpay verify"Confirming payment..."
Razorpay verify succeeded/marketplace/orders/:orderId/success: "You're all set". A visit plan says "Request sent. The carer confirms the time."
Cashfree paid, or Razorpay verify failed, or a page reloadThe same page, pending branch: "Payment received ... our system is still confirming the details. This usually takes a minute." A class, visit or meal order links to My classes, My visits or My meal plans. Any other order links to My Care. When the account has an email, the page says "We send a receipt to when the payment is confirmed." (also on the confirmed view). With no email, the page makes no promise.
Initiate refusedA toast "Could not start checkout" with the API message. A seat hold that ended is handled by the class page.
Stripe vendorA toast "Stripe checkout coming soon"

After the payment, the patient finds the order on My Packages (MyCarePage.tsx), My classes, My visits or My meal plans. Each of those rows hosts OrderRefundRequest (section 4.6).

4. Refunds

4.1 Every path that refunds

PathEndpoint or triggerAmountRefund keyWhen the gateway failsEnds the service
Vendor removes a paid seat, choice fullDELETE /connect/me/class_enrolments/:id with { "refund": "full" }The full remaindercs_ + enrolment idThe kind's status and code (section 4.9). The seat does not change. Refunds that the gateway read found are still recorded.Yes: the seat is released and the package subscription is cancelled
Vendor removes a paid seat, choice noneSame, with "none"No refundnonen/aYes
Vendor cancels a batchPOST /connect/me/class_batches/:id/cancelThe full remainder of each paid seatcs_ + enrolment idThe seat closes anyway. The failure is stored in metadata.class_refund_failed, and the id is in meta.refund_failures. The roster shows "Refund failed".Yes
Retry of a failed batch cancel refundDELETE /connect/me/class_enrolments/:id with "full"The full remaindercs_ + enrolment idThe kind's status and code (section 4.9)Already ended
Vendor declines a visit requestPOST /connect/me/visit_series/:id/declineThe full remaindervs_ + series idThe kind's status and code (section 4.9). The request stays requested. Refunds that the gateway read found are still recorded.Yes: declined, and the subscription is cancelled
Visit request expires after 72 hConnect::VisitRequestSweepJob, every 15 minutesThe full remaindervs_ + series idThe request stays requested. The next run tries again.Yes: declined with decline_reason: 'no_response'
Patient cancels an open visit requestPOST /patient/vendor_visit_series/:id/cancelThe full remaindervs_ + series idThe kind's status and code (section 4.9)Yes: cancelled
Refund request approved by the vendorPOST /connect/me/refund_requests/:id/approveThe requested amountrr_ + request idThe status and code of the failure kind (section 4.9). The request stays pending.Only when the amount is the full remainder
Refund request approved by staffPOST /connect/admin/refund_requests/:id/approve, after 7 daysThe requested amountrr_ + request idThe same as the vendor approveOnly when the amount is the full remainder
Support refundPOST /billing/marketplace/orders/:id/refund (platform admin only, no tenant)amount_minor in the body, or the full remainderadm_ + the client's idempotency_key (a UUID, required)502 REFUND_FAILED while the result is not clear, else the kind's status and code (section 4.9)Only when the refund leaves nothing on the order

The patient cannot cancel a paid seat. DELETE /connect/class_enrolments/:id answers 422 PAID_SEAT_CANCEL_BY_VENDOR: "You paid for this seat. Ask for a refund, and the provider decides."

A meal plan has no refund path of its own. Its money comes back only through a refund request or the support refund. The vendor End action of a visit series and the meal plan end do not refund.

4.2 The one refund design and RefundService

Billing::Marketplace::RefundService.call(order_intent:, payment_id:, amount_minor: nil, actor:, reason: nil, refund_id: nil, found_refund: nil) (refund_service.rb). B0 merged #2653 into #2636, so every refund path uses one design.

The ledger

Table billing.marketplace_refunds (model Billing::MarketplaceRefund, helpers RefundLedger) has one row for each refund attempt: order_id, provider, refund_id (the merchant refund id sent to the gateway), base_refund_id (the caller key), attempt, gateway_refund_id (Cashfree cf_refund_id, Stripe re_), amount_minor, status (pending, succeeded or failed), gateway_status, source (kulcare or gateway_dashboard) and failure_reason.

  • The ledger is the truth. The refunded total of an order is the sum of its rows that are not failed. A pending row counts, because Cashfree sent no refund webhook in the live sandbox run. A total of succeeded rows only could stay short, and a second refund could then take more than the order amount.
  • refunded_amount_minor is a copy. RefundLedger.sync_refunded_amount! copies the ledger total to the intent in the same transaction as the ledger row. RefundService and ApplyGatewayRefund both call it. So the remainder that the refund request screens read agrees with the gateway.
  • Partial refunds. charge_state becomes refunded only when the ledger total reaches the order amount. A partial refund leaves it succeeded, so the rest can be refunded. A legacy order with no amount_minor becomes refunded after one refund.
  • Gateway statuses (MarketplaceRefund::STATUS_BY_GATEWAY_STATUS): SUCCESS, SUCCEEDED and PROCESSED are succeeded. PENDING, ONHOLD, REQUIRES_ACTION and CREATED are pending. CANCELLED, CANCELED, FAILED and REJECTED are failed. An unknown status stays pending and is reported.

Caller keys and attempt ids

CallerKey
Refund request approve (vendor and staff)rr_<request id>
Class seat removal and batch cancelcs_<enrolment id>
Visit decline, expiry and patient cancelvs_<series id>
Support refundadm_<idempotency_key>
A caller with no keymkt_rfnd_<20 hex characters>

RefundLedger.next_attempt gives the id that goes to the gateway. Attempt 1 sends the caller key as it is. For Cashfree and Stripe, while that row is pending or succeeded, a retry sends the same id, so the gateway answers with the first refund. Another provider always gets the next attempt. A Stripe row older than 23 hours (STRIPE_KEY_WINDOW) is answered from the ledger, and nothing goes to Stripe. After a failed row, the next attempt sends <key>_a<n>. A uuid key loses its dashes, so rr_ and a uuid stay inside Cashfree's 40 characters (MarketplaceRefund.refund_id_for). An id that has a failed row is never sent again.

The steps

  1. It refuses a nil intent (order_missing) and a blank payment_id (payment_id_missing).
  2. It refuses an order whose charge_state is not succeeded (not_refundable), and a package that is not vendor-owned (not_vendor_owned).
  3. It locks the intent row and holds the lock through the gateway call, so two refunds of one order cannot both pass the checks. It reads the state again under the lock.
  4. An open marker that names another key stops the refund with refund_in_progress, before any gateway call (see "Open markers").
  5. Cashfree and Stripe: it reads the refunds of the order at the gateway (list_refunds), and records each one through ApplyGatewayRefund. A refund that Kulcare did not make (for example in the gateway dashboard) gets a gateway_dashboard row and a GatewayRefundRecorded report. A Stripe row keeps the Kulcare key from the refund metadata (kulcare_refund_id).
  6. It gets the attempt id (next_attempt), after the read, so an attempt that failed only at the gateway (no webhook) is failed in the ledger and is not sent again. A full refund (no amount asked) then sends what the gateway has left as an exact amount. A retry of a full refund that the gateway already has sends the same amount again. A gateway with no list_refunds gets a nil amount for a full refund. A Stripe attempt older than 23 hours goes to Stripe no more: the service answers the recorded row, and another amount gets replay_amount_mismatch.
  7. It refuses an amount above what is left on the order (amount_above_remainder), and a full refund with nothing left at the gateway (nothing_left_at_gateway). A retry with the same id does not count its own row against the remainder. Stripe: it refuses a payment_id that is not the order PaymentIntent (payment_id_mismatch).
  8. RefundSplit gives the vendor part (section 4.3). Then it calls GatewayFactory.for_vendor(vendor).create_refund with payment_id, the amount, reverse_transfer: true, refund_application_fee: false, notes: { reason, actor_id, refund_id }, order_id, order_amount_minor, refund_id (the attempt id), vendor_refund_minor and vendor_account_id. A support resolve passes found_refund: (a refund that the gateway already has) and makes no gateway call.
  9. It records the attempt row with the gateway answer (RefundLedger.record_attempt). When the gateway answers that the attempt failed (for example Cashfree FAILED), no money moved: the order stays as it was, and the service returns refund_failed. The next call sends the next attempt id.
  10. Under the same lock it sets charge_state, metadata.refund_split and refunded_amount_minor.
  11. After the lock it writes the owed row when the gateway did not take the whole refund from the vendor (section 4.3). When the remainder is 0, it calls CloseOnFullRefund (section 4.6).
  12. It returns { refund, order_id, vendor_id, owed_minor, amount_minor, remainder_minor }. refund has refund_id (the merchant id), gateway_refund_id and status. Cashfree also gives cf_refund_id.

Every failure has a kind, an HTTP status and a fixed message (RefundFailure, section 4.9). The log line [Marketplace::RefundService] refund refused for order_intent=... keeps the full gateway error. A gateway error after the refund request went out is state_unknown, and the service reports it as RefundService::RefundStateUnknown with the refund id. A gateway error before the refund request (the refund list read) is gateway_unavailable, because no refund went out.

The gateway call cannot be undone. When the database write after it fails, the service still writes the owed row, reports the error with the refund, and returns local_write_failed ("Do not retry").

Open markers

A refund whose result is not clear (a timeout, a lost answer) may have moved money that the refunded total does not show. So two callers write a marker on the intent's metadata before the gateway call (Billing::Marketplace::RefundMarker):

MarkerCallerKeyWhen it commits
admin_refund_pending (key, amount_minor, actor_id, started_at, last_try_at, reason)Support refundadm_<key>Under the row lock, in its own transaction, before the gateway call
refund_request_pending (refund_id, refund_request_id, amount_minor, started_at, last_try_at)Refund request approverr_<request id>Under the row lock, before the gateway call, in the request transaction. It commits with the answer.
  • While a marker is open, every RefundService path with another key fails with refund_in_progress (409 REFUND_IN_PROGRESS). The class seat and visit endpoints answer it too (RefundFailure.answer_for).
  • A refund request approve while a support marker, or the approve marker of another request, is open gets 409 REFUND_IN_PROGRESS. A support refund while an approve marker is open gets 409 REFUND_IN_PROGRESS. A reject or a buyer withdraw of a request whose own approve marker is open gets 409 REFUND_IN_PROGRESS, because its refund can still complete. A marker is never written over (RefundMarker::MarkerTaken).
  • A success clears the marker in the transaction that records the refund.
  • A failure that moved no money clears the marker. A failure that may have moved money (state_unknown, local_write_failed) or a gateway that did not answer (gateway_unavailable) keeps it. A failure of a later try (a retry) keeps it too, because it says nothing about the earlier try. Both callers follow these rules.
  • An error out of the approve after the marker (a database error after the gateway call, an error that the service does not wrap) keeps the marker and answers 409 REFUND_FAILED, not a 500.
  • A marker never ends by time. Each try writes last_try_at. Only a try with the same key, or the resolve endpoint, ends it. A retry with the same key gets the first refund back from the gateway read-back.
  • When the ledger already has a refund under the marker key (a webhook, the reconcile task or the read before a refund recorded it), a retry of the approve, and resolve, finish with that refund: the request closes, or the support refund is stored, and the marker ends. Nothing goes to the gateway, and nothing is recorded two times.

Resolve: POST /billing/marketplace/orders/:id/refund/resolve

A platform admin calls it. It asks the gateway for the attempt id that a retry of the open marker would send (GatewayInterface#find_refund). It never makes a refund. It resolves the support marker. When no support marker is open, it resolves the refund request marker.

Gateway answerResult
The last try was less than 15 minutes ago409 RESOLVE_TOO_EARLY, because a call can still reach the gateway
No refund, or the refund failedThe marker ends, meta.resolved: 'no_refund'. Support: a new key may refund. Refund request: the request stays pending.
A refund with the amount of the markerRefundService records it with found_refund:, and the answer has meta.recovered: true. Support: it is the support refund. Refund request: the request closes as approved by staff (LinkSupportRefund).
A refund with another amountNot recorded. 409 REFUND_AMOUNT_MISMATCH, reported. The marker stays.
The ledger already has a refund under the keyNo gateway call. The marker ends with that refund (the request closes, or the support refund is stored).
No clear answer (a gateway error)502 REFUND_FAILED, "The gateway gave no clear answer, so the open refund stays open. Check again later." The marker stays.
No open markermeta.resolved: 'no_open_refund'

find_refund: Cashfree reads GET /orders/{order_id}/refunds/{refund_id}. Stripe lists the refunds of the PaymentIntent and matches kulcare_refund_id in the refund metadata. More than 100 refunds is not a clear answer.

A refund that fails at the gateway after it was recorded

A Cashfree refund can be PENDING and later FAILED or CANCELLED. A Stripe refund can move from succeeded to failed. The refund webhook, the reconcile task and the read before a refund all report this through ApplyGatewayRefund (apply_gateway_refund.rb):

  • the ledger row becomes failed with the gateway reason, so the refunded total and its copy refunded_amount_minor drop;
  • an open owed row of that refund becomes void (mark_void!). A recovered row stays as it is: a person already collected the money and must pay it back by hand, and the report names the row;
  • a refunded order goes back to succeeded when the refunds that are left do not cover it;
  • ApplyGatewayRefund::RefundFailedError goes to Rails.error;
  • an approved refund request whose refund it was goes back to pending, with no reviewer, no gateway_refund_id and the note "The refund failed at the payment gateway, so no money went back. Approve the request again." (Connect::RefundRequests::ReopenOnRefundFailure). A reject of it answers 422 REFUND_REQUEST_REOPENED: the reject reads the fixed start of the note (NOTE_PREFIX).
  • a class seat refund (cs_ key) marks the closed seat "Refund failed" (SeatRefund.record_gateway_failure, metadata.class_refund_failed), so the roster shows it and the vendor retries it with "full". A visit refund (vs_ key) has no retry screen: support refunds it from the support refund screen. The next approve sends a new attempt id. The request is the one whose gateway_refund_id is the refund's gateway id or merchant id, the one that the rr_ key names, or one that this refund closed as system or staff.

A replay of the same status changes nothing. A failed row that the gateway shows succeeded moves to succeeded, and the move is reported as an error (check for a second refund). A gateway status that would move a final row back to pending is reported and not applied.

4.3 Gateway behaviour, the refund split and owed amounts

CashfreeRazorpayStripe
What is refundedThe order: POST /orders/{order_id}/refunds, with the intent's order_id (cashfree_gateway.rb:299-319)The payment: Payment.fetch(payment_id).refund(...)The PaymentIntent: refunds.create(payment_intent: payment_id) (stripe_gateway.rb:266-289)
Amount unitRupees: refund_amount = amount / 100.0, 2 decimalsPaiseCents
Full refundRefundService reads list_refunds first and sends what Cashfree has left as an exact amount. With no amount at all, the gateway raises GatewayRefusedError before the request.No amount: Razorpay refunds what is leftRefundService reads list_refunds first and sends what Stripe has left as an exact amount
Idempotencyrefund_id is the attempt id (3 to 40 characters). Cashfree keeps one refund for each refund_id on an order, and answers a second POST with HTTP 400 refund_id_invalid. With no key, the gateway makes rfnd_ and 16 hex characters.No idempotency key. The key goes in as the refund receipt.The attempt id is the Stripe idempotency_key, and also kulcare_refund_id in the refund metadata
Read-backWhen the POST fails and a key was given, it reads GET /orders/{order_id}/refunds/{refund_id}. A refund that did not fail comes back in place of the error, with the vendor part from its refund_splits. A 404 or a failed refund raises the first error. Any other read error raises as an unknown state (a plain GatewayError), because the POST may have refunded. A refund under the key with another amount is reported and raised as an unknown state.Before a new refund, it looks for a refund of the payment with that receipt and a status other than failed, and returns itBefore a create with a key, find_refund looks for a refund with that kulcare_refund_id, because Stripe forgets an idempotency key after 24 hours. A found refund with another amount is an unknown state. A repeat of an attempt older than 23 hours goes to Stripe no more (RefundLedger.next_attempt replay_row).
Vendor partrefund_splits: [{ vendor_id, amount }] for the vendor part while the vendor share is not settled. No split when it is settled or the state is not known (below).No split, and no vendor_taken_minor in the answer, so no owed row: RefundService::VendorPartUnknown is reportedreverse_transfer: true takes the whole refund from the vendor's transfer, so no owed row
CommissionKulcare keeps it. The vendor owes the commission part of a refund (owed row).Ignores refund_application_feerefund_application_fee: false: Kulcare keeps its fee
Noterefund_note: the reason only, cut to 100 characters, left out below 3notes hash, speed: 'normal'metadata: the notes (reason, actor_id, refund_id) and kulcare_refund_id
Answerrefund_id (the merchant id), cf_refund_id, gateway_refund_id (the cf_refund_id), status, vendor_taken_minor, owed_reasonrefund_id, statusrefund_id and gateway_refund_id (the re_ id), status, vendor_taken_minor
Error wrapBilling::Gateways::Cashfree::HttpClient::RequestError, with the HTTP status::Razorpay::Error becomes GatewayErrorA connection error becomes GatewayUnavailableError. InvalidRequestError, CardError, AuthenticationError and PermissionError become GatewayRefusedError. Any other StripeError becomes GatewayError.

The refund split (plan A7)

The vendor bears every refund, and Kulcare keeps its full commission. RefundSplit (refund_split.rb) gives the part to take from the vendor:

  • Cashfree and Razorpay: the vendor share is the order amount less the commission. The vendor part of a refund is the refund, up to the share that earlier refunds left. So a full refund leaves the commission part for Kulcare to pay.
  • Stripe: the vendor part is the whole refund.
  • A legacy intent with no amount or no commission_bps gets no split.

Cashfree sends refund_splits only while the vendor share is not settled. CashfreeGateway.vendor_share_state reads vendors[].settlement_id in GET /easy-split/orders/{order_id}: null is not settled, a value is settled, and a missing vendor or field is not known. A failed read is not known and is reported. The buyer's refund never waits on a vendor balance.

Owed amounts

When the gateway did not take the whole refund from the vendor share, Kulcare paid the rest. RefundService writes one row in billing.marketplace_vendor_owed_amounts (Billing::MarketplaceVendorOwedAmount) for that refund (unique on the attempt refund_id), and reports RefundService::VendorOwedAmountRecorded. A failed insert is reported and does not fail the refund.

ReasonWhen
commission_partThe vendor share paid its part, and the commission part of the refund is owed
vendor_share_settledThe vendor share was settled, so the refund went with no split
split_state_unknownThe split order read failed, the vendor or the settlement_id field was missing, or the intent has no split amount
StatusMeaning
openThe vendor owes the amount
recoveredA person collected it and called mark_recovered!(note:), which needs a note
voidThe refund failed at the gateway, so nothing is owed (ApplyGatewayRefund)

metadata.refund_split on the intent keeps the split of the latest refund (refund_id, refund_minor, vendor_taken_minor, owed_minor).

The vendor sees the open rows on the Payments page, in "Owed to Kulcare" (OwedToKulcareSection.tsx). It reads GET /connect/me/owed_amounts (owed_amounts_controller.rb): the open rows of the signed-in vendor, newest first (at most 200), each with amount_minor, currency, reason, order_id and created_at, and meta.totals for each currency. The section is read only. It shows nothing when the vendor owes nothing. Its text says that Kulcare support recovers the amounts, for example from a later payout. The vendor comes from the session, never from the request, because the table has no RLS.

4.4 order_id and payment_id

Each caller must give a non-blank payment_id.

  • Approve uses RefundRequests::OrderPayment.refund_key_for(intent) (order_payment.rb:39-49). For a Cashfree vendor that is the intent's order_id. For the other gateways it is the external_payment_id of the package subscription that the order granted.
  • SeatRefund and Visits::FullRefund read the external_payment_id of the subscription. For Cashfree that is the cf_payment_id. This works because RefundService also sends order_id, and CashfreeGateway refunds by order_id when it has one.
  • The support refund also uses refund_key_for(intent). It does not read a payment_id from the body: Stripe refunds the PaymentIntent it is given, so a wrong id would refund another payment and book it on this order.

4.5 What a full refund ends

A full refund through Approve runs Fulfilment#end_service!, then cancels the package subscription (approve.rb:243-263). The step runs in its own savepoint after the money moved. An error there goes to Rails.error with step: 'end_service', the request stays approved, and the endpoint answers meta.service_ended: false. The vendor then ends the service by hand.

Offeringend_service!Result
Class seatClassEnrolment#end_service!Only the order that paid for the seat ends it. The enrolment is released with the "no refund" code (EnrolmentService.end_seat). The waitlist moves up.
Visit planVisitPlan#end_service!An active or paused series ends (Visits::EndSeries). A requested series becomes cancelled.
Meal planMealSubscription#end_service!Meals::EndPlan with status cancelled
1:1 packagenoneOnly the subscription cancel!. Booked sessions stay.

A partial approval moves money only. The seat, the plan and the subscription do not change.

The other full refund paths end the service themselves. A seat removal and a batch cancel release the seat and cancel the subscription. A visit decline, expiry or patient cancel changes the series status and cancels the subscription. The support refund runs the same step as Approve after a refund that leaves nothing on the order, and answers meta.service_ended (refunds_controller.rb). After a partial refund it ends nothing.

4.6 Refund requests

Table connect.vendor_refund_requests, model Connect::VendorRefundRequest. It has FORCE RLS keyed on app.current_vendor_id, app.current_patient_user_id or app.platform_admin.

Rules

  • The order must be verified, succeeded, vendor-owned, with a remainder above 0 (Refundability.refundable_minor). Else 422 ORDER_NOT_REFUNDABLE. A legacy order has a refundable amount of 0. Support refunds it through the support refund endpoint.
  • Only the buyer (acting_user) finds the order. The URL takes the intent id or the gateway order_id (BuyerOrder). Anyone else gets 404.
  • requested_amount_minor is a positive whole number, at most the remainder. Else 422 VALIDATION_FAILED or REFUND_AMOUNT_TOO_HIGH.
  • reason has 1 to 500 characters. review_notes has at most 1000 characters. A reject needs a note.
  • One open request for each order. A second gets 409 REFUND_REQUEST_OPEN. The intent row lock and a partial unique index enforce it.
  • The buyer withdraws only a pending request. Else 422 REFUND_REQUEST_NOT_PENDING.
  • Staff decide only a pending request older than 7 days (OVERDUE_AFTER). Else 422 REFUND_REQUEST_NOT_OVERDUE.

Endpoints

WhoMethod and path
BuyerGET /patient/orders/:order_id/refund_request: refundable_minor, currency, open_request, past_requests, vendor, offering
BuyerPOST /patient/orders/:order_id/refund_requests, body { refund_request: { requested_amount_minor, reason } }
BuyerPOST /patient/refund_requests/:id/withdraw
VendorGET /connect/me/refund_requests?status=&page=&per_page=, GET /connect/me/refund_requests/:id
VendorPOST /connect/me/refund_requests/:id/approve (review_notes optional), POST .../reject (review_notes required)
StaffGET /connect/admin/refund_requests?status=&overdue=true&vendor_id=&created_from=&created_to=, GET .../:id, POST .../:id/approve, POST .../:id/reject

created_from and created_to are dates (YYYY-MM-DD) in the app time zone, and both days are in the range. A value that is not a date gets 422. The staff view also gives order_reference (the gateway order id, which the support refund names the order by), remainder_minor and support_refunds (from metadata.admin_refunds).

The staff screen is in kulcare-admin: Billing > Refund Requests (#2650). The list filters by status, overdue, vendor and date. The detail page shows the order, the client, the vendor, the amounts, the reason and the history. Staff approve or reject an overdue request, and make a support refund (POST /billing/marketplace/orders/:order_reference/refund). The support refund key is a name-based UUID of the order, the refunded amount, the remainder and the amount. So the same refund on the same order state gets the same key from a second tab or a second admin, and the server replays it. After no answer, a 5xx or a 502 REFUND_FAILED, the screen keeps the key and the amount (in localStorage, never the reason) and sends both again on each retry. Approve stays blocked while that attempt is open. Only a success ends the attempt, or a 4xx on its first try. An admin can abandon an attempt after a check at the gateway (refund adm_<key>).

A key whose refund failed at the gateway later (a webhook or the reconcile task) is not a replay: the next try with that key refunds again with the next attempt id.

The server allows one open support refund for each order, through the admin_refund_pending marker (section 4.2, "Open markers"). Each retry sends the reason and the actor of the first try, so Stripe sees the same request. The answers of the support refund are in section 4.9. The staff view gives an open support marker as pending_support_refund, and the admin screen sends that key again or calls resolve ("Check with the gateway"). The resolve endpoint also resolves an open refund request approve marker (section 4.2, "Resolve"). The staff view gives that marker as pending_approve_refund (refund_id, refund_request_id, amount_minor, started_at, last_try_at). The refund request detail then shows the open approve, disables the support refund (the server answers 409), and offers "Check with the gateway" after a confirm.

A support refund names the refund request that was pending on the order (refund_request_id on the refund record). When the refund is at least the requested amount, Connect::RefundRequests::LinkSupportRefund closes the request as approved by staff, with the refund id in gateway_refund_id, and the buyer and the vendor get a notice. The answer has meta.refund_request_closed. A smaller refund leaves the request pending.

Approve (approve.rb)

The vendor endpoint and the staff endpoint run in one transaction (their RLS context), so the marker and the ledger rows commit with the answer.

  1. Claim (a savepoint). It locks the intent and the request. It checks that the request is pending, the 7-day rule for staff, and that no support marker is open (else 409 REFUND_IN_PROGRESS). It reads the remainder, refuses a remainder of 0 (ORDER_NOT_REFUNDABLE) and an amount above it (REFUND_AMOUNT_TOO_HIGH), and records full = (requested_amount_minor == remainder). It writes the refund_request_pending marker with the key rr_<request id>.
  2. Refund and record (a second savepoint). It calls RefundService with the requested amount, the reason refund_request <id> and refund_id: "rr_#{request.id}". On success, it sets the request to approved, with the reviewer, the role, reviewed_at, the notes and gateway_refund_id, and clears the marker, in the same savepoint as the ledger row. gateway_refund_id comes from refund[:gateway_refund_id]: the Cashfree cf_refund_id or the Stripe re_ id. An older answer with only refund_id gives that.
  3. For a full approval, it ends the service (section 4.5).
  4. It sends the notices.

A failed refund:

  • The request stays pending. The ledger row of a failed attempt stays, so the next approve sends a new attempt id.
  • The marker ends when no money moved. It stays when the money may have moved (state_unknown, local_write_failed). Then only an approve of this request (the same key gets the first refund back) or the resolve endpoint can end it.
  • The endpoint answers the status and the code of the failure kind (result.data[:http_status], RefundFailure.code_for), not a fixed 502. The error goes to Rails.error as RefundNotDone. A gateway or network error that reaches Approve past RefundService is classified the same way.

The approve dialog in Connect (ApproveRefundDialog.tsx) shows a plain text for REFUND_FAILED, REFUND_REFUSED and REFUND_IN_PROGRESS.

A refund that fails at the gateway after the approve puts the request back to pending (ReopenOnRefundFailure, section 4.2).

Close on a full refund

When any refund makes the remainder 0, RefundService calls CloseOnFullRefund (close_on_full_refund.rb). It sets each pending request of the order to approved, with reviewer_role: 'system', no reviewer, and the note Connect::VendorRefundRequest::SYSTEM_CLOSE_NOTE. It leaves out the request whose own approve marker is open: that approve closes it as vendor or staff. It runs under RlsContext.with_vendor, because the sweep job has no GUC. It runs in its own savepoint, never raises, sends no notice and does not call end_service!.

4.7 Notices

EventRecipientText (title)Kind
Refund request createdVendor"Client asks for a refund of ₹X for Offering."refund_request_received
Vendor approveBuyer"Vendor approved your refund of ₹X." plus "Your seat / visit plan / meal plan / package has ended." for a full approvalrefund_request_approved
Vendor rejectBuyer"Vendor did not approve your refund." The body has the vendor's note.refund_request_rejected
Staff decisionBuyer and vendor"Kulcare support approved (or did not approve) the refund of ₹X for Offering." The vendor text adds "The request was open more than 7 days."refund_request_decided_by_support
System closenobodyn/an/a
Refund failed at the gateway, request open againBuyer and vendor"The refund of ₹X for Offering failed at the payment gateway. No money went back." The buyer body says that the request is open again. The vendor body says "Approve it again."refund_request_reopened
Seat removedBuyer, else attendee"Your place in Batch ended". The body adds "You get a full refund." when the refund went through.seat_removed
Batch cancelledBuyer, else attendee"Batch is cancelled". The body adds "You get a full refund." Also an SMS.batch_cancelled
Visit request cancelled by the patientVendor"Client cancelled a visit request", body "The client got a full refund."request_cancelled
Visit request declined or expiredBuyer"Your visit request was declined", with the reason and "You get a full refund."request_declined

A notice to a NULL user is skipped. A notice error goes to Rails.error and never fails the refund. No notice text holds the buyer's reason. The support refund sends a notice only when it closes a refund request (LinkSupportRefund, to the buyer and the vendor). A request that goes back to pending after a failed refund (ReopenOnRefundFailure) sends Notices.reopened to the buyer and the vendor.

4.8 Refund webhooks

ApplyEventService#apply_refund_processed (apply_event_service.rb:295-312) moves the refund ledger row through ApplyGatewayRefund (section 4.2). The events are the Cashfree REFUND_STATUS_WEBHOOK, the Stripe charge.refunded and charge.refund.updated, and the Razorpay refund.processed. The Cashfree translator gives amount_minor in paise.

  • Cashfree sends refund events to the dashboard URL, not to the order's notify_url. The single-merchant endpoint hands each MKT- event to the marketplace intake (section 3).
  • A Cashfree refund event for an order that has no intent and does not start with MKT- is not_a_marketplace_order, with no alert.
  • An MKT- refund with no intent returns no_matching_intent. A refund in it that did not complete is reported (RefundNotCompletedError).
  • ONHOLD is reported as a warning, because it can still complete. The row stays pending.
  • A Stripe charge.refunded with no refund status counts as succeeded.
  • A pending row moves to succeeded or failed. A failed refund is reversed (section 4.2). A refund that Kulcare did not make gets a gateway_dashboard row. A replay changes nothing.

Cashfree sent no refund webhook in the live sandbox run. The reconcile task (scope R, section 6) reads the refunds at the gateway when a webhook is lost.

4.9 Error codes and HTTP statuses

Failure kinds

Billing::Marketplace::RefundFailure (refund_failure.rb) gives each RefundService failure a kind, an HTTP status and a fixed message. The failure carries them as data[:failure_kind] and data[:http_status].

KindHTTPMeaning
order_missing422The order is missing.
payment_id_missing422The order has no payment id. A retry does not help.
not_refundable409The order is not in a refundable state.
not_vendor_owned422The order package is not vendor-owned.
gateway_credentials422The gateway did not accept the Kulcare credentials. An engineer must fix the gateway keys.
gateway_refused422The gateway refused the refund. A retry does not help.
gateway_unavailable502The gateway did not take the refund. Try again in a few minutes.
state_unknown409The refund state is unknown. Check the refund at the gateway before a retry.
refund_failed422The gateway answered that the attempt failed, so no money went out. A new call sends a new attempt id.
amount_above_remainder422The amount is more than what is left to refund.
nothing_left_at_gateway409The gateway shows the order refunded in full already.
local_write_failed409The gateway refund is done, but the order record did not save. Do not retry.
refund_in_progress409Another refund on the order has an open marker.
payment_id_mismatch422Stripe: the payment id is not the PaymentIntent of the order.
replay_amount_mismatch409A Stripe key older than 23 hours came back with another amount. Send a new key.

RefundFailure.classify(error) sets the kind of a gateway error, in this order:

  1. gateway_credentials: GatewayConfigurationError, NullGateway::NotConfiguredError, a missing Cashfree key, a Cashfree 401 or 403, or a Stripe AuthenticationError or PermissionError.
  2. gateway_unavailable: a rate limit (a Cashfree 429, a Stripe RateLimitError).
  3. gateway_refused: a clear refusal (GatewayRefusedError, another Cashfree 4xx, a Stripe InvalidRequestError or CardError).
  4. state_unknown: anything else (a 5xx, a connection error, an unreadable answer, an unknown gateway error). RefundService reports it as RefundService::RefundStateUnknown. An error before the refund request went out is gateway_unavailable.

The API error code is RefundFailure.code_for(kind): REFUND_IN_PROGRESS for refund_in_progress, REFUND_REFUSED for gateway_refused, and REFUND_FAILED for every other kind.

What each caller answers

CallerAnswer to a failed refund
Refund request approve (vendor and staff)The status and the code of the kind. The request stays pending.
Support refundThe rules below
Seat removal with full, retry of a failed seat refund, visit decline, patient visit cancelThe status, the code and the fixed message of the kind (RefundFailure.answer_for). An error with no kind: 502 REFUND_FAILED. The seat or the series does not change. Refunds that the gateway read found are still recorded.
Batch cancelThe seat closes. The failure goes to metadata.class_refund_failed and meta.refund_failures.
Visit request expiry (sweep job)The request stays requested. The next run tries again.

The support refund (POST /billing/marketplace/orders/:id/refund, refunds_controller.rb):

  • The key. idempotency_key (a UUID) is required for every support refund, partial or full. A blank key or a key that is not a UUID gets 422. The gateway gets adm_<key>.
  • A replay. A key that is in metadata.admin_refunds with the same amount_minor gets 200 with the first refund and meta.replayed: true, and makes no gateway call. The same key with another amount gets 422 IDEMPOTENCY_KEY_REUSED.
  • An open marker. An open support refund with another key gets 409 REFUND_IN_PROGRESS. An open refund request approve marker also gets 409 REFUND_IN_PROGRESS.
  • No payment. An order with no payment to refund gets 422.
  • A result that is not clear. A kind that keeps the marker (state_unknown, local_write_failed, gateway_unavailable) answers 502 REFUND_FAILED: "The refund did not go through. Try again with the same idempotency_key." This is safe, because only that key can refund while the marker is open. The gateway error goes to Rails.error, not to the client.
  • A refusal of the first try. gateway_refused ends the marker and answers 422 REFUND_REFUSED: "The gateway refused this refund. No money moved."
  • Another failure of the first try. It ends the marker and answers the status and the code of its kind, for example 422 REFUND_FAILED with the "above the remainder" message.
  • A failure of a later try (a kind that does not keep the marker): the marker stays, and the answer is 422 REFUND_RETRY_REFUSED, because an earlier try may have refunded. Resolve ends it.
  • Resolve. 409 RESOLVE_TOO_EARLY, 409 REFUND_AMOUNT_MISMATCH or 502 REFUND_FAILED (section 4.2).
  • Access. A user who is not a platform admin gets 403 from Shared::PlatformAdminBaseController. The Pundit check runs before the order lookup, so a caller cannot learn which order ids exist. An unknown order gets 404 order_not_found. amount_minor that is not a positive integer gets 422.

Other codes

CodeHTTPWhere
REFUND_IN_PROGRESS409Refund request approve while a support refund marker is open. Refund request reject while the approve marker of that request is open.
ORDER_NOT_REFUNDABLE422Refund request create and approve
REFUND_AMOUNT_TOO_HIGH422Refund request create and approve
REFUND_REQUEST_OPEN409Refund request create
REFUND_REQUEST_NOT_PENDING422Approve, reject, withdraw
REFUND_REQUEST_NOT_OVERDUE422Staff approve and reject
VALIDATION_FAILED422Bad amount, reason or note
REFUND_CHOICE_REQUIRED422Seat removal of a paid seat without full or none
PAID_SEAT_CANCEL_BY_VENDOR422Patient cancel of a paid seat
ENROLMENT_CLOSED422Removal of a seat that is not open
BATCH_CLOSED422Cancel of a cancelled or completed batch
VISIT_REQUEST_NOT_OPEN, VISIT_REQUEST_EXPIRED422Visit decline and patient cancel

4.10 Account deletion

Identity::User#destroy runs Connect::AccountClosure before the foreign keys fire (plan section 13). The effects on payments and refunds:

  • The order stays. The foreign key sets user_id to NULL. The amounts, the states and refunded_amount_minor stay. AccountClosure::OrderDetails clears fulfilment_params with update_columns.
  • A refund request stays. The foreign key sets buyer_user_id and attendee_user_id to NULL. The vendor and staff can still decide a pending request. No buyer notice goes out.
  • A paid requested visit series gets respond_by = now. The next VisitRequestSweepJob run declines it with a full refund. The closure makes no gateway call itself.
  • An active or paused visit series ends, and an active or paused meal plan ends, with no refund. Class enrolments are cancelled with no refund.
  • A refund after the delete still works: the model checks user on create only.
  • A payment that arrives after the delete (B2) grants nothing and refunds nothing. The order is paid with a failed fulfilment and metadata.failure_reason: 'buyer_deleted', and support decides the refund (section 3, "Apply a payment").

5. State diagrams

Order intent: charge_state

A late OrderPaid or a late failure does not change a succeeded, refunded or disputed order. Verify does not change charge_state. No code writes disputed. status goes from pending to verified on verify (Razorpay, Stripe, Cashfree) or on the OrderPaid webhook.

Order intent: fulfilment_state

Only a later fulfil! call moves failed to fulfilled. A replayed OrderPaid and RetryFailedFulfilmentsJob make that call (section 3, "The fulfilment seam"). A buyer_deleted order stays failed.

Refund request

A failed gateway call leaves the request pending. A request goes back from approved to pending only through ReopenOnRefundFailure (section 4.2). After rejected or withdrawn, the buyer can create a new request on the same order.

Class enrolment (refund paths)

A batch cancel refund that failed leaves the row cancelled with metadata.class_refund_failed on the order. SeatRefund.failed_pending? is true while the remainder is above 0.

Visit series (refund paths)

The End action has no refund. A refund failure on decline, expiry or patient cancel leaves the series requested.

6. Operations and support

Where to look

QuestionWhere
What did the buyer pay, and what was refunded?billing.marketplace_order_intents: amount_minor, refunded_amount_minor, charge_state, metadata. The truth for refunds is billing.marketplace_refunds (one row for each attempt, with status and gateway_status).
Is a refund open with no clear result?The intent metadata: admin_refund_pending or refund_request_pending. The staff view: pending_support_refund and pending_approve_refund.
What does a vendor owe Kulcare?billing.marketplace_vendor_owed_amounts (status = 'open'). Report RefundService::VendorOwedAmountRecorded.
Did the fulfilment fail?The same row: fulfilment_state = 'failed', metadata.fulfilment_attempts, metadata.failure_reason. Rails.error reports with source: 'Billing::Marketplace::Fulfilment' and the order_id, then FulfilmentRetriesExhausted after the last try, or BuyerDeletedError.
Which subscription and payment id?catalog.patient_package_subscriptions where marketplace_order_intent_id is the intent id: external_payment_id, status
Did a webhook arrive?billing.marketplace_webhook_events: provider, event_type, processed_at, processed_outcome (applied, noop_<reason>, failed_apply_failed, failed_retries_exhausted). For Cashfree, payload._delivery has the attempt, the version, the x-idempotency-key and the endpoint.
Why did a webhook fail?Logs [Marketplace::ProcessWebhookEventJob] Apply failed id=..., error tracking ApplyFailedError. Controller refusals log [Marketplace::CashfreeWebhook], [Marketplace::RazorpayWebhook] or [Marketplace::StripeConnectWebhook] with the reason.
Refund requestsconnect.vendor_refund_requests (needs a GUC; use RlsContext.with_platform_admin), or GET /connect/admin/refund_requests
Why did a refund fail?Logs [Marketplace::RefundService] refund refused for order_intent=.... Error tracking: RefundService::RefundStateUnknown, Connect::RefundRequests::Approve::RefundNotDone (context failure_kind, gateway_error), ApplyGatewayRefund::RefundFailedError, Connect::Classes::SeatRefund, Connect::Visits::FullRefund.
Visit request expiryLog [connect.visit_request_expired]
Vendor accountidentity.vendors: payment_account_id, payment_account_provider, payment_account_status, payment_account_requirements, payment_account_activated_at
Vendor viewGET /connect/me/earnings (rows with refunded_amount_minor and fulfilment_state)

A lost webhook, and the reconcile task

Symptoms: the patient paid, but no subscription, seat, visit request or meal plan exists, and marketplace_webhook_events has no row for the order. Or a refund stays pending in the ledger.

  1. Confirm the payment at the gateway: Cashfree order status PAID, Razorpay payment captured, Stripe PaymentIntent succeeded.

  2. A resend from the gateway dashboard works inside the replay window: 25 hours for Cashfree (the x-webhook-timestamp header) and Razorpay (the payload created_at). Stripe signs each delivery again, so a Stripe retry passes the 300 s check.

  3. Else run the reconcile task. It is a dry run unless CONFIRM=1, and its gateway calls are reads only:

    bin/rails billing:marketplace:reconcile                       # dry run: a table, and a CSV in tmp/
    REFUNDS=1 bin/rails billing:marketplace:reconcile             # also scope R, the refunds at the gateway
    CONFIRM=1 bin/rails billing:marketplace:reconcile             # apply, stop on the first error
    CONFIRM=1 CONTINUE_ON_ERROR=1 bin/rails billing:marketplace:reconcile
    • Scope A: intents with charge_state pending or failed, older than 30 minutes, that the gateway shows paid. The action is an Events::OrderPaid through ApplyEventService, only when the amount and the currency match. An order paid two times or refunded at the gateway is skipped for a person.
    • Scope E (report only): refunds from before the refund split, and a split refund whose owed amount has no owed row.
    • Scope R (REFUNDS=1): for each succeeded or refunded Cashfree or Stripe order of the last 180 days, the gateway refunds that Kulcare has no row for, and the pending rows that the gateway shows done or failed. With CONFIRM=1, each row goes through ApplyEventService as an Events::RefundProcessed, the same path as a refund webhook.

    A person reads the dry-run CSV before any CONFIRM=1 run (reconcile_orders.rb, lib/tasks/billing_marketplace_reconcile.rake).

For Razorpay, the client verify already granted the subscription and ran the fulfilment, but charge_state stays pending until the webhook. Refunds and refund requests are blocked until then.

A Cashfree refund that ended FAILED under a stable key

Cashfree keeps one refund for each refund_id. Before B0, a FAILED refund under rr_, cs_ or vs_ blocked each retry with that key. Now the ledger row of that attempt is failed, and the next call sends a new attempt id, <key>_a<n> (section 4.2). So:

  • A refund request: approve it again. A request that ReopenOnRefundFailure put back to pending shows the note "The refund failed at the payment gateway, ...".
  • A batch cancel seat: remove the seat again with full (DELETE /connect/me/class_enrolments/:id).
  • A visit request: the sweep tries again on its next run.
  • A support refund: send a new refund with a new idempotency_key.

Before a retry, read the refund in the Cashfree dashboard or with GET /orders/{order_id}/refunds/{refund_id}, and check that no other refund moved the money.

A refund with no clear result

A support refund that answered 502, or a refund request approve that answered state_unknown or local_write_failed, keeps its marker (section 4.2). While the marker is open, no other refund path can refund the order.

  1. Send the same refund again: the same idempotency_key for a support refund, or approve the same request again. The gateway read-back gives the first refund back, or the refund is made once.
  2. Or, 15 minutes after the last try, call POST /billing/marketplace/orders/:id/refund/resolve. It records a refund that the gateway has, or ends the marker when the gateway has none.

A partial refund in a gateway dashboard

Do not refund in a gateway dashboard. When it already happened, Kulcare reads it before the next Cashfree or Stripe refund of the order (list_refunds), or the reconcile task finds it (scope R). It gets a gateway_dashboard ledger row and a GatewayRefundRecorded report, and the refunded total includes it. Razorpay has no refund list, so a Razorpay dashboard refund is not read: correct refunded_amount_minor and charge_state by hand, and write the reason in the support ticket.

A fulfilment failure

  1. B1 tries the fulfilment again: on a replayed OrderPaid, and with RetryFailedFulfilmentsJob every 30 minutes, at most 3 tries (metadata.fulfilment_attempts). Most transient errors need no action.
  2. After the last failed try, error tracking has FulfilmentRetriesExhausted (context order_id, fulfilment_type). Find the first error too, and decide whether the vendor can still give the service.
  3. When the service cannot be given (the batch was cancelled, a second payment for one seat), refund in full through the support refund endpoint.
  4. When it can be given, make the record by hand, or run Billing::Marketplace::Fulfilment.for(intent).fulfil!(intent: intent, subscription: subscription) from a console. fulfilment_params must still be present. An account closure clears it.
  5. A buyer_deleted order (B2, BuyerDeletedError) is not tried again. Support decides whether to refund it.

FailedOrdersNotice keeps the order on the vendor's Payments page while fulfilment_state is failed and the order is not refunded in full. The full refund removes it from the notice.

Owed amounts: recovery

An open row in billing.marketplace_vendor_owed_amounts is money that Kulcare paid for the vendor. After the vendor pays it back, a person marks the row from a Rails console:

Billing::MarketplaceVendorOwedAmount.find(id).mark_recovered!(note: 'Recovered from a later payout')

The note is required. A row that is already recovered raises. A void row needs no action: its refund failed at the gateway, so nothing is owed. When the refund of a recovered row fails later, the row stays recovered and the RefundFailedError report names it: pay the vendor back by hand.

Sandbox tests for each gateway

Common setup:

  • A vendor with a country, linked through the Payments page or the onboarding endpoint, and set to active by a sandbox webhook or by hand in a sandbox database.
  • A vendor package with effective_price = 20.00 and the default commission (5%).
  • Expected order: amount_minor = 2000, commission_bps = 500, split { total: 2000, vendor_amount: 1900, commission_amount: 100 }.

Cashfree (BILLING_CASHFREE_ENABLED on, credentials cashfree.client_id, client_secret, webhook_secret_current):

StepExpected
InitiateCashfree order body order_amount: 20.0, order_splits: [{ vendor_id, amount: 19.0 }], order_meta.notify_url. Intent order_id = MKT-....
Pay with a sandbox methodWebhook row provider = 'cashfree', outcome applied. Intent charge_state = 'succeeded', status = 'verified'. A subscription with external_payment_id = the cf_payment_id.
Refund request of 500, approved, before the vendor settlementGET /orders/MKT-.../refunds, GET /easy-split/orders/MKT-..., then POST /orders/MKT-.../refunds with refund_amount: 5.0, refund_id: rr_<request id> and refund_splits: [{ vendor_id, amount: 5.0 }]. One ledger row. Intent refunded_amount_minor = 500, charge_state = 'succeeded'. No owed row. The service continues.
Second request of 1500, approvedrefund_amount: 15.0, refund_splits amount 14.0 (the vendor share left). One owed row of 100, commission_part. refunded_amount_minor = 2000, charge_state = 'refunded'. The service ends.
Single full refund insteadrefund_amount: 20.0, refund_splits amount 19.0, one owed row of 100, commission_part
A refund after the vendor settlementNo refund_splits. One owed row of the whole refund, vendor_share_settled.

Razorpay (flag off only; credentials razorpay_marketplace.key_id, key_secret, webhook_secret_current, or the RAZORPAY_MARKETPLACE_* environment variables):

StepExpected
InitiateRazorpay order amount: 2000, transfers: [{ account_id, amount: 1900 }]. Check the transfers shape first (section 7).
Pay and verifyVerify answers 200 with the subscription. The intent is verified, charge_state is still pending.
payment.captured webhookcharge_state = 'succeeded'. The subscription is not created twice.
Partial refund of 500payment.refund(amount: 500, receipt: 'rr_<id>', speed: 'normal', notes: ...). No owed row, and a VendorPartUnknown report.
Full refundNo amount in the refund call. refunded_amount_minor = 2000.

Stripe (credentials stripe_connect.platform_secret_key, webhook_secret_connect_current, webhook_secret_account_current; checkout is blocked by #2564):

StepExpected
InitiatePaymentIntent amount: 2000, currency: 'usd', application_fee_amount: 100, transfer_data.destination = the vendor acct_...
Full refundA refund list read and a find_refund first, then refunds.create(payment_intent: pi_..., amount: 2000, reverse_transfer: true, refund_application_fee: false) with the attempt id as the idempotency_key and as kulcare_refund_id in the metadata. No owed row.
Partial refund of 500The same with amount: 500

7. Known gaps and follow-ups

Open

People must do these:

  • Add charge.refund.updated to the events of the Stripe Connect webhook endpoint. Without it, a Stripe refund that fails after it succeeded does not reach Kulcare by webhook.
  • After the deploy, run REFUNDS=1 bin/rails billing:marketplace:reconcile (a dry run) on staging and on production, and read the CSV before any CONFIRM=1 run.
  • Run SyncVendorAccountsJob one time on each environment after the deploy.

Code gaps:

  • metadata.refund_split keeps only the latest refund of an order. The owed rows and the ledger keep every refund.
  • The refund request approve marker commits with the answer, not before the gateway call. A crash during the gateway call leaves no marker. The rr_ key and the gateway read-back still stop a double refund on a retry.
  • Razorpay only (not used while BILLING_CASHFREE_ENABLED is on; plan units A5 and A6 were dropped): the client verify leaves charge_state pending until payment.captured; Razorpay Route documents account and currency in an order transfer, and Kulcare sends { account_id, amount }; a Razorpay refund gives no vendor part, so it gets no owed row; Razorpay takes no refund idempotency key (the receipt match lowers the risk); RAZORPAY_AUTH_MUTEX does not cover the single-merchant callers of the global Razorpay.auth.

Other issues:

  • #2564: Stripe payment onboarding and checkout for US vendors in the Connect app.
  • #2563: Guidance and a dashboard link after the vendor sends KYC.

Fixed in B0

PR #2653 (with the #2650 lanes) merged into #2636, and the B0 to B3 units of the gateway contracts plan:

  • Cashfree event names: the translator reads PAYMENT_SUCCESS_WEBHOOK, PAYMENT_FAILED_WEBHOOK, REFUND_STATUS_WEBHOOK and VENDOR_STATUS_UPDATE (A2).
  • Cashfree webhook gate: the signature string, the secret list, the timestamp in milliseconds, the dedup key from signed body fields, notify_url, and MKT- events on the single-merchant URL (A1, A4, F1).
  • Cashfree vendor statuses: STATUS_MAP, the live status on each event, SyncVendorAccountsJob, and the PATCH for a rejected vendor (A3, B3).
  • The listing after an instant activation: Onboarding::CreateService calls VendorActivation.activate! (A3).
  • Late events: a late OrderPaid cannot bring back a refunded order, and a late failure cannot undo a paid order (A8).
  • Replay windows: 25 hours for Cashfree and Razorpay. Stripe checks only the signature header (A9).
  • The initiate idempotency key: the gateways read it (A10).
  • Stripe onboarding URLs (#2639, A11).
  • Who pays for a refund: the vendor, through refund_splits and owed rows (A7). The vendor sees the open rows in "Owed to Kulcare".
  • A Cashfree refund that FAILED under a stable key: the next attempt sends a new id (#2650).
  • A Cashfree refund that is PENDING and later FAILED: ApplyGatewayRefund reverses it, voids the owed row and reopens the refund request (#2650, B0).
  • Refund approve answers the status and the code of the failure kind, not a fixed 502 (#2650, B0). The class seat removal, the visit decline and the patient visit cancel do the same (#2636 final review).
  • A refund request that a failed refund opens again sends a notice to the buyer and the vendor; the admin refund request detail checks an open approve with the gateway; PlanEnd.back ends a resumed paid visit plan on a visit day (#2636 final review).
  • Rupees in amount_minor for refund events: the translator gives paise (A2).
  • Gateway dashboard refunds: read before a Cashfree or Stripe refund, and by the reconcile task scope R (#2650).
  • One open refund per order, for the support refund and the refund request approve (markers, resolve).
  • The fulfilment retry (B1) and a buyer deleted before the webhook (B2).
  • The receipt email (#2650). The checkout success page promises it again when the account has an email.
  • India vendors see only Cashfree when the flag is on.
  • A user guide for vendors: docs/guides/user/connect-vendor-payments-and-refunds.html.

Fixed earlier in PR B (#2636): the rejected vendor "Try again" on the page (B3), the Payments page refund text (B4), the support refund endpoint (B5), and the failed orders notice (B6).

On this page

1. OverviewWho pays whomGatewaysThe lifecycle in one paragraphCode map2. Vendor payout setupEndpointsSteps for each gatewayAccount statusesWhen the listing goes live (plan decision A2)Approve KYC on a test environment (#2204)What the vendor sees on the Payments pageFailure and rejected states3. PurchasesOffering kindsThe order intentFlowInitiate: POST /billing/marketplace/ordersGateway checkout in the patient appVerify: POST /billing/marketplace/orders/:id/verifyWebhooksIdempotencyAmounts and the splitThe fulfilment seamWhat the patient sees4. Refunds4.1 Every path that refunds4.2 The one refund design and RefundServiceThe ledgerCaller keys and attempt idsThe stepsOpen markersResolve: POST /billing/marketplace/orders/:id/refund/resolveA refund that fails at the gateway after it was recorded4.3 Gateway behaviour, the refund split and owed amountsThe refund split (plan A7)Owed amounts4.4 order_id and payment_id4.5 What a full refund ends4.6 Refund requestsRulesEndpointsApprove (approve.rb)Close on a full refund4.7 Notices4.8 Refund webhooks4.9 Error codes and HTTP statusesFailure kindsWhat each caller answersOther codes4.10 Account deletion5. State diagramsOrder intent: charge_stateOrder intent: fulfilment_stateRefund requestClass enrolment (refund paths)Visit series (refund paths)6. Operations and supportWhere to lookA lost webhook, and the reconcile taskA Cashfree refund that ended FAILED under a stable keyA refund with no clear resultA partial refund in a gateway dashboardA fulfilment failureOwed amounts: recoverySandbox tests for each gateway7. Known gaps and follow-upsOpenFixed in B0