Skip to content

PROVIDER organizations — complete technical implementation plan #662

@teetangh

Description

@teetangh

PROVIDER Organizations — Technical Implementation Plan

What is a PROVIDER org?

A PROVIDER organization is a consultant agency that hosts multiple consultants under its umbrella. Unlike BUYER orgs (schools/corporates that consume sessions), PROVIDER orgs supply services — the org recruits consultants, manages their catalog, and captures a slice of every booking via a 3-way revenue split:

Payment (100%)
├── Platform fee (10% default, configurable per org)
├── Org retain (5% default, configurable per org)
└── Consultant payout (85% default, configurable per org)
    └── Can be overridden per-consultant via customConsultantPayoutRate

Example: A ₹10,000 consultation booked through a PROVIDER org consultant:

  • Platform: ₹1,000 (10%)
  • Org: ₹500 (5%)
  • Consultant: ₹8,500 (85%)

Current state

PR #655 (feature/enterprise) shipped the complete PROVIDER foundation:

Already built (gated behind ENABLE_PROVIDER_ORGS=false)

Schema — all models exist in prisma/schema.prisma:

  • OrganizationProfile.kind supports PROVIDER and HYBRID values
  • OrganizationProfile has platformCommissionRate (0.10), orgRetainRate (0.05), consultantPayoutRate (0.85), autoApproveConsultants
  • OrganizationMemberProfile.customConsultantPayoutRate — per-consultant rate override
  • OrganizationPayoutAccount — org's bank account for receiving payouts
  • OrganizationPayout — payout history (parallels ConsultantPayout)
  • OrganizationEarnings — org's share per payment (parallels ConsultantEarnings)
  • OrgMemberRole includes ORG_CONSULTANT (rank 40) and ORG_SUPPORT (rank 30)

API routes — all return 501 when flag is off:

  • POST /api/organizations — rejects kind=PROVIDER or kind=HYBRID
  • POST /api/organizations/[orgId]/members — rejects role=ORG_CONSULTANT or ORG_SUPPORT
  • PATCH /api/organizations/[orgId]/members/[memberId] — rejects role changes to ORG_CONSULTANT/ORG_SUPPORT
  • POST /api/organizations/[orgId]/invitations — rejects ORG_CONSULTANT/ORG_SUPPORT invitation roles
  • POST /api/organizations/invitations/accept — rejects acceptance of gated roles
  • GET /api/organizations/[orgId]/consultants — lists ORG_CONSULTANT members
  • GET/POST /api/organizations/[orgId]/payouts — org payout list + batch creation
  • GET/PUT/DELETE /api/organizations/[orgId]/payout-account — bank account CRUD

Dashboard pages:

  • /dashboard/organization/[orgId]/consultants/page.tsx — renders "Provider tier required" lock card when 501
  • /dashboard/organization/[orgId]/payouts/page.tsx — same lock card
  • Sidebar conditionally shows Consultants + Payouts nav items for PROVIDER/HYBRID orgs

Role gating:

  • PROVIDER_GATED_ROLES = ["ORG_CONSULTANT", "ORG_SUPPORT"] checked in 4 route files
  • Role hierarchy already ranks ORG_CONSULTANT (40) and ORG_SUPPORT (30) between ORG_MANAGER (60) and ORG_LEARNER (20)

NOT yet built

  1. 3-way earnings splitlib/payments/payouts/earnings-service.ts has zero PROVIDER logic
  2. Org payout batch creation — POST payouts returns 501 even when flag is on
  3. Payout processing pipeline — no OrganizationPayout row creation or gateway disbursement
  4. Payout account encryption — currently base64 stub, needs real encryption
  5. Seed data — no PROVIDER orgs, no ORG_CONSULTANT members seeded
  6. PROVIDER onboarding — no signup path for agency owners (ORG_ADMIN onboarding only creates BUYER)
  7. Consultant application/approval workflowautoApproveConsultants field exists but not enforced
  8. HYBRID org behavior — no explicit handling for orgs that are both BUYER and PROVIDER

Implementation plan

Phase 1: Flip the flag + verify existing 501-gated routes (1-2 hours)

Objective: Set ENABLE_PROVIDER_ORGS=true and verify all gated routes become functional.

Steps:

  1. Set ENABLE_PROVIDER_ORGS=true in .env
  2. Test each currently-gated route:
    • POST /api/organizations with kind: "PROVIDER" → should succeed
    • POST /api/organizations/[orgId]/members with role: "ORG_CONSULTANT" → should succeed
    • POST /api/organizations/[orgId]/invitations with role: "ORG_CONSULTANT" → should succeed
    • POST /api/organizations/invitations/accept for ORG_CONSULTANT invitation → should succeed
    • GET /api/organizations/[orgId]/consultants → should return ORG_CONSULTANT members
    • GET /api/organizations/[orgId]/payouts → should return (empty) payouts list
    • GET/PUT/DELETE /api/organizations/[orgId]/payout-account → should CRUD
  3. Verify dashboard pages render real data instead of lock cards
  4. Verify sidebar shows Consultants + Payouts nav for PROVIDER orgs
  5. Test rate-sum validation: PATCH org with rates that don't sum to 1.0 → should 400

