Transactions
Transactions covers payment captures, refunds, payouts, and wallet adjustments. This page documents the Web/Admin views and actions that reflect backend payment and ledger rules with critical business logic for fee calculations, refund policies, and financial reconciliation.
Core Concepts
- Payment Statuses: PENDING, SUCCESSFUL, FAILED, EXPIRED with automatic retry logic
- Transaction Types: PAYMENT, REFUND, PAYOUT, ADJUSTMENT with specific business rules
- Fee Structure: Consultation fees, platform fees, taxes with dynamic calculation
- Refund Policies: Time-based refund rules, penalty calculations, and wallet adjustments
- Reconciliation: Real-time sync with payment providers (Razorpay) and internal ledger
- Audit Trail: Complete transaction history with compliance tracking
Web/Admin Capabilities
1) List & Filter
Business Rules:
- Filter by transaction type, status, date range, and user/appointment
- Show real-time payment status with provider reconciliation
- Display fee breakdown and net amounts for each transaction
- Highlight failed transactions requiring manual intervention
Implementation:
// Filter logic with payment status and fee calculations
const filtered = rows.filter((transaction) => {
// Basic filtering
const matchesType = !typeFilter || transaction.type === typeFilter;
const matchesStatus = !statusFilter || transaction.status === statusFilter;
const matchesDateRange =
!dateRange ||
(transaction.created_at >= dateRange.start &&
transaction.created_at <= dateRange.end);
// Calculate fee breakdown for display
const feeBreakdown = calculateFeeBreakdown(transaction);
transaction.feeBreakdown = feeBreakdown;
transaction.netAmount = calculateNetAmount(transaction);
// Check reconciliation status
const reconciliationStatus = checkReconciliationStatus(transaction);
transaction.reconciliationStatus = reconciliationStatus;
return matchesType && matchesStatus && matchesDateRange;
});
// Fee calculation formula
const calculateFeeBreakdown = (transaction) => {
const baseAmount = transaction.amount;
const consultationFee = transaction.consultation_fee || 0;
const platformFee = calculatePlatformFee(baseAmount);
const taxes = calculateTaxes(baseAmount + platformFee);
return {
consultation_fee: consultationFee,
platform_fee: platformFee,
taxes: taxes,
total_fees: platformFee + taxes,
net_amount: baseAmount - platformFee - taxes,
};
};
// Platform fee calculation (percentage-based)
const calculatePlatformFee = (amount) => {
const platformFeeRate = 0.05; // 5% platform fee
return Math.round(amount * platformFeeRate * 100) / 100;
};
// Tax calculation (GST)
const calculateTaxes = (amount) => {
const gstRate = 0.18; // 18% GST
return Math.round(amount * gstRate * 100) / 100;
};
2) Refund Processing (GraphQL)
Refund Business Rules:
- Time-Based Policies: Different refund rules based on cancellation timing
- Penalty Calculations: Automatic penalty deduction for late cancellations
- Partial Refunds: Support for partial refunds with specific amounts
- Provider Integration: Real-time refund processing through Razorpay
- Wallet Adjustments: Automatic wallet credit for refunded amounts
Refund Calculation Logic:
// Calculate refund amount based on cancellation timing and policies
const calculateRefundAmount = (appointment, cancellationTime) => {
const appointmentTime = new Date(appointment.start_time);
const hoursUntilAppointment =
(appointmentTime - cancellationTime) / (1000 * 60 * 60);
let refundPercentage = 1.0; // Full refund by default
let penaltyAmount = 0;
// Time-based refund policies
if (hoursUntilAppointment < 24) {
// Less than 24 hours: 50% refund + ₹250 penalty
refundPercentage = 0.5;
penaltyAmount = 250;
} else if (hoursUntilAppointment < 48) {
// Less than 48 hours: 75% refund + ₹100 penalty
refundPercentage = 0.75;
penaltyAmount = 100;
}
const baseRefund = appointment.amount * refundPercentage;
const finalRefund = Math.max(0, baseRefund - penaltyAmount);
return {
refund_amount: finalRefund,
penalty_amount: penaltyAmount,
refund_percentage: refundPercentage,
reason: generateRefundReason(hoursUntilAppointment, penaltyAmount),
};
};
// Generate refund reason based on timing
const generateRefundReason = (hoursUntil, penalty) => {
if (hoursUntil < 24) {
return `Late cancellation (${Math.round(hoursUntil)}h notice): 50% refund + ₹${penalty} penalty`;
} else if (hoursUntil < 48) {
return `Short notice cancellation (${Math.round(hoursUntil)}h notice): 75% refund + ₹${penalty} penalty`;
}
return 'Full refund - adequate notice provided';
};
GraphQL Implementation:
import { gql, ApolloClient } from '@apollo/client';
const REFUND = gql`
mutation Refund($input: RefundInput!) {
refund(input: $input) {
success
message
data {
id
refund_amount
penalty_amount
provider_refund_id
wallet_credit_amount
status
processing_time
}
}
}
`;
async function refund(client: ApolloClient<unknown>, input: RefundInput) {
// Pre-calculate refund amount and validate
const appointment = await getAppointmentDetails(input.appointment_id);
const refundCalculation = calculateRefundAmount(appointment, new Date());
// Validate refund amount
if (input.amount > refundCalculation.refund_amount) {
throw new Error(
`Refund amount cannot exceed calculated amount: ₹${refundCalculation.refund_amount}`,
);
}
const processedInput = {
...input,
calculated_refund: refundCalculation.refund_amount,
penalty_amount: refundCalculation.penalty_amount,
refund_reason: refundCalculation.reason,
};
const { data } = await client.mutate({
mutation: REFUND,
variables: { input: processedInput },
});
if (!data?.refund?.success) {
throw new Error(data?.refund?.message || 'Refund failed');
}
return data.refund.data;
}
3) Therapist Payouts (GraphQL)
Payout Business Rules:
- Earning Calculation: Based on completed appointments with platform fee deduction
- Payout Schedule: Weekly/monthly payout cycles with automatic processing
- Tax Deduction: TDS calculation and deduction for tax compliance
- Bank Account Validation: Verify therapist bank account details before payout
Payout Calculation Logic:
// Calculate therapist earnings and payout amount
const calculateTherapistPayout = (therapist, dateRange) => {
const completedAppointments = getCompletedAppointments(
therapist.id,
dateRange,
);
let totalEarnings = 0;
let totalPlatformFees = 0;
let totalTaxes = 0;
completedAppointments.forEach((appointment) => {
const consultationFee = appointment.consultation_fee;
const platformFee = calculatePlatformFee(consultationFee);
const taxes = calculateTaxes(consultationFee - platformFee);
totalEarnings += consultationFee;
totalPlatformFees += platformFee;
totalTaxes += taxes;
});
const netEarnings = totalEarnings - totalPlatformFees - totalTaxes;
const minimumPayout = 500; // Minimum payout threshold
return {
total_earnings: totalEarnings,
platform_fees: totalPlatformFees,
taxes_deducted: totalTaxes,
net_payout: Math.max(0, netEarnings),
eligible_for_payout: netEarnings >= minimumPayout,
minimum_threshold: minimumPayout,
};
};
// TDS calculation for tax compliance
const calculateTDS = (payoutAmount) => {
const tdsRate = 0.1; // 10% TDS for professional services
const tdsThreshold = 40000; // Annual threshold for TDS
if (payoutAmount > tdsThreshold) {
return Math.round(payoutAmount * tdsRate * 100) / 100;
}
return 0;
};
GraphQL Implementation:
const PAYOUT = gql`
mutation Payout($input: PayoutInput!) {
payout(input: $input) {
success
message
data {
id
payout_amount
tds_deducted
net_amount
bank_account
processing_time
status
transaction_id
}
}
}
`;
async function payout(client: ApolloClient<unknown>, input: PayoutInput) {
// Calculate payout amount and validate
const therapist = await getTherapistDetails(input.therapist_id);
const payoutCalculation = calculateTherapistPayout(
therapist,
input.date_range,
);
// Validate minimum payout threshold
if (!payoutCalculation.eligible_for_payout) {
throw new Error(
`Minimum payout threshold not met. Required: ₹${payoutCalculation.minimum_threshold}, Available: ₹${payoutCalculation.net_payout}`,
);
}
// Calculate TDS
const tdsAmount = calculateTDS(payoutCalculation.net_payout);
const netPayoutAmount = payoutCalculation.net_payout - tdsAmount;
const processedInput = {
...input,
calculated_amount: payoutCalculation.net_payout,
tds_amount: tdsAmount,
net_payout: netPayoutAmount,
earnings_breakdown: payoutCalculation,
};
const { data } = await client.mutate({
mutation: PAYOUT,
variables: { input: processedInput },
});
if (!data?.payout?.success) {
throw new Error(data?.payout?.message || 'Payout failed');
}
return data.payout.data;
}
4) Manual Adjustments (GraphQL)
Adjustment Business Rules:
- Admin-Only: Only admins can create manual adjustments
- Audit Trail: All adjustments require detailed notes and approval
- Amount Validation: Positive/negative adjustments with balance verification
- Wallet Integration: Automatic wallet balance updates
- Compliance: All adjustments logged for financial audit
Adjustment Validation Logic:
// Validate adjustment request
const validateAdjustment = (adjustment, user) => {
const errors = [];
// Check admin permissions
if (user.role !== 'ADMIN') {
errors.push('Only administrators can create manual adjustments');
}
// Validate amount
if (Math.abs(adjustment.amount) > 10000) {
// Large adjustment threshold
errors.push('Large adjustments require additional approval');
}
// Check wallet balance for negative adjustments
if (adjustment.amount < 0) {
const currentBalance = getUserWalletBalance(adjustment.user_id);
if (Math.abs(adjustment.amount) > currentBalance) {
errors.push('Insufficient wallet balance for negative adjustment');
}
}
// Validate note requirement
if (!adjustment.note || adjustment.note.trim().length < 10) {
errors.push('Detailed note required for audit trail');
}
return errors;
};
// Calculate new wallet balance after adjustment
const calculateNewBalance = (userId, adjustmentAmount) => {
const currentBalance = getUserWalletBalance(userId);
return currentBalance + adjustmentAmount;
};
GraphQL Implementation:
const ADJUST = gql`
mutation Adjust($input: AdjustmentInput!) {
adjust(input: $input) {
success
message
data {
id
amount
note
previous_balance
new_balance
created_by
created_at
approval_status
}
}
}
`;
async function adjust(
client: ApolloClient<unknown>,
input: AdjustmentInput,
currentUser: User,
) {
// Validate adjustment request
const validationErrors = validateAdjustment(input, currentUser);
if (validationErrors.length > 0) {
throw new Error(`Validation failed: ${validationErrors.join(', ')}`);
}
// Calculate new balance
const previousBalance = getUserWalletBalance(input.user_id);
const newBalance = calculateNewBalance(input.user_id, input.amount);
const processedInput = {
...input,
previous_balance: previousBalance,
new_balance: newBalance,
created_by: currentUser.id,
approval_status:
Math.abs(input.amount) > 10000 ? 'PENDING_APPROVAL' : 'APPROVED',
};
const { data } = await client.mutate({
mutation: ADJUST,
variables: { input: processedInput },
});
if (!data?.adjust?.success) {
throw new Error(data?.adjust?.message || 'Adjustment failed');
}
return data.adjust.data;
}
5) Export & Financial Reports
Export Business Rules:
- Transaction Reports: Include fee breakdown, refunds, and adjustments
- Reconciliation Reports: Compare internal records with payment provider data
- Tax Reports: Generate TDS and GST reports for compliance
- Audit Reports: Complete transaction history with approval trails
Export Implementation:
// Generate comprehensive transaction report
const generateTransactionReport = (transactions, dateRange) => {
const report = {
summary: {
total_transactions: transactions.length,
total_amount: transactions.reduce((sum, t) => sum + t.amount, 0),
total_fees: transactions.reduce(
(sum, t) => sum + t.feeBreakdown.total_fees,
0,
),
total_refunds: transactions
.filter((t) => t.type === 'REFUND')
.reduce((sum, t) => sum + t.amount, 0),
net_revenue: 0,
},
transactions: transactions.map((transaction) => ({
id: transaction.id,
type: transaction.type,
amount: transaction.amount,
fee_breakdown: transaction.feeBreakdown,
status: transaction.status,
created_at: transaction.created_at,
provider_reference: transaction.provider_reference,
reconciliation_status: transaction.reconciliationStatus,
})),
reconciliation_summary: {
matched_transactions: transactions.filter(
(t) => t.reconciliationStatus === 'MATCHED',
).length,
pending_reconciliation: transactions.filter(
(t) => t.reconciliationStatus === 'PENDING',
).length,
discrepancies: transactions.filter(
(t) => t.reconciliationStatus === 'DISCREPANCY',
).length,
},
};
// Calculate net revenue
report.summary.net_revenue =
report.summary.total_amount -
report.summary.total_fees -
report.summary.total_refunds;
return report;
};
const csv = toCsv(generateTransactionReport(rows, dateRange));
downloadCsv('financial-transaction-report.csv', csv);
Data Flow (Web ↔ Backend)
Critical Business Logic Flow:
- Payment Processing: Payment capture → Fee calculation → Provider reconciliation → Status update
- Refund Processing: Refund request → Policy validation → Amount calculation → Provider refund → Wallet credit
- Payout Processing: Earnings calculation → TDS deduction → Bank transfer → Status tracking
- Adjustment Processing: Admin request → Validation → Balance update → Audit logging
GraphQL Operations with Business Rules:
| Action | Operation | Business Rules |
|---|---|---|
| List | transactions(filter) | Fee breakdown, reconciliation status, real-time updates |
| Refund | refund(input) | Time-based policies, penalty calculation, provider integration |
| Payout | payout(input) | Earning calculation, TDS deduction, minimum thresholds |
| Adjustment | adjust(input) | Admin-only, audit trail, balance validation |
Error Handling & Validation:
- Refund Exceeded: "Refund amount cannot exceed calculated amount"
- Minimum Payout: "Minimum payout threshold not met"
- Insufficient Balance: "Insufficient wallet balance for negative adjustment"
- Provider Error: "Payment provider reconciliation failed"
- Large Adjustment: "Large adjustments require additional approval"
Security & Access Control
- Admin-Only Operations: Manual adjustments and large refunds require admin access
- Audit Trail: All financial operations logged with user information and timestamps
- Provider Integration: Secure API keys and webhook validation for payment providers
- Data Privacy: Financial data protected by role-based permissions and encryption
- Compliance: TDS and GST calculations for tax compliance and reporting
Price Breakup Tooltip Component
import * as Tooltip from '@radix-ui/react-tooltip';
export function PriceBreakup({
consultation,
platform,
taxes,
other = 0,
}: {
consultation: number;
platform: number;
taxes: number;
other?: number;
}) {
const total = consultation + platform + taxes + other;
return (
<Tooltip.Provider delayDuration={150}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className="cursor-help underline">₹{total.toFixed(2)}</span>
</Tooltip.Trigger>
<Tooltip.Content
className="rounded border bg-white p-3 shadow text-xs space-y-1"
sideOffset={6}
>
<div className="flex justify-between">
<span>Consultation</span>
<span>₹{consultation.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span>Platform fee (5%)</span>
<span>₹{platform.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span>GST (18%)</span>
<span>₹{taxes.toFixed(2)}</span>
</div>
{other !== 0 && (
<div className="flex justify-between">
<span>Other</span>
<span>₹{other.toFixed(2)}</span>
</div>
)}
<div className="my-1 border-t" />
<div className="flex justify-between font-medium">
<span>Total</span>
<span>₹{total.toFixed(2)}</span>
</div>
<Tooltip.Arrow className="fill-white" />
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
);
}