Revenue Sharing & Payouts
Mallnline operates as a marketplace where Malet Owners sell through the platform. The Revenue Sharing infrastructure handles automatic commission splitting on every Murchase, routing funds to sellers via Stripe Connect while retaining a tier-based platform fee.
Architecture Overview
Revenue sharing spans two subgraphs:
| Subgraph | Entity | Responsibility |
|---|---|---|
payments |
ConnectedAccount |
Stripe Connect Express onboarding, payout scheduling, balance tracking |
murchases |
CommissionLedger |
Per-Murchase commission calculation, earnings aggregation, refund clawback |
Buyer โ Stripe (Destination Charge) โ Seller's Connected Account
โณ Application Fee โ Mallnline Platform
ConnectedAccount Entity
Each Malet has at most one active ConnectedAccount, linking it to a Stripe Connect Express account.
type ConnectedAccount {
id: ID!
maletId: ID!
userId: ID!
provider: PayoutProvider! # STRIPE_CONNECT (future: PAYSTACK, MPESA, MANUAL)
providerAccountId: String # e.g. acct_xxx
status: ConnectedAccountStatus! # PENDING โ ONBOARDING โ ACTIVE โ SUSPENDED โ DEACTIVATED
payoutSchedule: PayoutSchedule
rollingReservePercent: Float! # Default 10% for new sellers
reserveExpiresAt: DateTime
totalEarnings: Int! # Lifetime (cents)
totalPayouts: Int! # Lifetime (cents)
pendingBalance: Int! # Awaiting next payout (cents)
reserveBalance: Int! # Held in reserve (cents)
currency: String!
onboardingUrl: String # Stripe-hosted, expires after use
kycVerified: Boolean!
}
Account Lifecycle
PENDING โ ONBOARDING โ ACTIVE โ SUSPENDED โ DEACTIVATED
โ โ โ โ
| | | โโโ Chargeback threshold exceeded
| | โโโ charges_enabled + payouts_enabled (webhook)
| โโโ Seller started Stripe Express flow
โโโ Account record created
Stripe Connect Express Onboarding
// 1. Malet Owner clicks "Set Up Payments"
mutation {
createConnectedAccount(input: { maletId: "..." }) {
id
onboardingUrl # Redirect user here
status # PENDING โ ONBOARDING
}
}
// 2. Stripe sends account.updated webhook
// WebhookService delegates to ConnectedAccountService.handleAccountUpdated()
// If charges_enabled + payouts_enabled โ status transitions to ACTIVE
// 3. If link expires, refresh it
mutation {
refreshOnboardingLink(connectedAccountId: "...") {
onboardingUrl
}
}
CommissionLedger Entity
Every Murchase generates a CommissionLedger entry recording the exact commission breakdown.
type CommissionLedger {
id: ID!
orderId: ID!
maletId: ID!
grossAmount: Int! # Total sale (cents)
commissionRate: Float! # e.g. 0.08 (8%)
commissionAmount: Int! # Platform take (cents)
processingFee: Int! # Stripe processing fee estimate
netPayout: Int! # Seller receives (cents)
reserveAmount: Int! # Held back for rolling reserve
currency: String!
status: LedgerStatus! # PENDING โ SETTLED โ REFUNDED โ PARTIALLY_REFUNDED
refundedAmount: Int
refundedCommission: Int
settlementDate: DateTime
}
Commission Calculation
Commission rates are resolved dynamically from the subscription tier โ never hardcoded:
import { getTierLimits } from '@app/common/config/tier-limits';
const tier = 'pro';
const { commissionRate } = getTierLimits(tier);
// commissionRate = 0.05 (5%)
| Tier | Commission Rate | Platform Take on $100 Sale |
|---|---|---|
| Starter | 8% | $8.00 |
| Pro | 5% | $5.00 |
| Enterprise | 3% | $3.00 |
Detailed Breakdown
Gross Sale: $100.00
- Commission (8%): -$8.00
- Processing Fee: -$3.20 (2.9% + $0.30)
- Reserve (10%): -$8.88 (10% of net, held 30 days)
= Net Payout: $79.92
Processing fee constants are stored as module-level variables in CommissionService:
const PROCESSING_FEE_RATE = 0.029; // 2.9%
const PROCESSING_FEE_FIXED = 30; // $0.30 in cents
const PLATFORM_PROCESSING_MARGIN_RATE = 0; // Platform absorbs 0% of processing
Stripe Destination Charges
The StripeHandler uses Destination Charges to route funds:
const paymentIntent = await stripe.paymentIntents.create({
amount: 10000, // $100.00
currency: 'usd',
transfer_data: {
destination: 'acct_xxx', // Seller's connected account
},
application_fee_amount: 800, // $8.00 platform fee
metadata: { orderId: '...' },
});
The Transaction model stores Connect metadata:
connectedAccountId?: string; // acct_xxx
applicationFeeAmount?: number; // 800 (cents)
transferId?: string; // tr_xxx
Rolling Reserve
New sellers have a 10% rolling reserve that holds back a portion of each payout for 30 days to protect against chargebacks.
| Condition | Reserve % | Duration |
|---|---|---|
| New seller (default) | 10% | 90 days from first sale |
| Enterprise tier | 0% | N/A |
| After maturity | 0% | Reserve lifts automatically |
The reserveExpiresAt field on ConnectedAccount tracks when the reserve period ends.
Refund Commission Clawback
When a Murchase is cancelled, the platform proportionally returns the commission to the seller:
// OrderService.updateStatus() โ on CANCELLED:
await this.commissionService.handleRefund(orderId, refundAmount);
The clawback is proportional โ a 40% partial refund returns 40% of the commission:
Original: Gross=$100, Commission=$8, Net=$92
40% Refund: RefundedAmount=$40, RefundedCommission=$3.20
Updated: Commission=$4.80, Net=$55.20
RBAC Permissions
| Permission | Scope | Usage |
|---|---|---|
VIEW_PAYOUTS |
Read connected account, earnings summary | Malet Owners |
MANAGE_PAYOUTS |
Create/update/deactivate connected accounts | Malet Owners |
VIEW_REVENUE |
Read commission ledger | Malet Owners |
MANAGE_COMMISSIONS |
Admin commission adjustments | Platform Admins |
Webhook Events
The payments webhook controller handles these Stripe Connect events:
| Event | Handler | Action |
|---|---|---|
account.updated |
handleAccountUpdated() |
Transition ONBOARDING โ ACTIVE when verified |
transfer.created |
handleTransferCreated() |
Log payout transfer for audit |
GraphQL API
Queries
# Get payout account for a Malet
query {
connectedAccount(maletId: "...") {
status
pendingBalance
totalEarnings
payoutSchedule { interval dayOfWeek }
}
}
# Get earnings summary (aggregated)
query {
earningsSummary(maletId: "...") {
totalGross
totalCommission
totalNetPayouts
pendingBalance
reserveBalance
}
}
# Get commission history (paginated)
query {
commissionLedger(maletId: "...", limit: 20, offset: 0) {
orderId
grossAmount
commissionRate
netPayout
status
}
}
Mutations
# Start Stripe Connect onboarding
mutation {
createConnectedAccount(input: { maletId: "..." }) {
onboardingUrl
}
}
# Update payout schedule
mutation {
updatePayoutSchedule(input: {
connectedAccountId: "..."
interval: WEEKLY
dayOfWeek: 1
}) { id payoutSchedule { interval dayOfWeek } }
}
Frontend Dashboard
The Malet Owner earnings dashboard is located at /{maletHandle}/manage/payouts and includes:
- PayoutOverview โ KPI cards (total earnings, pending balance, next payout, commission rate)
- StripeConnectOnboarding โ Gradient CTA card for first-time Stripe Express setup
- CommissionTable โ Paginated per-Murchase commission history with status pills
- PayoutSettings โ Schedule configuration, KYC status, rolling reserve info
The dashboard conditionally renders the onboarding flow for new accounts or the full dashboard for active accounts.
Provider Abstraction
The ConnectedAccount model includes a PayoutProvider enum supporting future payment providers:
enum PayoutProvider {
STRIPE_CONNECT // Phase 1 (current)
PAYSTACK_SUBACCOUNT // Phase 2 (West Africa)
MPESA_DIRECT // Phase 3 (East Africa)
MANUAL // Fallback
}
The PaymentHandler interface accepts optional ConnectPaymentOptions to keep the payment flow provider-agnostic.
Related
- Subscription & Billing โ Tier configuration that determines commission rates
- Custom RBAC โ Permission model for VIEW_PAYOUTS, MANAGE_PAYOUTS, VIEW_REVENUE
- Alerts & Resilience โ Notification infrastructure for payout events
- Management Dashboard โ Malet Owner management interface where the payouts tab lives