Files touched: .env only (no code changes for this phase)

Phase 2: 3-way earnings split (4-6 hours)

Objective: When a payment is made for a booking through a PROVIDER org consultant, create both ConsultantEarnings (consultant's share) AND OrganizationEarnings (org's share) with the correct split.

File: lib/payments/payouts/earnings-service.ts

Implementation:

In createEarningsFromPayment(), after the existing platform fee calculation:

// After calculating platformFee and before creating ConsultantEarnings:

// Check if this payment is tagged to a PROVIDER/HYBRID org
if (payment.organizationProfileId) {
  const orgProfile = await prisma.organizationProfile.findUnique({
    where: { id: payment.organizationProfileId },
    select: { kind: true, platformCommissionRate: true, orgRetainRate: true, consultantPayoutRate: true },
  });

  if (orgProfile && (orgProfile.kind === "PROVIDER" || orgProfile.kind === "HYBRID")) {
    // Check for per-consultant rate override
    const memberProfile = await prisma.organizationMemberProfile.findFirst({
      where: {
        organizationProfileId: orgProfile.id,
        consultantProfileId: consultantProfile.id,
        role: "ORG_CONSULTANT",
        status: "ACTIVE",
      },
      select: { customConsultantPayoutRate: true },
    });

    const effectiveConsultantRate = memberProfile?.customConsultantPayoutRate ?? orgProfile.consultantPayoutRate;
    const effectiveOrgRate = orgProfile.orgRetainRate;
    const effectivePlatformRate = orgProfile.platformCommissionRate;

    // Calculate splits
    const grossAmount = payment.originalAmount; // pre-discount amount
    const platformFee = Math.round(grossAmount * effectivePlatformRate);
    const orgShare = Math.round(grossAmount * effectiveOrgRate);
    const consultantShare = grossAmount - platformFee - orgShare; // remainder to consultant

    // Create OrganizationEarnings
    await prisma.organizationEarnings.create({
      data: {
        organizationProfileId: orgProfile.id,
        paymentId: payment.id,
        grossAmount,
        platformFee,
        orgShare,
        refundedAmount: 0,
        status: "PENDING",
        holdUntil: calculateHoldUntil(), // reuse existing hold logic
        currency: payment.currency,
      },
    });

    // Create ConsultantEarnings with the reduced consultant share
    // (instead of the standard 80/20 split)
    await prisma.consultantEarnings.create({
      data: {
        consultantProfileId: consultantProfile.id,
        paymentId: payment.id,
        grossAmount,
        platformFee,
        consultantShare,
        refundedAmount: 0,
        status: "PENDING",
        holdUntil: calculateHoldUntil(),
        currency: payment.currency,
      },
    });

    return; // Skip the standard BUYER earnings path
  }
}

// ... existing BUYER earnings logic (unchanged)

