KLOYST
Billing Module — Complete Implementation Plan
| Internal Engineering Document | v1.1 | June 2026 | | --- |
Prepared by: Engineering Team | Blogsage Technologies Pvt. Ltd.
Confidential — Internal Use Only
Table of Contents
1. Overview & Objectives
This document is the complete engineering and product reference for Kloyst's Billing Module. It covers system architecture decisions, database schema, execution logic, payment gateway integration, refund policies, multi-currency strategy, and phased implementation roadmap.
Kloyst currently has 2 active vendors on informal billing (monthly collection). This module formalises that into a self-serve, automated, and auditable billing system — starting with a solid data foundation in Phase 1 and progressively adding payment automation through Phase 4.
1.1 Billing Model Summary​
The Kloyst billing model has three distinct revenue components that work together:
| Component | Frequency | Description |
|---|---|---|
| Setup Fee | One-time | Collected at vendor onboarding before WABA activation. Covers integration effort and Meta onboarding assistance. |
| Subscription | Monthly / Quarterly / Annual | Platform access fee. Includes a conversation routing capacity cap per billing cycle. Does not cover messaging costs. |
| Wallet (Prepaid) | Pay-as-you-go | Prepaid balance in vendor's currency. Deducted per conversation at Meta's base rate + 25% platform markup. Always active regardless of subscription. |
| Core PrincipleThe wallet is always charged per conversation. The subscription cap is a routing capacity limit — not a free message allocation. Vendors must maintain positive wallet balance to send any message, regardless of their plan tier. | ||
| --- |
1.2 Pricing Structure​
SMB Plan
| Fee Component | Base (INR) | GST 18% (INR) | Total (INR) |
|---|---|---|---|
| Onboarding Setup Fee | 999.00 | 179.82 | 1,178.82 |
| Monthly Subscription (50,000 conv/mo cap) | 2,499.00 | 449.82 | 2,948.82 |
| Quarterly Contract (saves 300) | 7,197.00 | 1,295.46 | 8,492.46 |
| Annual Contract (saves 1,500) | 28,488.00 | 5,127.84 | 33,615.84 |
Enterprise Plan
| Fee Component | Base (INR) | GST 18% (INR) | Total (INR) |
|---|---|---|---|
| Onboarding Setup Fee | 2,499.00 | 449.82 | 2,948.82 |
| Monthly Subscription (1,50,000 conv/mo cap) | 10,000.00 | 1,800.00 | 11,800.00 |
| Quarterly Contract (saves 850) | 29,150.00 | 5,247.00 | 34,397.00 |
| Annual Contract (saves 4,000) | 1,18,499.00 | 21,329.82 | 1,39,828.82 |
1.3 Wallet Pricing — 25% Markup Model​
All conversation charges follow a transparent markup model: Meta's base rate + 25% platform fee. This is shown as a line-item breakdown on every transaction.
| Conversation Category | Meta Rate (INR) | Vendor Rate (INR) | Your Margin (INR) |
|---|---|---|---|
| Marketing | ~0.863 | ~1.079 | ~0.216 |
| Utility | ~0.115 | ~0.145 | ~0.03 |
| Authentication | ~0.115 | ~0.145 | ~0.03 |
| Service (incoming) | Free (first 1,000/mo) | Free | — |
Rates shown are current Meta published rates for India (IN). Exact rates vary by country. All rates must be stored in the MessagePricing table and updated whenever Meta revises their published pricing. Last updated: June 2026.
2. Money Flow Architecture
There are three distinct money flows. Each maps to a separate billing subsystem. All must be understood before implementation begins.
2.1 Flow 1 — Vendor Onboards (Setup Fee + First Month Subscription)​
| Vendor completes signup & WABA onboarding |
|---|
| -> Admin reviews and approves account |
| -> System generates combined invoice: |
| - Onboarding Setup Fee (one-time) |
| - First Month Subscription Fee |
| Both charged together in a single Razorpay order |
| -> Vendor pays combined amount via Razorpay |
| -> Webhook verified -> Invoice marked PAID |
| -> Wallet created with zero balance |
| -> Subscription activated (billing cycle Day 1 starts) |
| -> WABA access granted |
| NOTE: Setup fee is non-refundable. |
| NOTE: First month subscription is refundable within 3 days of payment. |
| After 3 days -> non-refundable. |
| All subsequent monthly charges -> non-refundable from Day 1. |
2.2 Flow 2 — Monthly Subscription​
| Billing cycle start date reached |
|---|
| -> Razorpay Subscription auto-charges vendor |
| -> Webhook fires on success |
| -> Subscription status stays ACTIVE |
| -> Invoice generated and marked PAID |
| -> conversationsUsed resets to 0 |
| -> Webhook fires on failure (Day 1) |
| -> Status -> PAST_DUE |
| -> Warning shown in dashboard |
| -> 4-day payment window starts |
| Day 5-7: Grace extension, full service continues |
| Day 8+: Hard freeze -> outbound blocked |
2.3 Flow 3 — Wallet & Messaging​
| Vendor recharges wallet |
|---|
| -> Razorpay order created |
| -> Vendor pays |
| -> Webhook verified |
| -> WalletTransaction CREDIT created |
| -> wallet.balanceMinorUnit increases |
| Vendor launches campaign |
| -> estimatedCost = contacts x vendorPricePaise |
| -> CHECK: wallet.balance >= estimatedCost? NO -> Reject |
| -> CHECK: conversationsUsed < conversationCap? NO -> Reject |
| -> DB TRANSACTION: deduct estimatedCost, create RESERVED tx |
| -> Messages sent via Meta API (each returns wamid) |
| Webhook per message arrives |
| -> DELIVERED: confirm deduction (no action if pre-deducted) |
| -> FAILED: refund that message cost -> REFUND transaction |
| Campaign complete |
| -> Reconcile: refund any over-reserved balance |
| -> Increment subscription.conversationsUsed |
| -> Final WalletTransaction closes campaign billing |
| Critical Rule — Pre-ReservationAlways deduct the full estimated campaign cost from the wallet BEFORE sending any messages. If a vendor has INR 50 and fires a 1,000-contact campaign at INR 0.73/conversation, their wallet goes -INR 680 if you deduct on delivery. Pre-reservation prevents this entirely. This is the most common billing bug in messaging platforms. |
| --- |
3. Database Schema (Prisma)
All monetary values are stored as integers in the smallest denomination of the vendor's currency (paise for INR, cents for USD, fils for AED). This eliminates floating-point arithmetic errors in all financial calculations. Never store money as Float or Decimal.
3.1 Wallet​
model Wallet {
id String @id @default(cuid())
vendorId String @unique
vendor Vendor @relation(fields: [vendorId], references: [id])
balanceMinorUnit Int @default(0) // paise for INR, cents for USD
currency String @default("INR") // ISO 4217
lastRechargedAt DateTime?
lowBalanceThreshold Int @default(20000) // 200 INR in paise
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions WalletTransaction[]
}
3.2 WalletTransaction​
model WalletTransaction {
id String @id @default(cuid())
walletId String
wallet Wallet @relation(fields: [walletId], references: [id])
type TxType // CREDIT | DEBIT | REFUND | BONUS | RESERVED
amountMinorUnit Int // always positive
balanceAfterMinorUnit Int // balance snapshot after this tx — audit critical
currency String
// Internal books (always INR equivalent)
amountInrPaise Int
exchangeRateUsed Decimal @default(1) // 1 for INR-INR
description String
referenceType String? // "CAMPAIGN" | "RECHARGE" | "SUBSCRIPTION"
referenceId String? // campaignId or razorpayPaymentId
idempotencyKey String @unique // prevents double-credit
createdAt DateTime @default(now())
}
enum TxType {
CREDIT DEBIT REFUND BONUS RESERVED
}
3.3 MessagePricing​
model MessagePricing {
id String @id @default(cuid())
category ConvCategory // MARKETING | UTILITY | AUTHENTICATION | SERVICE
countryCode String // "IN", "AE", "US"
currency String // pricing currency, matches vendor billing currency
metaCostUsdCents Int // Meta's cost in USD cents (source of truth)
markupPercent Int @default(25) // per-vendor or per-tier configurable
effectiveFrom DateTime
isActive Boolean @default(true)
createdAt DateTime @default(now())
@@unique([category, countryCode, currency, effectiveFrom])
}
enum ConvCategory {
MARKETING UTILITY AUTHENTICATION SERVICE
}
3.4 Subscription​
model Subscription {
id String @id @default(cuid())
vendorId String @unique
vendor Vendor @relation(fields: [vendorId], references: [id])
plan PlanType // SMB | ENTERPRISE | CUSTOM
status SubStatus // ACTIVE | PAST_DUE | CANCELLED | FROZEN | TRIAL
monthlyAmountPaise Int
billingCycleStart DateTime
billingCycleEnd DateTime
conversationCap Int // 50000 SMB | 150000 Enterprise
conversationsUsed Int @default(0)
capResetDate DateTime // resets conversationsUsed each cycle
razorpaySubId String? // Razorpay subscription ID
frozenAt DateTime? // set on Day 8 non-payment
cancelledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
3.5 Invoice​
model Invoice {
id String @id @default(cuid())
vendorId String
vendor Vendor @relation(fields: [vendorId], references: [id])
invoiceNumber String @unique // KL-2026-00001
type InvoiceType // SETUP | SUBSCRIPTION | WALLET_RECHARGE
amountMinorUnit Int // pre-GST
gstAmountMinorUnit Int // 18%
totalMinorUnit Int // amount + gst
currency String
status InvoiceStatus // DRAFT | PAID | OVERDUE | VOID
dueDate DateTime
paidAt DateTime?
razorpayPaymentId String? @unique
// GST compliance fields
vendorGstin String?
hsnCode String @default("998313")
createdAt DateTime @default(now())
}
3.6 CurrencyConfig​
model CurrencyConfig {
code String @id // "INR", "USD", "AED"
symbol String // "Rs.", "$", "AED"
decimalPlaces Int // 2 for INR/USD, 0 for JPY, 3 for KWD
locale String // "en-IN", "en-US", "ar-AE"
isActive Boolean @default(true)
}
model ExchangeRate {
id String @id @default(cuid())
fromCurrency String
toCurrency String
rate Decimal
fetchedAt DateTime @default(now())
@@unique([fromCurrency, toCurrency])
}
Schema Rule — Money ColumnsEvery column storing a monetary value must be named with the unit suffix (e.g., balanceMinorUnit, amountPaise, totalMinorUnit) and must be type Int. Never use Float or Decimal for money. The naming convention makes it impossible to accidentally mix units.
# 4\. Wallet & Deduction Logic
## 4.1 Pre-Send Checks (Both Must Pass)
Before any campaign sends even a single message, both of these checks must pass atomically inside a database transaction:
Check | Logic & Response
--- | ---
Check 1 — Wallet Balance | wallet.balanceMinorUnit >= estimatedCampaignCost. If false: reject with "Insufficient balance". Show estimated cost and recharge button.
Check 2 — Conversation Cap | subscription.conversationsUsed + campaignContactCount <= subscription.conversationCap. If false: reject with "Monthly conversation cap reached". Show upgrade or top-up option.
## 4.2 Cost Calculation
// TypeScript — WalletService
async calculateCampaignCost(
vendorId: string,
contactCount: number,
templateCategory: ConvCategory,
countryCode: string
): Promise<number> {
const pricing = await this.prisma.messagePricing.findFirst({
where: { category: templateCategory, countryCode, isActive: true },
orderBy: { effectiveFrom: 'desc' },
});
if (!pricing) throw new Error(`No pricing found for ${templateCategory} in ${countryCode}`);
const metaCost = pricing.metaCostUsdCents * exchangeRate; // convert to vendor currency
const vendorCost = Math.ceil(metaCost * (1 + pricing.markupPercent / 100));
return vendorCost * contactCount; // total in minor units
}
4.3 Deduction Flow (NestJS Service)​
async reserveCampaignFunds(
---
vendorId: string,
campaignId: string,
estimatedCost: number
) {
return this.prisma.$transaction(async (tx) => {
// Lock the wallet row for this transaction
const wallet = await tx.wallet.findUniqueOrThrow({
where: { vendorId },
});
// Check 1: balance
if (wallet.balanceMinorUnit < estimatedCost) {
throw new BadRequestException('Insufficient wallet balance');
}
| |
| // Check 2: cap |
| const sub = await tx.subscription.findUniqueOrThrow({ where: { vendorId } }); |
| if (sub.conversationsUsed + contactCount > sub.conversationCap) { |
| throw new BadRequestException('Monthly conversation cap reached'); |
| } |
| |
| // Deduct and create transaction record |
| const newBalance = wallet.balanceMinorUnit - estimatedCost; |
| await tx.wallet.update({ |
| where: { vendorId }, |
| data: { balanceMinorUnit: newBalance }, |
| }); |
| |
| await tx.walletTransaction.create({ |
| data: { |
| walletId: wallet.id, |
| type: 'RESERVED', |
| amountMinorUnit: estimatedCost, |
| balanceAfterMinorUnit: newBalance, |
| currency: wallet.currency, |
| amountInrPaise: estimatedCost, // convert if non-INR |
| exchangeRateUsed: 1, |
| description: Campaign $\{campaignId\} — funds reserved, |
| referenceType: 'CAMPAIGN', |
| referenceId: campaignId, |
| idempotencyKey: reserve-$\{campaignId\}, |
| }, |
| }); |
| }); |
| } |
4.4 Webhook-Driven Reconciliation​
Meta sends status updates for every message via webhook. The reconciliation logic handles three outcomes:
| Webhook Status | Action | Notes |
|---|---|---|
| sent / delivered / read | Confirm deduction (no action if pre-deducted). Increment subscription.conversationsUsed by 1. | Happy path. Funds already deducted. |
| failed | Refund the per-conversation cost to wallet. Create REFUND WalletTransaction. Do NOT increment conversationsUsed. | Vendor is not charged for failed messages. |
| Campaign complete | Release any over-reserved balance (contacts listed vs actual delivered). Close campaign billing cycle. | Reconciliation must run within 24h of campaign end. |
4.5 Low Balance Handling​
| Scenario | Behaviour |
|---|---|
| Low balance alert | Email + in-app notification when balance drops below lowBalanceThreshold (default INR 200 / 20,000 paise). Configurable per vendor. |
| Campaign block | Block campaign launch if wallet.balanceMinorUnit < estimated cost. Show exact shortfall and deep-link to recharge. |
| Mid-campaign pause | If balance hits zero mid-campaign (edge case), pause remaining sends. Notify vendor. Resume on recharge. |
| Zero balance auto-pause | No messages sent when balance is zero regardless of conversation cap remaining. Both conditions must be met. |
5. Payment Gateway — Razorpay Integration
Razorpay is the recommended payment gateway for India-first operations. It supports UPI, cards, netbanking, and wallets natively in INR/paise. Razorpay Subscriptions handles recurring billing with auto-debit and failed payment webhooks out of the box.
5.1 Wallet Recharge Flow​
The webhook handler uses a 6-step defence-in-depth pattern combining signature verification, Redis distributed locking, DB-level idempotency, and async email dispatch. This fully prevents double-crediting under any failure or retry scenario.
POST /webhooks/razorpay
---
│
├── Step 1: Verify Razorpay Signature (HMAC-SHA256)
│ X-Razorpay-Signature header vs HMAC(body, webhookSecret)
│ -> If INVALID: return 400 immediately. Log attempt.
│
├── Step 2: Acquire Redis Distributed Lock
│ Key: "lock:payment:{paymentId}" TTL: 30 seconds
│ -> If lock ACQUIRED: proceed
│ -> If lock FAILS (another instance processing):
│ Return 200 (swallow retry) OR 429
│ Razorpay will retry — the lock holder will process it
│ NEVER return 5xx here (Razorpay retries aggressively)
│
├── Step 3: Open DB Transaction
│ │
│ ├── Check: Invoice WHERE razorpayPaymentId = paymentId
│ │ -> If EXISTS and status == PAID:
│ │ Commit & return 200 (idempotent success, no action)
│ │
│ ├── Credit wallet: balanceMinorUnit += amountPaise
│ │
│ ├── INSERT WalletTransaction
│ │ idempotencyKey = paymentId (UNIQUE constraint in DB)
│ │
│ └── INSERT Invoice (status: PAID, razorpayPaymentId = paymentId)
│ razorpayPaymentId has UNIQUE constraint in DB
│ -> If DB throws UniqueConstraintError:
│ Rollback & return 200 (safe duplicate handling)
│
├── Step 4: Commit DB Transaction
│
├── Step 5: Release Redis Lock
│
└── Step 6: Dispatch Async Background Job
-> Send confirmation email to vendor
-> NEVER put email sending inside the DB transaction
(email failure must not roll back the credit)
| Why Three Layers of Idempotency Protection?Layer 1 (Redis Lock): Prevents two concurrent webhook retries from entering the DB transaction simultaneously — a race condition that could bypass the unique constraint check if both read "not paid" before either writes. Layer 2 (DB Invoice Check): Handles the case where the Redis lock expired or was never acquired (e.g. Redis is down). Layer 3 (DB Unique Constraint): The final safety net — even if both previous layers fail, the DB unique constraint on razorpayPaymentId makes it physically impossible to insert a duplicate payment record. All three layers are required. |
|---|
// NestJS webhook handler — full implementation
---
async handleRazorpayWebhook(body: any, signature: string): Promise\<void\> {
// Step 1: Signature verification
const expectedSig = crypto
.createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET)
.update(JSON.stringify(body))
.digest("hex");
if (signature !== expectedSig) throw new BadRequestException("Invalid signature");
const paymentId = body.payload.payment.entity.id;
const amountPaise = body.payload.payment.entity.amount;
const lockKey = `lock:payment:${paymentId}`;
// Step 2: Redis distributed lock (TTL 30s)
const lock = await this.redis.set(lockKey, "1", "EX", 30, "NX");
if (!lock) {
// Another instance is processing — swallow gracefully
return; // returns 200 to Razorpay
}
| | | try { | | // Steps 3 + 4: DB transaction | | await this.prisma.$transaction(async (tx) => { | | | | // Idempotency check | | const existing = await tx.invoice.findUnique({ | | where: { razorpayPaymentId: paymentId }, | | }); | | if (existing?.status === "PAID") return; // already processed | | | | const wallet = await tx.wallet.findUniqueOrThrow({ where: { vendorId } }); | | const newBalance = wallet.balanceMinorUnit + amountPaise; | | | | await tx.wallet.update({ | | where: { id: wallet.id }, | | data: { balanceMinorUnit: newBalance, lastRechargedAt: new Date() }, | | }); | | | | await tx.walletTransaction.create({ | | data: { | | walletId: wallet.id, type: "CREDIT", | | amountMinorUnit: amountPaise, | | balanceAfterMinorUnit: newBalance, | | currency: wallet.currency, | | idempotencyKey: paymentId, // UNIQUE constraint | | description: "Wallet recharge", | | referenceType: "RECHARGE", referenceId: paymentId, | | }, | | }); | | | | await tx.invoice.create({ | | data: { | | vendorId, type: "WALLET_RECHARGE", status: "PAID", | | amountMinorUnit: amountPaise, | | razorpayPaymentId: paymentId, // UNIQUE constraint | | paidAt: new Date(), | | // ... other fields | | }, | | }); | | }); | | | | } catch (e) { | | if (e.code === "P2002") return; // Prisma unique constraint -> already processed | | throw e; | | } finally { | | // Step 5: Always release lock | | await this.redis.del(lockKey); | | } | | | | // Step 6: Async email — OUTSIDE transaction | | this.emailQueue.add("wallet-recharged", { vendorId, amountPaise }); | | } |
5.2 Subscription Auto-Billing​
// Create Razorpay Subscription at vendor onboarding
---
razorpay.subscriptions.create({
plan_id: RAZORPAY_PLAN_ID_SMB, // or ENTERPRISE
customer_notify: 1,
total_count: 12, // or 0 for indefinite
start_at: billingCycleStart,
})
| -> Store razorpaySubId in Subscription table | | | | // Webhook: subscription.charged (success) | | -> Subscription.status = ACTIVE | | -> conversationsUsed = 0 (reset cap) | | -> billingCycleStart/End updated | | -> Invoice created and marked PAID | | | | // Webhook: subscription.halted (payment failed) | | -> Subscription.status = PAST_DUE | | -> Dashboard warning shown | | -> Day 4 reminder email | | -> Day 7 final warning email | | -> Day 8: status = FROZEN, outbound blocked |
| Webhook Security — Non-NegotiableThree layers protect against double-credit: (1) Redis distributed lock prevents concurrent processing of the same paymentId across multiple server instances. (2) DB Invoice existence check before any write. (3) DB UNIQUE constraints on razorpayPaymentId in both Invoice and WalletTransaction tables — physically impossible to insert a duplicate even if the first two layers fail. Email sending is always dispatched as an async background job AFTER the DB transaction commits — never inside the transaction. |
|---|
5.3 Invoice Generation​
Every payment event generates a GST-compliant PDF invoice. Required fields on every invoice:
| Field | Details |
|---|---|
| Invoice Number | Sequential: KL-YYYY-00001. Never reuse. |
| Vendor GSTIN | Optional — collected at onboarding. Required if vendor needs input tax credit. |
| Kloyst GSTIN | Your company GSTIN — mandatory on all invoices. |
| HSN Code | 998313 — Software as a Service (SaaS) under Indian GST. |
| Base Amount | Pre-GST amount in vendor's currency. |
| GST @ 18% | Calculated on base amount. Split as CGST 9% + SGST 9% (intra-state) or IGST 18% (inter-state). |
| Total Amount | Base + GST. Both in vendor's currency and INR equivalent. |
| Payment Reference | Razorpay payment ID for audit trail. |
Use the pdfkit or puppeteer library (NestJS) to generate PDFs server-side. Store generated PDFs in Google Cloud Storage (same as media files). Link from Invoice record.
6. Refund Policies & Execution
Refunds in Kloyst fall into four distinct categories, each with different eligibility rules, execution paths, and timelines. Every refund must be documented with a reason and approved trail.
6.1 Failed Message Refund (Automatic)​
| PolicyAll messages that fail delivery (status: FAILED in Meta webhook) are automatically refunded to the vendor wallet within the same billing session. This is non-negotiable — vendors are never charged for messages Meta could not deliver. |
|---|
| Attribute |
| --- |
| Trigger |
| Eligibility |
| Refund destination |
| Amount refunded |
| Transaction created |
| Vendor notification |
| Timeline |
// RefundService — called from webhook handler
---
async refundFailedMessage(wamid: string, vendorId: string): Promise\<void\> {
const msgCost = await this.getMessageCost(wamid); // lookup from campaign record
await this.prisma.$transaction(async (tx) => {
const wallet = await tx.wallet.findUniqueOrThrow({ where: { vendorId } });
const newBalance = wallet.balanceMinorUnit + msgCost;
await tx.wallet.update({
where: { vendorId },
data: { balanceMinorUnit: newBalance },
});
await tx.walletTransaction.create({
data: {
walletId: wallet.id,
type: 'REFUND',
amountMinorUnit: msgCost,
balanceAfterMinorUnit: newBalance,
currency: wallet.currency,
description: `Refund: message ${wamid} failed delivery`,
referenceType: 'MESSAGE',
referenceId: wamid,
idempotencyKey: `refund-msg-${wamid}`, // prevents double-refund
},
});
});
}
`
## 6.2 Setup Fee & First Month Subscription Refund
| Policy — Setup FeeThe setup fee is non-refundable under all circumstances. It is charged after onboarding is completed and WABA access is granted, covering the work already performed. No exceptions. |
| --- |
| Policy — First Month SubscriptionThe first month subscription fee is charged together with the setup fee in a single payment at the end of onboarding. It carries a 3-day refund window: if the vendor requests cancellation within 3 calendar days of the payment date, the subscription fee (not the setup fee) is refunded in full to the original payment method. After Day 3, it becomes non-refundable. All subsequent monthly subscription charges (Month 2 onwards) are non-refundable from Day 1 of each billing cycle. |
| --- |
| Attribute | Detail |
| --- | --- |
| Combined charge timing | After onboarding completion and WABA activation. Both setup fee and first month subscription in one Razorpay order. |
| Setup fee — refundable? | Never. Non-refundable from the moment of payment. |
| First month sub — refund window | Within 3 calendar days of payment date. Vendor must request via support. |
| First month sub — after Day 3 | Non-refundable. No exceptions. |
| Month 2+ subscriptions | Non-refundable from Day 1 of each billing cycle. Auto-charged via Razorpay Subscription. |
| Refund amount (if eligible) | Subscription fee only (e.g. INR 2,499 base + INR 449.82 GST = INR 2,948.82 for SMB). Setup fee portion is never included. |
| Refund method | Via Razorpay refund API to original payment method (card / UPI / bank). |
| Timeline | 5-7 business days for card/bank. Instant for UPI (Razorpay dependent). |
| Who approves | Admin must manually approve in the admin panel. No self-serve. |
| What happens after refund | Subscription set to CANCELLED. WABA access revoked. Vendor may re-onboard with fresh setup fee. |
| Invoice handling | Original combined invoice voided. Two separate invoices re-issued: setup fee (PAID) and subscription (VOID/REFUNDED). |
| // Combined onboarding charge — single Razorpay order |
| --- |
| async chargeOnboarding(vendorId: string, plan: PlanType) \{ |
| const setupFee = plan === "SMB" ? 117882 : 294882; // paise incl. GST |
| const subFee = plan === "SMB" ? 294882 : 1180000; |
| const totalPaise = setupFee + subFee; |
| |
| const order = await razorpay.orders.create(\{ |
| amount: totalPaise, |
| currency: "INR", |
| notes: \{ |
| vendorId, |
| type: "ONBOARDING", |
| setupFeePaise: setupFee, |
| subFeePaise: subFee, |
| subRefundDeadline: addDays(new Date(), 3).toISOString(), |
| \}, |
| \}); |
| |
| // On webhook payment.captured: |
| // -> Create Invoice type SETUP (paid, non-refundable) |
| // -> Create Invoice type SUBSCRIPTION (paid, refundable until subRefundDeadline) |
| // -> Store subRefundDeadline on Subscription record |
| // -> Activate subscription, grant WABA access |
| \} |
## 6.3 Subscription Refund
| PolicyMonthly subscriptions are non-refundable once the billing cycle has started. Vendors who cancel mid-cycle retain platform access until the end of the paid cycle. Annual and quarterly contracts are non-refundable but may be eligible for credit towards plan upgrade at admin discretion. |
| --- |
| Scenario | Policy |
| --- | --- |
| Monthly — mid-cycle cancellation | No refund. Access continues until billing cycle end date. |
| Monthly — double-charged (platform error) | Full refund of duplicate charge via Razorpay. Admin approved. |
| Quarterly / Annual — cancellation | No cash refund. Remaining balance may be credited as wallet bonus at admin discretion. |
| Quarterly / Annual — platform error | Full or prorated refund depending on issue. Admin approved. |
| Downgrade scenario | Difference credited to wallet as BONUS transaction, not cash refund. |
| Refund method | Via Razorpay refund API where applicable. Wallet credit for other cases. |
| Timeline | 5-7 business days for cash. Instant for wallet credit. |
## 6.4 Wallet Balance Refund (Vendor Exit)
| PolicyIf a vendor closes their account and requests a refund of remaining wallet balance, a cash refund of the unused balance (minus any platform processing fee of 2% to cover Razorpay charges) is issued via original payment methods used. Minimum refundable balance: INR 100. |
| --- |
| Attribute | Detail |
| --- | --- |
| Eligibility | Vendor account is closed. No active campaigns. No pending transactions. |
| Minimum balance | INR 100 (10,000 paise). Below this, balance is forfeited. |
| Processing fee | 2% deducted to cover Razorpay charges on the refund transaction. |
| Refund method | Original payment method if traceable. Bank transfer as fallback. |
| Timeline | 7-10 business days. Manual process. |
| Who approves | Admin only. No self-serve exit refund. |
| BONUS credits | Bonus wallet credits (referrals, promotions) are forfeited on exit. Not refunded. |
| GST treatment | Consult CA — prepaid credit refunds have nuanced GST implications. |
## 6.5 Refund Execution via Razorpay API
| // Razorpay refund — backend only, admin-triggered |
| --- |
| async issuePaymentRefund( |
| razorpayPaymentId: string, |
| amountPaise: number, |
| reason: string |
| ): Promise\<void\> \{ |
| |
| const refund = await razorpay.payments.refund(razorpayPaymentId, \{ |
| amount: amountPaise, |
| speed: 'normal', // or 'optimum' for faster |
| notes: \{ reason \}, |
| \}); |
| |
| // Update invoice status |
| await this.prisma.invoice.update(\{ |
| where: \{ razorpayPaymentId \}, |
| data: \{ status: 'VOID', refundId: refund.id, refundedAt: new Date() \}, |
| \}); |
| |
| // Audit log |
| await this.auditLog.create(\{ |
| action: 'REFUND_ISSUED', |
| razorpayRefundId: refund.id, |
| amountPaise, |
| reason, |
| approvedBy: adminUserId, |
| \}); |
| \} |
## 6.6 Refund Policy Summary
| Type | Process | Method | Eligibility |
| --- | --- | --- | --- |
| Failed messages | Automatic, instant | Wallet credit | Always eligible |
| Setup fee | Not refundable | N/A | Non-refundable in all cases |
| First month subscription | Admin approved | Original payment method | Within 3 calendar days of payment only |
| Subscription Month 2+ | Admin approved | Original payment method | Platform error / double-charge only |
| Annual / quarterly sub | Admin approved | Wallet credit or cash | Platform error or admin discretion |
| Wallet balance on exit | Admin approved | Bank transfer | Min INR 100, 2% processing fee |
| Bonus wallet credits | Not refundable | N/A | Forfeited on account exit |
# 7\. Multi-Currency Strategy
Kloyst is India-first but designed for global expansion. The schema and utility layer must be built currency-agnostic from day one. Going global later should be a feature addition, not a rewrite.
## 7.1 Storage Model
| Entity | Storage Strategy |
| --- | --- |
| Vendor wallet | Stored in vendor's billing currency as integer minor units (balanceMinorUnit). Currency field always present alongside amount. |
| Internal books | Every transaction mirrors an INR paise equivalent (amountInrPaise) with the exchange rate used (exchangeRateUsed). Never reconstruct historical conversions. |
| MessagePricing source | Meta publishes in USD. Store metaCostUsdCents. Convert to vendor currency at charge time using cached exchange rate. |
| Invoices | Generated in vendor's billing currency. INR equivalent shown for your internal records. |
## 7.2 Frontend Formatting Utility
One utility function handles all currency display. Never hardcode currency symbols or decimal places anywhere else in the codebase.
| // currency.util.ts — use this everywhere |
| --- |
| export const formatCurrency = ( |
| amountMinorUnit: number, |
| currency: string, |
| locale: string, |
| decimalPlaces: number |
| ): string => \{ |
| const amount = amountMinorUnit / Math.pow(10, decimalPlaces); |
| return new Intl.NumberFormat(locale, \{ |
| style: 'currency', |
| currency, |
| minimumFractionDigits: decimalPlaces, |
| \}).format(amount); |
| \}; |
| |
| // Examples: |
| // formatCurrency(50000, "INR", "en-IN", 2) -> "Rs.500.00" |
| // formatCurrency(5000, "USD", "en-US", 2) -> "$50.00" |
| // formatCurrency(18367, "AED", "ar-AE", 2) -> "AED 183.67" |
| // formatCurrency(5000, "JPY", "ja-JP", 0) -> "5,000" |
## 7.3 Currency Edge Cases
| Currency | Decimal Places | Notes |
| --- | --- | --- |
| JPY (Japanese Yen) | 0 | No subunit. 1 JPY = 1 minor unit. decimalPlaces = 0. |
| KWD (Kuwaiti Dinar) | 3 | 1 KWD = 1000 fils. decimalPlaces = 3. |
| BHD (Bahraini Dinar) | 3 | 1 BHD = 1000 fils. decimalPlaces = 3. |
| INR (Indian Rupee) | 2 | en-IN locale gives correct lakh/crore formatting. |
| USD / AED / GBP | 2 | Standard 2 decimal places. Most common case. |
## 7.4 Build Now vs Build Later
| Phase | Actions |
| --- | --- |
| Build NOW (India-only but future-proof) | Use balanceMinorUnit (not balancePaise) in schema. Add currency field to Wallet and WalletTransaction, hardcoded to "INR" for now. Use formatCurrency() utility. Store exchangeRateUsed = 1 for INR. Set decimalPlaces = 2 in CurrencyConfig. |
| Build WHEN going global | CurrencyConfig table activation. Exchange rate fetching service (frankfurter.app or fixer.io). amountInrPaise mirror on all transactions. Multi-currency invoice templates. Vendor billingCurrency selection at onboarding. |
# 8\. Invoicing & Billing Display
## 8.1 Transaction Bill Layout
Every campaign deduction shows a full breakdown in the vendor's transaction history. Transparency reduces support tickets and builds trust:
| Campaign: Diwali Promo 2026 |
| --- |
| ───────────────────────────────────────────── |
| Conversation category Marketing |
| Contacts reached 500 |
| |
| Meta base rate Rs.0.58 x 500 = Rs.290.00 |
| Platform fee (25%) = Rs.72.50 |
| ───────────────────────────────────────────── |
| Total charged Rs.362.50 |
| |
| Wallet before: Rs.1,606.00 |
| Wallet after: Rs.1,243.50 |
| |
| Failed messages: 3 |
| Refunded: Rs.2.19 |
| Net deducted: Rs.360.31 |
## 8.2 Vendor Wallet Dashboard — Required Elements
| Element | Description |
| --- | --- |
| Current balance | Large, prominent. In vendor's currency. Real-time. |
| Add money button | Opens Razorpay checkout. Suggested amounts: INR 500 / 1,000 / 2,000 / custom. |
| This month summary | Total conversations sent, total spent, conversations remaining in cap. |
| Cap progress bar | Visual: 23,450 / 50,000 conversations used this cycle. |
| Recent transactions | Last 10. Type icon (campaign/recharge/refund), amount, date, balance after. |
| Transaction detail | On click: full breakdown with Meta rate + platform fee + refund if any. |
| Download invoice | PDF download for every recharge and subscription payment. |
## 8.3 Grace Window & Suspension Display
| Stage | UI Behaviour |
| --- | --- |
| Day 1-4 (payment window) | Invoice banner at top of dashboard. Yellow warning. "Payment due in X days." Full service operational. |
| Day 5-7 (grace extension) | Red persistent banner. "Emergency grace period active — please pay immediately." All features remain active. Slack/email to vendor admin. |
| Day 8+ (frozen) | Full-screen overlay on login: "Account frozen — outstanding invoice." All outbound blocked. Incoming messages still received. Support chat still accessible. |
| Post-payment unfreeze | Immediate. Razorpay webhook fires -> status ACTIVE -> overlay removed -> outbound unblocked. |
# 8B. GST Treatment of Wallet Balance
This is a common point of confusion. The wallet balance is virtual prepaid credit held on Kloyst's platform. Its GST treatment is distinct from the subscription and setup fees.
## 8B.1 Short Answer
| Wallet Recharge = No GST at Recharge TimeWhen a vendor adds money to their wallet (e.g. INR 500), that is a prepayment / advance deposit — not a taxable service supply. You are not delivering any service at the time of recharge. GST is not applicable at the point of wallet top-up. The wallet balance is simply virtual credit sitting on your platform. |
| --- |
| Messaging Consumption = Where GST May ApplyWhen the wallet balance is consumed (conversation charges deducted), that is when a taxable service is being supplied — the platform routing and delivery service. However, since the messaging charges are your platform fee (Meta's cost + 25% markup), the GST on that service consumption is what your CA needs to advise on. Most SaaS platforms treat the markup/platform fee portion as taxable supply and Meta's passthrough cost as exempt or outside scope. Get explicit CA guidance before going live. |
| --- |
## 8B.2 Practical Implementation
| Item | GST Treatment |
| --- | --- |
| Wallet recharge invoice | No GST line item. Invoice shows: "Prepaid wallet credit — INR 500.00". Not a taxable supply at this stage. |
| Subscription invoice | GST at 18% applies. This is a clear SaaS service supply. CGST 9% + SGST 9% (intra-state) or IGST 18% (inter-state). |
| Setup fee invoice | GST at 18% applies. One-time service fee for onboarding work performed. |
| Messaging deduction receipts | Show the breakdown (Meta cost + platform fee) but do NOT show GST on per-transaction deduction receipts until CA confirms the treatment. Flag as "tax advisory pending". |
| HSN Code for SaaS | 998313 — Information Technology (IT) design and development services. Used on subscription and setup fee invoices. |
| What to do now | Implement wallet recharge invoices WITHOUT GST. Implement subscription/setup invoices WITH 18% GST. Mark messaging deductions as pending CA advisory on GST. This is safe, conservative, and correct for the invoices you are certain about. |
| CA Advisory Required Before Go-LiveEngage your CA on exactly two questions: (1) Is the platform fee (25% markup) on messaging a taxable SaaS supply requiring GST invoicing per deduction? (2) If yes, is a monthly consolidated tax invoice acceptable instead of per-transaction? Most B2B SaaS platforms in India issue monthly consolidated GST invoices for usage-based charges rather than per-transaction invoices. Your CA can confirm the approach that suits your registration type. |
| --- |
# 9\. Implementation Phases
Four phases, each independently deployable. Phase 1 is critical to start immediately — even without a payment gateway, it gives you real usage data and a proper audit trail for your current 2 vendors.
## Phase 1 — Data Foundation (Week 1-2)
| GoalBuild the data layer. No payment gateway yet. Admin manually credits vendor wallets. Real usage tracked from day one. |
| --- |
| Task | Detail |
| --- | --- |
| Prisma migrations | Create Wallet, WalletTransaction, MessagePricing, Subscription, Invoice, CurrencyConfig tables with all fields as specified in Section 3. |
| Seed MessagePricing | Add current Meta rates for IN (all 4 categories). Set markupPercent = 25. Add effectiveFrom = today. |
| Admin credit endpoint | POST /admin/wallet/credit — manually top up vendor wallet. Used for current 2 vendors immediately. |
| Balance check | Implement pre-send balance check in CampaignService. Block launch if insufficient. |
| Cap check | Implement conversationCap check. Block if cap reached. |
| Wallet deduction | Wire deduction into campaign send flow. Create RESERVED transaction. |
| Webhook refund | Handle FAILED webhook status. Call refundFailedMessage(). Idempotency key. |
| Campaign reconciliation | Post-campaign: release over-reserved balance. Increment conversationsUsed. |
## Phase 2 — Pricing & Alerts (Week 2-3)
| GoalHarden the deduction logic, add conversation window tracking, low-balance alerts, and vendor-facing billing dashboard. |
| --- |
| Task | Detail |
| --- | --- |
| Conversation window tracking | Track 24-hour Meta conversation windows per (vendorId, contactPhone, category). Avoid double-charging within the same window. |
| Low balance email alert | Trigger email + in-app notification when balance < lowBalanceThreshold. Use NestJS scheduler (cron). |
| Subscription status management | ACTIVE / PAST_DUE / FROZEN state machine. Manual triggers for now (Razorpay in Phase 3). |
| Vendor billing dashboard | Frontend: wallet balance, cap progress bar, transaction history, transaction detail modal. |
| Transaction history API | GET /billing/transactions with pagination. Filter by type (CREDIT/DEBIT/REFUND). |
| Conversation cap API | GET /billing/usage — returns conversationsUsed, conversationCap, resetDate, percentUsed. |
## Phase 3 — Payment Gateway (Week 3-4)
| GoalIntegrate Razorpay for self-serve wallet recharges and automated subscription billing. Vendors no longer need admin to top up. |
| --- |
| Task | Detail |
| --- | --- |
| Razorpay account setup | Create Razorpay account. Configure webhook endpoint. Store webhook secret in environment variables. Test with test mode first. |
| Wallet recharge flow | POST /billing/wallet/create-order. Razorpay checkout integration. Webhook handler: payment.captured. Idempotency check. |
| Razorpay Subscriptions | Create Razorpay plans for SMB and Enterprise. Link to Subscription table via razorpaySubId. Handle subscription.charged and subscription.halted webhooks. |
| Setup fee collection | At onboarding: create Razorpay order for setup fee. Block WABA activation until paid. |
| Invoice PDF generation | pdfkit or puppeteer. Store in GCS. Link from Invoice record. Include all GST fields. |
| Refund admin panel | Admin UI: list refund requests. Approve/reject. Trigger Razorpay refund API. Audit log. |
## Phase 4 — Admin Controls & Compliance (Week 4-5)
| GoalFull admin visibility and control over all billing operations. GST compliance groundwork. Pricing config without code deploy. |
| --- |
| Task | Detail |
| --- | --- |
| Admin billing dashboard | View all vendor balances, subscription statuses, outstanding invoices. Filter by status, plan, date. |
| Admin manual credit/debit | Admin endpoint with reason field. Audit logged. Used for goodwill credits, corrections. |
| Pricing config panel | Admin UI to update MessagePricing without deploy. effectiveFrom scheduling. |
| GST invoice fields | vendorGstin collection at onboarding. HSN code 998313 on all invoices. CGST/SGST vs IGST split by state. |
| Idempotency audit | Review all webhook handlers. Ensure every handler has unique idempotency key check. Prevent any double-credit/debit scenario. |
| Exchange rate service | Implement rate fetching from frankfurter.app. Cache 1 hour. ExchangeRate table. Ready for global vendors. |
# 10\. Risk Register & Mitigations
| Risk | Severity | Mitigation | Phase |
| --- | --- | --- | --- |
| Negative wallet balance | High | Pre-reservation in DB transaction before any send. Both balance and cap checks atomic. | Phase 1 |
| Double-credit on recharge | High | Unique idempotencyKey on every Razorpay payment ID. DB unique constraint prevents duplicates. | Phase 3 |
| Race condition on concurrent sends | High | SELECT FOR UPDATE (Prisma.$transaction) on wallet row. Single atomic deduction. | Phase 1 |
| Outdated Meta pricing | High | MessagePricing table with effectiveFrom. Audit Meta pricing page quarterly. Never hardcode. | Phase 1 |
| Webhook spoofing | High | HMAC-SHA256 signature verification on every Razorpay webhook. Reject without valid signature. | Phase 3 |
| Failed messages not refunded | Medium | Explicit FAILED webhook handler with refund logic. Unit tested. Idempotency key prevents double-refund. | Phase 1 |
| Razorpay webhook retry storms | Medium | Idempotency key check before processing. Return 200 on duplicate webhook. Never reprocess. | Phase 3 |
| Subscription lapses silently | Medium | Webhook on payment failure -> PAST_DUE. Cron job audits subscription statuses daily. | Phase 3 |
| GST non-compliance | Medium | Invoice model has all GST fields from day one. CA review before going live. | Phase 4 |
| Currency display bug | Low | Single formatCurrency() utility. Never hardcode symbols. Unit tested for all supported currencies. | Phase 1 |
| GST AdvisorySetup fees and subscriptions attract 18% GST (HSN 998313). Wallet recharges (prepaid credits) have nuanced GST treatment — some interpretations treat them as advance payments, others as non-taxable prepayments. Consult your CA before going live with the payment gateway. Razorpay generates payment receipts but not tax invoices — your system must generate GST-compliant invoices. |
| --- |
# 11\. Quick Reference
## 11.1 Key Decisions Made
| Decision | Resolution |
| --- | --- |
| Storage unit | Integer minor units (balanceMinorUnit). INR uses paise. Never Float. |
| Pricing model | Meta base rate + 25% markup (markupPercent field, configurable per vendor). |
| Wallet model | Prepaid always. Deduct before send. Refund on failure. |
| Subscription cap | Routing capacity limit only. Wallet always charges regardless of cap. |
| Payment gateway | Razorpay (India-first). Stripe considered for global Phase 2. |
| Refund destination | Failed messages -> wallet (instant). All others -> original payment method or wallet credit. |
| Currency display | Always vendor's currency on UI. Paise/minor units internal only. |
| Multi-currency | Schema ready now. Exchange rate service in Phase 4. |
| GST | 18% on subscriptions and setup fees. Wallet recharge treatment — consult CA. |
## 11.2 Environment Variables Required
| # Razorpay |
| --- |
| RAZORPAY_KEY_ID=rzp_live_... |
| RAZORPAY_KEY_SECRET=... |
| RAZORPAY_WEBHOOK_SECRET=... |
| RAZORPAY_PLAN_ID_SMB=plan_... |
| RAZORPAY_PLAN_ID_ENTERPRISE=plan_... |
| |
| # Redis (distributed locking for webhook idempotency) |
| REDIS_URL=redis://... |
| WEBHOOK_LOCK_TTL_SECONDS=30 |
| |
| # Billing config |
| LOW_BALANCE_THRESHOLD_PAISE=20000 # INR 200 |
| GRACE_WINDOW_DAYS=4 |
| GRACE_EXTENSION_DAYS=3 |
| FIRST_MONTH_SUB_REFUND_WINDOW_DAYS=3 |
| |
| # Exchange rates |
| EXCHANGE_RATE_API_URL=https://api.frankfurter.app |
| EXCHANGE_RATE_CACHE_TTL_SECONDS=3600 |
| |
| # GST |
| COMPANY_GSTIN=... |
| COMPANY_HSN_CODE=998313 |
## 11.3 Document Revision History
| Version | Date | Changes |
| --- | --- | --- |
| v1.0 | June 2026 | Initial draft — complete billing module plan including multi-currency, refund policies, and phased roadmap. |
| v1.1 | June 2026 | Updated: Meta rates (1.3), onboarding flow (2.1), setup fee + first-month sub refund policy (6.2), full Redis lock + idempotency webhook pattern (5.1), wallet GST treatment section (8B). |
_End of Document_