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
| Party | Role | Record |
|---|---|---|
| Buyer (patient) | Pays for an offering. Gets the refunds. | Identity::User, billing.marketplace_order_intents.user_id |
| Attendee | The 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 |
| Vendor | Sells 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) |
| Kulcare | Runs 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).
| Country | Gateway | Product | Notes |
|---|---|---|---|
| IN | Cashfree | Easy Split | When the BILLING_CASHFREE_ENABLED feature flag is on. available_providers is then ['cashfree'] (vendor.rb:286-292), so the vendor has no other choice. |
| IN | Razorpay | Route | Only 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, AE | Stripe | Connect Express | The 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
| Concern | File |
|---|---|
| Checkout start | backend/domains/billing/services/marketplace/initiate_order_service.rb |
| Client verify | backend/domains/billing/services/marketplace/verify_order_service.rb |
| Webhook effects | backend/domains/billing/services/marketplace/apply_event_service.rb |
| Subscription grant | backend/domains/billing/services/marketplace/subscription_creator.rb |
| Fulfilment seam | backend/domains/billing/services/marketplace/fulfilment.rb, handlers in backend/domains/connect/services/fulfilments/ |
| Refund core | backend/domains/billing/services/marketplace/refund_service.rb |
| Refund ledger, markers and errors | refund_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 refund | backend/domains/billing/controllers/marketplace/refunds_controller.rb |
| Fulfilment retry | fulfilment_retry.rb, backend/domains/billing/jobs/marketplace/retry_failed_fulfilments_job.rb |
| Gateways | cashfree_gateway.rb, razorpay_gateway.rb, stripe_gateway.rb in the same folder |
| Webhook translation | cashfree/event_translator.rb, razorpay/event_translator.rb, stripe/event_translator.rb |
| Webhook endpoints | backend/domains/billing/controllers/marketplace/*_webhooks_controller.rb; Cashfree intake cashfree_webhook_intake.rb |
| Webhook job | backend/domains/billing/jobs/marketplace/process_webhook_event_job.rb |
| Vendor status poll | backend/domains/billing/jobs/marketplace/sync_vendor_accounts_job.rb |
| Reconcile task | backend/lib/tasks/billing_marketplace_reconcile.rake, reconcile_orders.rb |
| Order record | backend/domains/billing/models/marketplace_order_intent.rb |
| Vendor onboarding | backend/domains/billing/controllers/marketplace/vendor_onboardings_controller.rb, services/marketplace/onboarding/ |
| Refund requests | backend/domains/connect/services/refund_requests/, connect/models/vendor_refund_request.rb |
| Seat refunds | backend/domains/connect/services/classes/seat_refund.rb, batch_service.rb, enrolment_service.rb |
| Visit refunds | backend/domains/connect/services/visits/full_refund.rb, close_request.rb, respond.rb, connect/jobs/visit_request_sweep_job.rb |
| Vendor Payments page | frontend/apps/connect/src/pages/VendorPaymentsPage.tsx; owed amounts components/refunds/OwedToKulcareSection.tsx, backend/domains/connect/controllers/me/owed_amounts_controller.rb |
| Patient checkout | patient/src/features/marketplace/components/MarketplaceCheckoutButton.tsx, pages/CheckoutSuccessPage.tsx |
2. Vendor payout setup
Endpoints
| Method and path | Who | What it does |
|---|---|---|
POST /billing/marketplace/vendors/:vendor_id/onboarding | The 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/onboarding | Same, with show? | Returns account_id, provider, status, country, available_providers, expected_provider and requirements. |
POST /billing/marketplace/vendors/:vendor_id/onboarding/refresh | Same | Stripe 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:
- It checks the policy and the vendor country.
- It selects the provider: the
providerparameter, elseexpected_provider. The provider must be inavailable_providers. - A payload builder checks the KYC form. A bad field raises
ArgumentError, and the endpoint answers 422. - 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). - The gateway creates the linked account.
- The service writes only
payment_account_id,payment_account_provider,payment_account_statusandpayment_account_requirementson the vendor (create_service.rb:116-129). Kulcare does not store PAN, bank or tax data. When the gateway answersactiveat create, the service callsVendorActivation.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.
| Gateway | KYC fields that Kulcare collects | API calls | Account id | First status |
|---|---|---|---|---|
| Cashfree | legal_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 returns | From STATUS_MAP (below). Cashfree answers IN_BANK_VALIDATION for a new vendor, which is under_review. |
| Razorpay | The same fields | Account.create, Stakeholder.create, Product.requested('route') (razorpay_gateway.rb) | acc_... | created maps to pending |
| Stripe | email, country, business_type, optional business_url | accounts.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):
| Status | Meaning | Can take payments |
|---|---|---|
none | No linked account | No |
pending | The account exists. The vendor must finish steps at the gateway. | No |
under_review | The gateway checks the KYC | No |
active | Ready | Yes, with a payment_account_id (can_receive_payments?, vendor.rb:247-249) |
suspended | The gateway paused the account | No |
rejected | The gateway refused the account | No |
Webhooks change the status through ApplyEventService#apply_linked_account_status_changed
(apply_event_service.rb:74-126):
| Gateway | Event | New status |
|---|---|---|
| Cashfree | VENDOR_STATUS_UPDATE | The live status at Cashfree, mapped by STATUS_MAP (below) |
| Razorpay | account.under_review, account.needs_clarification | under_review |
| Razorpay | account.activated | active |
| Razorpay | account.suspended, account.rejected | suspended, rejected |
| Stripe | account.updated | From Stripe::EventTranslator.map_account_status |
| Stripe | account.application.deauthorized | suspended |
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 status | Kulcare status | Requirements |
|---|---|---|
ACTIVE | active | none |
IN_BANK_VALIDATION, IN_BENE_CREATION, IN_KYC_REVIEW | under_review | none |
ACTION_REQUIRED | pending | disabled_reason: 'action_required', currently_due: the documents that are not VERIFIED |
BANK_VALIDATION_FAILED, BENE_CREATION_FAILED | rejected | disabled_reason: 'bank_validation_failed', currently_due: ['bank_account'] |
ON_HOLD | suspended | disabled_reason: 'on_hold' |
BLOCKED | suspended | disabled_reason: 'blocked' |
DELETED | suspended | disabled_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 path | What it does |
|---|---|
GET /identity/admin/vendors | Lists vendors. Filters: search (name, business name, email, phone) and payment_account_status. |
GET /identity/admin/vendors/:id | The vendor, with can_mark_kyc_approved. The admin app shows the button only when it is true. |
POST /identity/admin/vendors/:id/mark_kyc_approved | Calls 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 theIdentity::Admin::VendorPolicy#mark_kyc_approved?check. - The action works only in the
development,testandstagingRails 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 andcan_mark_kyc_approvedisfalse. - It works only for a Cashfree account, and only when
Billing::Gateways::Cashfree::Environmentresolves to the sandbox. A staging process that uses the live Cashfree account gets 422KYC_APPROVAL_GATEWAY_NOT_SANDBOX. An approval there would let real patients pay a vendor that Cashfree did not check. - It accepts only a
pendingorunder_reviewaccount with apayment_account_id. Other states get 422 with aKYC_APPROVAL_*error code. - It calls no gateway and no other external service.
- The approval and its audit entry commit in one transaction. The
Audit::AdminAccessLogrow of the request records the admin and the vendor. Itsparams_redacted.kyc_approvalrecords 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 oncontroller = 'Identity::Admin::VendorsController'andaction = 'mark_kyc_approved'. Do not filter on thekyc_approvalkey 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_forinProfessionalProfilesControllersets the listing tounlisted, 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:
| Status | Banner 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. |
rejected | A 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. |
suspended | A 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 .../onboardingfor a vendor that has apayment_account_idgets 409onboarding_already_started. The body namesrefresh_endpointfor Stripe, and nil for the other gateways. - A
rejectedvendor 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 onePATCH /easy-split/vendors/{id}with the new bank details and no second create (B3). Cashfree then checks the bank again. - A
suspendedCashfree vendor (ON_HOLD,BLOCKED,DELETED) cannot onboard again by itself. Only support can help. - A
suspendedorrejectedvendor cannot take new orders:InitiateOrderServicerefuses 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 kind | fulfilment_type | Handler | Params at checkout | What fulfil! makes |
|---|---|---|---|---|
session (1:1 package) | none | none | none | Only the package subscription. The buyer becomes the vendor's client (VendorClients::Upsert, source purchase). |
class (group class seat) | class_enrolment | ClassEnrolment | { type, enrolment_id } of a held seat | The 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_plan | visit_plan | VisitPlan | days, start_time_local, starts_on, timezone, visit_address, access_notes, contact_phone, attendee_user_id | A requested VendorVisitSeries, with respond_by = now plus 72 hours and ends_on from the subscription expiry. The vendor confirms or declines. |
meal_plan | meal_subscription | MealSubscription | delivery_days, delivery_window, delivery_address, starts_on, dietary_notes, contact_phone | An 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.
| Column | Meaning |
|---|---|
order_id | The 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_id | The buyer. NULL after the buyer's account is deleted (section 4.10). |
service_package_id | The vendor package. Its owner is the vendor. |
status | pending, 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_state | pending, succeeded, failed, refunded, disputed. No code writes disputed. |
payout_state | pending, processing, succeeded, failed, reversed. Only a transfer or settlement webhook writes succeeded. |
amount_minor | The price in minor units. Nil only on legacy orders. |
currency | ISO 4217 from the package |
commission_bps | The frozen commission rate (500 = 5%) |
refunded_amount_minor | The 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_state | The fulfilment seam. The state is pending, fulfilled or failed, or NULL for a 1:1 package. |
fulfilment_params | Encrypted JSON. The fulfilment clears it on success. Account closure clears it too. |
share_phone | The 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.preparerefuses. The answer haserror.code:FULFILMENT_REQUIRED,FULFILMENT_MISMATCH,FULFILMENT_NOT_AVAILABLE,ATTENDEE_NOT_ALLOWED,ENROLMENT_HOLD_EXPIRED,ENROLMENT_NOT_AVAILABLE,REGISTRATION_REQUIRED,VISIT_PLAN_INVALIDorMEAL_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.
| Gateway | Client step | After a success |
|---|---|---|
| Cashfree | Opens 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. |
| Razorpay | Opens 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. |
| Stripe | Shows 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):
- 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. - 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 needssucceededand the vendor astransfer_data.destination. - Cashfree (
webhook_authoritative?): it marks the intentverifiedand returnspending_webhook: true. It grants nothing (:79-87). - Razorpay and Stripe: in one transaction,
SubscriptionCreatorgrants the subscription keyed onpayment_id, the intent becomesverified, andFulfilment#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
| Gateway | Endpoint | Signature | Replay window | Dedup key |
|---|---|---|---|---|
| Cashfree | POST /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) |
| Razorpay | POST /billing/marketplace/webhooks/razorpay | HMAC-SHA256 of the raw body, razorpay_marketplace.webhook_secret_current or _previous | The payload created_at: 25 hours in the past, 1 minute in the future | rzp:<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. |
| Stripe | POST /billing/marketplace/webhooks/stripe/connect and /stripe/account | Stripe::Webhook::Signature.verify_header, one secret for each endpoint | The 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_idanddata.order.order_id. The status is the payment, refund or vendor status. A key above 255 characters becomescf:sha256:<hex>. A body with no type or no id gets 400missing_event_id. A Cashfree retry keeps itsevent_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_UPDATEkeeps onlymerchant_vendor_id,past_status,updated_status,updated_byanddocument_status. AVENDOR_SETTLEMENT_*event keeps an allowlist ofdata.settlementfields (SETTLEMENT_FIELDS), with noaccount_number,ifscorvpa. The unsigned delivery headers (x-idempotency-key,x-webhook-attempt,x-webhook-version) and the endpoint go underpayload._delivery.RedactWebhookPayloadsJob(daily at 03:50) clearspayloadon rows older than 180 days. - The dashboard test ping. A signed body with
type: 'WEBHOOK'anddata.test_objectgets 200{ received: true, test: true }, with no row and no job. notify_url.InitiateOrderServicesendsorder_meta.notify_urlfromCallbackUrls.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, andMARKETPLACE_WEBHOOK_BASE_URLhas 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 nonotify_url. A URL that is not https is not sent. Outside a local environment, that is reported asNoNotifyUrlError.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'snotify_url. That endpoint sends each event whose order id starts withMKT-to the sameCashfreeWebhookIntake(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 withmarketplace_order: trueand reportsIntakeRefused. A failed enqueue keeps its 503.
The job translates the payload to an event and calls ApplyEventService:
| Event | Razorpay | Cashfree | Stripe | Effect |
|---|---|---|---|---|
OrderPaid | payment.captured | PAYMENT_SUCCESS_WEBHOOK | payment_intent.succeeded | See "Apply a payment" below. |
OrderPaymentFailed | payment.failed | PAYMENT_FAILED_WEBHOOK, PAYMENT_USER_DROPPED_WEBHOOK | payment_intent.payment_failed | Sets charge_state: 'failed', unless it is failed, succeeded, refunded or disputed |
TransferProcessed | transfer.processed | none | none | Sets payout_state: 'succeeded' when the order id matches |
RefundProcessed | refund.processed | REFUND_STATUS_WEBHOOK | charge.refunded, charge.refund.updated | ApplyGatewayRefund moves the refund ledger row (section 4.8) |
LinkedAccountStatusChanged | account.* | VENDOR_STATUS_UPDATE | account.updated, account.application.deauthorized | Section 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:
- 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. - A second payment id on an order that already granted is reported (
SecondPaymentError) and not granted. - B1. A paid order whose fulfilment failed runs the fulfilment again (
FulfilmentRetry, reasonfulfilment_retried). See "The fulfilment seam". - A
succeededorder returnsalready_succeeded. Arefundedordisputedorder returnsalready_terminal, so a late payment event cannot bring back a refunded order. - A payment on an intent that a newer checkout cancelled, when the buyer already paid for the package: the
charge is recorded,
SecondPaymentErroris reported, and nothing is granted a second time. - A blank payment id, or an amount or a currency that does not match the intent, is a failure.
- B2. The buyer's account was deleted before the webhook (
user_idis NULL). The order getscharge_state: 'succeeded',fulfilment_state: 'failed'andmetadata.failure_reason: 'buyer_deleted'. Nothing is granted and nothing is refunded.BuyerDeletedErrorgoes toRails.error, and support decides the refund (section 6). - The grant: the package subscription,
charge_state: 'succeeded',status: 'verified',paid_atandpayment_methodinmetadata. 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
| Layer | Key | Effect |
|---|---|---|
| Initiate | An 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 apply | charge_state in succeeded, refunded, disputed | A second OrderPaid does nothing, except the B1 retry (apply_event_service.rb:144-151). |
| Subscription | external_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). |
| Fulfilment | The intent row lock and fulfilment_state == 'fulfilled' | A second fulfil! does nothing (fulfilment.rb:181-188). |
| Receipt | metadata.receipt_sent_at | SendOrderReceiptJob 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.
| Gateway | Order request | Kulcare gets | Vendor gets |
|---|---|---|---|
| Cashfree | order_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 |
| Razorpay | amount: 2000, transfers: [{ account_id, amount: 1900 }], in paise (razorpay_gateway.rb:170-187) | The part not in transfers: 100 paise | 1900 paise |
| Stripe | amount: 2000, application_fee_amount: 100, transfer_data: { destination }, on_behalf_of, card only (stripe_gateway.rb:163-202) | The application fee: 100 cents | The 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
fulfilledorder returns at once. - The handler runs in its own savepoint. On success the intent gets
fulfilment_state: 'fulfilled', the handler's record id infulfilment_id, andfulfilment_params: nil. - On any error the savepoint rolls back the handler's writes. The error goes to
Rails.errorwith the order id and the type, andupdate_columnssetsfulfilment_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 replayedOrderPaid, before the terminal guard;RetryFailedFulfilmentsJob, every 30 minutes (config/schedule.rb). It adds 1 tometadata.fulfilment_attemptsbefore each try, and it stops after 3 tries (MAX_ATTEMPTS). The last failed try is reported asFulfilmentRetriesExhausted, 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
| Moment | What 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 reload | The 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 refused | A toast "Could not start checkout" with the API message. A seat hold that ended is handled by the class page. |
| Stripe vendor | A 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
| Path | Endpoint or trigger | Amount | Refund key | When the gateway fails | Ends the service |
|---|---|---|---|---|---|
Vendor removes a paid seat, choice full | DELETE /connect/me/class_enrolments/:id with { "refund": "full" } | The full remainder | cs_ + enrolment id | The 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 none | Same, with "none" | No refund | none | n/a | Yes |
| Vendor cancels a batch | POST /connect/me/class_batches/:id/cancel | The full remainder of each paid seat | cs_ + enrolment id | The 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 refund | DELETE /connect/me/class_enrolments/:id with "full" | The full remainder | cs_ + enrolment id | The kind's status and code (section 4.9) | Already ended |
| Vendor declines a visit request | POST /connect/me/visit_series/:id/decline | The full remainder | vs_ + series id | The 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 h | Connect::VisitRequestSweepJob, every 15 minutes | The full remainder | vs_ + series id | The request stays requested. The next run tries again. | Yes: declined with decline_reason: 'no_response' |
| Patient cancels an open visit request | POST /patient/vendor_visit_series/:id/cancel | The full remainder | vs_ + series id | The kind's status and code (section 4.9) | Yes: cancelled |
| Refund request approved by the vendor | POST /connect/me/refund_requests/:id/approve | The requested amount | rr_ + request id | The 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 staff | POST /connect/admin/refund_requests/:id/approve, after 7 days | The requested amount | rr_ + request id | The same as the vendor approve | Only when the amount is the full remainder |
| Support refund | POST /billing/marketplace/orders/:id/refund (platform admin only, no tenant) | amount_minor in the body, or the full remainder | adm_ + 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. Apendingrow counts, because Cashfree sent no refund webhook in the live sandbox run. A total ofsucceededrows only could stay short, and a second refund could then take more than the order amount. refunded_amount_minoris a copy.RefundLedger.sync_refunded_amount!copies the ledger total to the intent in the same transaction as the ledger row.RefundServiceandApplyGatewayRefundboth call it. So the remainder that the refund request screens read agrees with the gateway.- Partial refunds.
charge_statebecomesrefundedonly when the ledger total reaches the order amount. A partial refund leaves itsucceeded, so the rest can be refunded. A legacy order with noamount_minorbecomesrefundedafter one refund. - Gateway statuses (
MarketplaceRefund::STATUS_BY_GATEWAY_STATUS):SUCCESS,SUCCEEDEDandPROCESSEDaresucceeded.PENDING,ONHOLD,REQUIRES_ACTIONandCREATEDarepending.CANCELLED,CANCELED,FAILEDandREJECTEDarefailed. An unknown status stayspendingand is reported.
Caller keys and attempt ids
| Caller | Key |
|---|---|
| Refund request approve (vendor and staff) | rr_<request id> |
| Class seat removal and batch cancel | cs_<enrolment id> |
| Visit decline, expiry and patient cancel | vs_<series id> |
| Support refund | adm_<idempotency_key> |
| A caller with no key | mkt_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
- It refuses a nil intent (
order_missing) and a blankpayment_id(payment_id_missing). - It refuses an order whose
charge_stateis notsucceeded(not_refundable), and a package that is not vendor-owned (not_vendor_owned). - 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.
- An open marker that names another key stops the refund with
refund_in_progress, before any gateway call (see "Open markers"). - Cashfree and Stripe: it reads the refunds of the order at the gateway (
list_refunds), and records each one throughApplyGatewayRefund. A refund that Kulcare did not make (for example in the gateway dashboard) gets agateway_dashboardrow and aGatewayRefundRecordedreport. A Stripe row keeps the Kulcare key from the refund metadata (kulcare_refund_id). - It gets the attempt id (
next_attempt), after the read, so an attempt that failed only at the gateway (no webhook) isfailedin 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 nolist_refundsgets 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 getsreplay_amount_mismatch. - 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 apayment_idthat is not the order PaymentIntent (payment_id_mismatch). RefundSplitgives the vendor part (section 4.3). Then it callsGatewayFactory.for_vendor(vendor).create_refundwithpayment_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_minorandvendor_account_id. A support resolve passesfound_refund:(a refund that the gateway already has) and makes no gateway call.- It records the attempt row with the gateway answer (
RefundLedger.record_attempt). When the gateway answers that the attempt failed (for example CashfreeFAILED), no money moved: the order stays as it was, and the service returnsrefund_failed. The next call sends the next attempt id. - Under the same lock it sets
charge_state,metadata.refund_splitandrefunded_amount_minor. - 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). - It returns
{ refund, order_id, vendor_id, owed_minor, amount_minor, remainder_minor }.refundhasrefund_id(the merchant id),gateway_refund_idandstatus. Cashfree also givescf_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):
| Marker | Caller | Key | When it commits |
|---|---|---|---|
admin_refund_pending (key, amount_minor, actor_id, started_at, last_try_at, reason) | Support refund | adm_<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 approve | rr_<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
RefundServicepath with another key fails withrefund_in_progress(409REFUND_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 409REFUND_IN_PROGRESS. A reject or a buyer withdraw of a request whose own approve marker is open gets 409REFUND_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 answer | Result |
|---|---|
| The last try was less than 15 minutes ago | 409 RESOLVE_TOO_EARLY, because a call can still reach the gateway |
| No refund, or the refund failed | The 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 marker | RefundService 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 amount | Not recorded. 409 REFUND_AMOUNT_MISMATCH, reported. The marker stays. |
| The ledger already has a refund under the key | No 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 marker | meta.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
failedwith the gateway reason, so the refunded total and its copyrefunded_amount_minordrop; - an open owed row of that refund becomes
void(mark_void!). Arecoveredrow stays as it is: a person already collected the money and must pay it back by hand, and the report names the row; - a
refundedorder goes back tosucceededwhen the refunds that are left do not cover it; ApplyGatewayRefund::RefundFailedErrorgoes toRails.error;- an
approvedrefund request whose refund it was goes back topending, with no reviewer, nogateway_refund_idand 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 422REFUND_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 whosegateway_refund_idis the refund's gateway id or merchant id, the one that therr_key names, or one that this refund closed assystemorstaff.
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
| Cashfree | Razorpay | Stripe | |
|---|---|---|---|
| What is refunded | The 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 unit | Rupees: refund_amount = amount / 100.0, 2 decimals | Paise | Cents |
| Full refund | RefundService 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 left | RefundService reads list_refunds first and sends what Stripe has left as an exact amount |
| Idempotency | refund_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-back | When 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 it | Before 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 part | refund_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 reported | reverse_transfer: true takes the whole refund from the vendor's transfer, so no owed row |
| Commission | Kulcare keeps it. The vendor owes the commission part of a refund (owed row). | Ignores refund_application_fee | refund_application_fee: false: Kulcare keeps its fee |
| Note | refund_note: the reason only, cut to 100 characters, left out below 3 | notes hash, speed: 'normal' | metadata: the notes (reason, actor_id, refund_id) and kulcare_refund_id |
| Answer | refund_id (the merchant id), cf_refund_id, gateway_refund_id (the cf_refund_id), status, vendor_taken_minor, owed_reason | refund_id, status | refund_id and gateway_refund_id (the re_ id), status, vendor_taken_minor |
| Error wrap | Billing::Gateways::Cashfree::HttpClient::RequestError, with the HTTP status | ::Razorpay::Error becomes GatewayError | A 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_bpsgets 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.
| Reason | When |
|---|---|
commission_part | The vendor share paid its part, and the commission part of the refund is owed |
vendor_share_settled | The vendor share was settled, so the refund went with no split |
split_state_unknown | The split order read failed, the vendor or the settlement_id field was missing, or the intent has no split amount |
| Status | Meaning |
|---|---|
open | The vendor owes the amount |
recovered | A person collected it and called mark_recovered!(note:), which needs a note |
void | The 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.
ApproveusesRefundRequests::OrderPayment.refund_key_for(intent)(order_payment.rb:39-49). For a Cashfree vendor that is the intent'sorder_id. For the other gateways it is theexternal_payment_idof the package subscription that the order granted.SeatRefundandVisits::FullRefundread theexternal_payment_idof the subscription. For Cashfree that is thecf_payment_id. This works becauseRefundServicealso sendsorder_id, andCashfreeGatewayrefunds byorder_idwhen it has one.- The support refund also uses
refund_key_for(intent). It does not read apayment_idfrom 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.
| Offering | end_service! | Result |
|---|---|---|
| Class seat | ClassEnrolment#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 plan | VisitPlan#end_service! | An active or paused series ends (Visits::EndSeries). A requested series becomes cancelled. |
| Meal plan | MealSubscription#end_service! | Meals::EndPlan with status cancelled |
| 1:1 package | none | Only 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 422ORDER_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 gatewayorder_id(BuyerOrder). Anyone else gets 404. requested_amount_minoris a positive whole number, at most the remainder. Else 422VALIDATION_FAILEDorREFUND_AMOUNT_TOO_HIGH.reasonhas 1 to 500 characters.review_noteshas 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
pendingrequest. Else 422REFUND_REQUEST_NOT_PENDING. - Staff decide only a
pendingrequest older than 7 days (OVERDUE_AFTER). Else 422REFUND_REQUEST_NOT_OVERDUE.
Endpoints
| Who | Method and path |
|---|---|
| Buyer | GET /patient/orders/:order_id/refund_request: refundable_minor, currency, open_request, past_requests, vendor, offering |
| Buyer | POST /patient/orders/:order_id/refund_requests, body { refund_request: { requested_amount_minor, reason } } |
| Buyer | POST /patient/refund_requests/:id/withdraw |
| Vendor | GET /connect/me/refund_requests?status=&page=&per_page=, GET /connect/me/refund_requests/:id |
| Vendor | POST /connect/me/refund_requests/:id/approve (review_notes optional), POST .../reject (review_notes required) |
| Staff | GET /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.
- 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 409REFUND_IN_PROGRESS). It reads the remainder, refuses a remainder of 0 (ORDER_NOT_REFUNDABLE) and an amount above it (REFUND_AMOUNT_TOO_HIGH), and recordsfull = (requested_amount_minor == remainder). It writes therefund_request_pendingmarker with the keyrr_<request id>. - Refund and record (a second savepoint). It calls
RefundServicewith the requested amount, the reasonrefund_request <id>andrefund_id: "rr_#{request.id}". On success, it sets the request toapproved, with the reviewer, the role,reviewed_at, the notes andgateway_refund_id, and clears the marker, in the same savepoint as the ledger row.gateway_refund_idcomes fromrefund[:gateway_refund_id]: the Cashfreecf_refund_idor the Stripere_id. An older answer with onlyrefund_idgives that. - For a full approval, it ends the service (section 4.5).
- 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 toRails.errorasRefundNotDone. A gateway or network error that reachesApprovepastRefundServiceis 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
| Event | Recipient | Text (title) | Kind |
|---|---|---|---|
| Refund request created | Vendor | "Client asks for a refund of ₹X for Offering." | refund_request_received |
| Vendor approve | Buyer | "Vendor approved your refund of ₹X." plus "Your seat / visit plan / meal plan / package has ended." for a full approval | refund_request_approved |
| Vendor reject | Buyer | "Vendor did not approve your refund." The body has the vendor's note. | refund_request_rejected |
| Staff decision | Buyer 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 close | nobody | n/a | n/a |
| Refund failed at the gateway, request open again | Buyer 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 removed | Buyer, else attendee | "Your place in Batch ended". The body adds "You get a full refund." when the refund went through. | seat_removed |
| Batch cancelled | Buyer, else attendee | "Batch is cancelled". The body adds "You get a full refund." Also an SMS. | batch_cancelled |
| Visit request cancelled by the patient | Vendor | "Client cancelled a visit request", body "The client got a full refund." | request_cancelled |
| Visit request declined or expired | Buyer | "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 eachMKT-event to the marketplace intake (section 3). - A Cashfree refund event for an order that has no intent and does not start with
MKT-isnot_a_marketplace_order, with no alert. - An
MKT-refund with no intent returnsno_matching_intent. A refund in it that did not complete is reported (RefundNotCompletedError). ONHOLDis reported as a warning, because it can still complete. The row stayspending.- A Stripe
charge.refundedwith no refund status counts assucceeded. - A
pendingrow moves tosucceededorfailed. A failed refund is reversed (section 4.2). A refund that Kulcare did not make gets agateway_dashboardrow. 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].
| Kind | HTTP | Meaning |
|---|---|---|
order_missing | 422 | The order is missing. |
payment_id_missing | 422 | The order has no payment id. A retry does not help. |
not_refundable | 409 | The order is not in a refundable state. |
not_vendor_owned | 422 | The order package is not vendor-owned. |
gateway_credentials | 422 | The gateway did not accept the Kulcare credentials. An engineer must fix the gateway keys. |
gateway_refused | 422 | The gateway refused the refund. A retry does not help. |
gateway_unavailable | 502 | The gateway did not take the refund. Try again in a few minutes. |
state_unknown | 409 | The refund state is unknown. Check the refund at the gateway before a retry. |
refund_failed | 422 | The gateway answered that the attempt failed, so no money went out. A new call sends a new attempt id. |
amount_above_remainder | 422 | The amount is more than what is left to refund. |
nothing_left_at_gateway | 409 | The gateway shows the order refunded in full already. |
local_write_failed | 409 | The gateway refund is done, but the order record did not save. Do not retry. |
refund_in_progress | 409 | Another refund on the order has an open marker. |
payment_id_mismatch | 422 | Stripe: the payment id is not the PaymentIntent of the order. |
replay_amount_mismatch | 409 | A 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:
gateway_credentials:GatewayConfigurationError,NullGateway::NotConfiguredError, a missing Cashfree key, a Cashfree 401 or 403, or a StripeAuthenticationErrororPermissionError.gateway_unavailable: a rate limit (a Cashfree 429, a StripeRateLimitError).gateway_refused: a clear refusal (GatewayRefusedError, another Cashfree 4xx, a StripeInvalidRequestErrororCardError).state_unknown: anything else (a 5xx, a connection error, an unreadable answer, an unknown gateway error).RefundServicereports it asRefundService::RefundStateUnknown. An error before the refund request went out isgateway_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
| Caller | Answer to a failed refund |
|---|---|
| Refund request approve (vendor and staff) | The status and the code of the kind. The request stays pending. |
| Support refund | The rules below |
Seat removal with full, retry of a failed seat refund, visit decline, patient visit cancel | The 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 cancel | The 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 getsadm_<key>. - A replay. A key that is in
metadata.admin_refundswith the sameamount_minorgets 200 with the first refund andmeta.replayed: true, and makes no gateway call. The same key with another amount gets 422IDEMPOTENCY_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 409REFUND_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 502REFUND_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 toRails.error, not to the client. - A refusal of the first try.
gateway_refusedends the marker and answers 422REFUND_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_FAILEDwith 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, 409REFUND_AMOUNT_MISMATCHor 502REFUND_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 404order_not_found.amount_minorthat is not a positive integer gets 422.
Other codes
| Code | HTTP | Where |
|---|---|---|
REFUND_IN_PROGRESS | 409 | Refund request approve while a support refund marker is open. Refund request reject while the approve marker of that request is open. |
ORDER_NOT_REFUNDABLE | 422 | Refund request create and approve |
REFUND_AMOUNT_TOO_HIGH | 422 | Refund request create and approve |
REFUND_REQUEST_OPEN | 409 | Refund request create |
REFUND_REQUEST_NOT_PENDING | 422 | Approve, reject, withdraw |
REFUND_REQUEST_NOT_OVERDUE | 422 | Staff approve and reject |
VALIDATION_FAILED | 422 | Bad amount, reason or note |
REFUND_CHOICE_REQUIRED | 422 | Seat removal of a paid seat without full or none |
PAID_SEAT_CANCEL_BY_VENDOR | 422 | Patient cancel of a paid seat |
ENROLMENT_CLOSED | 422 | Removal of a seat that is not open |
BATCH_CLOSED | 422 | Cancel of a cancelled or completed batch |
VISIT_REQUEST_NOT_OPEN, VISIT_REQUEST_EXPIRED | 422 | Visit 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_idto NULL. The amounts, the states andrefunded_amount_minorstay.AccountClosure::OrderDetailsclearsfulfilment_paramswithupdate_columns. - A refund request stays. The foreign key sets
buyer_user_idandattendee_user_idto NULL. The vendor and staff can still decide apendingrequest. No buyer notice goes out. - A paid
requestedvisit series getsrespond_by = now. The nextVisitRequestSweepJobrun declines it with a full refund. The closure makes no gateway call itself. - An
activeorpausedvisit series ends, and anactiveorpausedmeal plan ends, with no refund. Class enrolments are cancelled with no refund. - A refund after the delete still works: the model checks
useron 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
| Question | Where |
|---|---|
| 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 requests | connect.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 expiry | Log [connect.visit_request_expired] |
| Vendor account | identity.vendors: payment_account_id, payment_account_provider, payment_account_status, payment_account_requirements, payment_account_activated_at |
| Vendor view | GET /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.
-
Confirm the payment at the gateway: Cashfree order status
PAID, Razorpay paymentcaptured, Stripe PaymentIntentsucceeded. -
A resend from the gateway dashboard works inside the replay window: 25 hours for Cashfree (the
x-webhook-timestampheader) and Razorpay (the payloadcreated_at). Stripe signs each delivery again, so a Stripe retry passes the 300 s check. -
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_statependingorfailed, older than 30 minutes, that the gateway shows paid. The action is anEvents::OrderPaidthroughApplyEventService, 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 eachsucceededorrefundedCashfree or Stripe order of the last 180 days, the gateway refunds that Kulcare has no row for, and thependingrows that the gateway shows done or failed. WithCONFIRM=1, each row goes throughApplyEventServiceas anEvents::RefundProcessed, the same path as a refund webhook.
A person reads the dry-run CSV before any
CONFIRM=1run (reconcile_orders.rb,lib/tasks/billing_marketplace_reconcile.rake). - Scope A: intents with
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
ReopenOnRefundFailureput back topendingshows 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.
- Send the same refund again: the same
idempotency_keyfor a support refund, or approve the same request again. The gateway read-back gives the first refund back, or the refund is made once. - 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
- B1 tries the fulfilment again: on a replayed
OrderPaid, and withRetryFailedFulfilmentsJobevery 30 minutes, at most 3 tries (metadata.fulfilment_attempts). Most transient errors need no action. - After the last failed try, error tracking has
FulfilmentRetriesExhausted(contextorder_id,fulfilment_type). Find the first error too, and decide whether the vendor can still give the service. - When the service cannot be given (the batch was cancelled, a second payment for one seat), refund in full through the support refund endpoint.
- 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_paramsmust still be present. An account closure clears it. - A
buyer_deletedorder (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 toactiveby a sandbox webhook or by hand in a sandbox database. - A vendor package with
effective_price = 20.00and 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):
| Step | Expected |
|---|---|
| Initiate | Cashfree 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 method | Webhook 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 settlement | GET /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, approved | refund_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 instead | refund_amount: 20.0, refund_splits amount 19.0, one owed row of 100, commission_part |
| A refund after the vendor settlement | No 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):
| Step | Expected |
|---|---|
| Initiate | Razorpay order amount: 2000, transfers: [{ account_id, amount: 1900 }]. Check the transfers shape first (section 7). |
| Pay and verify | Verify answers 200 with the subscription. The intent is verified, charge_state is still pending. |
payment.captured webhook | charge_state = 'succeeded'. The subscription is not created twice. |
| Partial refund of 500 | payment.refund(amount: 500, receipt: 'rr_<id>', speed: 'normal', notes: ...). No owed row, and a VendorPartUnknown report. |
| Full refund | No 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):
| Step | Expected |
|---|---|
| Initiate | PaymentIntent amount: 2000, currency: 'usd', application_fee_amount: 100, transfer_data.destination = the vendor acct_... |
| Full refund | A 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 500 | The same with amount: 500 |
7. Known gaps and follow-ups
Open
People must do these:
- Add
charge.refund.updatedto 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 anyCONFIRM=1run. - Run
SyncVendorAccountsJobone time on each environment after the deploy.
Code gaps:
metadata.refund_splitkeeps 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_ENABLEDis on; plan units A5 and A6 were dropped): the client verify leavescharge_statependinguntilpayment.captured; Razorpay Route documentsaccountandcurrencyin 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 (thereceiptmatch lowers the risk);RAZORPAY_AUTH_MUTEXdoes not cover the single-merchant callers of the globalRazorpay.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_WEBHOOKandVENDOR_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, andMKT-events on the single-merchant URL (A1, A4, F1). - Cashfree vendor statuses:
STATUS_MAP, the live status on each event,SyncVendorAccountsJob, and thePATCHfor a rejected vendor (A3, B3). - The listing after an instant activation:
Onboarding::CreateServicecallsVendorActivation.activate!(A3). - Late events: a late
OrderPaidcannot 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_splitsand 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
PENDINGand laterFAILED:ApplyGatewayRefundreverses 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.backends a resumed paid visit plan on a visit day (#2636 final review). - Rupees in
amount_minorfor 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).