Edge cases:

  • Collaborator splits (webinars/classes with multiple consultants): the org's share stays fixed; the consultant share is split among collaborators proportionally (same as existing collaborator split logic)
  • customConsultantPayoutRate overrides only the consultant's share; org + platform shares adjust accordingly (or: org share stays fixed, consultant+platform adjust — decide policy)
  • When ENABLE_PROVIDER_ORGS is false but org.kind === PROVIDER (shouldn't happen since creation is gated): fall through to BUYER path

Also update:

  • refundEarnings() — when refunding a payment with OrganizationEarnings, reverse the org earnings proportionally
  • Webhook handlePaymentSuccess() — no changes needed (it calls createEarningsFromPayment which handles the branching)

Phase 3: Org payout batch creation + processing (4-6 hours)

Objective: Allow org owners to create payout batches for accumulated org earnings, and process them via the existing payout pipeline.

New file: lib/payments/payouts/org-payout-service.ts

export async function createOrgPayoutBatch(organizationProfileId: string): Promise<string> {
  // 1. Find all OrganizationEarnings where status = "READY" and holdUntil < now()
  // 2. Sum the orgShare amounts
  // 3. Create an OrganizationPayout row with the total
  // 4. Update all included earnings to status = "PAID" and link payoutId
  // 5. Return the payout ID
}

export async function processOrgPayout(payoutId: string): Promise<void> {
  // 1. Load the OrganizationPayout + linked OrganizationPayoutAccount
  // 2. Call Razorpay Contacts + Fund Account API (RazorpayX) OR
  //    create a manual bank transfer record
  // 3. Update payout status to PROCESSING → COMPLETED (via webhook) or FAILED
}

Wire into: POST /api/organizations/[orgId]/payouts — replace the 501 stub with:

const batchId = await createOrgPayoutBatch(access.org.id);
return NextResponse.json({ batchId }, { status: 201 });

Dashboard update: /dashboard/organization/[orgId]/payouts/page.tsx — replace the lock card with:

  • Payout history table (similar to admin payouts page)
  • "Create payout batch" button (ORG_OWNER only)
  • Payout status badges (PENDING/PROCESSING/COMPLETED/FAILED)
  • Period date range display

Phase 4: Payout account encryption (2-3 hours)

Objective: Replace the base64 stub with real encryption for bank account numbers.

Current state: app/api/organizations/[orgId]/payout-account/route.ts line 71:

const accountNumberEncrypted = Buffer.from(accountNumber).toString("base64");

Implementation:

  • Use libsodium-wrappers (already in package.json) for symmetric encryption
  • Server-side encryption key from PAYOUT_ENCRYPTION_KEY env var
  • encryptAccountNumber(accountNumber) → returns encrypted + last4
  • decryptAccountNumber(encrypted) → returns plaintext (for admin view / payout processing)
  • The encrypted value is stored in accountNumberEncrypted; accountNumberLast4 stays plaintext for display

Phase 5: Consultant application/approval workflow (3-4 hours)

Objective: When a consultant wants to join a PROVIDER org, the org can auto-approve or require manual review.

Schema field: OrganizationProfile.autoApproveConsultants (already exists, default false)

Implementation:

  • When POST /api/organizations/[orgId]/members adds an ORG_CONSULTANT:
    • If autoApproveConsultants === true: set status = ACTIVE immediately
    • If autoApproveConsultants === false: set status = PENDING
  • Same logic for invitation acceptance with role = ORG_CONSULTANT
  • Dashboard members page: show "Pending" badge for PENDING consultants + "Approve" button (ORG_ADMIN+)
  • New API: PATCH /api/organizations/[orgId]/members/[id] with status: "ACTIVE" to approve

The PATCH route already supports status changes — just need the frontend UI for the approval flow.

Phase 6: PROVIDER onboarding + seed data (2-3 hours)

Objective: Allow agency owners to sign up as PROVIDER orgs and seed realistic PROVIDER test data.

Onboarding:

  • The existing ORG_ADMIN onboarding creates BUYER orgs. Extend OrgAdminOrgSetupStep to show a kind selector (BUYER / PROVIDER) when the flag is on.
  • When PROVIDER is selected, hide the billing mode selector (PROVIDER orgs don't use TAG_ONLY/SEAT_PACK/INVOICED_MONTHLY — they receive payouts, not invoices)
  • Show rate configuration fields (platform/org/consultant %) instead

Seed data (prisma/seedFiles/15a-create-organizations.ts):

  • Add PROVIDER org creation when ENABLE_PROVIDER_ORGS === "true"
  • Seed ORG_CONSULTANT members linked to existing ConsultantProfiles
  • Seed OrganizationPayoutAccount with test bank details
  • Seed OrganizationEarnings for a few payments
  • Seed OrganizationPayout with COMPLETED status

Phase 7: HYBRID org behavior (1-2 hours)

Objective: Define and implement how HYBRID orgs work — they are both BUYER and PROVIDER simultaneously.

Use case: A university that both sponsors training for students (BUYER) AND hosts its professors as consultants (PROVIDER).

Implementation:

  • HYBRID orgs see ALL nav items: Learners + Consultants + Billing + Payouts + Credits
  • Both billing modes AND payout flows are active
  • Members can be ORG_LEARNER (consuming sessions) or ORG_CONSULTANT (providing sessions)
  • Earnings split applies to bookings made through ORG_CONSULTANT members
  • Standard BUYER billing applies to bookings by ORG_LEARNER members

Most of this is already handled by the isProviderOrHybrid / isBuyerOrHybrid checks in the dashboard layout. The main work is verifying the earnings split and payout flows handle HYBRID correctly.


Verification checklist

  • POST /api/organizations with kind=PROVIDER succeeds when flag is on
  • POST /api/organizations/[orgId]/members with role=ORG_CONSULTANT succeeds
  • Consultant is linked via consultantProfileId on OrganizationMemberProfile
  • Booking through a PROVIDER org consultant creates both ConsultantEarnings + OrganizationEarnings
  • Rate split: sum of platform + org + consultant = payment.originalAmount
  • customConsultantPayoutRate overrides the default when set
  • Org payout batch aggregates READY earnings correctly
  • Payout account CRUD works with encrypted bank details
  • autoApproveConsultants=false → new ORG_CONSULTANT starts as PENDING
  • Dashboard: Consultants page lists real ORG_CONSULTANT members
  • Dashboard: Payouts page shows payout history + "Create batch" button
  • HYBRID org shows both Learners + Consultants + Billing + Payouts
  • Refund on PROVIDER booking reverses both ConsultantEarnings + OrganizationEarnings
  • Seed data creates realistic PROVIDER orgs with consultants and earnings

Dependencies

Phase 1 (flip flag) → no deps, first step
Phase 2 (earnings split) → depends on Phase 1
Phase 3 (payout batch) → depends on Phase 2
Phase 4 (encryption) → independent, can parallelize with Phase 2
Phase 5 (approval workflow) → depends on Phase 1
Phase 6 (onboarding + seed) → depends on Phase 1
Phase 7 (HYBRID) → depends on Phase 2 + Phase 3

Estimated total effort

~18-24 hours of focused implementation, split across 7 phases. Phase 2 (earnings split) is the most critical and complex piece.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    EnterpriseEnterprise tier — B2B org features, Architecture 4enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions