diff --git a/.github/workflows/cleanup-abandoned-payments.yml b/.github/workflows/cleanup-abandoned-payments.yml index fc3522459..18d694547 100644 --- a/.github/workflows/cleanup-abandoned-payments.yml +++ b/.github/workflows/cleanup-abandoned-payments.yml @@ -26,8 +26,6 @@ jobs: STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} RAZORPAY_KEY_ID: ${{ secrets.RAZORPAY_KEY_ID }} RAZORPAY_SECRET: ${{ secrets.RAZORPAY_SECRET }} - LEMON_SQUEEZY_API_KEY: ${{ secrets.LEMON_SQUEEZY_API_KEY }} - XFLOW_SECRET_KEY: ${{ secrets.XFLOW_SECRET_KEY }} steps: - name: Checkout code diff --git a/.github/workflows/detect-consultant-no-shows.yml b/.github/workflows/detect-consultant-no-shows.yml new file mode 100644 index 000000000..36407ea6c --- /dev/null +++ b/.github/workflows/detect-consultant-no-shows.yml @@ -0,0 +1,53 @@ +name: Detect Consultant No-Shows + +on: + schedule: + # Run hourly (offset to avoid colliding with other :00/:07 crons) + - cron: "17 * * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + detect-consultant-no-shows: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + # Database connection (required for Prisma) + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 cron locks load lib/redis at import — every job entry needs these + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + # #471 auto-refund reuses refundPayment (#990) — gateway creds for parity + # with the other money jobs (reconcile-pending-refunds). + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + RAZORPAY_KEY_ID: ${{ secrets.RAZORPAY_KEY_ID }} + RAZORPAY_SECRET: ${{ secrets.RAZORPAY_SECRET }} + # Both-party no-show + refund notifications + NOVU_SECRET_KEY: ${{ secrets.NOVU_SECRET_KEY }} + NEXT_PUBLIC_APP_URL: ${{ secrets.NEXT_PUBLIC_APP_URL }} + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npx prisma generate + + - name: Detect consultant no-shows + run: npx tsx jobs/appointments/detect-consultant-no-shows.ts + + - name: Notify on failure + if: failure() + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + run: bash scripts/ci/notify-ops-failure.sh "detect-consultant-no-shows" diff --git a/__tests__/booking-algorithm/rescheduleCancel.test.ts b/__tests__/booking-algorithm/rescheduleCancel.test.ts index 0e3062372..447de4088 100644 --- a/__tests__/booking-algorithm/rescheduleCancel.test.ts +++ b/__tests__/booking-algorithm/rescheduleCancel.test.ts @@ -216,6 +216,10 @@ function makeMockTx() { update: jest.fn(), // B2 — the cancel/reschedule CAS guards use updateMany. updateMany: jest.fn().mockResolvedValue({ count: 1 }), + // #448 — a PARTIAL (slotIds) subscription reschedule only terminal-guards + // via count (no status write); a positive count means the from-state is + // still reschedulable so the route proceeds without flipping to PENDING. + count: jest.fn().mockResolvedValue(1), }, webinar: { update: jest.fn(), diff --git a/__tests__/booking-algorithm/rescheduleResponses.test.ts b/__tests__/booking-algorithm/rescheduleResponses.test.ts index 01dd08463..e3ef3c024 100644 --- a/__tests__/booking-algorithm/rescheduleResponses.test.ts +++ b/__tests__/booking-algorithm/rescheduleResponses.test.ts @@ -155,6 +155,10 @@ function makeMockTx(appointmentData: any) { update: jest.fn(), // B2 — the cancel/reschedule CAS guards use updateMany. updateMany: jest.fn().mockResolvedValue({ count: 1 }), + // #448 — a PARTIAL (slotIds) subscription reschedule only terminal-guards + // via count (no status write); positive count keeps the route on the + // happy path without flipping the whole subscription to PENDING. + count: jest.fn().mockResolvedValue(1), }, webinar: { update: jest.fn(), diff --git a/__tests__/booking-algorithm/slotAllocationService.test.ts b/__tests__/booking-algorithm/slotAllocationService.test.ts index bc06fc2c1..346294591 100644 --- a/__tests__/booking-algorithm/slotAllocationService.test.ts +++ b/__tests__/booking-algorithm/slotAllocationService.test.ts @@ -2213,8 +2213,12 @@ describe("Manual allocation - distributed lock", () => { slots: ["2025-01-06T10:00:00Z", "2025-01-06T10:30:00Z"], }); - // Lock should have been acquired with the consultant profile ID - expect(lockAutoAllocate).toHaveBeenCalledWith("consultant-profile-1"); + // Lock should have been acquired with the consultant profile ID, + // day-sharded (#860) by the earliest target slot's day. + expect(lockAutoAllocate).toHaveBeenCalledWith( + "consultant-profile-1", + "2025-01-06", + ); // Lock should have been released in finally block expect(unlockAutoAllocate).toHaveBeenCalled(); }); diff --git a/__tests__/enterprise/live-payout-submission.test.ts b/__tests__/enterprise/live-payout-submission.test.ts index 40aac953c..2e08dc4d1 100644 --- a/__tests__/enterprise/live-payout-submission.test.ts +++ b/__tests__/enterprise/live-payout-submission.test.ts @@ -280,10 +280,12 @@ describe("processOrgPayout — live submission gating", () => { }), ); - // Earnings release: PAID → READY, orgPayoutId nulled. + // #993 — a PROCESSING→FAILED submission never reached PAID: batch creation + // staged the earnings READY→BATCHED, so the failure release is BATCHED→READY + // (orgPayoutId nulled), not PAID→READY. expect(mockedPrisma.organizationEarnings.updateMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { orgPayoutId: PAYOUT_ID, status: "PAID" }, + where: { orgPayoutId: PAYOUT_ID, status: "BATCHED" }, data: { status: "READY", orgPayoutId: null }, }), ); diff --git a/__tests__/payments/capture-amount-parity.test.ts b/__tests__/payments/capture-amount-parity.test.ts index a1ad1e8aa..1570fb085 100644 --- a/__tests__/payments/capture-amount-parity.test.ts +++ b/__tests__/payments/capture-amount-parity.test.ts @@ -3,15 +3,17 @@ */ /** - * #677 — defence-in-depth capture-amount parity in handlePaymentSuccess. + * #677 / #990 — defence-in-depth capture-amount parity in handlePaymentSuccess. * * The gateway order is created at checkout for exactly Payment.amount and the * webhook is HMAC-verified, so a captured amount that differs is a gateway - * anomaly or our-own bug. handlePaymentSuccess must NOT silently confirm the - * booking — it marks the payment REQUIRES_MANUAL_RECOVERY, pages (Sentry), and - * returns before confirming. (The matching-amount happy path is exercised - * end-to-end by the live signed-webhook verification; here we pin the new guard, - * whose mismatch branch returns early — before any Phase-2 work.) + * anomaly or our-own bug. handlePaymentSuccess must NOT confirm the booking. + * #990 changed the remediation: Phase 1 pages (Sentry, fatal) and stamps + * REQUIRES_MANUAL_RECOVERY as a FALLBACK marker, then Phase 2 AUTO-REFUNDS the + * wrong-amount capture via refundPayment and clears the marker. The manual + * marker only survives if the refund call itself throws. Either way the booking + * is never confirmed (no appointment lookup, no earnings, no Phase-2 confirm + * work). The matching-amount happy path is inert on the guard. */ const captureException = jest.fn(); @@ -35,9 +37,17 @@ const txStub = { payment: { findUnique: paymentFindUnique, update: paymentUpdate }, appointment: { findUnique: appointmentFindUnique }, }; +// #990 — the Phase-2 clear-marker write runs on the base client (outside the +// tx). Give it its own update mock so the auto-refund success path completes. +const prismaPaymentUpdate = jest.fn( + async (_args: { where: unknown; data: { description?: string } }) => ({}), +); jest.mock("../../lib/prisma", () => ({ __esModule: true, - default: { $transaction: async (fn: (tx: unknown) => unknown) => fn(txStub) }, + default: { + $transaction: async (fn: (tx: unknown) => unknown) => fn(txStub), + payment: { update: (...a: unknown[]) => prismaPaymentUpdate(...(a as [never])) }, + }, })); // Side-effectful import graph — present only so the module loads; the mismatch @@ -46,7 +56,10 @@ const createEarningsFromPayment = jest.fn(); jest.mock("../../lib/payments/payouts", () => ({ createEarningsFromPayment: (...a: unknown[]) => createEarningsFromPayment(...a), })); -jest.mock("../../lib/payments/operations/refund", () => ({ refundPayment: jest.fn() })); +const refundPayment = jest.fn(); +jest.mock("../../lib/payments/operations/refund", () => ({ + refundPayment: (...a: unknown[]) => refundPayment(...a), +})); jest.mock("../../lib/email", () => ({ sendPaymentSuccessEmail: jest.fn(), sendPaymentFailedEmail: jest.fn(), @@ -92,22 +105,60 @@ beforeEach(() => { }); }); -describe("#677 — handlePaymentSuccess capture-amount parity", () => { - it("blocks confirmation + pages when the captured amount ≠ Payment.amount", async () => { +describe("#677 / #990 — handlePaymentSuccess capture-amount parity", () => { + it("blocks confirmation, pages, and AUTO-REFUNDS when captured amount ≠ Payment.amount", async () => { + refundPayment.mockResolvedValue({ id: "rfnd1" }); + await handlePaymentSuccess("order1", { appointmentType: "CONSULTATION" }, 9999); - // Paged with the mismatch error. + // Paged exactly once with the mismatch error (the fatal Phase-1 page). No + // second page fires because the refund succeeds. expect(captureException).toHaveBeenCalledTimes(1); expect(String(captureException.mock.calls[0][0])).toContain( "Capture amount mismatch", ); - // Marked for manual recovery (and NOT a normal confirmation). + // Phase 1 stamped the REQUIRES_MANUAL_RECOVERY fallback marker (in-tx). expect(paymentUpdate).toHaveBeenCalledTimes(1); const update = paymentUpdate.mock.calls[0][0]; expect(update.data.description).toContain("REQUIRES_MANUAL_RECOVERY"); - // Returned before confirming the booking or doing any Phase-2 work. + // #990 — Phase 2 auto-refunded the wrong-amount capture for this payment. + expect(refundPayment).toHaveBeenCalledTimes(1); + expect(refundPayment.mock.calls[0][0]).toMatchObject({ + paymentId: "pay1", + initiatedByUserId: null, + }); + + // …then cleared the fallback marker on the base client (refund succeeded). + expect(prismaPaymentUpdate).toHaveBeenCalledTimes(1); + expect(prismaPaymentUpdate.mock.calls[0][0].data.description).toContain( + "Auto-refunded", + ); + + // Booking was never confirmed and no Phase-2 confirm work ran. + expect(appointmentFindUnique).not.toHaveBeenCalled(); + expect(createEarningsFromPayment).not.toHaveBeenCalled(); + }); + + it("keeps REQUIRES_MANUAL_RECOVERY + pages twice when the auto-refund itself fails", async () => { + // #990 fallback: if refundPayment throws, the manual-recovery marker is NOT + // cleared (no clear-marker write) and the refund failure is paged too. + refundPayment.mockRejectedValue(new Error("gateway 500")); + + await handlePaymentSuccess("order1", { appointmentType: "CONSULTATION" }, 9999); + + // Two pages: the Phase-1 mismatch page + the Phase-2 refund-failure page. + expect(captureException).toHaveBeenCalledTimes(2); + expect(String(captureException.mock.calls[0][0])).toContain( + "Capture amount mismatch", + ); + + // The REQUIRES_MANUAL_RECOVERY marker survives (clear-marker write skipped). + expect(refundPayment).toHaveBeenCalledTimes(1); + expect(prismaPaymentUpdate).not.toHaveBeenCalled(); + + // Still no booking confirmation. expect(appointmentFindUnique).not.toHaveBeenCalled(); expect(createEarningsFromPayment).not.toHaveBeenCalled(); }); diff --git a/__tests__/payments/stuck-payouts-money-handler.test.ts b/__tests__/payments/stuck-payouts-money-handler.test.ts index 74b0f7897..026aa70c3 100644 --- a/__tests__/payments/stuck-payouts-money-handler.test.ts +++ b/__tests__/payments/stuck-payouts-money-handler.test.ts @@ -54,7 +54,9 @@ const PAYOUT: Row = { tdsDeducted: 1000, tdsRateAppliedBps: 100, tdsFinancialYear: "2026-27", - earnings: [{ id: "ce_1", payoutId: "po_1", status: "READY" }], + // #993 — a PROCESSING payout's earnings were staged READY→BATCHED at batch + // creation; the COMPLETED webhook flips BATCHED→PAID. + earnings: [{ id: "ce_1", payoutId: "po_1", status: "BATCHED" }], }; let payoutRow: Row; @@ -96,7 +98,7 @@ import { handlePayoutWebhook } from "../../lib/payments/payouts/payout-service"; beforeEach(() => { jest.clearAllMocks(); - payoutRow = { ...PAYOUT, earnings: [{ id: "ce_1", payoutId: "po_1", status: "READY" }] }; + payoutRow = { ...PAYOUT, earnings: [{ id: "ce_1", payoutId: "po_1", status: "BATCHED" }] }; }); describe("PM-15 — handlePayoutWebhook records TDS + ledger on COMPLETED", () => { @@ -119,11 +121,13 @@ describe("PM-15 — handlePayoutWebhook records TDS + ledger on COMPLETED", () = idempotencyKey: "payout:po_1", }); - // Canonical state landed: COMPLETED + earnings PAID. + // Canonical state landed: COMPLETED + earnings BATCHED→PAID. #993 — the PAID + // flip is CAS-guarded on the BATCHED from-state (staged at batch creation), + // so only genuinely-batched earnings settle at completion. expect(payoutRow.status).toBe("COMPLETED"); expect(prismaStub.consultantEarnings.updateMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { payoutId: "po_1" }, + where: { payoutId: "po_1", status: "BATCHED" }, data: expect.objectContaining({ status: "PAID" }), }), ); diff --git a/__tests__/stream/__mocks__/stream-mocks.ts b/__tests__/stream/__mocks__/stream-mocks.ts index 29c14f45b..dc7a6dc01 100644 --- a/__tests__/stream/__mocks__/stream-mocks.ts +++ b/__tests__/stream/__mocks__/stream-mocks.ts @@ -98,7 +98,7 @@ export const createMockRoleMapper = () => ({ ADMIN: "admin", CONSULTANT: "user", CONSULTEE: "user", - STAFF: "user", + STAFF: "admin", }; return mapping[role] || "user"; }), diff --git a/__tests__/stream/channel-actions.test.ts b/__tests__/stream/channel-actions.test.ts index 0616aa97b..bebdfc9cc 100644 --- a/__tests__/stream/channel-actions.test.ts +++ b/__tests__/stream/channel-actions.test.ts @@ -39,11 +39,27 @@ jest.mock("../../actions/stream/chat/user.action", () => ({ upsertUsersToStream: jest.fn().mockResolvedValue({ users: {} }), })); +// #899 — addMemberToChannel is session-gated; mocking auth-server also keeps +// jest away from lib/auth's better-auth ESM imports. Default: privileged. +const mockGetSession = jest.fn(); +jest.mock("../../lib/auth-server", () => ({ + getSession: () => mockGetSession(), +})); + +// auth-helpers imports next/server (NextResponse), which needs the fetch +// globals jest's node env lacks — mirror the real one-liner instead. +jest.mock("../../lib/auth-helpers", () => ({ + isPrivileged: (role?: string | null) => role === "ADMIN" || role === "STAFF", +})); + describe("Channel Actions", () => { beforeEach(() => { jest.clearAllMocks(); mockStreamClient.channel.mockReturnValue(mockChannel); mockStreamClient.queryChannels.mockResolvedValue([]); + mockGetSession.mockResolvedValue({ + user: { id: "staff-user", role: "ADMIN" }, + }); }); describe("createChannel", () => { @@ -264,6 +280,63 @@ describe("Channel Actions", () => { await expect(addMemberToChannel("", "user")).rejects.toThrow(); await expect(addMemberToChannel("channel", "")).rejects.toThrow(); }); + + // #899 — server-side Stream calls bypass Stream's permission system, so + // the app-layer guard is the only gate. + it("should reject unauthenticated callers", async () => { + mockGetSession.mockResolvedValueOnce(null); + + const { addMemberToChannel } = + await import("../../actions/stream/chat/channel.action"); + + await expect( + addMemberToChannel("consultation-123", "new-user-id"), + ).rejects.toThrow("Unauthorized"); + expect(mockChannel.addMembers).not.toHaveBeenCalled(); + }); + + it("should reject a non-privileged caller who is not the creator", async () => { + mockGetSession.mockResolvedValueOnce({ + user: { id: "random-user", role: "CONSULTEE" }, + }); + // mockReset flushes unconsumed query Onces leaked from earlier tests + // (clearAllMocks doesn't), which would otherwise shift this value + mockChannel.query.mockReset(); + mockChannel.query.mockResolvedValue({ + channel: { created_by: { id: "someone-else" } }, + }); + + const { addMemberToChannel } = + await import("../../actions/stream/chat/channel.action"); + + await expect( + addMemberToChannel("consultation-123", "new-user-id"), + ).rejects.toThrow("Forbidden"); + expect(mockChannel.addMembers).not.toHaveBeenCalled(); + expect(mockChannel.create).not.toHaveBeenCalled(); + }); + + it("should allow the channel creator without lazy channel creation", async () => { + mockGetSession.mockResolvedValueOnce({ + user: { id: "creator-user", role: "CONSULTANT" }, + }); + mockChannel.query.mockReset(); + mockChannel.query.mockResolvedValue({ + channel: { created_by: { id: "creator-user" } }, + }); + + const { addMemberToChannel } = + await import("../../actions/stream/chat/channel.action"); + + const result = await addMemberToChannel( + "consultation-123", + "new-user-id", + ); + + expect(result.success).toBe(true); + expect(mockChannel.addMembers).toHaveBeenCalledWith(["new-user-id"]); + expect(mockChannel.create).not.toHaveBeenCalled(); + }); }); }); @@ -637,6 +710,9 @@ describe("addMemberToChannel error handling", () => { beforeEach(() => { jest.clearAllMocks(); mockStreamClient.channel.mockReturnValue(mockChannel); + mockGetSession.mockResolvedValue({ + user: { id: "staff-user", role: "ADMIN" }, + }); }); it("should throw error when addMembers fails", async () => { diff --git a/__tests__/stream/types.test.ts b/__tests__/stream/types.test.ts index a73016abc..e6dd1e181 100644 --- a/__tests__/stream/types.test.ts +++ b/__tests__/stream/types.test.ts @@ -164,7 +164,7 @@ describe("Stream Chat Types", () => { ADMIN: "admin", CONSULTANT: "user", CONSULTEE: "user", - STAFF: "user", + STAFF: "admin", }; return mapping[role] || "user"; }; @@ -172,7 +172,7 @@ describe("Stream Chat Types", () => { expect(mapRoleToStream("ADMIN")).toBe("admin"); expect(mapRoleToStream("CONSULTANT")).toBe("user"); expect(mapRoleToStream("CONSULTEE")).toBe("user"); - expect(mapRoleToStream("STAFF")).toBe("user"); + expect(mapRoleToStream("STAFF")).toBe("admin"); }); }); }); diff --git a/actions/stream/chat/channel.action.ts b/actions/stream/chat/channel.action.ts index 2b8bfafd0..1a392c1ca 100644 --- a/actions/stream/chat/channel.action.ts +++ b/actions/stream/chat/channel.action.ts @@ -8,6 +8,8 @@ import { markChannelExists } from "@/lib/stream-cache"; import { upsertUsersToStream } from "./user.action"; import { getDmChannelId } from "@/lib/stream-utils"; import { getChannelTypeFromId } from "@/lib/stream-channel-ids"; +import { getSession } from "@/lib/auth-server"; +import { isPrivileged } from "@/lib/auth-helpers"; import * as Sentry from "@sentry/nextjs"; // Input validation schemas @@ -31,6 +33,29 @@ const createChannelSchema = z.object({ organizationId: z.string().min(1).nullable().optional(), }); +/** + * Best-effort channel-scoped `channel_moderator` grant (#899). Non-fatal: chat + * still works without it. Shared by createChannel and the collaborator-channel + * path so the grant contract lives in one place. + */ +async function grantChannelModerator( + channel: ReturnType["channel"]>, + userId: string, + channelId: string, +): Promise { + try { + await channel.assignRoles([ + { user_id: userId, channel_role: "channel_moderator" }, + ]); + } catch (error) { + streamLogger.warn("Failed to grant channel_moderator to channel host", { + channelId, + userId, + error, + }); + } +} + /** * Generic function to create a channel * Validates inputs and handles member deduplication @@ -98,6 +123,23 @@ export async function createChannel(input: { const channelData = await channel.create(); + // Channel-scoped moderation replaces the old global-admin Stream role + // (#899). Only the channel HOST may moderate — never an arbitrary creator: + // - team channels (webinar/class): the creator IS the consultant host. + // - messaging channels: only consultation/subscription DMs carry a + // `dm_consultant_user_id`; grant moderation to that consultant. Peer DMs + // (createDirectMessageChannel) have no host, so `moderatorId` is + // undefined and no grant is issued — this prevents a consultee who + // opens a 1:1 DM from being able to mute/remove the consultant (#981). + const moderatorId = + validated.channelType === "team" + ? validated.createdById + : (mergedAdditionalData.dm_consultant_user_id as string | undefined); + + if (moderatorId) { + await grantChannelModerator(channel, moderatorId, validated.channelId); + } + // Cache the channel existence markChannelExists(validated.channelType, validated.channelId); @@ -747,6 +789,10 @@ export async function createCollaboratorChannel( await channel.create(); markChannelExists("messaging", channelId); + // Host moderates their own collab channel — this path bypasses + // createChannel, so the #899 channel-scoped grant is repeated here. + await grantChannelModerator(channel, hostUserId, channelId); + // Query current channel membership for diffing const channelData = await channel.query(); const currentMemberIds = (channelData.members ?? []) @@ -794,7 +840,12 @@ export async function createCollaboratorChannel( } /** - * Adds a user to a specific channel + * Adds a user to a specific channel. + * + * Stream's server-side API bypasses its permission system entirely, so the + * authz gate lives here (#899): ADMIN/STAFF may add to any channel; anyone + * else only to a channel they created — mirroring the create-route checks. + * Non-privileged callers never lazily create channels they don't own. */ export async function addMemberToChannel( channelId: string, @@ -804,6 +855,11 @@ export async function addMemberToChannel( channelIdSchema.parse(channelId); memberIdSchema.parse(userId); + const session = await getSession(); + if (!session?.user?.id) { + throw new Error("Unauthorized: sign in to manage channel members"); + } + const client = getStreamChatClient(); const resolvedChannelType = channelType ?? getChannelTypeFromId(channelId); @@ -816,7 +872,18 @@ export async function addMemberToChannel( try { const channel = client.channel(resolvedChannelType, channelId); - await channel.create(); // Creates if doesn't exist, no-op if exists + const privileged = isPrivileged(session.user.role); + if (privileged) { + await channel.create(); // Creates if doesn't exist, no-op if exists + } else { + const state = await channel.query({}); + const createdById = state.channel?.created_by?.id; + if (createdById !== session.user.id) { + throw new Error( + "Forbidden: only the channel creator or staff may add members", + ); + } + } const response = await channel.addMembers([userId]); diff --git a/actions/stream/chat/event-channel.action.ts b/actions/stream/chat/event-channel.action.ts index f36439c46..405ec9277 100644 --- a/actions/stream/chat/event-channel.action.ts +++ b/actions/stream/chat/event-channel.action.ts @@ -193,6 +193,20 @@ export async function addUserToEventChannel( }, ); + // Lazy-create bypasses createChannel, so the #899 channel-scoped host + // grant is repeated here. Non-fatal: chat still works without it. + try { + await channelWithData.assignRoles([ + { user_id: consultantId, channel_role: "channel_moderator" }, + ]); + } catch (grantError) { + streamLogger.warn("Failed to grant channel_moderator to event host", { + channelId, + consultantId, + error: grantError, + }); + } + markChannelExists(channelType, channelId); markMembership(channelId, userId, true); created = true; @@ -823,6 +837,20 @@ async function addUserToDmChannel( }, ); + // Lazy-create bypasses createChannel, so the #899 channel-scoped host + // grant is repeated here. Non-fatal: chat still works without it. + try { + await channelWithData.assignRoles([ + { user_id: consultantUserId, channel_role: "channel_moderator" }, + ]); + } catch (grantError) { + streamLogger.warn("Failed to grant channel_moderator to DM consultant", { + channelId, + consultantUserId, + error: grantError, + }); + } + markChannelExists(channelType, channelId); markMembership(channelId, currentUserId, true); streamLogger.info("Created DM channel", { diff --git a/actions/stream/chat/stream.action.ts b/actions/stream/chat/stream.action.ts index c81941145..bd25a6aea 100644 --- a/actions/stream/chat/stream.action.ts +++ b/actions/stream/chat/stream.action.ts @@ -7,6 +7,8 @@ import { isStreamConfigured, } from "@/lib/stream-client"; import { streamLogger } from "@/lib/stream-logger"; +import { getSession } from "@/lib/auth-server"; +import { isPrivileged } from "@/lib/auth-helpers"; import * as Sentry from "@sentry/nextjs"; // Token expiry for both chat and video (1 hour) @@ -15,6 +17,29 @@ const TOKEN_EXPIRATION_SECONDS = 3600; // Input validation const userIdSchema = z.string().min(1, "User ID is required"); +/** + * Tokens may only be minted for the caller's own userId (staff/admin may mint + * for anyone), and never for a banned user — Stream's server-side API skips all + * permission checks, so this session bind is the only gate against identity + * spoofing and re-minting a revoked/suspended identity (#693/#899). + */ +async function assertCanMintToken(forUserId: string): Promise { + // Bypass the cookie-session cache so a just-demoted staff/admin (or a + // just-banned user) can't keep minting cross-user tokens until the cache + // expires (#899). + const session = await getSession(true); + if (!session?.user?.id) { + throw new Error("Unauthorized: sign in to request a Stream token"); + } + // Never mint for a banned/suspended user (#693). + if (session.user.banned) { + throw new Error("Forbidden: account suspended"); + } + if (session.user.id !== forUserId && !isPrivileged(session.user.role)) { + throw new Error("Forbidden: cannot mint a token for another user"); + } +} + /** * Generate a video call token for a user * Token is valid for 1 hour by default @@ -24,6 +49,7 @@ const userIdSchema = z.string().min(1, "User ID is required"); export async function tokenProvider(userId: string): Promise { // Validate input const validatedUserId = userIdSchema.parse(userId); + await assertCanMintToken(validatedUserId); if (!isStreamConfigured()) { streamLogger.error("Stream not configured for video token generation"); @@ -54,6 +80,7 @@ export async function tokenProvider(userId: string): Promise { export async function chatTokenProvider(userId: string): Promise { // Validate input const validatedUserId = userIdSchema.parse(userId); + await assertCanMintToken(validatedUserId); if (!isStreamConfigured()) { streamLogger.error("Stream not configured for chat token generation"); diff --git a/actions/stream/meetings/meeting.action.ts b/actions/stream/meetings/meeting.action.ts index 3bc6fa502..9ff5af265 100644 --- a/actions/stream/meetings/meeting.action.ts +++ b/actions/stream/meetings/meeting.action.ts @@ -103,7 +103,9 @@ export async function createDbMeetingSession( if (slot.appointmentId) { const appointment = await prisma.appointment.findUnique({ where: { id: slot.appointmentId }, - select: { organizationId: true }, + select: { + organizationId: true, + }, }); organizationId = appointment?.organizationId ?? null; } diff --git a/app/(pages)/about/page.tsx b/app/(pages)/about/page.tsx index a7847ba8a..1db024bfb 100644 --- a/app/(pages)/about/page.tsx +++ b/app/(pages)/about/page.tsx @@ -143,13 +143,6 @@ export default function AboutPage() {

{COMPANY_INFO.name}

-
-

- Registered Address -

-

{COMPANY_INFO.address}

-
-

Contact Email diff --git a/app/(pages)/constants.ts b/app/(pages)/constants.ts index d46c27814..af0d8137e 100644 --- a/app/(pages)/constants.ts +++ b/app/(pages)/constants.ts @@ -1,8 +1,9 @@ // Company Information - Update these values with your actual business details export const COMPANY_INFO = { - name: "[COMPANY NAME]", - address: "[ADDRESS]", + name: "Practitionist", + // TODO: real contact email before launch email: "[EMAIL]", + // TODO: real contact email before launch supportEmail: "[SUPPORT_EMAIL]", phone: "[PHONE]", jurisdiction: "[JURISDICTION]", diff --git a/app/(pages)/contactus/page.tsx b/app/(pages)/contactus/page.tsx index a70c2cfd8..d0d5c8837 100644 --- a/app/(pages)/contactus/page.tsx +++ b/app/(pages)/contactus/page.tsx @@ -12,7 +12,7 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; -import { Mail, MapPin, Phone, Clock, MessageSquare } from "lucide-react"; +import { Mail, Phone, Clock, MessageSquare } from "lucide-react"; import { COMPANY_INFO, PAGE_META, @@ -51,23 +51,6 @@ export default function ContactUsPage() { - {/* Company Address */} -

-
- -
-
-

Address

-

- {COMPANY_INFO.name} -
- {COMPANY_INFO.address} -

-
-
- - - {/* Email */}
diff --git a/app/(pages)/privacy/page.tsx b/app/(pages)/privacy/page.tsx index f418ebd27..22679cd6d 100644 --- a/app/(pages)/privacy/page.tsx +++ b/app/(pages)/privacy/page.tsx @@ -478,9 +478,6 @@ export default function PrivacyPolicyPage() {

Company Name: {COMPANY_INFO.name}

-

- Address: {COMPANY_INFO.address} -

Email:{" "} Company Name: {COMPANY_INFO.name}

-

- Address: {COMPANY_INFO.address} -

Email:{" "} Company Name: {COMPANY_INFO.name}

-

- Address: {COMPANY_INFO.address} -

Email:{" "} 0, + ); + movedStatus = isPartialSubscriptionReschedule + ? await tx.subscription.count({ + where: { + id: appointment.subscription.id, + status: { in: [...RESCHEDULABLE_FROM] }, + }, + }) + : ( + await tx.subscription.updateMany({ + where: { + id: appointment.subscription.id, + status: { in: [...RESCHEDULABLE_FROM] }, + }, + data: { status: "PENDING" }, + }) + ).count; } else if (appointment.webinar) { // Explicit allowed-from (was notIn) — robust against future enum // additions (#837). diff --git a/app/api/bookings/classes/[classId]/allocate/route.ts b/app/api/bookings/classes/[classId]/allocate/route.ts index 5fc0540ed..fd8da34ec 100644 --- a/app/api/bookings/classes/[classId]/allocate/route.ts +++ b/app/api/bookings/classes/[classId]/allocate/route.ts @@ -77,6 +77,9 @@ export async function PATCH( eventId: classId, mode, slots: body.slots, + // #837 — client dedupe key; a double-submit with the same value returns + // the first batch instead of allocating twice. + idempotencyKey: request.headers.get("Idempotency-Key") ?? undefined, }); const duration = Date.now() - startTime; diff --git a/app/api/bookings/consultations/[consultationId]/allocate/route.ts b/app/api/bookings/consultations/[consultationId]/allocate/route.ts index 1e60f2dae..2eeb721a9 100644 --- a/app/api/bookings/consultations/[consultationId]/allocate/route.ts +++ b/app/api/bookings/consultations/[consultationId]/allocate/route.ts @@ -78,6 +78,9 @@ export async function PATCH( eventId: consultationId, mode, slots: body.slots, + // #837 — client dedupe key; a double-submit with the same value returns + // the first batch instead of allocating twice. + idempotencyKey: request.headers.get("Idempotency-Key") ?? undefined, }); const duration = Date.now() - startTime; diff --git a/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts b/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts index bd480eee9..1569cc141 100644 --- a/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts +++ b/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts @@ -77,6 +77,9 @@ export async function PATCH( eventId: subscriptionId, mode, slots: body.slots, + // #837 — client dedupe key; a double-submit with the same value returns + // the first batch instead of allocating twice. + idempotencyKey: request.headers.get("Idempotency-Key") ?? undefined, }); const duration = Date.now() - startTime; diff --git a/app/api/bookings/webinars/[webinarId]/allocate/route.ts b/app/api/bookings/webinars/[webinarId]/allocate/route.ts index a2fe97e76..fc781f377 100644 --- a/app/api/bookings/webinars/[webinarId]/allocate/route.ts +++ b/app/api/bookings/webinars/[webinarId]/allocate/route.ts @@ -77,6 +77,9 @@ export async function PATCH( eventId: webinarId, mode, slots: body.slots, + // #837 — client dedupe key; a double-submit with the same value returns + // the first batch instead of allocating twice. + idempotencyKey: request.headers.get("Idempotency-Key") ?? undefined, }); const duration = Date.now() - startTime; diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index f68795793..ab1852451 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -8,6 +8,7 @@ import { import { NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/auth-server"; import { EventCheckoutLockUnavailableError } from "@/utils/appointmentlock"; +import { WalletFrozenError } from "@/lib/payments/wallet-freeze"; import { checkoutLimiter, applyRateLimit } from "@/lib/rate-limit"; import { ZodError } from "zod"; import { Prisma } from "@prisma/client"; @@ -133,6 +134,21 @@ export async function POST(req: NextRequest) { ); } + // #837 — a frozen wallet (ledger drift caught by reconcile) is a specific, + // retryable 409, not a generic 500. classifyError is message-only and would + // mislabel it; honor the structured httpStatus like the lock error above. + if (error instanceof WalletFrozenError) { + return NextResponse.json( + { + error: + "This organization's wallet is temporarily on hold pending a balance review. Your card was not charged. Please try again shortly or contact support.", + errorType: "WALLET_FROZEN", + timestamp: new Date().toISOString(), + }, + { status: error.httpStatus }, + ); + } + Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "checkout" } }); const classified = classifyError(error, "Checkout failed"); logClassifiedError("Checkout", classified, error); diff --git a/app/api/cleanup/transfer-expiring-recordings/route.ts b/app/api/cleanup/transfer-expiring-recordings/route.ts index 1604966b7..113f859a3 100644 --- a/app/api/cleanup/transfer-expiring-recordings/route.ts +++ b/app/api/cleanup/transfer-expiring-recordings/route.ts @@ -75,10 +75,12 @@ export async function GET(req: NextRequest): Promise { "transfer-expiring-recordings", { failMode: "open" }, async () => { - // Phase 1: Auto-transfer SUPABASE_PERMANENT recordings + // Phase 1: Auto-transfer SUPABASE_PERMANENT recordings. + // #899 — 14-day window sweeps every READY permanent recording + // (near-ready transfer), matching the GH Actions entry. const transferResult = await RecordingTransferService.processExpiringRecordings( - 5, // 5 days before expiry + 14, // days before expiry (= full Stream URL lifetime) 10, // batch size "SUPABASE_PERMANENT", ); diff --git a/app/api/collaborations/class/[planId]/route.ts b/app/api/collaborations/class/[planId]/route.ts index 80cfd3841..7b12741e6 100644 --- a/app/api/collaborations/class/[planId]/route.ts +++ b/app/api/collaborations/class/[planId]/route.ts @@ -83,7 +83,15 @@ export async function POST( ); } - const { consultantProfileId, role, revenueSharePercentage } = parsed.data; + const { + consultantProfileId, + role, + revenueSharePercentage, + canApprovePayment, + canViewAnalytics, + canEditEvent, + canSeeAttendees, + } = parsed.data; if (consultantProfileId === ownerProfile.id) { return NextResponse.json( @@ -116,6 +124,7 @@ export async function POST( role, revenueSharePercentage, ownerProfile.id, + { canApprovePayment, canViewAnalytics, canEditEvent, canSeeAttendees }, ); if (!collab) { diff --git a/app/api/collaborations/webinar/[planId]/route.ts b/app/api/collaborations/webinar/[planId]/route.ts index 0234ed94a..cfc498022 100644 --- a/app/api/collaborations/webinar/[planId]/route.ts +++ b/app/api/collaborations/webinar/[planId]/route.ts @@ -84,7 +84,15 @@ export async function POST( ); } - const { consultantProfileId, role, revenueSharePercentage } = parsed.data; + const { + consultantProfileId, + role, + revenueSharePercentage, + canApprovePayment, + canViewAnalytics, + canEditEvent, + canSeeAttendees, + } = parsed.data; if (consultantProfileId === ownerProfile.id) { return NextResponse.json( @@ -117,6 +125,7 @@ export async function POST( role, revenueSharePercentage, ownerProfile.id, + { canApprovePayment, canViewAnalytics, canEditEvent, canSeeAttendees }, ); if (!collab) { diff --git a/app/api/organizations/[orgId]/billing-account/route.ts b/app/api/organizations/[orgId]/billing-account/route.ts index cb8ed5c3d..64f7762c0 100644 --- a/app/api/organizations/[orgId]/billing-account/route.ts +++ b/app/api/organizations/[orgId]/billing-account/route.ts @@ -26,6 +26,7 @@ import { requireOrgAccess } from "@/lib/auth-helpers"; // description in `lib/labels/org-labels.ts`. import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { assertVerifiedDomainOrThrow } from "@/lib/enterprise/governance"; // PROJECT is reserved in the Prisma enum for v2 project-billing; the // API layer rejects it so callers can't quietly land a BillingAccount @@ -201,6 +202,13 @@ export async function PATCH( ); } } + // K-02 / #687 — enabling INVOICE funding requires a verified domain + // (governance.ts documents this gate for the fundingSource→INVOICE + // transition). tx-scoped read: TOCTOU-safe against a concurrent + // verification rollback. + if (body.fundingSource === "INVOICE") { + await assertVerifiedDomainOrThrow(tx, orgId, "INVOICE_FUNDING"); + } } const next = await tx.billingAccount.update({ diff --git a/app/api/organizations/[orgId]/earnings/route.ts b/app/api/organizations/[orgId]/earnings/route.ts index 90044707c..d5b165d78 100644 --- a/app/api/organizations/[orgId]/earnings/route.ts +++ b/app/api/organizations/[orgId]/earnings/route.ts @@ -22,11 +22,13 @@ import { z } from "zod"; import prisma from "@/lib/prisma"; import { requireOrgAccess } from "@/lib/auth-helpers"; import { sumPaise } from "@/lib/payments/utils/money"; +import { ENABLE_HOST_ORGS } from "@/lib/feature-flags"; const EarningStatusSchema = z.enum([ "PENDING", "HELD", "READY", + "BATCHED", // #837 — batched, cash not yet disbursed; filterable + distinct from PAID "PAID", "REFUNDED", ]); @@ -48,7 +50,10 @@ export async function GET( const access = await requireOrgAccess(orgId, "MANAGER"); if (access.error) return access.error; - if (!access.org.canHost) { + // Honesty gate: an org's canHost column can be true from when the flag was + // on, but with ENABLE_HOST_ORGS off resolveOrgSplit returns null so NO new + // OrganizationEarnings accrue — surfacing a split dashboard would lie. + if (!ENABLE_HOST_ORGS || !access.org.canHost) { return NextResponse.json( { error: "Organization does not host — no earnings to list" }, { status: 404 }, diff --git a/app/api/organizations/[orgId]/payouts/route.ts b/app/api/organizations/[orgId]/payouts/route.ts index 5e5d38942..10f1a5e20 100644 --- a/app/api/organizations/[orgId]/payouts/route.ts +++ b/app/api/organizations/[orgId]/payouts/route.ts @@ -6,7 +6,8 @@ * row. The creation path is deliberately admin-gated and narrow: * 1. Pick all READY earnings in [periodStart, periodEnd). * 2. Create the payout with aggregated totals (gross/fee/refunds/net). - * 3. Attach those earnings to the payout + flip their status to PAID. + * 3. Attach those earnings to the payout + flip their status to BATCHED + * (#837 — not PAID; PAID happens only at payout COMPLETED + UTR). * * Actual fund-movement (RazorpayX / Cashfree) happens asynchronously in * jobs/payouts/** — this endpoint only records the intent and reserves the @@ -39,8 +40,6 @@ const PayoutStatusSchema = z.enum([ const PaymentGatewaySchema = z.enum([ "STRIPE", "RAZORPAY", - "LEMON_SQUEEZY", - "XFLOW", "CARD", ]); @@ -168,7 +167,7 @@ export async function POST( // earning. // (3) Re-read the claimed rows (authoritatively scoped by orgPayoutId), // compute totals, and patch the payout row with the real numbers. - // (4) Flip the claimed rows READY → PAID in the same tx. + // (4) Flip the claimed rows READY → BATCHED in the same tx (#837). // If no rows are claimed, throw to abort the tx so the placeholder // payout row is rolled back too. const created = await tx.organizationPayout.create({ @@ -268,11 +267,12 @@ export async function POST( }, }); - // Flip the claimed earnings READY → PAID. The cron that flips the - // payout to COMPLETED does not touch earnings.status. + // #837 E-03/E-04 — batch creation only STAGES the earnings; cash has not + // left. Flip READY → BATCHED, not PAID. markOrgPayoutCompleted performs + // the BATCHED → PAID flip when the payout reaches COMPLETED (+ UTR). await tx.organizationEarnings.updateMany({ where: { orgPayoutId: created.id, status: "READY" }, - data: { status: "PAID" }, + data: { status: "BATCHED" }, }); await tx.orgAuditLog.create({ diff --git a/app/api/organizations/[orgId]/route.ts b/app/api/organizations/[orgId]/route.ts index c5620f378..0357c9a08 100644 --- a/app/api/organizations/[orgId]/route.ts +++ b/app/api/organizations/[orgId]/route.ts @@ -570,7 +570,10 @@ export async function DELETE( purchaseOrders: { where: { remainingAmountPaise: { gt: 0 } } }, earnings: { where: { - status: { in: ["PENDING_TRUST", "PENDING", "HELD", "READY"] }, + // #837 — BATCHED is unsettled (payout in flight, cash not moved yet). + status: { + in: ["PENDING_TRUST", "PENDING", "HELD", "READY", "BATCHED"], + }, }, }, payouts: { diff --git a/app/api/organizations/[orgId]/sso/route.ts b/app/api/organizations/[orgId]/sso/route.ts index 69c6357fd..12732251b 100644 --- a/app/api/organizations/[orgId]/sso/route.ts +++ b/app/api/organizations/[orgId]/sso/route.ts @@ -15,7 +15,7 @@ import * as Sentry from "@sentry/nextjs"; import { NextResponse, type NextRequest } from "next/server"; import { z } from "zod"; -import prisma from "@/lib/prisma"; +import prisma, { type Tx } from "@/lib/prisma"; import { requireOrgAccess, requireOrgOwner } from "@/lib/auth-helpers"; import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; import { DomainSchema } from "@/lib/enterprise/validators"; @@ -35,11 +35,199 @@ const PatchBodySchema = z // = "OWNER"` would make the first SSO user co-owner. See audit // Phase A.1 + docs/enterprise/20-iam-and-security/01-sso-and-authentication.md. defaultRoleForAutoJoin: JitDefaultRoleSchema.optional(), + // Optimistic-lock clock. When present, a concurrent admin PATCH that + // already bumped the version 409s instead of silently clobbering. + expectedVersion: z.coerce.number().int().min(1).optional(), }) - .refine((v) => Object.keys(v).length > 0, { + .refine((v) => Object.keys(v).some((k) => k !== "expectedVersion"), { message: "PATCH body must contain at least one field", }); +type PatchBody = z.infer; +type SsoSettingsRow = NonNullable< + Awaited> +>; + +// Optimistic lock — two admins editing SSO settings from stale tabs would +// last-write-wins the enforcement/domain config without this. The version bump +// is UNCONDITIONAL on any settings write to an existing row: it is the increment +// (not the caller's expectedVersion) that makes a concurrent stale writer's CAS +// miss. expectedVersion only gates the 409 conflict check — a client that +// supplies it opts into failing fast, but omitting it must never silently bypass +// the clock. The create path is guarded by the organizationId unique constraint. +async function enforceSsoVersionLock( + tx: Tx, + orgId: string, + existing: SsoSettingsRow, + expectedVersion: number | undefined, +): Promise { + const cas = await tx.organizationSSOSettings.updateMany({ + where: { + organizationId: orgId, + ...(expectedVersion !== undefined && { version: expectedVersion }), + }, + data: { version: { increment: 1 } }, + }); + if (cas.count === 0) { + throw Object.assign( + new Error( + "SSO settings were changed in another session — reload and retry", + ), + { + httpStatus: 409, + code: "VERSION_CONFLICT", + currentVersion: existing.version, + }, + ); + } +} + +// Enforcing SSO without at least one allowed domain OR an SSO provider would +// lock every user out of the org. Catch it here. +async function assertEnforceSsoIsSafe( + tx: Tx, + orgId: string, + body: PatchBody, + existing: SsoSettingsRow | null, +): Promise { + if (body.enforceSSO !== true) return; + const effectiveDomains = + body.allowedEmailDomains ?? existing?.allowedEmailDomains ?? []; + const providerCount = await tx.ssoProvider.count({ + where: { organizationId: orgId }, + }); + if (effectiveDomains.length === 0 && providerCount === 0) { + throw Object.assign( + new Error( + "Cannot enforce SSO without at least one allowed domain or SSO provider configured.", + ), + { httpStatus: 409 }, + ); + } +} + +// PR-1d / #675: SSO settings (the high-impact ones — enforcement + auto-join) +// require a verified domain. Without this gate any org could enforce SSO against +// an unverified domain and lock out members of a third-party org that happens to +// share the email suffix. +async function assertSensitiveChangeVerified( + tx: Tx, + orgId: string, + body: PatchBody, +): Promise { + const sensitiveChange = + body.enforceSSO === true || + (body.allowedEmailDomains !== undefined && + body.allowedEmailDomains.length > 0); + if (sensitiveChange && !(await hasVerifiedDomain(tx, orgId))) { + throw new DomainVerificationRequiredError("SSO"); + } +} + +function upsertSsoSettings( + tx: Tx, + orgId: string, + body: PatchBody, +) { + return tx.organizationSSOSettings.upsert({ + where: { organizationId: orgId }, + create: { + organizationId: orgId, + allowedEmailDomains: body.allowedEmailDomains ?? [], + enforceSSO: body.enforceSSO ?? false, + defaultRoleForAutoJoin: body.defaultRoleForAutoJoin ?? "LEARNER", + }, + update: { + ...(body.allowedEmailDomains !== undefined && { + allowedEmailDomains: body.allowedEmailDomains, + }), + ...(body.enforceSSO !== undefined && { enforceSSO: body.enforceSSO }), + ...(body.defaultRoleForAutoJoin !== undefined && { + defaultRoleForAutoJoin: body.defaultRoleForAutoJoin, + }), + }, + }); +} + +// SSO_ENABLED/DISABLED specifically fires on enforceSSO flips, not generic +// setting edits. Domain list changes still count as SETTINGS_CHANGED. +function resolveSsoAuditAction(existing: SsoSettingsRow | null, body: PatchBody) { + const ssoStateChanged = + body.enforceSSO !== undefined && + body.enforceSSO !== (existing?.enforceSSO ?? false); + if (!ssoStateChanged) return AUDIT_ACTIONS.SETTINGS.SETTINGS_CHANGED; + return body.enforceSSO + ? AUDIT_ACTIONS.SETTINGS.SSO_ENABLED + : AUDIT_ACTIONS.SETTINGS.SSO_DISABLED; +} + +async function writeSsoAuditLog( + tx: Tx, + params: { + orgId: string; + actorMembershipId: string; + existing: SsoSettingsRow | null; + next: SsoSettingsRow; + body: PatchBody; + }, +): Promise { + const { orgId, actorMembershipId, existing, next, body } = params; + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId, + category: "SETTINGS", + action: resolveSsoAuditAction(existing, body), + description: "SSO settings updated", + details: { + from: { + allowedEmailDomains: existing?.allowedEmailDomains ?? [], + enforceSSO: existing?.enforceSSO ?? false, + defaultRoleForAutoJoin: + existing?.defaultRoleForAutoJoin ?? "LEARNER", + }, + to: { + allowedEmailDomains: next.allowedEmailDomains, + enforceSSO: next.enforceSSO, + defaultRoleForAutoJoin: next.defaultRoleForAutoJoin, + }, + }, + }, + }); +} + +// Maps known/expected errors to their JSON response; returns null for +// unexpected errors so the caller can capture + rethrow. +function buildKnownSsoErrorResponse(err: unknown): NextResponse | null { + if (err instanceof DomainVerificationRequiredError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: err.httpStatus }, + ); + } + if (err instanceof Error && "httpStatus" in err) { + const status = + typeof err.httpStatus === "number" ? err.httpStatus : 500; + // VERSION_CONFLICT carries currentVersion so the client can + // refetch-and-retry without an extra GET. + const code = + "code" in err && typeof err.code === "string" ? err.code : undefined; + const currentVersion = + "currentVersion" in err && typeof err.currentVersion === "number" + ? err.currentVersion + : undefined; + return NextResponse.json( + { + error: err.message, + ...(code && { code }), + ...(currentVersion !== undefined && { currentVersion }), + }, + { status }, + ); + } + return null; +} + export async function GET( _req: NextRequest, { params }: { params: Promise<{ orgId: string }> }, @@ -75,6 +263,7 @@ export async function GET( allowedEmailDomains: [], enforceSSO: false, defaultRoleForAutoJoin: "LEARNER", + version: 1, }, providers: providers.map(({ samlConfig, oidcConfig, ...rest }) => ({ ...rest, @@ -108,91 +297,19 @@ export async function PATCH( where: { organizationId: orgId }, }); - // Enforcing SSO without at least one allowed domain OR an SSO - // provider would lock every user out of the org. Catch it here. - if (body.enforceSSO === true) { - const effectiveDomains = - body.allowedEmailDomains ?? - existing?.allowedEmailDomains ?? - []; - const providerCount = await tx.ssoProvider.count({ - where: { organizationId: orgId }, - }); - if (effectiveDomains.length === 0 && providerCount === 0) { - throw Object.assign( - new Error( - "Cannot enforce SSO without at least one allowed domain or SSO provider configured.", - ), - { httpStatus: 409 }, - ); - } + if (existing) { + await enforceSsoVersionLock(tx, orgId, existing, body.expectedVersion); } + await assertEnforceSsoIsSafe(tx, orgId, body, existing); + await assertSensitiveChangeVerified(tx, orgId, body); - // PR-1d / #675: SSO settings (the high-impact ones — enforcement - // + auto-join) require a verified domain. Without this gate any - // org could enforce SSO against an unverified domain and lock - // out members of a third-party org that happens to share the - // email suffix. - const sensitiveChange = - body.enforceSSO === true || - (body.allowedEmailDomains !== undefined && - body.allowedEmailDomains.length > 0); - if (sensitiveChange && !(await hasVerifiedDomain(tx, orgId))) { - throw new DomainVerificationRequiredError("SSO"); - } - - const next = await tx.organizationSSOSettings.upsert({ - where: { organizationId: orgId }, - create: { - organizationId: orgId, - allowedEmailDomains: body.allowedEmailDomains ?? [], - enforceSSO: body.enforceSSO ?? false, - defaultRoleForAutoJoin: body.defaultRoleForAutoJoin ?? "LEARNER", - }, - update: { - ...(body.allowedEmailDomains !== undefined && { - allowedEmailDomains: body.allowedEmailDomains, - }), - ...(body.enforceSSO !== undefined && { - enforceSSO: body.enforceSSO, - }), - ...(body.defaultRoleForAutoJoin !== undefined && { - defaultRoleForAutoJoin: body.defaultRoleForAutoJoin, - }), - }, - }); - - // SSO_ENABLED/DISABLED specifically fires on enforceSSO flips, - // not generic setting edits. Domain list changes still count as - // SETTINGS_CHANGED. - const ssoStateChanged = - body.enforceSSO !== undefined && - body.enforceSSO !== (existing?.enforceSSO ?? false); - await tx.orgAuditLog.create({ - data: { - organizationId: orgId, - actorMembershipId: access.member.id, - category: "SETTINGS", - action: ssoStateChanged - ? body.enforceSSO - ? AUDIT_ACTIONS.SETTINGS.SSO_ENABLED - : AUDIT_ACTIONS.SETTINGS.SSO_DISABLED - : AUDIT_ACTIONS.SETTINGS.SETTINGS_CHANGED, - description: "SSO settings updated", - details: { - from: { - allowedEmailDomains: existing?.allowedEmailDomains ?? [], - enforceSSO: existing?.enforceSSO ?? false, - defaultRoleForAutoJoin: - existing?.defaultRoleForAutoJoin ?? "LEARNER", - }, - to: { - allowedEmailDomains: next.allowedEmailDomains, - enforceSSO: next.enforceSSO, - defaultRoleForAutoJoin: next.defaultRoleForAutoJoin, - }, - }, - }, + const next = await upsertSsoSettings(tx, orgId, body); + await writeSsoAuditLog(tx, { + orgId, + actorMembershipId: access.member.id, + existing, + next, + body, }); return next; @@ -200,18 +317,12 @@ export async function PATCH( return NextResponse.json({ settings: updated }); } catch (err) { - if (err instanceof DomainVerificationRequiredError) { - return NextResponse.json( - { error: err.message, code: err.code }, - { status: err.httpStatus }, - ); - } - if (err instanceof Error && "httpStatus" in err) { - const status = - typeof err.httpStatus === "number" ? err.httpStatus : 500; - return NextResponse.json({ error: err.message }, { status }); - } - Sentry.captureException(err instanceof Error ? err : new Error(String(err)), { tags: { subsystem: "enterprise" } }); + const known = buildKnownSsoErrorResponse(err); + if (known) return known; + Sentry.captureException( + err instanceof Error ? err : new Error(String(err)), + { tags: { subsystem: "enterprise" } }, + ); throw err; } } diff --git a/app/api/participants/class/[classId]/route.ts b/app/api/participants/class/[classId]/route.ts index ad5d2d104..125c6839e 100644 --- a/app/api/participants/class/[classId]/route.ts +++ b/app/api/participants/class/[classId]/route.ts @@ -40,16 +40,31 @@ export async function GET( try { const { classId } = await params; - // Non-privileged users can only view participants for classes they own as consultant - const classEvent = await prisma.class.findUnique({ + // Non-privileged users can view the roster if they own the plan OR are an + // accepted collaborator granted canSeeAttendees (#768). Everyone else 404s. + const classEvent = await prisma.class.findFirst({ where: { id: classId, ...(isPrivileged(session.user.role) ? {} : { classPlan: { - consultantProfileId: - session.user.consultantProfileId ?? "__none__", + OR: [ + { + consultantProfileId: + session.user.consultantProfileId ?? "__none__", + }, + { + collaborators: { + some: { + consultantProfileId: + session.user.consultantProfileId ?? "__none__", + status: "ACCEPTED", + canSeeAttendees: true, + }, + }, + }, + ], }, }), }, @@ -145,7 +160,7 @@ export async function DELETE( // Ownership check only — the old shape loaded the entire roster // (every appointment × every slot × every full User row) just to find // the one participant being removed. - const classEvent = await prisma.class.findUnique({ + const classEvent = await prisma.class.findFirst({ where: { id: classId, ...(isPrivileged(session.user.role) diff --git a/app/api/participants/webinar/[webinarId]/route.ts b/app/api/participants/webinar/[webinarId]/route.ts index 36f819f35..b03a82235 100644 --- a/app/api/participants/webinar/[webinarId]/route.ts +++ b/app/api/participants/webinar/[webinarId]/route.ts @@ -40,16 +40,31 @@ export async function GET( try { const { webinarId } = await params; - // Non-privileged users can only view participants for webinars they own as consultant - const webinarEvent = await prisma.webinar.findUnique({ + // Non-privileged users can view the roster if they own the plan OR are an + // accepted collaborator granted canSeeAttendees (#768). Everyone else 404s. + const webinarEvent = await prisma.webinar.findFirst({ where: { id: webinarId, ...(isPrivileged(session.user.role) ? {} : { webinarPlan: { - consultantProfileId: - session.user.consultantProfileId ?? "__none__", + OR: [ + { + consultantProfileId: + session.user.consultantProfileId ?? "__none__", + }, + { + collaborators: { + some: { + consultantProfileId: + session.user.consultantProfileId ?? "__none__", + status: "ACCEPTED", + canSeeAttendees: true, + }, + }, + }, + ], }, }), }, @@ -140,7 +155,7 @@ export async function DELETE( // Ownership check only — the old shape loaded the entire roster // (every slot × every full User row) just to find the one participant // being removed. - const webinarEvent = await prisma.webinar.findUnique({ + const webinarEvent = await prisma.webinar.findFirst({ where: { id: webinarId, ...(isPrivileged(session.user.role) diff --git a/app/api/plans/classes/[classPlanId]/route.ts b/app/api/plans/classes/[classPlanId]/route.ts index d7f443d0d..95f6a97c0 100644 --- a/app/api/plans/classes/[classPlanId]/route.ts +++ b/app/api/plans/classes/[classPlanId]/route.ts @@ -202,61 +202,76 @@ export async function DELETE( ); } - // Check if there are any associated classes - const associatedClasses = await prisma.class.findMany({ - where: { classPlanId: classPlanId }, - }); - - if (associatedClasses.length > 0) { - return NextResponse.json( - { error: "Cannot delete class plan with associated classes" }, - { status: 400 }, - ); - } - - // Check for active collaborators (PENDING or ACCEPTED) - const activeCollaborators = await prisma.collaborator.count({ - where: { - classPlanId, - status: { in: ["PENDING", "ACCEPTED"] }, - }, - }); + // #837 — guard-check + delete must be atomic. Under check-then-act, a class + // or collaborator created between the count and the delete would be orphaned + // (or cascade-deleted); Serializable aborts such a racing write. + const classPlan = await prisma.$transaction( + async (tx) => { + const associatedClasses = await tx.class.count({ + where: { classPlanId }, + }); + if (associatedClasses > 0) { + throw Object.assign( + new Error("Cannot delete class plan with associated classes"), + { httpStatus: 400 }, + ); + } - if (activeCollaborators > 0) { - return NextResponse.json( - { - error: - "Cannot delete class plan with active collaborators. Remove or notify collaborators first.", - }, - { status: 400 }, - ); - } + const activeCollaborators = await tx.collaborator.count({ + where: { + classPlanId, + status: { in: ["PENDING", "ACCEPTED"] }, + }, + }); + if (activeCollaborators > 0) { + throw Object.assign( + new Error( + "Cannot delete class plan with active collaborators. Remove or notify collaborators first.", + ), + { httpStatus: 400 }, + ); + } - const classPlan = await prisma.classPlan.delete({ - where: { id: classPlanId }, - include: { - consultantProfile: { + return tx.classPlan.delete({ + where: { id: classPlanId }, include: { - user: { - select: { - id: true, - name: true, - email: true, - image: true, + consultantProfile: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + domain: true, + subDomains: true, + tags: true, }, }, - domain: true, - subDomains: true, - tags: true, + topics: true, + classContents: true, }, - }, - topics: true, - classContents: true, + }); }, - }); + { isolationLevel: "Serializable" }, + ); return NextResponse.json({ data: classPlan }, { status: 200 }); } catch (error) { + // Guard-check failures thrown inside the tx carry an httpStatus. + if (error instanceof Error && "httpStatus" in error) { + return NextResponse.json( + { error: error.message }, + { + status: + typeof (error as { httpStatus?: number }).httpStatus === "number" + ? (error as { httpStatus: number }).httpStatus + : 400, + }, + ); + } if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025" diff --git a/app/api/plans/webinars/[webinarPlanId]/route.ts b/app/api/plans/webinars/[webinarPlanId]/route.ts index 674af1efc..3bf7dc7f3 100644 --- a/app/api/plans/webinars/[webinarPlanId]/route.ts +++ b/app/api/plans/webinars/[webinarPlanId]/route.ts @@ -169,60 +169,75 @@ export async function DELETE( ); } - // Check if there are any associated webinars - const associatedWebinars = await prisma.webinar.findMany({ - where: { webinarPlanId: webinarPlanId }, - }); - - if (associatedWebinars.length > 0) { - return NextResponse.json( - { error: "Cannot delete webinar plan with associated webinars" }, - { status: 400 }, - ); - } - - // Check for active collaborators (PENDING or ACCEPTED) - const activeCollaborators = await prisma.collaborator.count({ - where: { - webinarPlanId, - status: { in: ["PENDING", "ACCEPTED"] }, - }, - }); - - if (activeCollaborators > 0) { - return NextResponse.json( - { - error: - "Cannot delete webinar plan with active collaborators. Remove or notify collaborators first.", - }, - { status: 400 }, - ); - } - - const webinarPlan = await prisma.webinarPlan.delete({ - where: { id: webinarPlanId }, - include: { - consultantProfile: { + // #837 — guard-check + delete must be atomic. Under check-then-act, a + // webinar or collaborator created between the count and the delete would be + // orphaned (or cascade-deleted); Serializable aborts such a racing write. + const webinarPlan = await prisma.$transaction( + async (tx) => { + const associatedWebinars = await tx.webinar.count({ + where: { webinarPlanId }, + }); + if (associatedWebinars > 0) { + throw Object.assign( + new Error("Cannot delete webinar plan with associated webinars"), + { httpStatus: 400 }, + ); + } + + const activeCollaborators = await tx.collaborator.count({ + where: { + webinarPlanId, + status: { in: ["PENDING", "ACCEPTED"] }, + }, + }); + if (activeCollaborators > 0) { + throw Object.assign( + new Error( + "Cannot delete webinar plan with active collaborators. Remove or notify collaborators first.", + ), + { httpStatus: 400 }, + ); + } + + return tx.webinarPlan.delete({ + where: { id: webinarPlanId }, include: { - user: { - select: { - id: true, - name: true, - email: true, - image: true, + consultantProfile: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + domain: true, + subDomains: true, + tags: true, }, }, - domain: true, - subDomains: true, - tags: true, + topics: true, }, - }, - topics: true, + }); }, - }); + { isolationLevel: "Serializable" }, + ); return NextResponse.json({ data: webinarPlan }, { status: 200 }); } catch (error) { + // Guard-check failures thrown inside the tx carry an httpStatus. + if (error instanceof Error && "httpStatus" in error) { + return NextResponse.json( + { error: error.message }, + { + status: + typeof (error as { httpStatus?: number }).httpStatus === "number" + ? (error as { httpStatus: number }).httpStatus + : 400, + }, + ); + } if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025" diff --git a/app/api/staff/moderation/reports/[reportId]/action/route.ts b/app/api/staff/moderation/reports/[reportId]/action/route.ts index 0fe0a6aea..421e3bd43 100644 --- a/app/api/staff/moderation/reports/[reportId]/action/route.ts +++ b/app/api/staff/moderation/reports/[reportId]/action/route.ts @@ -1,6 +1,6 @@ /** * Staff Moderation Report Action API - * Take moderation action on a report + * Take moderation action on a report — with real side-effects (#693). */ import { NextRequest, NextResponse } from "next/server"; @@ -8,6 +8,11 @@ import prisma from "@/lib/prisma"; import { ModerationActionType } from "@prisma/client"; import { requirePrivilegedAuth } from "@/lib/auth-helpers"; +import { + applyTransactionalEffects, + applyBestEffortEffects, + type SideEffectSummary, +} from "@/lib/moderation/side-effects"; import * as Sentry from "@sentry/nextjs"; interface RouteParams { params: Promise<{ reportId: string }>; @@ -17,6 +22,141 @@ interface RouteParams { * POST /api/staff/moderation/reports/[reportId]/action * Take moderation action on a report */ +const VALID_ACTIONS: ModerationActionType[] = [ + "WARNING_ISSUED", + "CONTENT_REMOVED", + "USER_SUSPENDED", + "USER_BANNED", + "PROFILE_UNVERIFIED", + "NO_ACTION", +]; + +type ModerationActionInput = { + actionType: ModerationActionType; + report: { id: string; targetUserId: string; reviewId: string | null }; + staffUserId: string; + notes?: string; + suspensionDays?: number; +}; + +// Returns a 400 response when the action-type / suspensionDays payload is +// invalid, or null when the request is well-formed. +function validateActionRequest( + actionType: unknown, + suspensionDays: unknown, +): NextResponse | null { + if ( + !actionType || + !VALID_ACTIONS.includes(actionType as ModerationActionType) + ) { + return NextResponse.json({ error: "Invalid action type" }, { status: 400 }); + } + if ( + actionType === "USER_SUSPENDED" && + (!Number.isInteger(suspensionDays) || + (suspensionDays as number) < 1 || + (suspensionDays as number) > 365) + ) { + return NextResponse.json( + { error: "suspensionDays must be an integer between 1 and 365" }, + { status: 400 }, + ); + } + return null; +} + +// Account-state side-effects commit atomically with the action row — the report +// can never read ACTION_TAKEN while the target kept access. +function applyModerationTransaction( + reportId: string, + actionType: ModerationActionType, + notes: string | undefined, + staffUserId: string, + input: ModerationActionInput, +) { + return prisma.$transaction( + async (tx) => { + // Status re-check rides the WHERE (CAS) — two staff racing the same + // report resolve to exactly one winner. + const moved = await tx.moderationReport.updateMany({ + where: { + id: reportId, + status: { in: ["PENDING", "UNDER_REVIEW", "ESCALATED"] }, + }, + data: { + status: actionType === "NO_ACTION" ? "DISMISSED" : "ACTION_TAKEN", + resolvedAt: new Date(), + resolvedBy: staffUserId, + }, + }); + if (moved.count === 0) { + throw Object.assign( + new Error("This report has already been resolved"), + { httpStatus: 409 }, + ); + } + + const action = await tx.moderationAction.create({ + data: { + reportId, + actionType, + notes, + takenById: staffUserId, + }, + include: { + takenBy: { + select: { id: true, name: true, email: true }, + }, + }, + }); + + const transactional = await applyTransactionalEffects(tx, input); + + const updatedReport = await tx.moderationReport.findUniqueOrThrow({ + where: { id: reportId }, + }); + + return { action, updatedReport, transactional }; + }, + { maxWait: 10000, timeout: 30000 }, + ); +} + +// Best-effort persistence of the side-effect summary for staff visibility — +// a failure here is captured but never surfaced to the caller. +async function persistSideEffects( + actionId: string, + sideEffects: SideEffectSummary, +): Promise { + await prisma.moderationAction + .update({ + where: { id: actionId }, + data: { sideEffects: JSON.parse(JSON.stringify(sideEffects)) }, + }) + .catch((error) => { + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "moderation" } }, + ); + }); +} + +function moderationActionErrorResponse(error: unknown): NextResponse { + if (error instanceof Error && "httpStatus" in error) { + const status = + typeof (error as { httpStatus?: number }).httpStatus === "number" + ? (error as { httpStatus: number }).httpStatus + : 500; + return NextResponse.json({ error: error.message }, { status }); + } + Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "staff" } }); + console.error("Error taking moderation action:", error); + return NextResponse.json( + { error: "Failed to take action" }, + { status: 500 }, + ); +} + export async function POST(req: NextRequest, { params }: RouteParams) { try { const auth = await requirePrivilegedAuth(); @@ -25,78 +165,71 @@ export async function POST(req: NextRequest, { params }: RouteParams) { const { reportId } = await params; const body = await req.json(); - const { actionType, notes } = body; - - // Validate action type - const validActions: ModerationActionType[] = [ - "WARNING_ISSUED", - "CONTENT_REMOVED", - "USER_SUSPENDED", - "USER_BANNED", - "PROFILE_UNVERIFIED", - "NO_ACTION", - ]; - - if (!actionType || !validActions.includes(actionType)) { - return NextResponse.json( - { error: "Invalid action type" }, - { status: 400 }, - ); - } + const { actionType, notes, suspensionDays } = body; + + const validationError = validateActionRequest(actionType, suspensionDays); + if (validationError) return validationError; // Check report exists const report = await prisma.moderationReport.findUnique({ where: { id: reportId }, - select: { id: true, status: true, targetUserId: true }, + select: { id: true, status: true, targetUserId: true, reviewId: true }, }); if (!report) { return NextResponse.json({ error: "Report not found" }, { status: 404 }); } - // Create action and update report status in a transaction - const [action, updatedReport] = await prisma.$transaction([ - prisma.moderationAction.create({ - data: { - reportId, - actionType, - notes, - takenById: session.user.id, - }, - include: { - takenBy: { - select: { id: true, name: true, email: true }, - }, - }, - }), - prisma.moderationReport.update({ - where: { id: reportId }, - data: { - status: actionType === "NO_ACTION" ? "DISMISSED" : "ACTION_TAKEN", - resolvedAt: new Date(), - resolvedBy: session.user.id, - }, - }), - ]); + // Idempotency: a resolved report never re-runs side-effects (a staff + // double-click on BAN must not double-refund). + if (report.status === "ACTION_TAKEN" || report.status === "DISMISSED") { + return NextResponse.json( + { error: "This report has already been resolved" }, + { status: 409 }, + ); + } + + const input = { + actionType: actionType as ModerationActionType, + report: { + id: report.id, + targetUserId: report.targetUserId, + reviewId: report.reviewId, + }, + staffUserId: session.user.id, + notes, + suspensionDays, + }; + + const { action, updatedReport, transactional } = + await applyModerationTransaction( + reportId, + actionType, + notes, + session.user.id, + input, + ); - // TODO: Execute actual actions based on actionType - // - WARNING_ISSUED: Send warning email to user - // - CONTENT_REMOVED: Delete/hide the reported content - // - USER_SUSPENDED: Temporarily disable user account - // - USER_BANNED: Permanently disable user account - // - PROFILE_UNVERIFIED: Set consultant isVerified to false + // Refunds, Stream revocation, and notifications are best-effort — each + // step's outcome (including failures) is persisted for staff visibility. + let sideEffects: SideEffectSummary = transactional; + try { + sideEffects = await applyBestEffortEffects(input, transactional); + } catch (error) { + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "moderation" } }, + ); + } + await persistSideEffects(action.id, sideEffects); return NextResponse.json({ action, report: updatedReport, + sideEffects, message: `Action '${actionType}' taken successfully`, }); } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "staff" } }); - console.error("Error taking moderation action:", error); - return NextResponse.json( - { error: "Failed to take action" }, - { status: 500 }, - ); + return moderationActionErrorResponse(error); } } diff --git a/app/api/staff/moderation/reviews/[reviewId]/route.ts b/app/api/staff/moderation/reviews/[reviewId]/route.ts index 8eff6c4c0..ca4b2be90 100644 --- a/app/api/staff/moderation/reviews/[reviewId]/route.ts +++ b/app/api/staff/moderation/reviews/[reviewId]/route.ts @@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { requirePrivilegedAuth } from "@/lib/auth-helpers"; +import { recomputeConsultantRating } from "@/lib/reviews"; interface RouteParams { params: Promise<{ reviewId: string }>; } @@ -38,24 +39,10 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) { // Delete review and recalculate consultant rating await prisma.$transaction(async (tx) => { - // Delete the review await tx.consultantReview.delete({ where: { id: reviewId }, }); - - // Recalculate average rating - const remainingReviews = await tx.consultantReview.aggregate({ - where: { consultantProfileId: review.consultantProfileId }, - _avg: { rating: true }, - _count: { id: true }, - }); - - await tx.consultantProfile.update({ - where: { id: review.consultantProfileId }, - data: { - rating: remainingReviews._avg.rating || 0, - }, - }); + await recomputeConsultantRating(tx, review.consultantProfileId); }); return NextResponse.json({ diff --git a/app/api/staff/support-tickets/[ticketId]/responses/route.ts b/app/api/staff/support-tickets/[ticketId]/responses/route.ts index 5db2d9c8c..796c646ab 100644 --- a/app/api/staff/support-tickets/[ticketId]/responses/route.ts +++ b/app/api/staff/support-tickets/[ticketId]/responses/route.ts @@ -67,8 +67,11 @@ export async function POST(req: NextRequest, { params }: RouteParams) { // Update ticket status to IN_PROGRESS if it was OPEN // Only update if this is not an internal note if (ticket.status === "OPEN" && !validatedData.isInternal) { - await prisma.supportTicket.update({ - where: { id: ticketId }, + // Status-guarded CAS: a concurrent staff edit that already moved the + // ticket off OPEN must not be clobbered back. updateMany is a no-op + // (count 0) when the guard misses, so the loser silently yields. + await prisma.supportTicket.updateMany({ + where: { id: ticketId, status: "OPEN" }, data: { status: "IN_PROGRESS", // Auto-assign to responding staff if not already assigned diff --git a/app/api/user/consultants/[id]/route.ts b/app/api/user/consultants/[id]/route.ts index a8d509181..0f16b8d0d 100644 --- a/app/api/user/consultants/[id]/route.ts +++ b/app/api/user/consultants/[id]/route.ts @@ -228,6 +228,7 @@ export async function GET( ? { where: planVisibilityFilter } : true, reviews: { + where: { deletedAt: null }, select: { id: true, rating: true }, take: 5, }, @@ -555,7 +556,7 @@ export async function PUT( }, webinarPlans: true, classPlans: true, - reviews: true, + reviews: { where: { deletedAt: null } }, }, }); diff --git a/app/api/user/consultants/route.ts b/app/api/user/consultants/route.ts index 6b1595507..1177a0f0f 100644 --- a/app/api/user/consultants/route.ts +++ b/app/api/user/consultants/route.ts @@ -125,7 +125,8 @@ export async function GET(request: NextRequest) { conditions.push({ OR: [ { user: { name: { contains: search, mode: "insensitive" } } }, - { user: { email: { contains: search, mode: "insensitive" } } }, + // No email match here — this is a public endpoint and email + // substring search is a PII enumeration key. { description: { contains: search, mode: "insensitive" } }, { headline: { contains: search, mode: "insensitive" } }, { domain: { name: { contains: search, mode: "insensitive" } } }, diff --git a/app/api/user/consultees/[id]/route.ts b/app/api/user/consultees/[id]/route.ts index 5cc27943e..59d74a221 100644 --- a/app/api/user/consultees/[id]/route.ts +++ b/app/api/user/consultees/[id]/route.ts @@ -119,7 +119,7 @@ export async function POST( user: { connect: { id: id } }, }, include: { - consultantReviews: true, + consultantReviews: { where: { deletedAt: null } }, user: true, }, }); @@ -190,7 +190,7 @@ export async function PATCH( budgetPreference: body.budgetPreference, }, include: { - consultantReviews: true, + consultantReviews: { where: { deletedAt: null } }, user: true, }, }); @@ -251,7 +251,7 @@ export async function DELETE( const deletedConsultee = await prisma.consulteeProfile.delete({ where: { id: id }, include: { - consultantReviews: true, + consultantReviews: { where: { deletedAt: null } }, user: true, }, }); diff --git a/app/api/user/consultees/route.ts b/app/api/user/consultees/route.ts index 983df963b..2a2659c51 100644 --- a/app/api/user/consultees/route.ts +++ b/app/api/user/consultees/route.ts @@ -29,7 +29,7 @@ export async function GET(request: NextRequest) { const consultees = await prisma.consulteeProfile.findMany({ include: { - consultantReviews: true, + consultantReviews: { where: { deletedAt: null } }, user: { select: { id: true, diff --git a/app/api/user/reviews/[id]/route.ts b/app/api/user/reviews/[id]/route.ts index ad83b3e7b..fc8f3d2fb 100644 --- a/app/api/user/reviews/[id]/route.ts +++ b/app/api/user/reviews/[id]/route.ts @@ -1,5 +1,6 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; import prisma from "@/lib/prisma"; import { requireApiAuth, @@ -7,6 +8,9 @@ import { checkOwnership, forbiddenResponse, } from "@/lib/auth-helpers"; +import { recomputeConsultantRating } from "@/lib/reviews"; +import { withSerializableRetry } from "@/lib/db/serializable-retry"; +import { UpdateReviewSchema } from "@/schemas/feedbacks"; // GET: Public read (for trust/SEO purposes) export async function GET( @@ -24,7 +28,8 @@ export async function GET( }, }); - if (!review) { + // #693 — a moderation-removed review reads as gone + if (!review || review.deletedAt) { return NextResponse.json({ error: "Review not found" }, { status: 404 }); } @@ -55,10 +60,15 @@ export async function PUT( // Fetch the review to check ownership const review = await prisma.consultantReview.findUnique({ where: { id: id }, - select: { consulteeProfileId: true }, + select: { + consulteeProfileId: true, + consultantProfileId: true, + deletedAt: true, + }, }); - if (!review) { + // #693 — a moderation-removed review cannot be edited back into view + if (!review || review.deletedAt) { return NextResponse.json({ error: "Review not found" }, { status: 404 }); } @@ -72,18 +82,41 @@ export async function PUT( return forbiddenResponse("You can only update your own reviews"); } - const body = await req.json(); - const updatedReview = await prisma.consultantReview.update({ - where: { id: id }, - data: { - rating: body.rating, - reviewDescription: body.reviewDescription, - }, - include: { - consultantProfile: true, - consulteeProfile: true, - }, - }); + const parsed = UpdateReviewSchema.safeParse(await req.json()); + if (!parsed.success) { + return NextResponse.json( + { error: "Validation failed", details: parsed.error.issues }, + { status: 400 }, + ); + } + const body = parsed.data; + + // Update + rating recompute in one transaction — ConsultantProfile.rating + // is denormalized for explore sort/filter and must track every mutation. + // Serializable + retry so concurrent review writes for the same consultant + // can't lose-update the recomputed average (P2034 aborts one, retry blocks). + const updatedReview = await withSerializableRetry(() => + prisma.$transaction( + async (tx) => { + const updated = await tx.consultantReview.update({ + where: { id: id }, + data: { + rating: body.rating, + reviewDescription: body.reviewDescription, + }, + include: { + consultantProfile: true, + consulteeProfile: true, + }, + }); + + await recomputeConsultantRating(tx, review.consultantProfileId); + + return updated; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + ); return NextResponse.json(updatedReview, { status: 200 }); } catch (error) { @@ -112,7 +145,7 @@ export async function DELETE( // Fetch the review to check ownership const review = await prisma.consultantReview.findUnique({ where: { id: id }, - select: { consulteeProfileId: true }, + select: { consulteeProfileId: true, consultantProfileId: true }, }); if (!review) { @@ -129,9 +162,18 @@ export async function DELETE( return forbiddenResponse("You can only delete your own reviews"); } - await prisma.consultantReview.delete({ - where: { id: id }, - }); + // Delete + rating recompute in one transaction — see PUT. + await withSerializableRetry(() => + prisma.$transaction( + async (tx) => { + await tx.consultantReview.delete({ + where: { id: id }, + }); + await recomputeConsultantRating(tx, review.consultantProfileId); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + ); return NextResponse.json( { message: "Review deleted successfully" }, diff --git a/app/api/user/reviews/route.ts b/app/api/user/reviews/route.ts index 9edeaaac4..0c533a900 100644 --- a/app/api/user/reviews/route.ts +++ b/app/api/user/reviews/route.ts @@ -7,6 +7,11 @@ import { CreateReviewSchema } from "@/schemas/feedbacks"; import { apiError } from "@/lib/errors"; import { getSession } from "@/lib/auth-server"; import { spamLimiter, applyRateLimit } from "@/lib/rate-limit"; +import { + hasCompletedBookingWith, + recomputeConsultantRating, +} from "@/lib/reviews"; +import { withSerializableRetry } from "@/lib/db/serializable-retry"; export async function GET(req: NextRequest) { try { @@ -39,6 +44,8 @@ export async function GET(req: NextRequest) { }; } + // #693 — moderation-removed reviews stay hidden + whereClause.deletedAt = null; const reviews = await prisma.consultantReview.findMany({ where: whereClause, take: 50, @@ -104,35 +111,80 @@ export async function POST(req: NextRequest) { } const validatedData = result.data; - const newReview = await prisma.consultantReview.create({ - data: { - rating: validatedData.rating, - reviewDescription: validatedData.reviewDescription, - consultantProfileId: validatedData.consultantProfileId, - consulteeProfileId: validatedData.consulteeProfileId, - }, - include: { - consultantProfile: { - include: { - user: { - select: { - name: true, + // Reviews are always authored as the session user's own consultee + // profile — the body's consulteeProfileId is only accepted if it matches. + const sessionConsulteeProfileId = session.user.consulteeProfileId; + if (!sessionConsulteeProfileId) { + return NextResponse.json( + { error: "You need a consultee profile to post a review" }, + { status: 403 }, + ); + } + if (validatedData.consulteeProfileId !== sessionConsulteeProfileId) { + return NextResponse.json( + { error: "You can only post reviews as yourself" }, + { status: 403 }, + ); + } + + // Only consultees with a completed booking may review the consultant. + const eligible = await hasCompletedBookingWith( + sessionConsulteeProfileId, + validatedData.consultantProfileId, + ); + if (!eligible) { + return NextResponse.json( + { + error: + "You can only review consultants after a completed session with them", + }, + { status: 403 }, + ); + } + + // Create + rating recompute in one transaction so the denormalized + // ConsultantProfile.rating (explore sort/filter) never drifts. Serializable + // + retry so two concurrent reviews for the same consultant can't lose-update + // the recomputed average (P2034 aborts one, retry then sees the committed row). + const newReview = await withSerializableRetry(() => + prisma.$transaction(async (tx) => { + const created = await tx.consultantReview.create({ + data: { + rating: validatedData.rating, + reviewDescription: validatedData.reviewDescription, + consultantProfileId: validatedData.consultantProfileId, + consulteeProfileId: sessionConsulteeProfileId, + }, + include: { + consultantProfile: { + include: { + user: { + select: { + name: true, + }, }, }, }, - }, - consulteeProfile: { - include: { - user: { - select: { - name: true, - image: true, + consulteeProfile: { + include: { + user: { + select: { + name: true, + image: true, + }, }, }, }, }, + }); + + await recomputeConsultantRating(tx, created.consultantProfileId); + + return created; }, - }); + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + ); // Notify the consultant about the new review void notifyNewReview(newReview.consultantProfile.userId, { @@ -145,6 +197,16 @@ export async function POST(req: NextRequest) { return NextResponse.json(newReview, { status: 201 }); } catch (error) { + // @@unique([consultantProfileId, consulteeProfileId]) — one review per pair. + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + return NextResponse.json( + { error: "You have already reviewed this consultant" }, + { status: 409 }, + ); + } Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "auth" } }); return apiError({ tag: "[Reviews.POST]", error }); } diff --git a/app/api/user/support-tickets/route.ts b/app/api/user/support-tickets/route.ts index 63697b8e4..4c1f64b10 100644 --- a/app/api/user/support-tickets/route.ts +++ b/app/api/user/support-tickets/route.ts @@ -169,6 +169,34 @@ export async function POST(req: NextRequest) { ); } + // Dedup: a payment-linked ticket reuses any still-open ticket the user + // already filed for the same payment. Kept a runtime check (not a schema + // unique) — a payment can legitimately spawn a second ticket once the + // first is RESOLVED/CLOSED, so uniqueness is scoped to open state. + if (validatedData.paymentId) { + const existing = await prisma.supportTicket.findFirst({ + where: { + paymentId: validatedData.paymentId, + userId: session.user.id, + status: { notIn: ["RESOLVED", "CLOSED"] }, + }, + // Match the user-facing GET shape: hide internal staff notes + include: { + responses: { + where: { isInternal: false }, + orderBy: { createdAt: "asc" }, + include: { + user: { select: { name: true, role: true } }, + }, + }, + attachments: { orderBy: { uploadedAt: "desc" } }, + }, + }); + if (existing) { + return NextResponse.json(existing, { status: 200 }); + } + } + const ticket = await prisma.supportTicket.create({ data: { title: validatedData.title, diff --git a/app/api/waitlist/route.ts b/app/api/waitlist/route.ts index f434fe474..0877d1a63 100644 --- a/app/api/waitlist/route.ts +++ b/app/api/waitlist/route.ts @@ -55,9 +55,12 @@ export async function POST(request: NextRequest) { }); if (!result.success) { + // Friendly 409 for the already-on-waitlist conflict (incl. the concurrent + // double-join race that P2002 catches); everything else is a 400. + const status = result.code === "ALREADY_ON_WAITLIST" ? 409 : 400; return NextResponse.json( { success: false, error: result.message }, - { status: 400 }, + { status }, ); } diff --git a/app/api/webhooks/lemon-squeezy/route.ts b/app/api/webhooks/lemon-squeezy/route.ts deleted file mode 100644 index 7c2b38ed2..000000000 --- a/app/api/webhooks/lemon-squeezy/route.ts +++ /dev/null @@ -1,349 +0,0 @@ -import * as Sentry from "@sentry/nextjs"; -import { NextRequest, NextResponse } from "next/server"; -import prisma, { type Tx } from "@/lib/prisma"; -import { Prisma, PaymentStatus, AppointmentStatus } from "@prisma/client"; -import { - isDbHealthy, - verifyHmacWebhookSignature, -} from "@/app/api/webhooks/utils"; - -export async function POST(req: NextRequest) { - try { - // #813 — capture the secret once before verification. - const secret = process.env.LEMON_SQUEEZY_WEBHOOK_SECRET; - if (!secret) { - console.error("LEMON_SQUEEZY_WEBHOOK_SECRET not configured"); - return NextResponse.json( - { error: "Webhook secret not configured" }, - { status: 500 }, - ); - } - - // #813/#812 — shared strict HMAC verify (hex-decode + length-64 gate + - // timingSafeEqual). REJECT a missing header (a forged unsigned POST used to - // skip verification). Lemon prefixes the digest with `sha256=`. - const { isValid, body, missingHeader } = await verifyHmacWebhookSignature( - req, - secret, - { header: "x-signature", prefix: "sha256=" }, - ); - if (missingHeader) { - console.error("Lemon Squeezy webhook missing signature header"); - return NextResponse.json({ error: "Missing signature" }, { status: 401 }); - } - if (!isValid) { - console.error("Lemon Squeezy webhook signature verification failed"); - return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); - } - - // DB health check — return 503 if DB is unreachable so Lemon Squeezy retries - if (!(await isDbHealthy())) { - console.warn( - "[lemon-squeezy webhook] DB unhealthy — returning 503 for retry", - ); - return NextResponse.json( - { error: "Service temporarily unavailable" }, - { status: 503 }, - ); - } - - const event = JSON.parse(body); - - Sentry.logger.info("lemon squeezy webhook received", { eventName: String(event.meta?.event_name ?? "unknown") }); - - // Log webhook events - console.log(`🍋 Lemon Squeezy Webhook Event: ${event.meta?.event_name}`, { - event_name: event.meta?.event_name, - webhook_id: event.meta?.webhook_id, - custom_data: event.meta?.custom_data, - }); - - // Handle different event types - switch (event.meta?.event_name) { - case "order_created": { - const order = event.data; - console.log("🍋 Order created:", { - id: order.id, - identifier: order.attributes.identifier, - total: order.attributes.total, - status: order.attributes.status, - customer_id: order.attributes.customer_id, - }); - - if (order.attributes.status === "paid") { - await handleLemonSqueezyPaymentSuccess( - order.attributes.identifier, - order.attributes.user_email, - ); - } - break; - } - - case "subscription_created": { - const subscription = event.data; - console.log("🍋 Subscription created:", { - id: subscription.id, - status: subscription.attributes.status, - customer_id: subscription.attributes.customer_id, - variant_id: subscription.attributes.variant_id, - }); - - if (subscription.attributes.status === "active") { - await handleLemonSqueezyPaymentSuccess( - subscription.id.toString(), - subscription.attributes.user_email, - ); - } - break; - } - - case "subscription_payment_success": { - const successfulPayment = event.data; - console.log("🍋 Subscription payment successful:", { - id: successfulPayment.id, - subscription_id: successfulPayment.attributes.subscription_id, - amount: successfulPayment.attributes.total, - }); - - await handleLemonSqueezyPaymentSuccess( - successfulPayment.attributes.subscription_id.toString(), - successfulPayment.attributes.user_email, - ); - break; - } - - case "subscription_payment_failed": { - const failedPayment = event.data; - console.log("🍋 Subscription payment failed:", { - id: failedPayment.id, - subscription_id: failedPayment.attributes.subscription_id, - failure_reason: failedPayment.attributes.failure_reason, - }); - - await handleLemonSqueezyPaymentFailure( - failedPayment.attributes.subscription_id.toString(), - ); - break; - } - - case "subscription_cancelled": { - const cancelledSub = event.data; - console.log("🍋 Subscription cancelled:", { - id: cancelledSub.id, - status: cancelledSub.attributes.status, - }); - // Handle subscription cancellation if needed - break; - } - - default: - console.log( - `🍋 Unhandled Lemon Squeezy event type: ${event.meta?.event_name}`, - ); - } - - return NextResponse.json({ status: "ok" }); - } catch (error) { - console.error("Lemon Squeezy webhook error:", error); - Sentry.captureException(error, { - tags: { subsystem: "payments", provider: "lemon-squeezy" }, - }); - return NextResponse.json( - { error: "Webhook handler failed" }, - { status: 500 }, - ); - } -} - -// Helper function to handle successful Lemon Squeezy payments -async function handleLemonSqueezyPaymentSuccess( - paymentIdentifier: string, - userEmail?: string, -) { - await prisma.$transaction(async (tx) => { - // Find payment record by identifier (stored as paymentIntent) - const payment = await tx.payment.findUnique({ - where: { paymentIntent: paymentIdentifier }, - include: { - user: { include: { consulteeProfile: true } }, - appointment: { - include: { - consultation: true, - subscription: true, - webinar: true, - class: true, - }, - }, - }, - }); - - if (!payment) { - console.error( - "Payment record not found for identifier:", - paymentIdentifier, - ); - return; - } - - if (!payment.user.consulteeProfile) { - console.error("User profile not found for payment:", payment.id); - return; - } - - // Update payment status - await tx.payment.update({ - where: { id: payment.id }, - data: { - paymentStatus: PaymentStatus.SUCCEEDED, - receiptUrl: userEmail ? `receipt_${userEmail}` : undefined, - }, - }); - - // Create appointment from metadata if not exists - if (!payment.appointmentId) { - console.log( - "Creating appointment for Lemon Squeezy payment:", - payment.id, - ); - await createAppointmentFromPayment(tx, payment); - } else { - // Confirm existing appointment - await confirmExistingAppointment(tx, payment.appointmentId); - } - - console.log("✅ Lemon Squeezy payment processed successfully"); - }); -} - -// Helper function to handle failed Lemon Squeezy payments -async function handleLemonSqueezyPaymentFailure(paymentIdentifier: string) { - await prisma.$transaction(async (tx) => { - // Find and update payment record - const payment = await tx.payment.findUnique({ - where: { paymentIntent: paymentIdentifier }, - include: { appointment: true }, - }); - - if (!payment) { - console.warn(`Payment not found for identifier: ${paymentIdentifier}`); - return; - } - - // Update payment status - await tx.payment.update({ - where: { id: payment.id }, - data: { paymentStatus: PaymentStatus.FAILED }, - }); - - // Cleanup failed payment appointment if exists - if (payment.appointment) { - await cleanupFailedPaymentAppointment(tx, payment.appointment.id); - } - - console.log(`Processed Lemon Squeezy payment failure: ${payment.id}`); - }); -} - -// Helper function to create appointment from payment record -async function createAppointmentFromPayment(_tx: Tx, _payment: unknown) { - // For Lemon Squeezy, like Razorpay, we need to store appointment metadata - // in the payment record or use custom_data from the webhook - console.log( - "Creating appointment for Lemon Squeezy payment - implementation needed", - ); - - // TODO: Implement appointment creation based on stored payment data - // This would require storing appointment metadata in the payment record - // or using Lemon Squeezy's custom_data field - - console.warn( - "Lemon Squeezy appointment creation needs implementation - metadata not available", - ); -} - -// Helper function to confirm existing appointment -async function confirmExistingAppointment(tx: Tx, appointmentId: string) { - // Make slots non-tentative - await tx.slotOfAppointment.updateMany({ - where: { appointmentId }, - data: { isTentative: false }, - }); - - // Update appointment status - const appointment = await tx.appointment.findUnique({ - where: { id: appointmentId }, - include: { - consultation: true, - subscription: true, - webinar: true, - class: true, - }, - }); - - if (appointment?.consultation) { - await tx.consultation.update({ - where: { id: appointment.consultation.id }, - data: { status: AppointmentStatus.PENDING }, // Keep as PENDING for consultant approval - }); - } - - if (appointment?.subscription) { - await tx.subscription.update({ - where: { id: appointment.subscription.id }, - data: { status: AppointmentStatus.PENDING }, // Keep as PENDING for consultant approval - }); - } - - if (appointment?.webinar) { - await tx.webinar.update({ - where: { id: appointment.webinar.id }, - data: { status: "SCHEDULED" }, - }); - } - - if (appointment?.class) { - await tx.class.update({ - where: { id: appointment.class.id }, - data: { status: "SCHEDULED" }, - }); - } -} - -// Helper function to cleanup failed payment appointments -async function cleanupFailedPaymentAppointment(tx: Tx, appointmentId: string) { - const appointment = await tx.appointment.findUnique({ - where: { id: appointmentId }, - include: { - consultation: true, - subscription: true, - webinar: true, - class: true, - }, - }); - - if (!appointment) return; - - // Delete associated records - if (appointment.consultation) { - await tx.consultation.delete({ - where: { id: appointment.consultation.id }, - }); - } - - if (appointment.subscription) { - await tx.subscription.delete({ - where: { id: appointment.subscription.id }, - }); - } - - // Delete slots and appointment - await tx.slotOfAppointment.deleteMany({ - where: { appointmentId }, - }); - - await tx.appointment.delete({ - where: { id: appointmentId }, - }); - - console.log(`Cleaned up failed payment appointment: ${appointmentId}`); -} diff --git a/app/api/webhooks/utils.ts b/app/api/webhooks/utils.ts index 04bf38bb2..78f053af6 100644 --- a/app/api/webhooks/utils.ts +++ b/app/api/webhooks/utils.ts @@ -581,51 +581,6 @@ export async function verifyWebhookSignature( } } -/** - * #813/#812 — generic HMAC-SHA256 webhook verifier for the hand-rolled - * Lemon Squeezy / XFlow routes (they differ only by header name and Lemon's - * `sha256=` prefix). Uses the STRICTER hex-decode + fixed-length-64 gate + - * timingSafeEqual that verifyWebhookSignature(razorpay) uses, replacing the old - * raw-UTF8 Buffer compare. Reads the body once and returns it alongside the - * verdict; `missingHeader` lets callers keep their distinct 401-missing / - * 400-invalid responses. - */ -export async function verifyHmacWebhookSignature( - req: Request, - secret: string, - opts: { header: string; prefix?: string }, -): Promise<{ isValid: boolean; body: string; missingHeader: boolean }> { - const body = await req.text(); - const raw = req.headers.get(opts.header); - if (!raw) { - return { isValid: false, body, missingHeader: true }; - } - // Strip the gateway's prefix (e.g. Lemon's `sha256=`) before hex-decoding. - const signature = - opts.prefix && raw.startsWith(opts.prefix) - ? raw.slice(opts.prefix.length) - : raw; - // hex-decode gate: a hex SHA-256 digest is exactly 64 chars; Buffer.from - // silently truncates odd/invalid input, so reject anything else outright. - if (signature.length !== 64) { - return { isValid: false, body, missingHeader: false }; - } - const expected = crypto - .createHmac("sha256", secret) - .update(body) - .digest("hex"); - const sigBuf = Buffer.from(signature, "hex"); - const expectedBuf = Buffer.from(expected, "hex"); - if (sigBuf.length !== expectedBuf.length) { - return { isValid: false, body, missingHeader: false }; - } - return { - isValid: crypto.timingSafeEqual(sigBuf, expectedBuf), - body, - missingHeader: false, - }; -} - // ============================================================================ // Refund Webhook Handlers // ============================================================================ diff --git a/app/api/webhooks/xflow/route.ts b/app/api/webhooks/xflow/route.ts deleted file mode 100644 index b5fe08e6d..000000000 --- a/app/api/webhooks/xflow/route.ts +++ /dev/null @@ -1,483 +0,0 @@ -import * as Sentry from "@sentry/nextjs"; -import { NextRequest, NextResponse } from "next/server"; -import prisma, { type Tx } from "@/lib/prisma"; -import { Prisma, PaymentStatus, AppointmentStatus } from "@prisma/client"; -import { - isDbHealthy, - verifyHmacWebhookSignature, -} from "@/app/api/webhooks/utils"; - -export async function POST(req: NextRequest) { - try { - // #813 — capture the secret once before verification. - const secret = process.env.XFLOW_WEBHOOK_SECRET; - if (!secret) { - console.error("XFLOW_WEBHOOK_SECRET not configured"); - return NextResponse.json( - { error: "Webhook secret not configured" }, - { status: 500 }, - ); - } - - // #813/#812 — shared strict HMAC verify (hex-decode + length-64 gate + - // timingSafeEqual). REJECT a missing header (a forged unsigned POST used to - // skip verification). XFlow sends the raw hex digest (no prefix). - const { isValid, body, missingHeader } = await verifyHmacWebhookSignature( - req, - secret, - { header: "x-xflow-signature" }, - ); - if (missingHeader) { - console.error("XFlow webhook missing signature header"); - return NextResponse.json({ error: "Missing signature" }, { status: 401 }); - } - if (!isValid) { - console.error("XFlow webhook signature verification failed"); - return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); - } - - // DB health check — return 503 if DB is unreachable so XFlow retries - if (!(await isDbHealthy())) { - console.warn("[xflow webhook] DB unhealthy — returning 503 for retry"); - return NextResponse.json( - { error: "Service temporarily unavailable" }, - { status: 503 }, - ); - } - - const event = JSON.parse(body); - - Sentry.logger.info("xflow webhook received", { type: String(event.type ?? "unknown") }); - - // Log webhook events - console.log(`🌊 XFlow Webhook Event: ${event.type}`, { - type: event.type, - id: event.id, - created: event.created, - data: event.data, - }); - - // Handle different event types - switch (event.type) { - case "payment.succeeded": { - const payment = event.data.object; - console.log("🌊 XFlow payment succeeded:", { - id: payment.id, - amount: payment.amount, - currency: payment.currency, - status: payment.status, - customer: payment.customer, - }); - - await handleXFlowPaymentSuccess( - payment.id, - payment.customer?.email, - payment.metadata || {}, - ); - break; - } - - case "payment.failed": { - const failedPayment = event.data.object; - console.log("🌊 XFlow payment failed:", { - id: failedPayment.id, - failure_code: failedPayment.failure_code, - failure_message: failedPayment.failure_message, - }); - - await handleXFlowPaymentFailure(failedPayment.id); - break; - } - - case "payment.pending": { - const pendingPayment = event.data.object; - console.log("🌊 XFlow payment pending:", { - id: pendingPayment.id, - status: pendingPayment.status, - }); - // Log but don't process pending payments - break; - } - - case "subscription.created": { - const subscription = event.data.object; - console.log("🌊 XFlow subscription created:", { - id: subscription.id, - status: subscription.status, - current_period_start: subscription.current_period_start, - current_period_end: subscription.current_period_end, - }); - - if (subscription.status === "active") { - await handleXFlowPaymentSuccess( - subscription.latest_invoice, - subscription.customer?.email, - subscription.metadata || {}, - ); - } - break; - } - - case "subscription.updated": { - const updatedSub = event.data.object; - console.log("🌊 XFlow subscription updated:", { - id: updatedSub.id, - status: updatedSub.status, - }); - - if (updatedSub.status === "active") { - await handleXFlowPaymentSuccess( - updatedSub.latest_invoice, - updatedSub.customer?.email, - updatedSub.metadata || {}, - ); - } else if ( - updatedSub.status === "canceled" || - updatedSub.status === "incomplete_expired" - ) { - await handleXFlowPaymentFailure(updatedSub.latest_invoice); - } - break; - } - - case "subscription.deleted": { - const deletedSub = event.data.object; - console.log("🌊 XFlow subscription deleted:", { - id: deletedSub.id, - status: deletedSub.status, - }); - // Handle subscription deletion if needed - break; - } - - default: - console.log(`🌊 Unhandled XFlow event type: ${event.type}`); - } - - return NextResponse.json({ status: "ok" }); - } catch (error) { - console.error("XFlow webhook error:", error); - Sentry.captureException(error, { - tags: { subsystem: "payments", provider: "xflow" }, - }); - return NextResponse.json( - { error: "Webhook handler failed" }, - { status: 500 }, - ); - } -} - -// Helper function to handle successful XFlow payments -async function handleXFlowPaymentSuccess( - paymentId: string, - userEmail?: string, - metadata: Record = {}, -) { - await prisma.$transaction(async (tx) => { - // Find payment record by payment ID (stored as paymentIntent) - const payment = await tx.payment.findUnique({ - where: { paymentIntent: paymentId }, - include: { - user: { include: { consulteeProfile: true } }, - appointment: { - include: { - consultation: true, - subscription: true, - webinar: true, - class: true, - }, - }, - }, - }); - - if (!payment) { - console.error("Payment record not found for XFlow payment:", paymentId); - return; - } - - if (!payment.user.consulteeProfile) { - console.error("User profile not found for payment:", payment.id); - return; - } - - // Update payment status - await tx.payment.update({ - where: { id: payment.id }, - data: { - paymentStatus: PaymentStatus.SUCCEEDED, - receiptUrl: userEmail ? `receipt_${userEmail}` : undefined, - }, - }); - - // Create appointment from metadata if not exists - if (!payment.appointmentId) { - console.log("Creating appointment for XFlow payment:", payment.id); - await createAppointmentFromPayment(tx, payment, metadata); - } else { - // Confirm existing appointment - await confirmExistingAppointment(tx, payment.appointmentId); - } - - console.log("✅ XFlow payment processed successfully"); - }); -} - -// Helper function to handle failed XFlow payments -async function handleXFlowPaymentFailure(paymentId: string) { - await prisma.$transaction(async (tx) => { - // Find and update payment record - const payment = await tx.payment.findUnique({ - where: { paymentIntent: paymentId }, - include: { appointment: true }, - }); - - if (!payment) { - console.warn(`Payment not found for XFlow ID: ${paymentId}`); - return; - } - - // Update payment status - await tx.payment.update({ - where: { id: payment.id }, - data: { paymentStatus: PaymentStatus.FAILED }, - }); - - // Cleanup failed payment appointment if exists - if (payment.appointment) { - await cleanupFailedPaymentAppointment(tx, payment.appointment.id); - } - - console.log(`Processed XFlow payment failure: ${payment.id}`); - }); -} - -// Helper function to create appointment from payment record -async function createAppointmentFromPayment( - tx: Tx, - payment: { id: string; userId: string; [key: string]: unknown }, - metadata: { - type?: string; - planId?: string; - eventId?: string; - slotIds?: string; - title?: string; - description?: string; - }, -) { - // For XFlow, we can use metadata like Stripe to store appointment details - const { type, planId, eventId, slotIds, title, description } = metadata; - - console.log("Creating appointment from XFlow metadata:", { - type, - planId, - eventId, - slotIds, - }); - - if (!type || !slotIds) { - console.warn( - "XFlow appointment creation needs implementation - metadata not available", - ); - return; - } - - const slotIdArray = - typeof slotIds === "string" ? slotIds.split(",") : slotIds; - - // TODO: XFlow appointment creation is a stub — field names below are - // placeholders and do NOT match the current Prisma schema. The casts - // silence TypeScript until the integration is fully implemented. - - // Create the appointment - const appointment = await tx.appointment.create({ - data: { - userId: payment.userId, - title: title || `${type} Appointment`, - description: description || "", - createdAt: new Date(), - updatedAt: new Date(), - } as unknown as Prisma.AppointmentUncheckedCreateInput, - }); - - // Create slot associations - for (const slotId of slotIdArray) { - await tx.slotOfAppointment.create({ - data: { - slotId: slotId.trim(), - appointmentId: appointment.id, - isTentative: false, - } as unknown as Prisma.SlotOfAppointmentUncheckedCreateInput, - }); - } - - // Create type-specific records - switch (type) { - case "consultation": { - if (!planId) throw new Error("Missing planId for consultation"); - await tx.consultation.create({ - data: { - appointmentId: appointment.id, - consultationPlanId: planId, - status: AppointmentStatus.PENDING, - } as unknown as Prisma.ConsultationUncheckedCreateInput, - }); - break; - } - - case "subscription": { - if (!planId) throw new Error("Missing planId for subscription"); - await tx.subscription.create({ - data: { - appointmentId: appointment.id, - subscriptionPlanId: planId, - status: AppointmentStatus.PENDING, - } as unknown as Prisma.SubscriptionUncheckedCreateInput, - }); - break; - } - - case "webinar": { - if (eventId) { - await tx.webinar.create({ - data: { - appointmentId: appointment.id, - webinarEventId: eventId, - status: "SCHEDULED", - } as unknown as Prisma.WebinarUncheckedCreateInput, - }); - } else if (planId) { - await tx.webinar.create({ - data: { - appointmentId: appointment.id, - webinarPlanId: planId, - status: "SCHEDULED", - } as unknown as Prisma.WebinarUncheckedCreateInput, - }); - } else { - throw new Error("Missing eventId or planId for webinar"); - } - break; - } - - case "class": { - if (eventId) { - await tx.class.create({ - data: { - appointmentId: appointment.id, - classEventId: eventId, - status: "SCHEDULED", - } as unknown as Prisma.ClassUncheckedCreateInput, - }); - } else if (planId) { - await tx.class.create({ - data: { - appointmentId: appointment.id, - classPlanId: planId, - status: "SCHEDULED", - } as unknown as Prisma.ClassUncheckedCreateInput, - }); - } else { - throw new Error("Missing eventId or planId for class"); - } - break; - } - - default: - throw new Error(`Unknown appointment type: ${type}`); - } - - // Link payment to the new appointment - await tx.payment.update({ - where: { id: payment.id }, - data: { appointmentId: appointment.id }, - }); - - console.log(`✅ Created ${type} appointment: ${appointment.id}`); -} - -// Helper function to confirm existing appointment -async function confirmExistingAppointment(tx: Tx, appointmentId: string) { - // Make slots non-tentative - await tx.slotOfAppointment.updateMany({ - where: { appointmentId }, - data: { isTentative: false }, - }); - - // Update appointment status - const appointment = await tx.appointment.findUnique({ - where: { id: appointmentId }, - include: { - consultation: true, - subscription: true, - webinar: true, - class: true, - }, - }); - - if (appointment?.consultation) { - await tx.consultation.update({ - where: { id: appointment.consultation.id }, - data: { status: AppointmentStatus.PENDING }, - }); - } - - if (appointment?.subscription) { - await tx.subscription.update({ - where: { id: appointment.subscription.id }, - data: { status: AppointmentStatus.PENDING }, - }); - } - - if (appointment?.webinar) { - await tx.webinar.update({ - where: { id: appointment.webinar.id }, - data: { status: "SCHEDULED" }, - }); - } - - if (appointment?.class) { - await tx.class.update({ - where: { id: appointment.class.id }, - data: { status: "SCHEDULED" }, - }); - } -} - -// Helper function to cleanup failed payment appointments -async function cleanupFailedPaymentAppointment(tx: Tx, appointmentId: string) { - const appointment = await tx.appointment.findUnique({ - where: { id: appointmentId }, - include: { - consultation: true, - subscription: true, - webinar: true, - class: true, - }, - }); - - if (!appointment) return; - - // Delete associated records - if (appointment.consultation) { - await tx.consultation.delete({ - where: { id: appointment.consultation.id }, - }); - } - - if (appointment.subscription) { - await tx.subscription.delete({ - where: { id: appointment.subscription.id }, - }); - } - - // Delete slots and appointment - await tx.slotOfAppointment.deleteMany({ - where: { appointmentId }, - }); - - await tx.appointment.delete({ - where: { id: appointmentId }, - }); - - console.log(`Cleaned up failed payment appointment: ${appointmentId}`); -} diff --git a/app/auth/signup/page.tsx b/app/auth/signup/page.tsx index c05c9cace..8ec94c365 100644 --- a/app/auth/signup/page.tsx +++ b/app/auth/signup/page.tsx @@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useToast } from "@/hooks/use-toast"; import { signUp, useSession, sendVerificationEmail } from "@/lib/auth-client"; -import { setPendingReferral, clearPendingReferral } from "@/lib/pending-referral"; +import { setPendingReferral } from "@/lib/pending-referral"; import { ssoSigninWithGuard } from "@/lib/sso/signin-with-toast"; import { GlobeIcon } from "@/components/auth/auth-icons"; import { SocialLoginButtons } from "@/components/auth/social-login-buttons"; @@ -88,9 +88,10 @@ function SignUpContent() { // Persist the referral code at first touch so it survives the OAuth redirect // and the email-verification gap; it is applied after authentication on the // onboarding landing. #880 + // #891 — landing here WITHOUT ?ref= must not wipe a previously-stashed code; + // an explicit different code simply overwrites the stash. useEffect(() => { if (refCode) setPendingReferral(refCode); - else clearPendingReferral(); }, [refCode]); // Show loading while checking session status (fallback for when middleware doesn't catch) diff --git a/app/checkout/payments.md b/app/checkout/payments.md index ce66e6dcb..8b60630db 100644 --- a/app/checkout/payments.md +++ b/app/checkout/payments.md @@ -225,13 +225,7 @@ const checkoutSchema = z.object({ slotStartTimeInUTC: z.string().optional(), slotEndTimeInUTC: z.string().optional(), notes: z.string().optional(), - paymentGateway: z.enum([ - "STRIPE", - "RAZORPAY", - "LEMON_SQUEEZY", - "XFLOW", - "CARD", - ]), + paymentGateway: z.enum(["STRIPE", "RAZORPAY", "CARD"]), slotOfAvailabilityWeeklyId: z.string().optional(), }); ``` @@ -499,7 +493,7 @@ try { ### Planned Features -1. **Additional Payment Gateways**: Lemon Squeezy, XFlow integration +1. **Additional Payment Gateways**: Dodo Payments (post-MVP, evaluation pending) 2. **Recurring Payments**: Automatic subscription renewals 3. **Partial Refunds**: Pro-rated cancellation handling 4. **Payment Analytics**: Revenue tracking and reporting diff --git a/app/checkout/plans/class/[planId]/page.tsx b/app/checkout/plans/class/[planId]/page.tsx index 2d8ea510f..c72226e06 100644 --- a/app/checkout/plans/class/[planId]/page.tsx +++ b/app/checkout/plans/class/[planId]/page.tsx @@ -14,8 +14,8 @@ import { SearchParams, searchParamsSchema, createCheckoutData, + type SupportedCheckoutGateway, } from "@/schemas/checkout"; -import { PaymentGateway } from "@prisma/client"; import { CreditCard as CreditCardIcon } from "lucide-react"; import { CompanyLogo } from "@/components/ui/company-logo"; import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -211,7 +211,7 @@ export default function ClassCheckoutPage({ const razorpayHandlers = createRazorpayCheckoutHandlers(toast); const handleCheckout = useCallback( - async (gateway: PaymentGateway, isMockPayment: boolean = false) => { + async (gateway: SupportedCheckoutGateway, isMockPayment: boolean = false) => { // Block checkout during maintenance mode if (isMaintenanceBlocked) { toast({ diff --git a/app/checkout/plans/consultation/[planId]/page.tsx b/app/checkout/plans/consultation/[planId]/page.tsx index 890b044bb..a927f46de 100644 --- a/app/checkout/plans/consultation/[planId]/page.tsx +++ b/app/checkout/plans/consultation/[planId]/page.tsx @@ -16,6 +16,7 @@ import { checkoutResponseSchema, consultationSearchParamsSchema, createCheckoutData, + type SupportedCheckoutGateway, } from "@/schemas/checkout"; import { MINIMUM_BOOKING_LEAD_TIME_MS, @@ -28,7 +29,6 @@ import { ConsultantProfile, ConsultantReview, ConsultationPlan, - PaymentGateway, } from "@prisma/client"; import { CreditCard as CreditCardIcon } from "lucide-react"; import { CompanyLogo } from "@/components/ui/company-logo"; @@ -265,7 +265,7 @@ export default function ConsultationCheckoutPage({ ); const handleCheckout = useCallback( - async (gateway: PaymentGateway, isMockPayment: boolean = false) => { + async (gateway: SupportedCheckoutGateway, isMockPayment: boolean = false) => { // Block checkout during maintenance mode if (isMaintenanceBlocked) { toast({ @@ -299,7 +299,7 @@ export default function ConsultationCheckoutPage({ const checkoutData = createCheckoutData({ appointmentType: "CONSULTATION", planId: resolvedParams.planId, - paymentGateway: gateway as PaymentGateway, + paymentGateway: gateway, startsAt: validatedSearchParams.startsAt, endsAt: validatedSearchParams.endsAt, slotOfAvailabilityWeeklyId: diff --git a/app/checkout/plans/subscription/[planId]/page.tsx b/app/checkout/plans/subscription/[planId]/page.tsx index dadae3388..ce7be8dcf 100644 --- a/app/checkout/plans/subscription/[planId]/page.tsx +++ b/app/checkout/plans/subscription/[planId]/page.tsx @@ -16,6 +16,7 @@ import { checkoutResponseSchema, subscriptionSearchParamsSchema, createCheckoutData, + type SupportedCheckoutGateway, } from "@/schemas/checkout"; import type { AppliedDiscount } from "@/types/checkout"; import { OrgPayerSelector } from "@/app/checkout/components/OrgPayerSelector"; @@ -23,7 +24,6 @@ import { ConsultantProfile, ConsultantReview, SubscriptionPlan, - PaymentGateway, } from "@prisma/client"; import { CreditCard as CreditCardIcon } from "lucide-react"; import { CompanyLogo } from "@/components/ui/company-logo"; @@ -204,7 +204,7 @@ export default function SubscriptionCheckoutPage({ ); const handleCheckout = useCallback( - async (gateway: PaymentGateway, isMockPayment: boolean = false) => { + async (gateway: SupportedCheckoutGateway, isMockPayment: boolean = false) => { // Block checkout during maintenance mode if (isMaintenanceBlocked) { toast({ diff --git a/app/checkout/plans/utils.ts b/app/checkout/plans/utils.ts index 256c2cdca..4deffd7be 100644 --- a/app/checkout/plans/utils.ts +++ b/app/checkout/plans/utils.ts @@ -188,7 +188,6 @@ export const paymentGateways = [ description: "UPI, cards & bank transfer", gateway: "RAZORPAY" as const, }, - // TODO: Add Lemon Squeezy and XFlow when webhook appointment creation is implemented ]; // Default success and error handlers for StripeCheckout component diff --git a/app/checkout/plans/webinar/[planId]/page.tsx b/app/checkout/plans/webinar/[planId]/page.tsx index 1ffde860e..f22e47ae8 100644 --- a/app/checkout/plans/webinar/[planId]/page.tsx +++ b/app/checkout/plans/webinar/[planId]/page.tsx @@ -14,8 +14,8 @@ import { createCheckoutData, WebinarSearchParams, webinarSearchParamsSchema, + type SupportedCheckoutGateway, } from "@/schemas/checkout"; -import { PaymentGateway } from "@prisma/client"; import { CreditCard as CreditCardIcon } from "lucide-react"; import { CompanyLogo } from "@/components/ui/company-logo"; import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -206,7 +206,7 @@ export default function WebinarCheckoutPage({ const razorpayHandlers = createRazorpayCheckoutHandlers(toast); const handleCheckout = useCallback( - async (gateway: PaymentGateway, isMockPayment: boolean = false) => { + async (gateway: SupportedCheckoutGateway, isMockPayment: boolean = false) => { // Block checkout during maintenance mode if (isMaintenanceBlocked) { toast({ diff --git a/app/dashboard/admin/home/AdminHomePageClient.tsx b/app/dashboard/admin/home/AdminHomePageClient.tsx index cefb8c081..7d6d28e21 100644 --- a/app/dashboard/admin/home/AdminHomePageClient.tsx +++ b/app/dashboard/admin/home/AdminHomePageClient.tsx @@ -289,7 +289,7 @@ export default function AdminHomePageClient() {

- {["STRIPE", "RAZORPAY", "LEMON_SQUEEZY", "XFLOW"].map( + {["STRIPE", "RAZORPAY"].map( (gateway) => { const isActive = gateway === "STRIPE" || gateway === "RAZORPAY"; diff --git a/app/dashboard/admin/payments/page.tsx b/app/dashboard/admin/payments/page.tsx index 1a78b05a3..6f5b52af3 100644 --- a/app/dashboard/admin/payments/page.tsx +++ b/app/dashboard/admin/payments/page.tsx @@ -251,8 +251,6 @@ export default function AdminPaymentsPage() { All Gateways Stripe Razorpay - Lemon Squeezy - Xflow diff --git a/app/dashboard/consultant/[consultantId]/(features)/earnings/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/earnings/page.tsx index 6584f7e13..9b9f68e02 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/earnings/page.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/earnings/page.tsx @@ -34,6 +34,7 @@ interface EarningsSummary { totalEarnings: number; pendingEarnings: number; readyEarnings: number; + batchedEarnings: number; paidEarnings: number; heldEarnings: number; pendingTrustEarnings: number; @@ -163,6 +164,7 @@ export default function EarningsPage({ { label: "All", value: "ALL" }, { label: "Pending", value: "PENDING" }, { label: "Ready", value: "READY" }, + { label: "Processing", value: "BATCHED" }, // #837 — batched, cash not yet disbursed { label: "Paid", value: "PAID" }, { label: "On Hold", value: "HELD" }, { label: "Org Trust", value: "PENDING_TRUST" }, @@ -282,7 +284,7 @@ export default function EarningsPage({ value={formatSummaryAmount(summary?.totalEarnings ?? 0)} icon={IndianRupee} variant="default" - tooltip="Total of all cleared earnings (pending + ready + paid + held). Excludes refunded amounts." + tooltip="Total of all cleared earnings (pending + ready + processing + paid + held). Excludes refunded amounts." /> + {summary && summary.batchedEarnings > 0 && ( + + )} {summary && summary.pendingTrustEarnings > 0 && ( = { STRIPE: "Stripe", RAZORPAY: "Razorpay", - LEMON_SQUEEZY: "Lemon Squeezy", - XFLOW: "Xflow", }; function formatGateway(gateway: string): string { diff --git a/app/dashboard/organization/[orgId]/my-arrangement/page.tsx b/app/dashboard/organization/[orgId]/my-arrangement/page.tsx index 2f6b85fb6..0553bf549 100644 --- a/app/dashboard/organization/[orgId]/my-arrangement/page.tsx +++ b/app/dashboard/organization/[orgId]/my-arrangement/page.tsx @@ -37,6 +37,7 @@ const EARNING_STATUS_LABEL: Record = { HELD: "On hold", PENDING: "Pending (hold window)", PENDING_TRUST: "Pending (trust window)", + BATCHED: "Processing payout", // #837 — batched, cash not yet disbursed PAID: "Paid", REFUNDED: "Refunded", }; diff --git a/app/dashboard/staff/[staffId]/(features)/moderation/page.tsx b/app/dashboard/staff/[staffId]/(features)/moderation/page.tsx index 8ea5679a2..8d9531741 100644 --- a/app/dashboard/staff/[staffId]/(features)/moderation/page.tsx +++ b/app/dashboard/staff/[staffId]/(features)/moderation/page.tsx @@ -105,6 +105,7 @@ export default function ContentModerationPage() { const [selectedProfile, setSelectedProfile] = useState(null); const [moderationNote, setModerationNote] = useState(""); + const [suspensionDays, setSuspensionDays] = useState(7); const { toast } = useToast(); const queryClient = useQueryClient(); @@ -192,7 +193,15 @@ export default function ContentModerationPage() { refetchReviews(); }; - // Handle report action (dismiss or take action) + // Handle report action (dismiss or take action). UI verbs map to the + // ModerationActionType enum the API validates against (#693). + const REPORT_ACTION_TYPE = { + DISMISS: "NO_ACTION", + WARN: "WARNING_ISSUED", + SUSPEND: "USER_SUSPENDED", + BAN: "USER_BANNED", + } as const; + const reportActionMutation = useMutation({ mutationFn: async ({ reportId, @@ -207,31 +216,54 @@ export default function ContentModerationPage() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - actionType: action, - reason: moderationNote, + actionType: REPORT_ACTION_TYPE[action], + notes: moderationNote, + ...(action === "SUSPEND" ? { suspensionDays } : {}), }), }, ); - if (!response.ok) throw new Error("Failed to process action"); + if (!response.ok) { + // surface the server's actionable message (400 validation, 409 resolved) + const body = await response.json().catch(() => null); + throw new Error(body?.error ?? "Failed to process action"); + } + return response.json() as Promise<{ + sideEffects?: { + sessionsRevoked?: number; + earningsHeld?: number; + cancellations?: { + engagementsCancelled: number; + refundsIssued: number; + }; + }; + }>; }, - onSuccess: (_data, { action }) => { + onSuccess: (data, { action }) => { + const cancelled = data?.sideEffects?.cancellations?.engagementsCancelled; + const refunded = data?.sideEffects?.cancellations?.refundsIssued; + const detail = + cancelled || refunded + ? ` ${cancelled ?? 0} appointment(s) cancelled, ${refunded ?? 0} refund(s) issued.` + : ""; toast({ title: "Action Completed", - description: `Report has been ${action === "DISMISS" ? "dismissed" : "processed"} successfully`, + description: `Report has been ${action === "DISMISS" ? "dismissed" : "processed"} successfully.${detail}`, }); setSelectedReport(null); setModerationNote(""); + setSuspensionDays(7); queryClient.invalidateQueries({ queryKey: ["staff-moderation-reports"], }); queryClient.invalidateQueries({ queryKey: ["staff-moderation-stats"] }); }, - onError: () => { + onError: (error) => { toast({ title: "Error", - description: "Failed to process action", + description: + error instanceof Error ? error.message : "Failed to process action", variant: "destructive", }); }, @@ -839,6 +871,54 @@ export default function ContentModerationPage() { onChange={(e) => setModerationNote(e.target.value)} />
+
+ +
+ {[7, 30, 90].map((days) => ( + + ))} + { + // NaN sentinel lets the field be cleared while typing; + // the Suspend button disables until a valid number is back + if (e.target.value === "") { + setSuspensionDays(Number.NaN); + return; + } + // Reject decimals (e.g. "7.5") rather than truncating + // them; invalid input falls back to the NaN sentinel so + // the Suspend button stays disabled until a valid whole + // number in range is entered. + const v = Number(e.target.value); + setSuspensionDays( + Number.isInteger(v) + ? Math.min(365, Math.max(1, v)) + : Number.NaN, + ); + }} + disabled={reportActionMutation.isPending} + aria-label="Custom suspension days" + /> +
+
+ + diff --git a/app/scim/v2/Users/route.ts b/app/scim/v2/Users/route.ts index abdd746ec..602c357cd 100644 --- a/app/scim/v2/Users/route.ts +++ b/app/scim/v2/Users/route.ts @@ -9,11 +9,16 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; import prisma from "@/lib/prisma"; import { requireScimAuth } from "@/lib/scim/auth"; import { scimError } from "@/lib/scim/errors"; import { createOrReprovisionScimUser } from "@/lib/scim/operations"; import { toScimUser } from "@/lib/scim/resource-user"; +import { + DomainVerificationRequiredError, + UNVERIFIED_ORG_SEAT_CAP, +} from "@/lib/enterprise/governance"; const SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"; const SCIM_LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; @@ -118,15 +123,42 @@ export async function POST(req: NextRequest) { .map((g) => (typeof g === "string" ? g : (g.value ?? g.display ?? ""))) .filter((s): s is string => s.length > 0); - const result = await createOrReprovisionScimUser(prisma, { - organizationId, - userName, - givenName: body.name?.givenName, - familyName: body.name?.familyName, - active: body.active ?? true, - externalId: body.externalId, - groupNames, - }); + let result: Awaited>; + try { + result = await createOrReprovisionScimUser(prisma, { + organizationId, + userName, + givenName: body.name?.givenName, + familyName: body.name?.familyName, + active: body.active ?? true, + externalId: body.externalId, + groupNames, + }); + } catch (err) { + // Unverified-org seat cap — surface as a permanent SCIM error so the + // IdP's provisioning report tells the admin to verify a domain. + if (err instanceof DomainVerificationRequiredError) { + return scimError( + 403, + `Seat cap reached — verify a domain to provision more than ${UNVERIFIED_ORG_SEAT_CAP} members.`, + ); + } + // Concurrent provisioning of the same user raced past the membership + // lookup; the (userId, organizationId) unique index is the backstop. + // Idempotent — the member already exists — so surface RFC 7644's + // uniqueness conflict rather than a 500 the IdP would keep retrying. + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + return scimError( + 409, + "User is already provisioned in this organization", + "uniqueness", + ); + } + throw err; + } if ("kind" in result) { if (result.kind === "USER_ERASED") { diff --git a/components/dashboard/shared/DisputesPage.tsx b/components/dashboard/shared/DisputesPage.tsx index 0b5963d21..8182a1834 100644 --- a/components/dashboard/shared/DisputesPage.tsx +++ b/components/dashboard/shared/DisputesPage.tsx @@ -389,8 +389,6 @@ export function DisputesPage({ All Gateways Stripe Razorpay - Lemon Squeezy - xFlow
diff --git a/components/dashboard/shared/RefundsPage.tsx b/components/dashboard/shared/RefundsPage.tsx index 2863f66f0..0c8626e34 100644 --- a/components/dashboard/shared/RefundsPage.tsx +++ b/components/dashboard/shared/RefundsPage.tsx @@ -343,8 +343,6 @@ export function RefundsPage({ All Gateways Stripe Razorpay - Lemon Squeezy - xFlow
diff --git a/docs/architecture/stack-decision-and-scaling-strategy.md b/docs/architecture/stack-decision-and-scaling-strategy.md index b0bc0a749..71a5c8266 100644 --- a/docs/architecture/stack-decision-and-scaling-strategy.md +++ b/docs/architecture/stack-decision-and-scaling-strategy.md @@ -46,7 +46,7 @@ This document explains why that decision was correct, what the trade-offs are, a | Auth | BetterAuth | Authentication and session management | | Cache | Upstash Redis | Distributed locks, maintenance state, rate limiting | | Video & Chat | Stream.io | Real-time video calls, messaging, presence | -| Payments | Stripe + Razorpay + Lemon Squeezy + Xflow | Multi-gateway payment processing (4 gateways) | +| Payments | Stripe + Razorpay | Multi-gateway payment processing (2 gateways) | | Notifications | Novu + Resend | Push notifications, transactional email | | Storage | Supabase Storage | File uploads, image hosting | | Hosting | Vercel | Edge network, serverless functions, CDN, automatic deployments | diff --git a/docs/booking/00-architecture-decisions.md b/docs/booking/00-architecture-decisions.md index ef3e8ffb8..3e2700ea6 100644 --- a/docs/booking/00-architecture-decisions.md +++ b/docs/booking/00-architecture-decisions.md @@ -57,9 +57,9 @@ The decision to share one helper rather than re-implement the expiry check on ea ## ADR B7 — Redis locks plus database constraints -Operations that could race — concurrent allocations for the same consultant, a checkout completing while a reschedule runs — are serialised by a consultant-level Redis distributed lock acquired before the transaction, and the database carries the constraints that make a double-book impossible even if a lock is ever missed. +Operations that could race — concurrent allocations for the same consultant, a checkout completing while a reschedule runs — are serialised by a Redis distributed lock acquired before the transaction, and the database carries the constraints that make a double-book impossible even if a lock is ever missed. Auto-allocation, which discovers its slots dynamically under the lock, holds a consultant-wide key. Manual allocation, where the target day is known up front, shards the key by that day (`auto-allocate:{consultantProfileId}:{day}`) so that allocations for different days no longer serialise against one another; same-day requests, which are the actual duplicate risk, still share the key. -The decision is defence in depth rather than relying on either mechanism alone. The lock removes the common-case contention cheaply, and the constraints are the correctness backstop. See [12-concurrency-and-locking.md](./12-concurrency-and-locking.md) for the lock keys and the reconciliation job that detects any overlap the locks did not prevent. +The decision is defence in depth rather than relying on either mechanism alone. The lock removes the common-case contention cheaply, and the constraints are the correctness backstop. Allocation is additionally idempotent: a client-supplied `Idempotency-Key` (persisted as a unique `Appointment.allocationIdempotencyKey`) makes a retried or double-submitted allocation return the original result instead of allocating a second time. See [12-concurrency-and-locking.md](./12-concurrency-and-locking.md) for the lock keys and the reconciliation job that detects any overlap the locks did not prevent. ## ADR B8 — GitHub Actions cron for background jobs diff --git a/docs/booking/07-rescheduling-flow.md b/docs/booking/07-rescheduling-flow.md index 9c4af4150..51edfa647 100644 --- a/docs/booking/07-rescheduling-flow.md +++ b/docs/booking/07-rescheduling-flow.md @@ -345,8 +345,8 @@ sequenceDiagram RescheduleAPI->>RescheduleAPI: Filter slots to requested slotIds RescheduleAPI->>RescheduleAPI: Validate 24-hour window for each slot - RescheduleAPI->>DB: UPDATE slots SET isTentative = true
WHERE id IN (s1, s2, s3) - RescheduleAPI->>DB: UPDATE subscription SET status = PENDING + RescheduleAPI->>DB: UPDATE slots SET isTentative = true, completionStatus = RESCHEDULED
WHERE id IN (s1, s2, s3) + Note over RescheduleAPI,DB: Partial reschedule (slotIds): subscription status left unchanged.
Only a full reschedule (no slotIds) sets status = PENDING. RescheduleAPI->>DB: COMMIT TRANSACTION RescheduleAPI-->>Frontend: success, rescheduleType = multiple_sessions,
slotsAffected = 3 @@ -551,9 +551,9 @@ Each of the 6 slots is checked. If `slot_5a` starts in 18 hours, the entire requ Only the 6 requested slots have `isTentative` set to `true`. The other 90 slots remain `isTentative: false`. -**Step 7: Update subscription status.** +**Step 7: Subscription status is left untouched.** -The subscription's `status` is set to `PENDING`. (Note: this is a known issue -- it should arguably remain `APPROVED` for partial reschedules. See [Known Issues](#known-issues).) +A partial reschedule of specific sessions no longer flips the whole subscription back to `PENDING`. The subscription keeps its existing status (for example `APPROVED`), and only the rescheduled slots are re-tentatived; session-level state is tracked on the slots via their `completionStatus` (`RESCHEDULED`). Only an entire-subscription reschedule (no `slotIds`) genuinely re-enters `PENDING`. **Step 8: Response.** @@ -755,7 +755,7 @@ The entire operation runs inside a Prisma `$transaction` with a 60-second timeou | Event Type | Partial reschedule? | Status field updated | New status value | Notes | | ---------------- | ------------------- | -------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------ | | **Consultation** | No (always entire) | `status` | `PENDING` | Single session, single appointment. All slots marked tentative. | -| **Subscription** | Yes (via `slotIds`) | `status` | `PENDING` | Supports all three reschedule types. See Known Issues for status behavior on partial reschedule. | +| **Subscription** | Yes (via `slotIds`) | `status` (entire reschedule only) | `PENDING` | Supports all three reschedule types. Only an entire reschedule (no `slotIds`) reverts `status` to `PENDING`; a partial reschedule of specific sessions leaves the subscription status unchanged and re-tentatives only the selected slots. | | **Webinar** | No (always entire) | `status` | `SCHEDULED` | All slots marked tentative. Uses `status` not `status` because webinars have a different state model. | | **Class** | No (always entire) | `status` | `SCHEDULED` | Same as webinar. Uses `status` instead of `status`. | @@ -1047,17 +1047,17 @@ Five issues have been validated against the codebase. All are legitimate and tra ### Issue 2: Status Always Set to PENDING -**Priority:** HIGH | **Status:** Tracked in [#448](https://github.com/Practitionist/familiarise_web/issues/448). +**Priority:** HIGH | **Status:** FIXED — a partial reschedule no longer flips the whole subscription to `PENDING`. -**The problem:** When a subscription reschedule occurs, the subscription's `status` is unconditionally set to `PENDING`, regardless of whether it is a partial or full reschedule. +**The former problem:** A subscription reschedule used to set the subscription's `status` to `PENDING` unconditionally, regardless of whether it was a partial or full reschedule. -**Why this matters:** Consider a 48-session subscription where the consultee reschedules 1 session. Setting the entire subscription to `PENDING` implies the whole subscription needs re-approval, which: +**Why this mattered:** Consider a 48-session subscription where the consultee reschedules 1 session. Setting the entire subscription to `PENDING` implied the whole subscription needed re-approval, which: -- Removes it from the "active" list and places it in the "pending" queue. -- May confuse the consultant into thinking the entire subscription needs attention. -- The other 47 sessions are fully confirmed and should not appear to need action. +- Removed it from the "active" list and placed it in the "pending" queue. +- Could confuse the consultant into thinking the entire subscription needed attention. +- The other 47 sessions were fully confirmed and should not have appeared to need action. -**Planned fix:** For partial reschedules (`individual_session` or `multiple_sessions`), keep the subscription status as `APPROVED` and rely on the `isTentative` flags to indicate which specific slots need attention. Only set `PENDING` for `entire_booking` reschedules. +**The fix:** For partial reschedules (`individual_session` or `multiple_sessions`), the subscription keeps its existing status (for example `APPROVED`) and only the rescheduled slots are re-tentatived and stamped `completionStatus = RESCHEDULED`. Only an entire (`entire_booking`) reschedule re-enters `PENDING`. ### Issue 3: No Partial Reschedule Tracking Field @@ -1110,7 +1110,7 @@ In practice, this edge case is unlikely because sessions rarely span midnight. B | Phase | Scope | Issues Fixed | Breaking Change? | | ------- | --------------------------- | ----------------------------------------------- | ---------------- | -| Phase 1 | API response + status logic | #2 (PENDING status), #5 (slot vs session count) | No | +| Phase 1 | API response + status logic | #2 (PENDING status, fixed), #5 (slot vs session count) | No | | Phase 2 | Schema migration | #3 (tracking field) | No (additive) | | Phase 3 | New endpoint | #1 (slotIds to appointmentIds) | Yes | | Phase 4 | Optimization | #4 (session-aware validation) | No | diff --git a/docs/booking/11-waitlist-system.md b/docs/booking/11-waitlist-system.md index 568ba7f13..b8bdf0844 100644 --- a/docs/booking/11-waitlist-system.md +++ b/docs/booking/11-waitlist-system.md @@ -9,6 +9,7 @@ Key characteristics: - Applies to webinars and classes only (not 1:1 consultations) - Priority ordering: higher `priority` value goes first; ties broken by earliest `joinedAt` - 48-hour response window on spot availability notifications +- The notified seat is soft-held for the duration of that window: a `NOTIFIED` entry inside its window is counted against capacity, so the "your spot is available" offer is a genuine, exclusive reservation rather than a first-come-first-served race. If the entry expires, is declined, or is skipped, the hold drops and the seat passes to the next person in the queue. - Automatic expiration processing via cron job - Integrates with checkout flow for payment completion @@ -326,4 +327,4 @@ if (available) -> show "Book Now" else -> show "Join Waitlist" (with waitlistCount display) ``` -For webinars, participant count is based on `slotsOfAppointment.length`. For classes, it counts unique users across all appointments to avoid double-counting participants enrolled in multiple sessions. +For webinars, participant count is based on `slotsOfAppointment.length`. For classes, it counts unique users across all appointments to avoid double-counting participants enrolled in multiple sessions. In both cases the availability test also adds the live waitlist holds -- `available` is `currentParticipants + countWaitlistHolds() < maxParticipants` -- so a seat that has been offered to a notified waitlisted user is reported as unavailable to a first-come buyer until that offer lapses. A notified user checking out via `fromWaitlist` has their own hold excluded from the count, so it never blocks them from claiming the seat they were offered. diff --git a/docs/booking/13-cron-jobs-and-background-tasks.md b/docs/booking/13-cron-jobs-and-background-tasks.md index f2bcbcfd6..3e752bc36 100644 --- a/docs/booking/13-cron-jobs-and-background-tasks.md +++ b/docs/booking/13-cron-jobs-and-background-tasks.md @@ -33,6 +33,7 @@ Both paths call the same core function exported from `scripts/appointments/`. Th | Cleanup invalid appointments | `0 * * * *` | Every hour, on the hour | `scripts/appointments/cleanup-invalid-appointments.ts` | `/api/cleanup/invalid-appointments` | | Expire stale requests | `0 1 * * *` | Daily at 01:00 UTC | `scripts/appointments/expire-stale-requests.ts` | `/api/cleanup/expire-stale-requests` | | Reconcile slot availability | `15 * * * *` | Every hour, at :15 | `scripts/appointments/reconcile-slot-availability.ts` | `/api/cleanup/reconcile-slot-availability` | +| Detect consultant no-shows | `17 * * * *` | Every hour, at :17 | `scripts/appointments/detect-consultant-no-shows.ts` | N/A (GitHub Actions only) | --- @@ -195,6 +196,26 @@ Both paths call the same core function exported from `scripts/appointments/`. Th --- +### g. Detect Consultant No-Shows + +| Field | Value | +| ------------------ | ----------------------------------------------------- | +| **Schedule** | `17 * * * *` -- every hour, at :17 | +| **Source** | `scripts/appointments/detect-consultant-no-shows.ts` | +| **API** | N/A -- runs via GitHub Actions only | +| **GitHub Actions** | `.github/workflows/detect-consultant-no-shows.yml` | +| **HTTP Methods** | N/A | + +**Purpose**: Closes the loop on the platform's promise of a full refund when the consultant fails to attend a paid session. The job scans confirmed `CONSULTATION` bookings whose session ended at least the grace window ago, uses the per-attendee `MeetingAttendance` records (stamped by the Stream session handlers) to identify the ones the consultant never joined, and for each such no-show it auto-refunds the consultee via `refundPayment`, marks the booking cancelled, and notifies both parties. + +**Scope**: Consultations only. A consultation is a single-session, single-consultant exclusive booking where a full refund of the one payment is the correct remedy. Subscriptions are multi-session, so a per-session consultant no-show is a partial refund of one session out of many and needs its own design; it is not yet handled. + +**Grace window**: A session must have ended at least 120 minutes ago (`NO_SHOW_GRACE_MINUTES = 120`) before a missing consultant is treated as a no-show, so a late join or a delayed Stream participant webhook cannot trigger a false-positive refund. + +**Safety**: The job runs under a fail-closed cron lock. Because it moves money, it refuses to run without a real Redis lock rather than risk a silent unlocked double-run, and `refundPayment`'s refundable-balance guard remains the correctness backstop. + +--- + ## Job Architecture All booking cron jobs follow the same three-layer pattern: diff --git a/docs/booking/14-local-development-and-testing.md b/docs/booking/14-local-development-and-testing.md index 922f936f7..2b0e0ffb9 100644 --- a/docs/booking/14-local-development-and-testing.md +++ b/docs/booking/14-local-development-and-testing.md @@ -172,7 +172,7 @@ Prisma Studio lets you browse all tables, filter by fields like `isTentative`, ` 1. **POST** to `/api/appointments/{appointmentId}/reschedule?type=SUBSCRIPTION` with optional `slotIds` array in the body. - No `slotIds`: marks **all** subscription slots as tentative (entire booking reschedule). - With `slotIds`: marks only specified slots as tentative (individual/multiple session reschedule). -2. **Verify**: Affected slots have `isTentative = true`, subscription `status` reverts to `PENDING`. +2. **Verify**: Affected slots have `isTentative = true`. Only an entire-subscription reschedule (no `slotIds`) reverts the subscription `status` to `PENDING`; a partial reschedule of specific sessions (with `slotIds`) leaves the subscription status untouched and re-tentatives only those slots. 3. **Re-allocate**: Consultant selects new slots via the Requests tab (uses `mode: "requested"`). **24-hour restriction**: Rescheduling is blocked if any affected slot starts within 24 hours. The API returns a `400` with details. diff --git a/docs/compliance/02-gst-overview.md b/docs/compliance/02-gst-overview.md index c92ea4fc7..6b717f1cc 100644 --- a/docs/compliance/02-gst-overview.md +++ b/docs/compliance/02-gst-overview.md @@ -29,7 +29,7 @@ Plus the orthogonal obligations: - Org-issued tax invoice → applies. `OrganizationInvoice` model is real. - TCS Sec 52 → **N/A**. No ECO event in B2B; the org pays the platform on consolidated invoice; there's no "supply by registered person through ECO" in this leg. - Place of supply uses **org's GST state** (`Organization.gstStateCode`). -- E-invoicing IRN → applies if AATO ≥ ₹5 cr (Familiarise's AATO determines this on a rolling-year basis). Connector + cron now live as of Round 2. +- E-invoicing IRN → applies if AATO ≥ ₹5 cr (Practitionist's AATO determines this on a rolling-year basis). Connector + cron now live as of Round 2. - LUT → applies for org invoices billed to non-resident parents (GCC / overseas HQ) with India delivery. - Credit note on org refund → applies. diff --git a/docs/compliance/03-msme-43b-h.md b/docs/compliance/03-msme-43b-h.md index 5fa990366..4a432c275 100644 --- a/docs/compliance/03-msme-43b-h.md +++ b/docs/compliance/03-msme-43b-h.md @@ -38,7 +38,7 @@ Udyam registration number format: `UDYAM-XX-NN-NNNNNNN` (19 chars: `UDYAM` + 2-c ### B2C (consumer marketplace) - **N/A on the consumer leg.** The consumer pays the platform; there's no buyer-supplier relationship between consumer and consultant under 43B(h). -- **N/A on the platform-to-consultant payout leg either** — Familiarise pays from the consumer's payment within days, well inside any 15/45 window. The platform's books bear the disallowance risk, but it is mathematically not breached when payouts run weekly/monthly. +- **N/A on the platform-to-consultant payout leg either** — Practitionist pays from the consumer's payment within days, well inside any 15/45 window. The platform's books bear the disallowance risk, but it is mathematically not breached when payouts run weekly/monthly. So 43B(h) is effectively a **B2B-only** compliance — but the schema fields (`msmeStatus`, etc.) sit on `ConsultantProfile` regardless of rail because the same consultant may earn through both rails. @@ -85,7 +85,7 @@ So 43B(h) is effectively a **B2B-only** compliance — but the schema fields (`m ## Related disclosure (not 43B(h), but adjacent) -**Form MSME-1** is the half-yearly ROC/MCA return (Companies Act, not Income-tax Act) disclosing payments to Micro & Small suppliers **outstanding beyond 45 days**. Due **31 Oct** (Apr–Sep half) and **30 Apr** (Oct–Mar half). It is a *company-law* obligation that runs parallel to 43B(h); if Familiarise (the company) ever owes an MSE supplier past 45 days it must file MSME-1. Tracked in the [compliance calendar (doc 12)](./12-india-compliance-calendar.md). Penalty: ₹20,000 + ₹1,000/day continuing (cap ₹3 lakh) under Companies Act §405(4). +**Form MSME-1** is the half-yearly ROC/MCA return (Companies Act, not Income-tax Act) disclosing payments to Micro & Small suppliers **outstanding beyond 45 days**. Due **31 Oct** (Apr–Sep half) and **30 Apr** (Oct–Mar half). It is a *company-law* obligation that runs parallel to 43B(h); if Practitionist (the company) ever owes an MSE supplier past 45 days it must file MSME-1. Tracked in the [compliance calendar (doc 12)](./12-india-compliance-calendar.md). Penalty: ₹20,000 + ₹1,000/day continuing (cap ₹3 lakh) under Companies Act §405(4). ## References diff --git a/docs/compliance/10-rbi-pa-and-payment-architecture.md b/docs/compliance/10-rbi-pa-and-payment-architecture.md index 509dd5b5f..2a8a28874 100644 --- a/docs/compliance/10-rbi-pa-and-payment-architecture.md +++ b/docs/compliance/10-rbi-pa-and-payment-architecture.md @@ -28,12 +28,12 @@ This affects every marketplace that today receives consumer payments and pays ou |---|---|---| | **C — Operating account + separate licensed payout** | Consumer → PA → platform's operating account (platform IS the merchant). Platform separately uses a licensed FAA (e.g. RazorpayX Bulk Payouts) to pay consultants from operating funds. | Two separate RBI-licensed flows. Consultant is NOT settled by the PA — they're paid by us via a different licensed product. | -## What architecture does Familiarise actually use? +## What architecture does Practitionist actually use? Verified at `lib/payments/payouts/razorpay-payouts.ts`: -``` -Consumer → Razorpay PG (PA license) → Familiarise operating account (we are the merchant) +```text +Consumer → Razorpay PG (PA license) → Practitionist operating account (we are the merchant) ↓ Cron → RazorpayX Payouts API (FAA license) → consultant bank / UPI / Stripe ``` @@ -123,7 +123,7 @@ This is a forward-looking note: the auto-top-up cron exists in schema, but live Add `docs/payments/06-pa-master-direction-architecture.md` (or similar): 1. The four paths permitted (A / B / C and C-prime). -2. The path Familiarise uses (Path C). +2. The path Practitionist uses (Path C). 3. Why Path C is consistent with the Sep 2025 direction. 4. What still applies even on Path C (refund SLA, chargeback handling, PCI-DSS, etc.). 5. The fact-specific risks: a regulator could reclassify the platform as a deemed PA if circumstantial evidence (volume, marketing language, brand integration) suggests we're aggregating rather than facilitating. Mitigate by clear marketing + ToS that we are a marketplace, not a payment intermediary. diff --git a/docs/compliance/12-india-compliance-calendar.md b/docs/compliance/12-india-compliance-calendar.md index f962e338e..f6e84494a 100644 --- a/docs/compliance/12-india-compliance-calendar.md +++ b/docs/compliance/12-india-compliance-calendar.md @@ -49,7 +49,7 @@ Interest on shortfall under §424/§425 of the 2025 Act (was §234B/§234C) at 1 ## MSME Form MSME-1 (half-yearly ROC return) -Verified 2026-06-05. Companies Act (MCA), **not** Income-tax — discloses payments to Micro & Small suppliers **outstanding beyond 45 days**. Applies to Familiarise (the company) whenever it owes an MSE supplier past 45 days. +Verified 2026-06-05. Companies Act (MCA), **not** Income-tax — discloses payments to Micro & Small suppliers **outstanding beyond 45 days**. Applies to Practitionist (the company) whenever it owes an MSE supplier past 45 days. | Half-year period | Filing due | |---|---| diff --git a/docs/compliance/15-india-compliance-shipping-checklist.md b/docs/compliance/15-india-compliance-shipping-checklist.md index 822e067d0..0151a99c4 100644 --- a/docs/compliance/15-india-compliance-shipping-checklist.md +++ b/docs/compliance/15-india-compliance-shipping-checklist.md @@ -93,7 +93,7 @@ MUST item is the actual law of the land in 2026. professional 10% (1027/1028). Low blast radius (194-O is the default), but the 194J override path over-withholds on technical-service consultants. - `Organization.gstStateCode` is the buyer state; `SUPPLIER_STATE_CODE` - env is the seller (Familiarise) state. Don't conflate. + env is the seller (Practitionist) state. Don't conflate. - DPDP consent is fail-closed for `STREAM_DATA_PROCESSING`. A user who has explicitly withdrawn that consent will be silently dropped from Stream channel upserts; surface this clearly in any new UX that diff --git a/docs/decisions/2026-07-11-moderation-enforcement-and-peer-chat-block.md b/docs/decisions/2026-07-11-moderation-enforcement-and-peer-chat-block.md new file mode 100644 index 000000000..181d9e299 --- /dev/null +++ b/docs/decisions/2026-07-11-moderation-enforcement-and-peer-chat-block.md @@ -0,0 +1,37 @@ +# ADR: Moderation enforcement design (#693) and the consultee↔consultee chat block + +- **Status**: Accepted +- **Date**: 2026-07-11 +- **Author**: teetangh +- **PR**: `feature/moderation-actions-693` → `dev` +- **Part of**: #693, #899, #725, #734 + +## Context + +Staff moderation actions were write-only theatre: the action route created a `ModerationAction` row, flipped the report status, and then hit a `// TODO` where every side-effect should have been. A "banned" user kept their session, their Stream chat and video access, their upcoming appointments, and their pending payouts. Issue #693 flagged this as the highest-severity item in the backlog, and the 2026-07-10 triage (now tracked at `docs/roadmap/2026-07-10-issue-triage-and-remediation-plan.md`) ranked it the first launch blocker. Two adjacent latent holes surfaced during the same triage: the Stream token-provider server actions minted tokens for any caller-supplied user id without any session check, and the `addMemberToChannel` server action performed no authorization at all. Both matter because Stream's server-side API deliberately bypasses its own permission system — whatever gate exists has to live in our application layer. + +## Decision + +### 1. Ban state lives on the BetterAuth admin plugin's native fields + +We registered the BetterAuth `admin()` plugin and use its own `User.banned`, `User.banReason`, and `User.banExpires` columns as the only user-level moderation state. A suspension is `banned: true` with `banExpires` set; a permanent ban is `banned: true` with `banExpires: null`. We deliberately did not add parallel `suspendedAt`/`bannedAt` columns: the `ModerationAction` table already records who acted, when, and why, so duplicating that history onto the `User` row would be denormalization without a reader. The plugin blocks sign-in for banned users and auto-unbans at sign-in once `banExpires` passes, which gives us lazy suspension expiry with no reactivation cron. Adopting the plugin also starts the Tier-1 work tracked in #725. Two operational notes: `defaultRole: "CONSULTEE"` is mandatory because the plugin otherwise stamps new users with the string `"user"`, which is not a valid `UserRole` enum value and would break signup; and we write the ban columns directly via Prisma inside the moderation transaction rather than calling `auth.api.banUser`, because the direct write is transactional with the action row and needs no admin request context. + +### 2. Side-effects run in two phases + +Phase one is transactional (`lib/moderation/side-effects.ts`): the ban flags, session deletion, earnings hold, profile unverification, and review soft-delete commit atomically with the `ModerationAction` row, so a report can never read `ACTION_TAKEN` while the target kept access. Phase two is best-effort and runs after commit: bulk cancellation with refunds (each refund runs in `refundPayment`'s own Serializable transaction), Stream token revocation and deactivation, and Novu notifications. Every phase-two step is individually caught, reported to Sentry, and best-effort persisted into `ModerationAction.sideEffects` (if that persistence write itself fails it is Sentry-logged rather than lost silently), so staff can see exactly what executed. Re-running the action is deliberately blocked by the route's 409 idempotency guard; partial best-effort failures are therefore remediated manually via the persisted summary until the reconciliation follow-up (see Consequences) lands, and only when that summary was successfully persisted. The bulk cancel runs under a wall-clock budget because Netlify functions are time-capped; anything unfinished is recorded and safely re-runnable since every cancel is CAS-guarded and every refund validates the refundable balance. + +### 3. Moderation cancellations refund 100% + +Booking-time cancellation-policy snapshots exist to arbitrate disputes between the two parties of a booking. A moderation cancellation is platform-initiated — the counterparty did nothing wrong — so the policy tiers do not apply and every affected payment is refunded in full. Banned consultants additionally have their unpaid earnings (`PENDING`, `PENDING_TRUST`, `READY`) moved to `HELD`, which the release cron never auto-releases, leaving payout disposition to an admin. + +### 4. Consultee↔consultee direct messages stay blocked + +There is deliberately no code path that creates a consultee↔consultee channel, and we are keeping it that way for launch. The trust-and-safety research is consistent: peer-to-peer chat helps marketplaces only when moderation infrastructure is already strong, and ours has only just gained real enforcement. Group spaces already cover the legitimate need — webinar and class event channels put consultees and consultants in one shared conversation, and collaborator channels cover consultant↔consultant joint sessions. We will revisit peer DMs as a post-launch community feature once #899's hardening ships. + +### 5. Stream access is gated at the token mint and at member-add + +The token-provider actions now require a session, only mint a token for the caller's own user id (staff and admin may mint for anyone), and refuse banned users — without this, a revoked token was trivially re-mintable. `addMemberToChannel` now requires a session and allows only staff, admins, or the channel's creator to add members, and no longer lazily creates channels for non-privileged callers. + +## Consequences + +The new schema columns (`User.banned/banReason/banExpires`, `Session.impersonatedBy`, `ConsultantReview.deletedAt`, `ModerationAction.sideEffects`, `CancellationReason.MODERATION`) land with the next coordinated `prisma db push`; this code must not deploy before that push because the session path reads the ban columns on every auth call. Named follow-ups, in rough priority order: an unban/reinstate staff action (symmetric `reactivateUser` plus clearing the ban columns), a reconciliation path for partial best-effort failures (the action route's 409 idempotency guard prevents double refunds but also prevents re-running failed refund/Stream/notification steps recorded in `sideEffects` — until it lands, remediation is manual via the persisted summary), refactoring the single-appointment cancel route onto the shared bulk-cancel core, and creating the three Novu dashboard workflows (`moderation-warning`, `account-suspended`, `account-banned`) whose triggers currently log-and-skip. diff --git a/docs/enterprise/10-money-and-ledger/05-booking-to-earnings.md b/docs/enterprise/10-money-and-ledger/05-booking-to-earnings.md index 5496b5e57..7057762d5 100644 --- a/docs/enterprise/10-money-and-ledger/05-booking-to-earnings.md +++ b/docs/enterprise/10-money-and-ledger/05-booking-to-earnings.md @@ -137,7 +137,7 @@ model ConsultantEarnings { shareBps Int // multi-collaborator split, basis points platformFeePaise Int consultantSharePaise Int - status EarningStatus // PENDING → READY → HELD → PAID + status EarningStatus // PENDING → READY → BATCHED → PAID (HELD on dispute) holdUntil DateTime? } ``` diff --git a/docs/enterprise/10-money-and-ledger/06-earnings-lifecycle.md b/docs/enterprise/10-money-and-ledger/06-earnings-lifecycle.md index aefc16fd8..1b6516219 100644 --- a/docs/enterprise/10-money-and-ledger/06-earnings-lifecycle.md +++ b/docs/enterprise/10-money-and-ledger/06-earnings-lifecycle.md @@ -37,18 +37,20 @@ The table below summarises which row each settlement path produces. ## 2. The `EarningStatus` state machine -Both row types move through the same `EarningStatus` enum (`prisma/schema.prisma`): `PENDING`, `PENDING_TRUST`, `HELD`, `READY`, `PAID`, and `REFUNDED`. The diagram below shows the legal transitions; each is explained in the paragraphs that follow. +Both row types move through the same `EarningStatus` enum (`prisma/schema.prisma`): `PENDING`, `PENDING_TRUST`, `HELD`, `READY`, `BATCHED`, `PAID`, and `REFUNDED`. The diagram below shows the legal transitions; each is explained in the paragraphs that follow. ```mermaid stateDiagram-v2 - [*] --> PENDING: payment settles (org verified, or consultant row) - [*] --> PENDING_TRUST: payment settles (org PENDING_VERIFICATION, no paid invoice) + [*] --> PENDING: payment settles (sponsoring org verified, or no sponsor) + [*] --> PENDING_TRUST: payment settles (sponsoring org PENDING_VERIFICATION, no paid invoice) PENDING_TRUST --> PENDING: org ACTIVE or first invoice paid PENDING --> READY: holdUntil elapsed (release cron) PENDING --> HELD: dispute opened READY --> HELD: dispute opened HELD --> READY: dispute resolved for the seller - READY --> PAID: rolled into a payout, gateway confirmed + READY --> BATCHED: claimed into a payout batch + BATCHED --> PAID: payout COMPLETED with a UTR + BATCHED --> READY: batch failed before cash moved PENDING --> REFUNDED: payment refunded (fully) HELD --> REFUNDED: dispute resolved for the buyer READY --> REFUNDED: payment refunded (fully) @@ -57,7 +59,7 @@ stateDiagram-v2 REFUNDED --> [*] ``` -A new consultant row, and an org row whose sponsoring org is already verified, begins in **`PENDING`** — the hold period. A row whose org is still `PENDING_VERIFICATION` and has never paid an invoice begins in **`PENDING_TRUST`** instead; this is the #687 guard explained in §3. +Both the consultant row and any org row begin in **`PENDING`** — the hold period — when the booking's **sponsoring** org (`payment.organizationId`) is already verified or the booking has no sponsor. When the sponsoring org is still `PENDING_VERIFICATION` and has never paid an invoice, every row the booking writes — the consultant row included — begins in **`PENDING_TRUST`** instead; this is the #687 guard explained in §3. The **`PENDING_TRUST → PENDING`** promotion is performed by the `release-pending-trust-earnings` cron (`jobs/cleanup/release-pending-trust-earnings.ts`) once the sponsoring org transitions to `ACTIVE` or its first invoice clears. Until then the row is invisible to payout batching, which only ever claims `READY` rows. @@ -65,7 +67,7 @@ The **`PENDING → READY`** transition is the hold elapsing. The hourly `release The **`PENDING → HELD`** and **`READY → HELD`** transitions freeze a row for a dispute. `holdEarnings` (`earnings-service.ts`) refuses to act unless the row is currently `PENDING` or `READY`, so a `PAID` or `REFUNDED` row can never be re-frozen. The inverse **`HELD → READY`** transition is `releaseHeldEarnings`, called when the dispute resolves in the seller's favour; it acts only on a row that is currently `HELD`. -The **`READY → PAID`** transition is the only one this doc hands off to the payout pipeline. When a payout's gateway leg confirms (`PROCESSING → COMPLETED`), the linked earnings are flipped to `PAID` and the settlement is posted to the ledger — see [payout pipeline §3](07-payout-pipeline.md). Note that batching claims a row by stamping its `payoutId` / `orgPayoutId` while it is still `READY`; the org-side batch additionally flips the claimed rows to `PAID` at batch-creation time, and a later failure releases them back to `READY` (§5). +The **`READY → BATCHED → PAID`** progression is where this doc hands off to the payout pipeline. Batching claims a row by stamping its `payoutId` / `orgPayoutId` and flipping it from `READY` to the intermediate **`BATCHED`** status at batch-creation time — on both the consultant and the org rail. A `BATCHED` row is committed to a payout but its cash has **not** yet left, so it is neither eligible to be batched again nor counted as disbursed by finance exports or dashboards. Only when the payout's gateway leg confirms (`PROCESSING → COMPLETED` **with a UTR**) does the pipeline flip `BATCHED → PAID` and post the settlement to the ledger — see [payout pipeline §3](07-payout-pipeline.md). A batch that fails before any cash moves releases its `BATCHED` rows back to `READY` for the next run (§5). The transitions into **`REFUNDED`** are driven by `refundEarnings` and are covered in §5. The guard `assertEarningStatusTransitionLegal` (`lib/payments/payouts/earning-status.ts`) makes `REFUNDED` terminal and permits a `PAID` row to move only to `REFUNDED` — any other transition out of `PAID`, or any transition out of `REFUNDED`, throws `IllegalEarningStatusTransitionError`. This is what stops a settled row, which has already triggered a real bank transfer and a TDS deduction, from being silently rewritten. @@ -73,10 +75,12 @@ The transitions into **`REFUNDED`** are driven by `refundEarnings` and are cover ## 3. `PENDING_TRUST` — the #687 invoice-fraud guard -`PENDING_TRUST` exists to close a fraud hole. An organization that is still `PENDING_VERIFICATION` and funds its bookings by INVOICE could otherwise accrue real consultant earnings against bookings it has not yet paid for, and then disappear before its first invoice ever clears — leaving the platform owing experts for work an unverified, unpaid org commissioned. To prevent that, when `createEarningsFromPayment` is about to write an `OrganizationEarnings` row, it checks the sponsoring org's status: if the org is `PENDING_VERIFICATION` and its count of `PAID` `OrganizationInvoice` rows is zero, the earnings row is minted in `PENDING_TRUST` rather than `PENDING` (`earnings-service.ts`). +`PENDING_TRUST` exists to close a fraud hole. An organization that is still `PENDING_VERIFICATION` and funds its bookings by INVOICE could otherwise accrue real consultant earnings against bookings it has not yet paid for, and then disappear before its first invoice ever clears — leaving the platform owing experts for work an unverified, unpaid org commissioned. To prevent that, `createEarningsFromPayment` resolves the booking's **sponsoring** org (`payment.organizationId` — the org that owes the invoice, not the expert's HOST org) once, up front: if that sponsoring org is `PENDING_VERIFICATION` and its count of `PAID` `OrganizationInvoice` rows is zero, **every** earnings row the booking writes — the consultant row, the primary org row, and each collaborator-org row — is minted in `PENDING_TRUST` rather than `PENDING` (`earnings-service.ts`). Keying on the sponsor rather than the host is what stops an unverified sponsor from letting either consultant *or* org earnings clear. A row parked in `PENDING_TRUST` is excluded from the hold-release cron (which only touches `PENDING` rows) and therefore can never reach `READY` or be batched into a payout. The `release-pending-trust-earnings` cron promotes it to `PENDING` only once the org has earned trust — it goes `ACTIVE`, or it pays its first invoice. The rejected alternative, accruing straight to `PENDING`, would have been one less state to carry but would have re-opened the ghost-org hole. +Upstream of parking, the checkout path now hard-requires a **verified domain claim** before an org may fund anything by INVOICE at all (`assertVerifiedDomainOrThrow`, called on the INVOICE funding branch of `checkout.ts`). The ghost-org window is therefore narrowed on two fronts: an unverified sponsor cannot accrue INVOICE debt without first proving domain ownership, and any earnings its bookings do generate park in `PENDING_TRUST` until it earns trust. + --- ## 4. Hold windows, dispute holds, and release diff --git a/docs/enterprise/10-money-and-ledger/07-payout-pipeline.md b/docs/enterprise/10-money-and-ledger/07-payout-pipeline.md index a5d26a74b..ce442f0e7 100644 --- a/docs/enterprise/10-money-and-ledger/07-payout-pipeline.md +++ b/docs/enterprise/10-money-and-ledger/07-payout-pipeline.md @@ -16,9 +16,9 @@ last-reviewed: 2026-06-05 ## 1. From `READY` earnings to a payout (two-sentence summary + link) -An `OrganizationEarnings` row is append-only — a refund increments `refundedAmountPaise` and never deletes — and it carries the basis-point snapshot of the split it was created with. The full `EarningStatus` machine (`PENDING`, `PENDING_TRUST`, `HELD`, `READY`, `PAID`, `REFUNDED`), the hold windows that gate `PENDING → READY`, the `#687` `PENDING_TRUST` fraud guard, and the refund decrements all now live in [earnings lifecycle](06-earnings-lifecycle.md); this doc picks the row up at `READY`. +An `OrganizationEarnings` row is append-only — a refund increments `refundedAmountPaise` and never deletes — and it carries the basis-point snapshot of the split it was created with. The full `EarningStatus` machine (`PENDING`, `PENDING_TRUST`, `HELD`, `READY`, `BATCHED`, `PAID`, `REFUNDED`), the hold windows that gate `PENDING → READY`, the `#687` `PENDING_TRUST` fraud guard, and the refund decrements all now live in [earnings lifecycle](06-earnings-lifecycle.md); this doc picks the row up at `READY`. -The roll-up has two entry points that share `createOrgPayoutBatch` (`lib/payments/payouts/org-payout-service.ts`): the route `POST /api/organizations/[orgId]/payouts` (OWNER only) and a periodic cron. The procedure, in full, is the following. The batch claims every `READY` `OrganizationEarnings` row for the org with a null `orgPayoutId` whose `createdAt` falls in `[periodStart, periodEnd)`, by creating a placeholder `OrganizationPayout(status = PENDING)` and then stamping each claimed row's `orgPayoutId` in a single conditional `updateMany`. It sums `orgSharePaise − refundedAmountPaise` across the claimed rows to get the net payout, rejecting the batch if that net is zero or negative (refunds exceeding earnings) or if the rows are in mixed currencies. It resolves the org's PAN and MSME status in one fetch, computes the TDS to withhold and the MSME `mustPayByDate`, and writes them back to the payout along with the gross / platform-fee / refunds / net totals. It then flips the claimed earnings from `READY` to `PAID`, and writes an `OrgAuditLog(PAYOUT, PAYOUT_INITIATED)` entry. +The roll-up has two entry points that share `createOrgPayoutBatch` (`lib/payments/payouts/org-payout-service.ts`): the route `POST /api/organizations/[orgId]/payouts` (OWNER only) and a periodic cron. The procedure, in full, is the following. The batch claims every `READY` `OrganizationEarnings` row for the org with a null `orgPayoutId` whose `createdAt` falls in `[periodStart, periodEnd)`, by creating a placeholder `OrganizationPayout(status = PENDING)` and then stamping each claimed row's `orgPayoutId` in a single conditional `updateMany`. It sums `orgSharePaise − refundedAmountPaise` across the claimed rows to get the net payout, rejecting the batch if that net is zero or negative (refunds exceeding earnings) or if the rows are in mixed currencies. It resolves the org's PAN and MSME status in one fetch, computes the TDS to withhold and the MSME `mustPayByDate`, and writes them back to the payout along with the gross / platform-fee / refunds / net totals. It then flips the claimed earnings from `READY` to the intermediate `BATCHED` status — **not** `PAID`, because cash has not left yet — and writes an `OrgAuditLog(PAYOUT, PAYOUT_INITIATED)` entry. The `BATCHED → PAID` flip happens only later, when the payout reaches `COMPLETED` with a UTR (§3); a batch that fails before the gateway moves money releases its `BATCHED` rows back to `READY`. ### 1.1 Worked walkthrough — LearnPro's weekly batch with TDS diff --git a/docs/enterprise/10-money-and-ledger/10-refunds.md b/docs/enterprise/10-money-and-ledger/10-refunds.md index 31d47a4f4..5b62f5493 100644 --- a/docs/enterprise/10-money-and-ledger/10-refunds.md +++ b/docs/enterprise/10-money-and-ledger/10-refunds.md @@ -66,7 +66,7 @@ sequenceDiagram The three triggers are: the **gateway webhook**, where `handleRefundCreated`'s inner `runRefundSideEffects` calls the cascade when a refund transitions to `SUCCEEDED`; the **cascade-refund-earnings cron** (`jobs/refunds/cascade-refund-earnings.ts` → `scripts/refunds/cascade-refund-earnings.ts`, every 15 minutes), which selects `SUCCEEDED` refunds where `cascadedAt IS NULL` and is the backstop for refunds whose webhook never landed; and the **reconcile-pending-refunds cron** (every 15 minutes), which does not call the cascade itself but flips stuck `pending_` placeholders to `SUCCEEDED`, leaving the cascade cron to pick them up on its next pass. -Inside the claimed transaction the cascade performs, in order: a **proportional PaymentLeg reversal** with the last leg absorbing the floor remainder, where a `WALLET` leg credits the org wallet back via `walletCredit`, an unbilled `INVOICE_ACCRUAL`/`OVERAGE_INVOICE_ACCRUAL` leg is netted through a negative `*_REVERSAL` sibling leg — the original leg is never mutated and the monthly rollup bills the net of the pair (#786; a PAID invoice instead defers to clawback), and `CARD`/`REFERRAL_CREDIT`/`LICENSE` legs are handled elsewhere; a **BookingUtilization reversal** that releases engagements proportionally; a **ConsultantEarnings** increment of `refundedShareAmount` capped at the consultant share; an **OrganizationEarnings** increment of `refundedAmountPaise` (org share only, never the consultant slice); an **OrganizationPayout clawback** that increments `clawbackAmountPaise` and stamps `clawbackInitiatedAt` when the earnings already rolled into a `COMPLETED` payout (manual recovery only in v1); the **credit-note mint** (§5); and finally a balanced **`REFUND` ledger transaction** (`idempotencyKey = refund:`) where `PLATFORM_FEE` is the residual plug that absorbs the ≤3-paise floor remainder so the posting always balances. The ledger post is wrapped in a try/catch that logs and pages on failure but never blocks the customer refund — the nightly reconciler's `EARNINGS_LEDGER_DRIFT`/`LEDGER_TXN_IMBALANCE` invariants catch any resulting divergence (see [ledger integrity](13-ledger-integrity.md)). +Inside the claimed transaction the cascade performs, in order: a **proportional PaymentLeg reversal** with the last leg absorbing the floor remainder, where a `WALLET` leg credits the org wallet back via `walletCredit`, an unbilled `INVOICE_ACCRUAL`/`OVERAGE_INVOICE_ACCRUAL` leg is netted through a negative `*_REVERSAL` sibling leg — the original leg is never mutated and the monthly rollup bills the net of the pair (#786; a PAID invoice instead defers to clawback), and `CARD`/`REFERRAL_CREDIT`/`LICENSE` legs are handled elsewhere; a **BookingUtilization reversal** that releases engagements proportionally; a **ConsultantEarnings** increment of `refundedShareAmount` capped at the consultant share; an **OrganizationEarnings** increment of `refundedAmountPaise` (org share only, never the consultant slice); an **OrganizationPayout clawback** that increments `clawbackAmountPaise` and stamps `clawbackInitiatedAt` when the earnings already rolled into a `COMPLETED` payout (manual recovery only in v1); the **credit-note mint** (§5); and finally a balanced **`REFUND` ledger transaction** (`idempotencyKey = refund:`) where `PLATFORM_FEE` is the residual plug that absorbs the ≤3-paise floor remainder so the posting always balances. The ledger post no longer silently swallows a failure: because the cascade runs inside the caller's transaction, a posting failure records a durable `SystemEvent` on its own connection (so the row survives the rollback) and fires a P0 Sentry alert, then **re-throws** to roll the whole cascade back rather than half-applying the earnings/leg reversals with no balanced journal (#812). The caller — the refund cron or the gateway webhook — re-drives the refund, so the customer refund is not lost; it is re-applied atomically. The nightly reconciler's `EARNINGS_LEDGER_DRIFT`/`LEDGER_TXN_IMBALANCE` invariants remain the backstop that catches any residual divergence (see [ledger integrity](13-ledger-integrity.md)). --- diff --git a/docs/enterprise/10-money-and-ledger/13-ledger-integrity.md b/docs/enterprise/10-money-and-ledger/13-ledger-integrity.md index 0b446a9f4..63bf3e75a 100644 --- a/docs/enterprise/10-money-and-ledger/13-ledger-integrity.md +++ b/docs/enterprise/10-money-and-ledger/13-ledger-integrity.md @@ -8,9 +8,9 @@ last-reviewed: 2026-06-05 # Ledger integrity & reconciliation -**What this covers:** the read-only auditor that proves the money model holds — the invariants it checks, the one informational coverage metric, how it runs (nightly cron + on-demand admin route), and what a finding means. This is the safety net that lets us trust derived balances and reconciled caches. +**What this covers:** the auditor that proves the money model holds — the invariants it checks, the one informational coverage metric, how it runs (nightly cron + on-demand admin route), what a finding means, and the single protective action a wallet-cache drift now triggers. This is the safety net that lets us trust derived balances and reconciled caches. -> **Why this exists.** The journal is the source of truth, but a few numbers are **cached** for the hot path (`walletBalance`) or **denormalized** for query speed (`engagementsUsed`, `activeSeatCount`, the `Earnings` amount columns). A cache is only safe if something independently re-derives it and screams on drift. That something is `scripts/reconcile/reconcile-ledgers.ts` — **read-only**; it never writes to an audited table, only to `LedgerReconciliationReport`. +> **Why this exists.** The journal is the source of truth, but a few numbers are **cached** for the hot path (`walletBalance`) or **denormalized** for query speed (`engagementsUsed`, `activeSeatCount`, the `Earnings` amount columns). A cache is only safe if something independently re-derives it and screams on drift. That something is `scripts/reconcile/reconcile-ledgers.ts` — it never hand-patches an audited table, writing only `LedgerReconciliationReport`. Its one protective side effect, added in #837, is that a `WALLET_BALANCE_DRIFT` finding makes the cron wrapper (`jobs/reconcile/reconcile-ledgers.ts`) **freeze that wallet's spend and page P0** (see the design notes below); it still never SQL-patches the drifted cache itself. --- @@ -128,7 +128,7 @@ The cutover (#772) shipped with this auditor returning `ok: true`, **0 findings* ## 5. Design decisions & trade-offs -- **Read-only by construction.** The reconciler writes *only* `LedgerReconciliationReport`; it never touches an audited table. The alternative — an "auto-heal" that SQL-patches a drifted cache — was rejected: a drift is a *symptom of an upstream writer bug*, and silently patching the cache would hide the bug while possibly papering over real lost/duplicated money. The cost is that every finding needs a human (or a follow-up counter-transaction); the benefit is the auditor can never itself become a source of corruption. +- **Never hand-patches a cache — but fails closed on wallet drift.** The reconciler writes *only* `LedgerReconciliationReport` and never touches an audited table. The alternative — an "auto-heal" that SQL-patches a drifted cache — was rejected: a drift is a *symptom of an upstream writer bug*, and silently patching the cache would hide the bug while possibly papering over real lost/duplicated money. The one action it now takes is not a patch but a *protective freeze* (#837): when a `WALLET_BALANCE_DRIFT` finding shows a wallet's cached balance no longer matches its journal, the cron wrapper (`jobs/reconcile/reconcile-ledgers.ts`) calls `freezeWalletSpend` for that `BillingAccount` and pages P0. The freeze rides the append-only `SystemEvent` log (a `WALLET_FREEZE` / `WALLET_UNFREEZE` pair keyed on `correlationId`), fails closed so an unreadable log blocks the spend rather than leaking it, and gates only discretionary checkout spend — never a top-up credit, which is money the bank already pulled and must not be stranded. It still does not re-derive or SQL-patch the balance: clearing the freeze is a manual ops `WALLET_UNFREEZE` once the drift is reconciled. The cost is that every finding still needs a human (or a follow-up counter-transaction); the benefit is that the auditor can never itself become a source of corruption, and a drifted wallet can no longer be spent against until it is trusted again. - **Counts and events reuse the `*Paise` fields.** Rather than a separate schema per check kind, count-based findings (`…ENGAGEMENTS_DRIFT`, `…SEAT_COUNT_DRIFT`, `OVERAGE_COUNT_DRIFT`) and link/state findings stuff counts into `expectedPaise`/`actualPaise`/`deltaPaise` and record the real unit in `details.unit`. One row shape for all fourteen kinds keeps the report queryable and the alerting uniform; the cost is that `*Paise` is a slight misnomer for those rows (the `unit` tag is the disambiguator). - **One informational metric that does *not* fail the run.** `earningsPaymentsWithoutBookingTxn` (#773) is reported but never flips `ok:false`, because `EARNINGS_LEDGER_DRIFT` deliberately only checks payments that *have* a booking txn. Letting a known, tracked coverage gap fail the nightly run would train ops to ignore a red reconciler — the worst possible outcome for a safety net. So it's surfaced as a trend-to-zero metric instead of a finding. diff --git a/docs/enterprise/20-iam-and-security/02-jit-and-session-refresh.md b/docs/enterprise/20-iam-and-security/02-jit-and-session-refresh.md index 7f0fcbb38..b5ddd1ed3 100644 --- a/docs/enterprise/20-iam-and-security/02-jit-and-session-refresh.md +++ b/docs/enterprise/20-iam-and-security/02-jit-and-session-refresh.md @@ -173,15 +173,18 @@ the session check into a guaranteed two-query round-trip on *every* page load and API call. With `cookieCache.maxAge = 5 min`, most requests are served from the signed cookie and skip the DB entirely. The cost is a bounded staleness window: a role change can take up to 5 minutes to -surface. We accept that ceiling because the only *dangerous* staleness — -a downgraded OWNER still acting as OWNER, or a removed member still -inside — is handled out-of-band by an explicit `revokeSession` kill on -the removal path, not by waiting for the cache to expire. So the 5-minute -window applies to benign role *changes*, where "your new capabilities -appear within a few minutes, no re-login" is the right UX. Shrink -`maxAge` toward zero and you trade DB load for fresher roles; the current -value says throughput wins for the benign case and the kill-switch covers -the dangerous one. +surface. We accept that ceiling for benign role *changes*, where "your new +capabilities appear within a few minutes, no re-login" is the right UX. The +*dangerous* staleness — a downgraded OWNER still acting as OWNER, or a +removed member still inside — is bounded by the **same** window rather than +cut short by a hard kill: there is no server-side revoke-by-userId +available (BetterAuth's `admin` plugin, which exposes `revokeUserSessions`, +is not installed, and core `revokeSession` needs the target's own token), +so removal also relies on the `sessionGeneration` bump and refetches on the +next cookie-cache miss, worst case at BetterAuth's 24h `updateAge` +rotation. Shrink `maxAge` toward zero and you trade DB load for fresher +roles; the current value says throughput wins for the benign case, and the +removal window is a known limitation rather than a kill switch. ### Why a counter, not a boolean @@ -196,11 +199,15 @@ against the current row value, unambiguously. The UX cost of "you've been signed out, please log in again" is high relative to the marginal security benefit. The catastrophic case is -role downgrade with stale OWNER session — and that's already followed -by a member-removal flow which calls BetterAuth's `revokeSession` as a -deliberate kill. - -The `sessionGeneration` bump handles the middle case: the membership +role downgrade with a stale OWNER session, or a removed member still +inside — and that is handled by the same `sessionGeneration` bump, not a +hard kill: no server-side revoke-by-userId is available (BetterAuth's +`admin` plugin is not installed, and core `revokeSession` needs the +target's own session token). So removal, downgrade, and soft-suspend all +bump the generation and refetch on the next cookie-cache miss, worst case +at the 24h `updateAge` rotation. + +The `sessionGeneration` bump also handles the middle case: the membership is still active, but the role / status changed. Next request reflects the new permissions; no UX disruption. diff --git a/docs/enterprise/50-operations/03-runbooks.md b/docs/enterprise/50-operations/03-runbooks.md index 227076c74..938ea3eef 100644 --- a/docs/enterprise/50-operations/03-runbooks.md +++ b/docs/enterprise/50-operations/03-runbooks.md @@ -286,7 +286,12 @@ longer matches its journal postings. `jobs/reconcile/reconcile-ledgers.ts` cron entry point): - `WALLET_BALANCE_DRIFT` — `BillingAccount.walletBalance` cache disagrees with the signed balance of the org's WALLET - `LedgerAccount` (`ledgerBalancePaise`). + `LedgerAccount` (`ledgerBalancePaise`). The nightly cron fails + closed on this finding (#837): it freezes that `BillingAccount`'s + discretionary wallet spend (a `WALLET_FREEZE` `SystemEvent`) and + pages P0, so no further checkout debits draw down an untrusted + balance until an operator reconciles the drift and clears the + freeze with a `WALLET_UNFREEZE`. Top-up credits are not gated. - `LEDGER_TXN_IMBALANCE` — a `LedgerTransaction` has `Σ DEBIT ≠ Σ CREDIT` across its `LedgerEntry` rows. Should be impossible (`postLedgerTxn` rejects unbalanced postings) — a hit @@ -314,7 +319,9 @@ balanced **counter-transaction** (Σ DEBIT == Σ CREDIT) that reverses the bad legs, then re-run reconcile. `WALLET_BALANCE_DRIFT` is the lone exception: the balance is a derived cache, so re-deriving it from the WALLET account is a legitimate repair (the journal is the source of -truth). +truth). Once the cache is re-derived and reconcile is clean, clear the +cron's protective spend-freeze on that account with a `WALLET_UNFREEZE` +so bookings can debit the wallet again. **Never** auto-close a finding. Every row represents real money drift. diff --git a/docs/enterprise/50-operations/07-chaos-test-runbook.md b/docs/enterprise/50-operations/07-chaos-test-runbook.md index 1f8d83777..905104adf 100644 --- a/docs/enterprise/50-operations/07-chaos-test-runbook.md +++ b/docs/enterprise/50-operations/07-chaos-test-runbook.md @@ -26,7 +26,9 @@ business fastest. Both contexts POST `/api/checkout` for the identical consultant slot, then both complete sandbox payments so both `payment.captured` webhooks land. The pass condition is exactly one confirmed slot, with the loser's payment -surfaced for refund (a `CONFIRMATION_BLOCKED_DOUBLE_BOOKING` system event). +**auto-refunded** — a `CONFIRMATION_BLOCKED_DOUBLE_BOOKING` system event +followed by a `refundPayment` call in the webhook handler, with +`REQUIRES_MANUAL_RECOVERY` recorded only if that refund call itself fails. This exercises the #827 confirm-time recheck end to end. **2. Webinar capacity overrun (k6 or Playwright, N+1 concurrent, ~20 min).** @@ -141,8 +143,9 @@ the webhook is always ACKed 2xx, the payment never stays PENDING, and the end state is exactly one of three consistent outcomes — cancel wins (EXPIRED, slots deleted, parent CANCELLED), webhook wins (SUCCEEDED, slots confirmed, cancel gets 409), or documented late-capture orphan -(SUCCEEDED after a 200 cancel; reconciler flags for refund, never a -half-confirmed booking). Second leg: two concurrent `DELETE` calls on +(SUCCEEDED after a 200 cancel; the webhook handler auto-refunds it via +`refundPayment`, never a half-confirmed booking, with +`REQUIRES_MANUAL_RECOVERY` recorded only if that refund call fails). Second leg: two concurrent `DELETE` calls on the same PENDING payment — exactly one 200, the loser 409 (CAS cancel-vs-cancel guard). Tracked by #849. diff --git a/docs/enterprise/70-design-decisions/10-session-generation-clock.md b/docs/enterprise/70-design-decisions/10-session-generation-clock.md index 0782eb95e..5bef043bf 100644 --- a/docs/enterprise/70-design-decisions/10-session-generation-clock.md +++ b/docs/enterprise/70-design-decisions/10-session-generation-clock.md @@ -50,10 +50,16 @@ skip-the-refetch fast-path — that optimization is noted as future work in (`session.cookieCache`, `maxAge: 5 min`) can serve a cached session shape for up to five minutes before `customSession` re-runs, so a benign role *change* surfaces within a few minutes. The dangerous case — a downgraded -OWNER or a removed member — is not left to the cache: the member-removal -path additionally calls BetterAuth's `revokeSession` as a deliberate kill -switch, so the catastrophic staleness is handled out-of-band while the -counter handles the safe middle case. +OWNER or a removed member — is handled by the **same** generation bump, not +by a hard session kill. There is no server-side revoke-by-userId available: +BetterAuth's `admin` plugin (which exposes `revokeUserSessions`) is not +installed, and core `revokeSession` needs the target user's own session +token, which an admin removing someone else does not hold. So removal, +downgrade, and soft-suspend all bump `sessionGeneration` too; the removed +member keeps their old payload only until their next request misses the +5-minute cookie cache, with BetterAuth's 24h `updateAge` rotation as the +worst-case ceiling. That residual window is a known limitation, not a hard +kill switch. ## Alternatives considered @@ -61,10 +67,12 @@ We considered revoking the session on every role change (full logout, re-authenticate). It lost on UX cost relative to the marginal security benefit. For the common case — a benign promotion or department move — logging the user out mid-session is a heavy, jarring interruption -(potentially mid-call), and the new capabilities don't justify it. -Revocation is therefore reserved for the one case that warrants it -(removal/downgrade, via the explicit `revokeSession` kill), not applied to -every mutation. +(potentially mid-call), and the new capabilities don't justify it. A hard +logout is not used for any mutation, removal/downgrade included — not +because it wouldn't be warranted there, but because no server-side revoke +is available (the `admin` plugin is not installed, and core `revokeSession` +needs the target's own token). The generation bump, bounded by the +cookie-cache / 24h `updateAge` ceiling, is what handles every case. We considered short session TTLs — make the cookie expire quickly so stale roles can't persist. It lost on UX and load together: a short TTL forces @@ -78,9 +86,10 @@ unconditionally with no cookie cache. It lost on database load: it turns the session check into a guaranteed two-query round-trip (live user row + `memberships.findMany`) on *every* page load and API call. The 5-minute cookie cache lets most requests be served from the signed cookie and skip -the database entirely; the price is a bounded staleness window we accept -for benign changes because the dangerous case is covered by the kill -switch. +the database entirely; the price is a bounded staleness window — the same +cookie-cache / 24h `updateAge` ceiling — that applies to the dangerous +removal/downgrade case as well, since no hard kill is available to shorten +it. A design note on *why a counter and not a boolean*: concurrent role mutations (a script bulk-promoting interns) race against a boolean "stale" @@ -94,9 +103,11 @@ unambiguously compare "I've seen up to N" against the current row value. The real cost is the up-to-5-minute staleness window for benign role changes: a freshly promoted member may not see their new capabilities for a few minutes if their requests keep hitting the cookie cache. We accept -that ceiling because the only *dangerous* staleness is handled by the -explicit `revokeSession` on removal, not by waiting for the cache to -expire. A second cost is the discipline requirement: every +that ceiling for benign changes; the *dangerous* staleness — a downgraded +OWNER or a removed member still acting — is bounded by the same +cookie-cache / 24h `updateAge` window rather than eliminated by a hard +kill, because no server-side revoke is available (the `admin` plugin is not +installed). A second cost is the discipline requirement: every permission-affecting mutation path must remember to call `bumpUserSessionGeneration` inside its transaction; forget it on one path and that mutation's effect is delayed all the way to BetterAuth's 24h diff --git a/docs/enterprise/70-design-decisions/12-pending-trust-earnings-parking.md b/docs/enterprise/70-design-decisions/12-pending-trust-earnings-parking.md index a73794f3d..c2a386c63 100644 --- a/docs/enterprise/70-design-decisions/12-pending-trust-earnings-parking.md +++ b/docs/enterprise/70-design-decisions/12-pending-trust-earnings-parking.md @@ -27,16 +27,24 @@ payable consultant liabilities until some trust signal arrives. ## Decision -Earnings accrued for a `PENDING_VERIFICATION`, INVOICE-funded org that has -never paid an invoice are parked in a dedicated -`EarningStatus.PENDING_TRUST` instead of the normal `PENDING`. The -earnings service makes this decision at accrual time -(`lib/payments/payouts/earnings-service.ts`): when an org-share earning is -about to be created, it checks the sponsoring org's status, and if the org -is `PENDING_VERIFICATION` it counts that org's `PAID` -`OrganizationInvoice` rows; only if that count is zero does it set -`initialStatus = EarningStatus.PENDING_TRUST` (the same guard runs on the -collaborator-split path). The enum carries the rationale inline +Earnings accrued for a booking whose **sponsoring** org — the org that owes +the invoice, `payment.organizationId`, not the expert's HOST org — is +`PENDING_VERIFICATION` and has never paid an invoice are parked in a +dedicated `EarningStatus.PENDING_TRUST` instead of the normal `PENDING`. +The earnings service makes this decision **once** at accrual time +(`lib/payments/payouts/earnings-service.ts`): it reads the sponsoring org's +status, and if that org is `PENDING_VERIFICATION` it counts its `PAID` +`OrganizationInvoice` rows; only if that count is zero does it park. The +decision then applies to **every** row the booking writes — the consultant +earning, the primary org earning, and each collaborator-org earning — so +an unverified sponsor cannot let consultant *or* org money clear. (Keying +on the sponsor rather than the host is the fix for the earlier version, +which keyed on the expert's HOST org and parked only the org-share row.) +Upstream of accrual, the checkout path additionally hard-requires a +verified domain claim before the org may fund anything by INVOICE at all +(`assertVerifiedDomainOrThrow`), so an unverified sponsor cannot even +accrue the INVOICE debt without first proving domain ownership. The enum +carries the rationale inline (`prisma/schema.prisma`, `EarningStatus.PENDING_TRUST`): "Without this state, an unverified org could accumulate real consultant earnings and ghost." A parked earning never reaches the payout pipeline, because that diff --git a/docs/enterprise/70-design-decisions/18-open-b2b-b2c-boundary.md b/docs/enterprise/70-design-decisions/18-open-b2b-b2c-boundary.md index a66d7a60d..884a71f79 100644 --- a/docs/enterprise/70-design-decisions/18-open-b2b-b2c-boundary.md +++ b/docs/enterprise/70-design-decisions/18-open-b2b-b2c-boundary.md @@ -18,7 +18,7 @@ The forces in play: the sponsor pitch is that a team gets the whole marketplace, The boundary stays open in all three places, and the two restrictions we might later want exist today only as unenforced schema stubs. A sponsor org can fund any marketplace consultant; the economics are already correct because host-side earnings attribute to the consultant's own org via their oldest `canHost` membership, and the platform fee is unaffected by who sponsored. Collaborations remain org-blind; each collaborator's earnings resolve to their own org independently, and the revenue-share guard (collaborators capped at 9000 bps, so the owner keeps at least ten percent) is the only structural limit. Exclusivity for `payoutRecipient=ORGANIZATION` consultants is a contract matter between the org and its consultant, not something the platform polices. -The stubs: `ProgramConsultantAllowlist` (Program × ConsultantProfile, unique pair) models a curated panel per Program — zero rows means the open network, and enforcement will live inside `revalidateInsideLock`, where the plan's consultant is already loaded and the distributed lock closes the check-then-book race (the Program-resolution point in `checkout.ts` carries an ADR-18 comment pointing there). `Membership.exclusiveEngagement Boolean @default(false)` records an org-declared exclusivity arrangement; a future feature can hide or block the consultant's independent plans while it is true. Neither column is read anywhere yet. +The stubs: `ProgramConsultantAllowlist` (Program × ConsultantProfile, unique pair) models a curated panel per Program — zero rows means the open network, and enforcement lives inside `revalidateInsideLock`, where the plan's consultant is already loaded and the distributed lock closes the check-then-book race (the Program-resolution point in `checkout.ts` carries an ADR-18 comment pointing there). `Membership.exclusiveEngagement Boolean @default(false)` records an org-declared exclusivity arrangement that hides or blocks the consultant's independent plans while it is true. As of 2026-07-11 checkout enforces both: allowlist rows on the funding Program restrict org-sponsored bookings to listed consultants, and an `ACTIVE` membership with `exclusiveEngagement` blocks bookings of the consultant's independent plans (those without an owning organization). The "hide" half of exclusivity — filtering the consultant's independent plans out of marketplace listings — remains future work, so the flag still must not be exposed in any UI that implies full enforcement. The #773 journal gap is not part of this decision because it was already fixed on `dev` (commit `6187c3f6`): all bookings, single or multi-collaborator, post one balanced `booking:` ledger transaction, and `scripts/reconcile/reconcile-ledgers.ts` holds `earningsPaymentsWithoutBookingTxn` to zero. @@ -30,4 +30,4 @@ Restricting sponsors to org-linked consultants was rejected because it kills the ## Consequences -We keep the strongest version of the sponsor value proposition and full collaboration liquidity, and we ship no new runtime behaviour, so nothing can regress. The price is two dormant schema surfaces that reviewers must understand are intentionally unread (the ADR-18 comments on both say so), and the risk that an org one day expects `exclusiveEngagement` to actually do something — the flag must not be exposed in any UI until enforcement exists. Revisit this decision if a paying sponsor demands a curated panel (implement the allowlist check at the marked checkout hook), if org-owned plans with external collaborators produce a real brand or quality incident (add an approval gate at invite time), or if a host org reports revenue leakage through an exclusive consultant's independent plans (enforce the flag against marketplace visibility). +We keep the strongest version of the sponsor value proposition and full collaboration liquidity, and the defaults change nothing at runtime: a Program with no allowlist rows and a membership with `exclusiveEngagement=false` behave exactly as before, so nothing regresses until an operator opts in. Revisit this decision if org-owned plans with external collaborators produce a real brand or quality incident (add an approval gate at invite time), or if a host org reports revenue leakage through an exclusive consultant's still-visible independent plans (extend the flag to marketplace visibility, the unimplemented "hide" half). diff --git a/docs/enterprise/90-audits/02-subsystem-checklist.md b/docs/enterprise/90-audits/02-subsystem-checklist.md index dd8e6b926..131d658c5 100644 --- a/docs/enterprise/90-audits/02-subsystem-checklist.md +++ b/docs/enterprise/90-audits/02-subsystem-checklist.md @@ -57,7 +57,7 @@ app/api/organizations/ └── [endpointId]/deliveries/route.ts · [deliveryId]/redeliver app/api/overage/route.ts · [overageEventId]/order/route.ts app/api/admin/organizations/route.ts · [orgId]/verify/route.ts # admin verify -app/api/webhooks/razorpay|stripe|xflow|lemon-squeezy|directus/route.ts +app/api/webhooks/razorpay|stripe|directus/route.ts app/dashboard/organization/[orgId]/ # 27 pages home · members · learners · experts · invitations · contracts · programs · diff --git a/docs/guides/cleanup-setup.md b/docs/guides/cleanup-setup.md index 8f75282c7..35c7f3206 100644 --- a/docs/guides/cleanup-setup.md +++ b/docs/guides/cleanup-setup.md @@ -47,8 +47,6 @@ DATABASE_URL=your_database_url STRIPE_SECRET_KEY=sk_test_... # Optional RAZORPAY_KEY_ID=rzp_test_... # Optional RAZORPAY_KEY_SECRET=... # Optional -LEMON_SQUEEZY_API_KEY=... # Optional -XFLOW_SECRET_KEY=... # Optional ``` --- @@ -110,8 +108,6 @@ DATABASE_URL # Required - Your production database URL STRIPE_SECRET_KEY # Optional - For Stripe payment cancellation RAZORPAY_KEY_ID # Optional - For Razorpay cancellation RAZORPAY_KEY_SECRET # Optional - For Razorpay cancellation -LEMON_SQUEEZY_API_KEY # Optional - For Lemon Squeezy cancellation -XFLOW_SECRET_KEY # Optional - For Xflow cancellation ``` ### **2. Enable GitHub Actions** @@ -158,8 +154,6 @@ success=true # Overall job success status // For each payment gateway: // - Stripe: stripe.paymentIntents.cancel() // - Razorpay: razorpay.payments.cancel() - // - Lemon Squeezy: DELETE API call - // - Xflow: Custom cancellation logic ``` 3. **Update Payment Status**: diff --git a/docs/hiring/contractor-research-report-v2.md b/docs/hiring/contractor-research-report-v2.md index 33b1bc79f..f780c1eca 100644 --- a/docs/hiring/contractor-research-report-v2.md +++ b/docs/hiring/contractor-research-report-v2.md @@ -618,7 +618,7 @@ Given your 10% commission structure, even 5 active consultants referring 2 other **Priority:** CRITICAL / IMMEDIATE | **Budget:** ₹20,000–60,000 for full package | **Hire By:** Week 1–2 -**Why:** Distinct from the generalist Legal contractor in V1. A marketplace handling Stripe, Razorpay, Lemon Squeezy, Xflow, consultant data, and video recordings (via Stream.io) needs a SaaS contract specialist who understands: +**Why:** Distinct from the generalist Legal contractor in V1. A marketplace handling Stripe, Razorpay, consultant data, and video recordings (via Stream.io) needs a SaaS contract specialist who understands: - India's **DPDP Act 2025** (fines up to ₹250 crore; Rules notified November 2025) - **GDPR** for international consultees (Data Processing Agreements) - **Data Processing Agreements** for Stream.io (video = sensitive data under DPDP) and Supabase diff --git a/docs/hiring/contractor-research-report.md b/docs/hiring/contractor-research-report.md index 8dbcd3458..3c5e8ee85 100644 --- a/docs/hiring/contractor-research-report.md +++ b/docs/hiring/contractor-research-report.md @@ -157,7 +157,7 @@ The cold-start problem (0 consultants → 0 users → no launch) is the single e ### Documents Needed 1. **Terms of Service** — two-sided marketplace liability, IT Act + Consumer Protection (E-Commerce) Rules 2020, dispute resolution, session IP for recorded calls, payment terms, refund policy -2. **Privacy Policy** — DPDPA 2023 compliant (NOT just GDPR); covers PAN/bank data, Stream.io session recordings, Razorpay/Lemon Squeezy/Xflow data sharing, grievance officer appointment +2. **Privacy Policy** — DPDPA 2023 compliant (NOT just GDPR); covers PAN/bank data, Stream.io session recordings, Razorpay data sharing, grievance officer appointment 3. **Consultant Agreement** — 80/20 revenue split, IP ownership of recorded sessions, TDS disclosure (Section 194-O), non-compete scope, code of conduct 4. **Data Processing Agreement (DPA)** — GDPR Article 28 for EU/UK users; maps to DPDPA Data Fiduciary ↔ Processor for Razorpay, Stream.io, Novu, Supabase diff --git a/docs/maintenance/04-cron-jobs-reference.md b/docs/maintenance/04-cron-jobs-reference.md index 09da7032d..9cdf90382 100644 --- a/docs/maintenance/04-cron-jobs-reference.md +++ b/docs/maintenance/04-cron-jobs-reference.md @@ -1,12 +1,12 @@ # Cron Jobs Reference -All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node.js scripts that connect directly to PostgreSQL via Prisma. They bypass the Next.js middleware entirely, but **all jobs call `abortIfMaintenance()` at startup** (`lib/maintenance-cron.ts`) — a clean exit (0) on OFFLINE mode, a logged warning on DEGRADED. The middleware bypass means they must check Redis themselves, which is exactly what this guard does. +All 28 scheduled jobs run as GitHub Actions workflows, executing standalone Node.js scripts that connect directly to PostgreSQL via Prisma. They bypass the Next.js middleware entirely, but **all jobs call `abortIfMaintenance()` at startup** (`lib/maintenance-cron.ts`) — a clean exit (0) on OFFLINE mode, a logged warning on DEGRADED. The middleware bypass means they must check Redis themselves, which is exactly what this guard does. ## Summary by Category | Category | Count | Most Critical | | ------------ | ----- | ---------------------------------------------------- | -| Appointments | 6 | Reconcile Slot Availability, Cleanup Tentative Slots | +| Appointments | 7 | Reconcile Slot Availability, Cleanup Tentative Slots | | Payments | 3 | Cleanup Abandoned Payments, Reconcile Payment Status | | Payouts | 4 | Process Payouts, Create Payout Batch | | Disputes | 3 | Handle Lost Disputes | @@ -90,9 +90,21 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Maintenance Risk** | **CRITICAL** -- Running during migration may detect false anomalies and "fix" them, corrupting slot state | | **Safe to skip?** | Yes, but run manually post-maintenance. Slot integrity depends on this job. | +### 7. Detect Consultant No-Shows + +| Field | Value | +| -------------------- | -------------------------------------------------------------------------------------------------------------------- | +| **Schedule** | `17 * * * *` (hourly, at :17) | +| **Script** | `jobs/appointments/detect-consultant-no-shows.ts` | +| **Description** | Detects confirmed CONSULTATION sessions where the consultant did not attend (past a 120-minute grace window), auto-refunds the consultee via `refundPayment`, marks the booking cancelled, and notifies both parties. Subscriptions are not yet covered. | +| **DB Connection** | Yes (Prisma) | +| **External APIs** | Payment gateway (refunds), Novu (notifications) | +| **Maintenance Risk** | HIGH -- This job moves money (auto-refund). It runs under a fail-closed cron lock and refuses to run without a real Redis lock. | +| **Safe to skip?** | Yes -- catch-up on next run. No-shows are detected on the next hourly cycle. | + ## Payments -### 7. Alert Orphaned Payments +### 8. Alert Orphaned Payments | Field | Value | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------- | @@ -100,11 +112,11 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Script** | `jobs/alerts/alert-orphaned-payments.ts` | | **Description** | Detects payments recorded in payment gateways but with no corresponding appointment created. Sends alerts for orphaned payments. | | **DB Connection** | Yes (Prisma) | -| **External APIs** | Stripe, Razorpay, Lemon Squeezy, Xflow | +| **External APIs** | Stripe, Razorpay | | **Maintenance Risk** | LOW -- Read-only detection, but may generate false alerts during maintenance | | **Safe to skip?** | Yes -- alerts delayed but no data corruption. | -### 8. Cleanup Abandoned Payments +### 9. Cleanup Abandoned Payments | Field | Value | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | @@ -112,11 +124,11 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Script** | `jobs/payments/cleanup-abandoned-payments.ts` | | **Description** | Cancels payment intents abandoned >30 min with no confirmed appointment. Also resets approval-pending consultation requests. | | **DB Connection** | Yes (Prisma) | -| **External APIs** | Stripe, Razorpay, Lemon Squeezy, Xflow | +| **External APIs** | Stripe, Razorpay | | **Maintenance Risk** | **CRITICAL** -- May cancel valid payment intents where appointment creation was delayed by maintenance downtime | | **Safe to skip?** | Must skip during maintenance. Run post-maintenance catch-up after confirming no in-flight payments. | -### 9. Reconcile Payment Status +### 10. Reconcile Payment Status | Field | Value | | -------------------- | ---------------------------------------------------------------------------------------------------- | @@ -130,7 +142,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node ## Payouts -### 10. Create Payout Batch +### 11. Create Payout Batch | Field | Value | | -------------------- | ------------------------------------------------------------------------------------------------------- | @@ -142,7 +154,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Maintenance Risk** | HIGH -- May create batch from incomplete/corrupted earnings data | | **Safe to skip?** | Yes -- batch creation can be triggered manually. Payouts delayed by one week if missed. | -### 11. Process Payouts +### 12. Process Payouts | Field | Value | | -------------------- | ------------------------------------------------------------------------------------ | @@ -154,7 +166,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Maintenance Risk** | **CRITICAL** -- May send incorrect amounts or process payouts based on stale data | | **Safe to skip?** | Must skip during maintenance. Payouts are irreversible once sent to payment gateway. | -### 12. Handle Stuck Payouts +### 13. Handle Stuck Payouts | Field | Value | | -------------------- | ----------------------------------------------------------------------------------------------------- | @@ -166,7 +178,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Maintenance Risk** | MEDIUM -- May retry payouts that should remain paused during maintenance | | **Safe to skip?** | Yes -- stuck payouts will be retried on next cycle. | -### 13. Reconcile Payout Status +### 14. Reconcile Payout Status | Field | Value | | -------------------- | -------------------------------------------------------------------------------------------- | @@ -180,7 +192,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node ## Disputes -### 14. Alert Dispute Deadlines +### 15. Alert Dispute Deadlines | Field | Value | | -------------------- | ------------------------------------------------------------------------------------------------------------ | @@ -192,7 +204,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Maintenance Risk** | LOW -- Alert-only, no state changes | | **Safe to skip?** | Yes -- alerts delayed but dispute deadlines are external. | -### 15. Handle Lost Disputes +### 16. Handle Lost Disputes | Field | Value | | -------------------- | -------------------------------------------------------------------------------------------------------------- | @@ -204,7 +216,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Maintenance Risk** | HIGH -- May incorrectly process earnings if data is in flux | | **Safe to skip?** | Yes -- lost disputes can wait 6 hours. Run manually if urgent. | -### 16. Reconcile Disputes +### 17. Reconcile Disputes | Field | Value | | -------------------- | ------------------------------------------------------------------------------------- | @@ -218,7 +230,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node ## Refunds -### 17. Cascade Refund to Earnings +### 18. Cascade Refund to Earnings | Field | Value | | -------------------- | -------------------------------------------------------------------------------------------------------------- | @@ -230,7 +242,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Maintenance Risk** | HIGH -- May incorrectly adjust earnings if refund/earnings tables are being migrated | | **Safe to skip?** | Yes -- earnings adjustments delayed but caught on next run. | -### 18. Reconcile Pending Refunds +### 19. Reconcile Pending Refunds | Field | Value | | -------------------- | ---------------------------------------------------------------------------------------------- | @@ -244,7 +256,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node ## Earnings -### 19. Sync Payment-Earning +### 20. Sync Payment-Earning | Field | Value | | -------------------- | ---------------------------------------------------------------------------------------------- | @@ -256,7 +268,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Maintenance Risk** | HIGH -- May create incorrect earnings if payment/earnings tables are being migrated | | **Safe to skip?** | Yes -- earnings sync delayed but caught on next run. | -### 20. Release Earnings from Hold +### 21. Release Earnings from Hold | Field | Value | | -------------------- | ----------------------------------------------------------------------------------------------------- | @@ -270,7 +282,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node ## Cleanup -### 21. Deactivate Expired Discounts +### 22. Deactivate Expired Discounts | Field | Value | | -------------------- | ------------------------------------------------------------------------------------------ | @@ -282,7 +294,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Maintenance Risk** | LOW -- Simple status update, unlikely to conflict | | **Safe to skip?** | Yes -- expired discounts persist one extra day. Minimal impact. | -### 22. Document Storage Reconciliation +### 23. Document Storage Reconciliation | Field | Value | | -------------------- | ------------------------------------------------------------------------------------------------------- | @@ -294,7 +306,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Maintenance Risk** | MEDIUM -- May incorrectly identify files as orphaned if document table is being migrated | | **Safe to skip?** | Yes -- orphaned files persist one extra day. Run manually post-maintenance. | -### 23. Archive Webhook Events +### 24. Archive Webhook Events | Field | Value | | -------------------- | ------------------------------------------------------------------------------------------------- | @@ -306,7 +318,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Maintenance Risk** | LOW -- Cleanup of old data, not time-sensitive | | **Safe to skip?** | Yes -- old events persist one extra week. | -### 24. Cleanup Auth Tokens +### 25. Cleanup Auth Tokens | Field | Value | | -------------------- | -------------------------------------------------------------------------------------------------- | @@ -320,7 +332,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node ## Stream -### 25. Stream User Sync +### 26. Stream User Sync | Field | Value | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- | @@ -334,7 +346,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node ## Waitlist -### 26. Send Waitlist Expiration Reminders +### 27. Send Waitlist Expiration Reminders | Field | Value | | -------------------- | ---------------------------------------------------------------------------------------------------------------- | @@ -346,7 +358,7 @@ All 27 scheduled jobs run as GitHub Actions workflows, executing standalone Node | **Maintenance Risk** | LOW -- Email sending, DB reads for waitlist entries | | **Safe to skip?** | Yes -- users miss one reminder but have 24h total offer window. | -### 27. Process Waitlist Expirations +### 28. Process Waitlist Expirations | Field | Value | | -------------------- | --------------------------------------------------------------------------------------------------------------- | diff --git a/docs/maintenance/05-webhook-behavior.md b/docs/maintenance/05-webhook-behavior.md index 3fa1e3c5f..4e31cf575 100644 --- a/docs/maintenance/05-webhook-behavior.md +++ b/docs/maintenance/05-webhook-behavior.md @@ -2,7 +2,7 @@ ## Overview -All webhook routes (`/api/webhooks/*`) are **exempt from maintenance mode**. This means webhooks from Stripe, Razorpay, Lemon Squeezy, XFlow, and Stream.io will be received and processed regardless of whether the site is in DEGRADED or OFFLINE mode. +All webhook routes (`/api/webhooks/*`) are **exempt from maintenance mode**. This means webhooks from Stripe, Razorpay, and Stream.io will be received and processed regardless of whether the site is in DEGRADED or OFFLINE mode. This is intentional: payment webhooks are critical for completing transactions and must not be blocked. @@ -12,8 +12,6 @@ This is intentional: payment webhooks are critical for completing transactions a | ----------------- | ---------------------------------- | ---------------------------------- | ----------------------------------------- | | **Stripe** | `POST /api/webhooks/stripe` | `stripe.webhooks.constructEvent()` | `logWebhookEvent()` with gateway event ID | | **Razorpay** | `POST /api/webhooks/razorpay` | HMAC SHA256 signature | `logWebhookEvent()` with gateway event ID | -| **Lemon Squeezy** | `POST /api/webhooks/lemon-squeezy` | HMAC SHA256 (custom) | `logWebhookEvent()` with gateway event ID | -| **XFlow** | `POST /api/webhooks/xflow` | HMAC SHA256 (custom) | `logWebhookEvent()` with gateway event ID | | **Stream.io** | `POST /api/stream/webhooks/` | HMAC SHA256 (constant-time) | `logWebhookEvent()` with event ID | ## Stripe Webhook Events Handled @@ -37,21 +35,6 @@ This is intentional: payment webhooks are critical for completing transactions a - `payment.dispute.created` / `payment.dispute.won` / `payment.dispute.lost` / `payment.dispute.closed` -- Dispute lifecycle - `payout.processed` / `payout.reversed` / `payout.rejected` / `payout.queued` / `payout.pending` / `payout.cancelled` -- Payout lifecycle -## Lemon Squeezy Webhook Events Handled - -- `order_created` -- New order -- `subscription_created` -- Subscription started -- `subscription_payment_success` -- Recurring payment succeeded -- `subscription_payment_failed` -- Recurring payment failed -- `subscription_cancelled` -- Subscription cancelled - -## XFlow Webhook Events Handled - -- `payment.succeeded` -- Payment completed -- `payment.failed` -- Payment failed -- `payment.pending` -- Payment pending -- `subscription.created` / `subscription.updated` / `subscription.deleted` -- Subscription lifecycle - ## Stream.io Webhook Events Handled - `call.recording_started` / `call.recording_stopped` -- Recording lifecycle @@ -77,19 +60,6 @@ This is intentional: payment webhooks are critical for completing transactions a - **Behavior on failure**: Retries on non-2xx response - **Dashboard**: Razorpay Dashboard > Webhooks > Recent Deliveries -### Lemon Squeezy - -- **Retry policy**: Retries with exponential backoff -- **Retry window**: Up to 7 days -- **Max retries**: Multiple attempts -- **Dashboard**: Lemon Squeezy Dashboard > Webhooks - -### XFlow - -- **Retry policy**: Retries on non-2xx response -- **Retry window**: Limited retry window -- **Dashboard**: XFlow merchant portal - ## Idempotency Protection All webhook handlers use `logWebhookEvent()` from `/api/webhooks/utils.ts`: @@ -100,7 +70,7 @@ All webhook handlers use `logWebhookEvent()` from `/api/webhooks/utils.ts`: **Database table**: `WebhookEvent` -- `gateway`: STRIPE | RAZORPAY | STREAM | LEMON_SQUEEZY | XFLOW +- `gateway`: STRIPE | RAZORPAY | STREAM - `eventId`: Unique identifier from the gateway - `eventType`: Event type string - `payload`: Full JSON payload diff --git a/docs/maintenance/07-post-maintenance-recovery.md b/docs/maintenance/07-post-maintenance-recovery.md index 4851b98cb..cbcd468f6 100644 --- a/docs/maintenance/07-post-maintenance-recovery.md +++ b/docs/maintenance/07-post-maintenance-recovery.md @@ -49,14 +49,6 @@ This is the most critical post-maintenance step for financial integrity. - Filter by time range covering the maintenance window - **Action**: Note any failed deliveries for manual reconciliation -- [ ] **Lemon Squeezy**: Check webhook delivery logs - - Path: Lemon Squeezy Dashboard > Webhooks - - Review any failures during the maintenance window - -- [ ] **XFlow**: Check webhook delivery logs - - Path: XFlow merchant portal - - Review any failures during the maintenance window - ## 4. Run Critical Reconciliation Jobs Manually trigger these jobs in order. Use the admin system jobs dashboard or run directly: diff --git a/docs/maintenance/09-future-improvements.md b/docs/maintenance/09-future-improvements.md index 472f21896..b6f8df9ef 100644 --- a/docs/maintenance/09-future-improvements.md +++ b/docs/maintenance/09-future-improvements.md @@ -122,8 +122,6 @@ export async function abortIfMaintenance(jobName: string): Promise { - `app/api/webhooks/stripe/route.ts` - `app/api/webhooks/razorpay/route.ts` -- `app/api/webhooks/lemon-squeezy/route.ts` -- `app/api/webhooks/xflow/route.ts` **Implementation**: @@ -305,7 +303,7 @@ File: `scripts/cleanup/reconcile-document-storage.ts` | --- | -------------------------------------------------- | -------- | ------- | | 2 | Cron job maintenance guard | CRITICAL | ✅ Done | | 1 | DEGRADED write-blocking | HIGH | ✅ Done | -| 3 | Webhook DB health check (Stripe/Razorpay/LS/XFlow) | HIGH | ✅ Done | +| 3 | Webhook DB health check (Stripe/Razorpay) | HIGH | ✅ Done | | A | Admin system-jobs DEGRADED blocking | MEDIUM | ✅ Done | | B | Stream.io webhook DB health check | MEDIUM | ✅ Done | | C | `reconcile-document-storage` storage probe | MEDIUM | ✅ Done | diff --git a/docs/payments/01-architecture.md b/docs/payments/01-architecture.md index f76fe722e..0706456cd 100644 --- a/docs/payments/01-architecture.md +++ b/docs/payments/01-architecture.md @@ -19,7 +19,7 @@ ## Overview -The payment system uses **Razorpay** as the sole active payment gateway (Stripe, Lemon Squeezy, and XFlow removed — see [gateways/gateway-evaluation-mar-2026.md](./gateways/gateway-evaluation-mar-2026.md)). It handles four appointment types: +The payment system uses **Razorpay** as the sole active payment gateway (Lemon Squeezy and XFlow removed, Stripe retained but inactive — see [gateways/gateway-evaluation-mar-2026.md](./gateways/gateway-evaluation-mar-2026.md)). It handles four appointment types: | Type | Description | Slot Handling | | ---------------- | -------------------- | ---------------------------------- | @@ -1183,7 +1183,6 @@ AppointmentStatus: | | DATABASE_URL, DIRECT_URL | | | | STRIPE_SECRET_KEY | | | | RAZORPAY_KEY_ID, RAZORPAY_SECRET | | -| | LEMON_SQUEEZY_API_KEY, XFLOW_SECRET_KEY | | | +-------------------------------------------------------------------------+ | +-----------------------------------------------------------------------------------+ diff --git a/docs/payments/payouts/02-earnings-lifecycle.md b/docs/payments/payouts/02-earnings-lifecycle.md index ea6f8f89d..d6349b4d9 100644 --- a/docs/payments/payouts/02-earnings-lifecycle.md +++ b/docs/payments/payouts/02-earnings-lifecycle.md @@ -16,7 +16,10 @@ stateDiagram-v2 PENDING --> REFUNDED: Payment Refunded READY --> HELD: Dispute Opened - READY --> PAID: Payout Completed + READY --> BATCHED: Rolled into payout batch + + BATCHED --> PAID: Payout Completed (COMPLETED + UTR) + BATCHED --> READY: Batch failed or rejected HELD --> READY: Dispute Resolved (favor consultant) HELD --> REFUNDED: Dispute Resolved (favor customer) @@ -49,7 +52,8 @@ stateDiagram-v2 | **PENDING** | Earnings created, within hold period | Wait for hold expiry | | **READY** | Hold period passed, eligible for payout | Include in batch | | **HELD** | Frozen due to dispute | Await resolution | -| **PAID** | Successfully paid to consultant | Terminal state | +| **BATCHED** | Rolled into a payout batch, but cash has not left yet | Await payout completion | +| **PAID** | Successfully paid to consultant (payout reached COMPLETED with a UTR) | Terminal state | | **REFUNDED** | Payment was refunded | Terminal state | --- @@ -381,7 +385,8 @@ enum EarningStatus { PENDING // Within hold period READY // Eligible for payout HELD // Frozen due to dispute - PAID // Payout completed + BATCHED // Rolled into a payout batch; cash has not been disbursed yet + PAID // Payout completed (reached COMPLETED with a UTR) REFUNDED // Payment was refunded } ``` diff --git a/docs/payments/payouts/03-payout-processing.md b/docs/payments/payouts/03-payout-processing.md index 4e430ae59..540890f6d 100644 --- a/docs/payments/payouts/03-payout-processing.md +++ b/docs/payments/payouts/03-payout-processing.md @@ -87,7 +87,7 @@ sequenceDiagram end PS->>DB: Link earnings to payout by ID - Note over DB: Set payoutId on each earning + Note over DB: Set payoutId and status = BATCHED
on each earning (cash has not left yet) PS->>PS: Count-mismatch guard Note over PS: Verify linked count == expected count end @@ -98,7 +98,7 @@ sequenceDiagram PS-->>GH: Batch complete ``` -> **Batch Integrity (Mar 2026):** Each consultant's payout is now wrapped in a `$transaction` that re-queries exact READY earnings, sums them, creates the payout, and links earnings by ID -- all atomically. A count-mismatch guard ensures the number of linked earnings matches expectations, preventing partial batches from concurrent modifications. +> **Batch Integrity (Mar 2026):** Each consultant's payout is now wrapped in a `$transaction` that re-queries exact READY earnings, sums them, creates the payout, and links earnings by ID while moving them to `BATCHED` (the cash has not left yet, so they are not yet `PAID`) -- all atomically. A count-mismatch guard ensures the number of linked earnings matches expectations, preventing partial batches from concurrent modifications. ### Eligibility Criteria diff --git a/docs/payments/payouts/05-api-reference.md b/docs/payments/payouts/05-api-reference.md index 574d7602f..79dd775bc 100644 --- a/docs/payments/payouts/05-api-reference.md +++ b/docs/payments/payouts/05-api-reference.md @@ -254,7 +254,7 @@ GET /api/admin/earnings | Param | Type | Description | | -------------- | ------ | ------------------------------------ | -| `status` | string | PENDING, READY, HELD, PAID, REFUNDED | +| `status` | string | PENDING, READY, HELD, BATCHED, PAID, REFUNDED | | `consultantId` | string | Filter by consultant | | `limit` | number | Results per page | | `offset` | number | Pagination offset | diff --git a/docs/payments/payouts/README.md b/docs/payments/payouts/README.md index b6800437a..4b4218202 100644 --- a/docs/payments/payouts/README.md +++ b/docs/payments/payouts/README.md @@ -11,7 +11,7 @@ How consultant earnings flow from payment success to bank deposit. Covers the fu | # | Document | Description | | --- | ---------------------------------------------------------- | -------------------------------------------------------------------- | | 01 | [Architecture](./01-architecture.md) | System design, service layer, database models, provider integrations | -| 02 | [Earnings Lifecycle](./02-earnings-lifecycle.md) | PENDING → READY → PAID status flow, hold periods | +| 02 | [Earnings Lifecycle](./02-earnings-lifecycle.md) | PENDING → READY → BATCHED → PAID status flow, hold periods | | 03 | [Payout Processing](./03-payout-processing.md) | Batch creation, approval workflow, processing pipeline | | 04 | [International Payments](./04-international-payments.md) | Cross-border scenarios, currency handling, regulatory compliance | | 05 | [API Reference](./05-api-reference.md) | Payout and earnings API endpoints | diff --git a/docs/prisma/schema-map.md b/docs/prisma/schema-map.md index 56f8b74b0..6b6489181 100644 --- a/docs/prisma/schema-map.md +++ b/docs/prisma/schema-map.md @@ -1898,7 +1898,7 @@ Every enum in the schema and its values. | `RecordingStoragePolicy` | STREAM_ONLY, SUPABASE_PERMANENT | | `RecordingStorageType` | STREAM_S3, SUPABASE | | `RecordingStatus` | RECORDING, PROCESSING, READY, TRANSFERRING, AVAILABLE, FAILED, EXPIRED | -| `PaymentGateway` | STRIPE, RAZORPAY, LEMON_SQUEEZY, XFLOW, CARD | +| `PaymentGateway` | STRIPE, RAZORPAY, DODO_PAYMENTS, CARD | | `PaymentStatus` | PENDING, SUCCEEDED, FAILED, EXPIRED | | `PaymentLegSource` | CARD, WALLET, REFERRAL_CREDIT, INVOICE_ACCRUAL, OVERAGE_INVOICE_ACCRUAL, LICENSE | | `RefundStatus` | PENDING, SUCCEEDED, FAILED, CANCELLED | diff --git a/docs/roadmap/2026-07-10-issue-triage-and-remediation-plan.md b/docs/roadmap/2026-07-10-issue-triage-and-remediation-plan.md new file mode 100644 index 000000000..428743809 --- /dev/null +++ b/docs/roadmap/2026-07-10-issue-triage-and-remediation-plan.md @@ -0,0 +1,312 @@ +# Issue Triage & Remediation Plan + +**Generated:** 2026-07-10 · **Scope:** all 105 open GitHub issues · **Method:** each issue read in full, then verified against the *current* code on `dev` (grep/read of routes, schema, services, crons, workflows). Code is treated as ground truth over issue text — many issues describe work that has since shipped or been superseded. + +> This is a **planning document only**. No code is changed here. Each remediation item becomes its own follow-up PR into the feature branch. + +> **Status addendum (2026-07-11):** Wave 0 item 0.1 (**#693** moderation side-effects — listed below as the top launch blocker) has since been implemented in PR #974, which wires the full enforcement pipeline (ban/suspend flags, session and Stream revocation, bulk cancellation with refunds, earnings hold, notifications). Read the #693 rows in this snapshot as historical; the remaining moderation tails (unban/reinstate action, best-effort reconciliation path, Novu dashboard workflows) are named as follow-ups in `docs/decisions/2026-07-11-moderation-enforcement-and-peer-chat-block.md`. The rest of the snapshot is unmodified. + +--- + +## 1. Headline numbers + +| Classification | Count | Meaning | +|---|---:|---| +| ✅ **Already fixed** — close | 12 | Code already does this; close the issue. | +| ♻️ **Duplicate / obsolete / wontfix** | 5 | Superseded, stale, or an ops-runbook not an eng deliverable. | +| 🟡 **Partially fixed** | 41 | Core landed; a named tail remains. Most trackers live here. | +| 🔴 **Legit-pending** | 47 | Genuinely unbuilt. | + +**The story the code tells:** the money/booking/payments core, resilience primitives (rate-limit, circuit breakers, failed-email DLQ, CAS/locks), Sentry, and the enterprise SSO/SCIM stack are **already built and hardened**. What remains splits into (a) a *small* set of genuine launch-blockers — mostly **security side-effects that were stubbed out** — and (b) a long tail of polish, compliance depth, and post-launch features. + +**The one finding that should stop everything else:** **#693 — moderation actions are a `TODO` stub.** Ban/suspend/unverify write no side-effects, so a "banned" user keeps full access (session, Stream token, bookings). That is the highest-severity item in the entire backlog. + +--- + +## 2. Close now — already fixed (verified in code) + +| # | Title | Evidence | +|---|---|---| +| #248 | Stream Chat sync on every dashboard load | `event-channel.action.ts:471` session guard + sessionStorage guard; per-load full-sync is gone | +| #279 | Support entire subscription reschedule | `reschedule/route.ts:40` `?type=SUBSCRIPTION` marks all slots tentative; UI wired | +| #300 | In-app notification system | Delivered via Novu Inbox (`NotificationInbox.tsx`) instead of custom table | +| #346 | Pagination on document dashboards | `documents/route.ts:47` limit/offset + client envelope; ACs all checked | +| #360 | Recording two-mode storage | `RecordingStoragePolicy` enum + auto-transfer service + cron | +| #379 | Consultant verification gate + moderation link | `checkConsultantVerification` gates plan creation; moderation → `isVerified=false` | +| #387 | Staff onboarding validation | STAFF/ADMIN rejected server-side as invite-only (`onboarding-server.ts:573`) | +| #437 | Referral qualifying actions / anti-gaming | Deferred referee bonus + consultant-referee qualification shipped | +| #474 | Critical email retry + DLQ | `FailedEmail` model + retry worker + cron + admin requeue | +| #475 | Sentry error tracking | Shipped via PR #901 | +| #534 | Safe Prisma migration workflow docs | `docs/prisma/migrations-guide.md` (~95% coverage, audited) | +| #855 | Capture-after-cancel auto-refund | `handlers.ts:360` `capturedAfterTerminal` → auto-refund + CAS guard | + +## 3. Duplicate / obsolete / wontfix + +| # | Title | Disposition | +|---|---|---| +| #636 | Next.js perf optimization | **Duplicate** of #639 (its own body says #639 supersedes it) | +| #613 | Codex Checkpoint-1 audit snapshot | **Obsolete** — a passive tracking snapshot; residuals live in their own issues | +| #875 | Detect→Triage→Decide→Remediate | **Obsolete** — open-ended discussion; detection backbone (Sentry) shipped | +| #481 | Billing guardrails across vendors | **Wontfix-as-code** — per-vendor dashboard config → keep as an ops runbook | +| #884 | Phone/SMS step-up verification | **Parked by decision** (2026-06-17); no-op seam kept in `auth-phone-stepup.ts` | + +--- + +## 4. The sequenced remediation plan ("the rightful order") + +Ordered by **launch dependency**, not by label. Each wave should largely finish before the next starts; items inside a wave are listed most-critical first. Effort: S ≤ half-day · M ≈ 1–2 days · L ≈ 3–5 days · XL = multi-week/epic. + +### 🚨 Wave 0 — Launch blockers (security, correctness, go/no-go) + +These are the gate to a public launch. + +| Order | # | What's actually left | Effort | Why it blocks | +|---:|---|---|:--:|---| +| 0.1 | **#693** | Wire real side-effects into moderation `actionType` (session + Stream-token revoke, suspend/ban flags, cancel appointments, earnings hold, Novu). `action/route.ts:82` is still `// TODO`. | M | Banned users currently keep full access. | +| 0.2 | **#690** (AUTH-2) | AES-256-GCM envelope-encrypt `Account.accessToken/refreshToken/idToken` (reuse the `panEncrypted` crypto pattern). Tokens are plaintext `@db.Text`. | M | DB leak → OAuth account takeover. | +| 0.3 | **#694** (DOC-4) | App-layer encryption + `virusScanStatus`/`fileHash` on verification docs; rate-limit the verification & plan-material upload routes (DOC-2). | M | PAN/Aadhaar scans stored unencrypted at rest. | +| 0.4 | **#695** (ADM-1/2) | Fix passwordless staff-create contract (`user/staff/route.ts` takes `password`, never calls BetterAuth); add audit-log rows to the 4 financial-admin routes (TDS, reconcile-ledgers, exchange-rates). | M | Irreversible TDS actions with no record; broken staff auth. | +| 0.5 | **#696** (SCH-3) | Drop `user.email` from the public consultant-search `OR` predicate (`consultants/route.ts:128`). | S | Email enumeration of the whole user base. | +| 0.6 | **#691** (NTF-2) | Add `List-Unsubscribe` + unsubscribe footer to email templates; add a `triggerWorkflowSafe()` wrapper around the 23 fire-and-forget triggers. | M | CAN-SPAM/GDPR legal exposure + silent notification loss. | +| 0.7 | **#486** | Fix consultee "Upcoming" filter — `event-processor.ts:512` filters by **time only**, so EXPIRED/PENDING appointments show with a live Join button. Gate Join on status + joinable window. | M | Users can "join" cancelled/unpaid sessions. | +| 0.8 | **#407/#405** | Add the Netlify **edge** rate-limit layer (Layer 1) — app-layer (Upstash) is done; re-audit the 21 lifecycle sub-items (2 CRITICALs already closed). | S–L | Pre-function IP throttling for launch. | +| 0.9 | **#932** | Verify/relocate Netlify Functions to Singapore (ap-southeast-1) to stop cross-region pooler timeouts; blast-radius + caching already shipped. | M | Prod stability — pooler connection timeouts under load. | +| 0.10 | **#837** | Run the staging chaos go/no-go gate (scenarios 1–4 + 2× peak ramp). **Code work is complete.** | S (ops) | Launch gate. | +| 0.11 | **#874** | Run + record the capacity go/no-go (chaos scenario 6 at 2× peak). Depends on caching (#734) + vendor-tier upgrades. | M | Explicit launch go/no-go gate. | + +### 🧊 Wave 1 — Schema freeze + money/tax correctness + +Per the project rule, **schema freeze is the launch gate** (deferred impl is OK, deferred schema is not). + +| Order | # | What's left | Effort | +|---:|---|---|:--:| +| 1.1 | **#688** | Extend `deletedAt` to money models (Payment/Refund/Invoice/Payout/Earnings); land residual bps-sum + wallet-non-negative CHECK constraints; DB trigger to enforce ledger append-only. | L | +| 1.2 | **#677** | GST intra/inter-state split + place-of-supply + B2B reverse-charge; Float→paise on remaining money fields; credit-note flow for PAID org invoices. (Runtime-verify tax math.) | XL | +| 1.3 | **#738** | Chargeback-LOST tax cascade; resolve the 194J→194O CA decision; multi-attendee webinar tax. Non-resident Sec-195 stays deferred. | XL | +| 1.4 | **#676** | Booking audit tail: AE-1/AE-2 allocation-engine gaps, A11 optimistic versioning, B4 audit logs. Mark A1–A4 + B1 closed. | L | +| 1.5 | **#834** | Add the explicit waitlist↔slot unique constraint on the pre-MVP schema reset (CAS race already fixed in code). | S | + +### 🛡️ Wave 2 — Production resilience & infra + +| Order | # | What's left | Effort | +|---:|---|---|:--:| +| 2.1 | **#697** | `withJobExecution()` wrapper writing `SystemJobExecution` (INF-2 silent cron failure); TTL on maintenance keys (INF-1); wrap Razorpay client in a circuit breaker (INF-3). | L | +| 2.2 | **#866** | Install `@upstash/qstash`; migrate ~10 event-shaped jobs off GitHub Actions to QStash→HTTP; add a dead-man heartbeat for the GA fleet. | L | +| 2.3 | **#899** | Event-driven Stream channel setup + thin reconciliation cron to retire per-load bulk sync (root cause of #248). | XL | +| 2.4 | **#689** | STR-2 transfer retry backoff + STR-4 per-participant audit trail (STR-1 revenue-leak already fixed). | M | +| 2.5 | **#473** | Add Stream status to `/api/health` + degradation UI (breaker itself done). | M | +| 2.6 | **#471/#472** | No-show auto-detection/marking + session-overrun timer & conflict alert (presence foundation exists). | L | +| 2.7 | **#920** | Finish making remaining DB-backed pages dynamic; delete the `IS_NEXT_BUILD` prerender workaround in `prisma.ts`. | M | +| 2.8 | **#937** | Evaluate Prisma Accelerate (ap-south-1 pool) vs the region move as the durable cross-region fix. | L | +| 2.9 | **#900** | Confirm Netlify build env (`SENTRY_AUTH_TOKEN`) + rotate token, then close. | S | + +### 🏢 Wave 3 — Compliance & enterprise runtime + +| Order | # | What's left | Effort | +|---:|---|---|:--:| +| 3.1 | **#840** | First-class org-invitee onboarding: detect invite token pre-picker, route to a no-profile shell (dominant enterprise acquisition path). | M | +| 3.2 | **#701** | LCY residuals: consent-withdrawal cascade, data-residency enforce-or-remove, `DataBreach` 72h write path; confirm HRIS scope (route absent). SSO/SCIM runtime itself is largely done. | L | +| 3.3 | **#725** | BetterAuth Tier-1 plugins — start with **2FA + Captcha** (enterprise-sales relevant), then LinkedIn/HIBP/Admin-RBAC. | XL | +| 3.4 | **#692** | Referral anti-fraud: velocity + IP-subnet checks in `applyReferralCode`; expire-credits cron endpoint (REF-2 lapsed-credit fix already done). | M | +| 3.5 | **#770** | Contract & BillingSubscription lifecycle — GAP-2→GAP-1→GAP-3 (edit/amend/renew), then `billingMode` PREPAID/POSTPAID. | L | +| 3.6 | **#684** | OrganizationPlan curation UI (backend/entitlement layer already real). | L | +| 3.7 | **#705** | Residual infra trio: analytics stack wiring, Postgres extension enablement, Supabase Realtime decision. | L | +| 3.8 | **#863** | Enterprise residuals register — tick off `ScimToken.expiresAt`; re-audit tails (ongoing tracker). | L | + +### 🎨 Wave 4 — UX & dashboard polish + +| # | What's left | Effort | +|---|---|:--:| +| **#868** | Finish dashboard-redesign residuals (StatCard accent colors), merge `feat/dashboard-redesign`→dev. | L | +| **#867** | Split the remaining 71 semantic-correctness findings (role×tab editability, races) into discrete issues. | XL | +| **#487** | Re-audit surviving P1/P2 consultant-dashboard findings against redesigned pages. | XL | +| **#906** | `/api/appointments` ~2.3s → parallelize reads, discriminate the single event include, defer/cache count. | M | +| **#448/#337** | Reschedule: add the re-allocation-complete notification + staff/admin visibility (request-side already done). | M | +| **#494** | Onboarding UX: draft persistence, clickable completed steps, consultee budget/session/domain fields. | M | +| **#698** | `calculateProfileCompletion` compute+persist; verify OB-2 session refresh after ORG_ADMIN assignment. | M | +| **#485** | Price + currency on consultee cards; scope chronological grouping separately. | M | +| **#536** | Shared enum→label formatter + populate missing Novu payload fields. | M | +| **#450** | Re-audit remaining query-batching claims (RSC conversion + review cap already done). | L | +| **#902** | Align org-members prefetch query-key/shape with client, or drop the dead prefetch. | S | +| **#309** | React Query + HTTP cache headers on slot-availability APIs. | M | +| **#348** | Supabase Realtime subscription on `AppointmentDocument`. | M | +| **#663** | Enterprise analytics endpoints + Recharts visualizations. | L | +| **#664** | "Recommended by [Org]" badge on explore cards. | S | +| **#341** | Form-based Send Inquiry (model + API + profile CTA). | L | + +### 📦 Wave 5 — Post-launch features & tech-debt (defer) + +**Quick wins worth grabbing early** (small, isolated): **#891** (referral code wiped on `?ref=`-less signup — one-line effect fix), **#664** badge, **#902** prefetch. + +**Cross-cutting workstreams** (see §5–§6): Server-Actions migration (relates to **#734**), app-wide animations. + +| Bucket | Issues | +|---|---| +| Perf tail | #734 (include→select + bundle split), #639 (re-run ANALYZE), #383 (query-perf runbook) | +| Notifications/CMS | #399 (Novu receiver), #536, #334 (ConvertKit), #312 (Directus), #767 (CMS/newsletter decision), #381 | +| Features | #366 (recording monetization), #367 (enterprise recording marketplace), #371 (AI mentor search), #739 (agentic support RFC), #341, #342 (chat roadmap), #469 (Google One-Tap), #377 (Intercom), #348 | +| Analytics/scanning | #378 (PostHog), #409 (Aikido/CodeQL) | +| Referrals | #880 (design umbrella), #692, #891 | +| Enterprise later | #702 (affiliate — defer to ~₹5L MRR), #746 (roadmap umbrella), #367 | +| Code quality | #531 (Winston), #640 (eslint strict), #654 (zod 4), #842 (de-export), #869 (reorg), #733 (folder debt), #270 (payment factory), #274 (lib/api extraction), #308 (seed realism) | +| Ops/cleanup | #535 (Stream hard-delete cron), #724 (collapse ORG_ADMIN backstop) | +| Parked by decision | #872 (DST timezone), #884 (phone step-up), #366 | + +--- + +## 5. Enterprise SSO — MVP recommendation + +**You do not need to build SAML, OIDC, or SCIM — Better Auth already provides all three, and most of it is already wired here.** Verified in code: + +- `lib/auth.ts` registers the `sso()` plugin (SAML 2.0 + OIDC, org-scoped, auto-generates `ssoProvider`), `lib/sso/enforce-session.ts` does session enforcement, plus a cert-expiry-alert cron. +- JIT provisioning exists — `ssoSettings.defaultRoleForAutoJoin` auto-joins SSO users to their org on first login. +- Full `lib/scim/` module (`operations.ts`, token auth, group-mappings) + routes under `app/api/organizations/[orgId]/{sso,scim}`. + +**MVP scope — what to turn on:** + +| Capability | MVP | Rationale | +|---|:--:|---| +| SAML + OIDC login | ✅ On | Free from the plugin; covers Okta/Entra/Google; unblocks enterprise deals. | +| JIT auto-provision | ✅ On | Already built; covers "new hire logs in → gets access." | +| SCIM auto-deprovision | 🟡 Present, flag-gated | Already built, so cost is maintenance/test surface, not build. Enable per-org for the first customer contractually requiring automated deprovisioning; until then JIT + admin-removal suffices. | + +**No WorkOS, no native protocol code, no new build.** The remaining SSO work is governance/lifecycle tails tracked under **#701** (consent cascade, data-residency, DataBreach) and the auth plugin roadmap **#725** (2FA/Captcha) — sequenced in Wave 3. + +## 6. Server Actions vs API Routes — validated strategy + +Researched against current Next.js guidance. Verdict: **the instinct is right but "migrate *most* routes" is the wrong scope.** Server Actions run **sequentially** (even `Promise.all` won't parallelize them) and are **POST-only with no GET caching**, so they are *bad* for the data-heavy reads this dashboard app is full of. Their win is narrow: first-party **mutations** save a hop and get `revalidatePath` for free. + +**Adopted pattern (incremental, tracked — not launch-critical):** + +- **Reads → React Server Components + `lib/data`** (already the convention here). +- **First-party form/button mutations → Server Actions**, incrementally, keeping the existing idempotency / CAS / serializable-retry money guards intact. +- **Stay Route Handlers permanently:** Razorpay webhooks, OAuth callbacks, QStash/cron triggers, anything a third party or future mobile client calls, streaming, public API. +- **Watch the sequential trap:** never fan out parallel Server Actions for dashboard tiles. + +A reference-slice PR (strategy doc + one representative mutation migrated with guards intact) establishes the pattern. Relates to the perf tail in **#734**. + +## 7. App-wide animations — perf-safe approach + +Direction chosen: **in-view element motion** (fade + rise on mount/scroll), **not** route-transition choreography — the latter delays interactivity and fights the instant-nav static shell (#938/#940). + +- Shared `FadeIn` / `Stagger` primitives on **`LazyMotion` + `m` components** (~5 kb feature set, not ~35 kb) to protect the Netlify bundle. +- Animate **only `transform` + `opacity`** (GPU-composited, zero layout thrash). +- `MotionConfig reducedMotion="user"` globally for accessibility. +- `whileInView` with `once: true` so animations never re-fire on scroll. +- Scoped to the content region, kept **out of the instant-nav shell**. Verify no LCP/INP regression before merge. + +--- + +## Appendix — full per-issue classification + +Legend: ✅ fixed · ♻️ dup/obsolete/wontfix · 🟡 partial · 🔴 pending. Crit = launch-blocker / high / med / low. + +| # | Class | Crit | Wave | One-line action | +|---|:--:|:--:|:--:|---| +| 248 | ✅ | low | — | Close — per-load full-sync removed | +| 270 | 🔴 | low | 5 | P3 refactor switch→service map (or wontfix) | +| 274 | 🟡 | low | 5 | Remaining `lib/api` handler-factory extraction | +| 279 | ✅ | med | — | Close — entire-subscription reschedule shipped | +| 300 | ✅ | med | — | Close — Novu inbox | +| 308 | 🟡 | low | 5 | Past appts → COMPLETED; reviews only on COMPLETED | +| 309 | 🔴 | low | 4 | React Query + cache headers on slot APIs | +| 312 | 🔴 | low | 5 | Directus CMS epic — only a stub webhook exists | +| 334 | 🟡 | low | 5 | Implement ConvertKit API + status fields | +| 337 | 🟡 | med | 4 | Add allocation-side approval notification | +| 341 | 🔴 | med | 4 | Build form-based inquiry (model+API+CTA) | +| 342 | 🔴 | low | 5 | Chat roadmap — split P1 items | +| 346 | ✅ | low | — | Close — pagination shipped | +| 348 | 🔴 | low | 4 | Supabase Realtime on AppointmentDocument | +| 360 | ✅ | low | — | Close — two-mode storage + transfer cron | +| 366 | 🔴 | low | 5 | Recording monetization — keep deferred | +| 367 | 🟡 | med | 5 | Narrow to deferred recording-marketplace slice | +| 371 | 🔴 | low | 5 | AI mentor search — options doc only | +| 377 | 🔴 | low | 5 | Intercom widget or de-prioritize | +| 378 | 🟡 | low | 5 | Split off PostHog (Sentry half done) | +| 379 | ✅ | med | — | Close — gate + moderation link live | +| 381 | 🟡 | low | 5 | Announcement audience/types/approval tail | +| 383 | 🟡 | low | 5 | Convert to monitoring runbook (hook shipped) | +| 387 | ✅ | high | — | Close — invite-only reject implemented | +| 399 | 🔴 | med | 5 | Build Novu webhook receiver | +| 405 | 🟡 | high | 0 | Re-audit 21 sub-items (2 CRITICALs done) | +| 407 | 🟡 | high | 0 | Add Netlify edge rate-limit layer | +| 409 | 🔴 | low | 5 | Add Aikido/CodeQL to CI | +| 437 | ✅ | med | — | Close — anti-gaming shipped | +| 438 | 🟡 | high | 1 | Enterprise PDF done; split B2C receipt/email | +| 448 | 🟡 | high | 4 | Verify re-allocation notify + staff visibility | +| 450 | 🟡 | med | 4 | Re-audit batching (RSC + review-cap done) | +| 469 | 🔴 | low | 5 | Google One-Tap sign-in | +| 471 | 🟡 | med | 2 | No-show detection on presence foundation | +| 472 | 🔴 | low | 2 | Session-overrun timer + conflict alert | +| 473 | 🟡 | med | 2 | Stream status in /health + degradation UI | +| 474 | ✅ | low | — | Close — DLQ + retry worker (opt: rate alert) | +| 475 | ✅ | low | — | Close — Sentry via #901 | +| 480 | 🟡 | high | 0/1 | Launch checklist tracker — tick code-done items | +| 481 | ♻️ | med | — | Ops runbook, not eng deliverable | +| 485 | 🔴 | low | 4 | Price+currency on cards; group toggle separate | +| 486 | 🔴 | high | 0 | Fix Upcoming filter + gate Join on status | +| 487 | 🟡 | high | 4 | Re-audit P1/P2 vs redesigned pages | +| 494 | 🔴 | med | 4 | Draft persistence + clickable steps + fields | +| 531 | 🔴 | low | 5 | Winston logger post-launch (Sentry covers now) | +| 534 | ✅ | low | — | Close — migration guide exists | +| 535 | 🔴 | low | 5 | Weekly Stream hard-delete cron | +| 536 | 🔴 | med | 4 | Enum→label formatter + payload fields | +| 613 | ♻️ | low | — | Close — stale audit snapshot | +| 636 | ♻️ | low | — | Close — duplicate of #639 | +| 639 | 🟡 | med | 5 | Re-run ANALYZE; file residual page items | +| 640 | 🔴 | low | 5 | eslint strict at `warn`, fix incrementally | +| 654 | 🔴 | low | 5 | Bump zod ^4 + fix `z.record()` sites | +| 663 | 🔴 | low | 4 | Analytics endpoints + Recharts | +| 664 | 🔴 | low | 4/5 | "Recommended by [Org]" badge (quick win) | +| 676 | 🟡 | high | 1 | Booking audit tail (AE/A11/B4) | +| 677 | 🟡 | high | 1 | GST split + FX + credit-note tax engine | +| 684 | 🟡 | med | 3 | OrganizationPlan curation UI | +| 688 | 🟡 | high | 1 | Money soft-delete + CHECK constraints + trigger | +| 689 | 🟡 | med | 2 | STR-2/4 resilience tail (STR-1 done) | +| 690 | 🟡 | high | 0 | Encrypt OAuth `Account` tokens (AUTH-2) | +| 691 | 🔴 | high | 0 | Unsubscribe links + `triggerWorkflowSafe()` | +| 692 | 🟡 | med | 3 | Velocity/IP anti-fraud + expire-credits cron | +| 693 | 🔴 | **blocker** | 0 | **Wire moderation actionType side-effects** | +| 694 | 🟡 | high | 0 | Encrypt verification docs + rate-limit uploads | +| 695 | 🟡 | high | 0 | Fix passwordless staff + audit financial admin | +| 696 | 🟡 | med | 0 | Drop email from public search predicate | +| 697 | 🟡 | high | 2 | `withJobExecution()` + maintenance TTL + breaker | +| 698 | 🔴 | med | 4 | `calculateProfileCompletion` + OB-2 refresh | +| 701 | 🟡 | med | 3 | LCY residuals (consent/residency/breach/HRIS) | +| 702 | 🔴 | low | 5 | Affiliate — defer to ~₹5L MRR | +| 705 | 🟡 | med | 3 | Analytics + PG extensions + Realtime trio | +| 724 | 🔴 | low | 5 | `ensureOrgWorkspaceProfile` + delete backstop | +| 725 | 🔴 | high | 3 | BetterAuth Tier-1 — 2FA + Captcha first | +| 733 | 🔴 | low | 5 | Folder Phase-1 READMEs + eslint layering | +| 734 | 🟡 | med | 5 | include→select sweep + bundle split | +| 738 | 🟡 | high | 1 | Chargeback tax cascade + 194O decision | +| 739 | 🔴 | low | 5 | Agentic support — RFC only | +| 746 | 🔴 | low | 5 | Enterprise roadmap umbrella | +| 767 | 🔴 | low | 5 | CMS/newsletter vendor decision (blocks 312/334) | +| 770 | 🔴 | med | 3 | Contract lifecycle GAP-2→1→3 + billingMode | +| 834 | ✅ | med | 1 | Add unique constraint on schema reset | +| 837 | 🟡 | high | 0 | Run staging chaos go/no-go (code done) | +| 840 | 🔴 | high | 3 | First-class org-invitee onboarding path | +| 842 | 🔴 | low | 5 | Re-run knip + de-export behind gate | +| 855 | ✅ | med | — | Close — auto-refund on capture-after-cancel | +| 860 | 🔴 | med | 2 | Narrow auto-allocate lock + per-event lock | +| 863 | 🟡 | med | 3 | Residuals register — tick ScimToken.expiresAt | +| 866 | 🔴 | high | 2 | Migrate ~10 jobs to QStash + GA heartbeat | +| 867 | 🟡 | high | 4 | Split 71 semantic-correctness findings | +| 868 | 🟡 | high | 4 | Finish redesign residuals + merge branch | +| 869 | 🔴 | low | 5 | Phased codebase reorg — defer | +| 872 | 🔴 | low | — | DST timezone — parked (schema frozen) | +| 874 | 🔴 | **blocker** | 0 | Run + record capacity go/no-go | +| 875 | ♻️ | low | — | Close — discussion issue | +| 880 | 🟡 | med | 5 | Confirm reward ramp + verify OAuth capture | +| 884 | ♻️ | low | — | Parked — stub kept | +| 891 | 🔴 | low | 5 | Only clear referral stash on manual empty submit | +| 899 | 🟡 | high | 2 | Event-driven channels + reconciliation cron | +| 900 | 🟡 | med | 2 | Confirm Netlify build env + rotate token | +| 902 | 🔴 | low | 4/5 | Align prefetch key/shape (quick win) | +| 906 | 🔴 | med | 4 | Parallelize `/api/appointments` reads | +| 920 | 🟡 | med | 2 | Finish dynamic pages + drop IS_NEXT_BUILD | +| 932 | 🟡 | high | 0 | Verify Functions region → Singapore | +| 937 | 🔴 | med | 2 | Evaluate Prisma Accelerate vs region move | + +*Runtime-verification flagged (money/security, code read only):* #677, #676, #738, #855, #337, #536, #405/#407 (Redis env), #690, #900, #932 (region), #932/#835 wallet refund. diff --git a/docs/roadmap/infrastructure/03-payment-system.md b/docs/roadmap/infrastructure/03-payment-system.md index 0997efb76..78f052374 100644 --- a/docs/roadmap/infrastructure/03-payment-system.md +++ b/docs/roadmap/infrastructure/03-payment-system.md @@ -6,7 +6,7 @@ ## Executive Summary -The payment system supports multiple gateways (Stripe, Razorpay, LemonSqueezy) with comprehensive webhook handling. However, critical vulnerabilities exist around idempotency, race conditions, and refund processing that must be addressed before production. +The payment system supports multiple gateways (Stripe, Razorpay) with comprehensive webhook handling. However, critical vulnerabilities exist around idempotency, race conditions, and refund processing that must be addressed before production. --- @@ -31,8 +31,6 @@ The payment system supports multiple gateways (Stripe, Razorpay, LemonSqueezy) w | ------------ | ----------- | ---------------------------- | | Stripe | Complete | Full feature support | | Razorpay | Complete | Full feature support | -| LemonSqueezy | Partial | Missing appointment creation | -| XFlow | Unknown | Limited documentation | | Mock | Development | Testing only | ### 1.2 File Structure @@ -53,8 +51,6 @@ lib/payments/ app/api/webhooks/ ├── stripe/route.ts # Stripe webhook handler ├── razorpay/route.ts # Razorpay webhook handler -├── lemon-squeezy/route.ts # LemonSqueezy webhook handler -├── xflow/route.ts # XFlow webhook handler └── utils.ts # Shared webhook utilities ``` @@ -143,7 +139,7 @@ Webhook 2 (event: abc123) → Process → DUPLICATE PROCESSING model WebhookLog { id String @id @default(cuid()) eventId String - gateway String // STRIPE, RAZORPAY, LEMONSQUEEZY + gateway String // STRIPE, RAZORPAY eventType String // payment_intent.succeeded, etc. payload Json? processed Boolean @default(true) @@ -600,19 +596,6 @@ function usePaymentExpiration(payment: Payment) { } ``` -### 6.4 LemonSqueezy Incomplete Implementation - -**File:** `app/api/webhooks/lemon-squeezy/route.ts:230-236` - -```typescript -// TODO: Implement appointment creation based on stored payment data -console.warn("Lemon Squeezy appointment creation needs implementation..."); -``` - -**Impact:** LemonSqueezy payments succeed but appointments are not created. - ---- - ## 7. Fraud Prevention ### 7.1 Current State @@ -810,12 +793,6 @@ model Appointment { } ``` -#### Implement LemonSqueezy Appointment Creation - -**File:** `app/api/webhooks/lemon-squeezy/route.ts` - -Complete the TODO implementation for appointment creation. - ### 8.3 Priority 3: Medium (Fix This Sprint) #### Add Fraud Detection diff --git a/docs/roadmap/infrastructure/05-scaling-architecture.md b/docs/roadmap/infrastructure/05-scaling-architecture.md index 106332a3d..56c91198c 100644 --- a/docs/roadmap/infrastructure/05-scaling-architecture.md +++ b/docs/roadmap/infrastructure/05-scaling-architecture.md @@ -365,7 +365,7 @@ export const inngest = new Inngest({ type Events = { "payment/webhook.received": { data: { - gateway: "stripe" | "razorpay" | "lemonsqueezy"; + gateway: "stripe" | "razorpay"; eventId: string; eventType: string; payload: unknown; diff --git a/docs/roadmap/infrastructure/06-implementation-roadmap.md b/docs/roadmap/infrastructure/06-implementation-roadmap.md index d21430ba4..44116d1bf 100644 --- a/docs/roadmap/infrastructure/06-implementation-roadmap.md +++ b/docs/roadmap/infrastructure/06-implementation-roadmap.md @@ -70,7 +70,6 @@ This roadmap provides a prioritized action plan for preparing the SaaS applicati | DB-02 | N+1 query problems | 02-database | 2 days | | DB-03 | Race conditions | 02-database | 1 day | | PAY-03 | Slot overlap detection bug | 03-payment | 2 hours | -| PAY-04 | LemonSqueezy incomplete | 03-payment | 4 hours | | RL-01 | Auth endpoint rate limiting | 04-ratelimit | 4 hours | | RL-02 | Webhook rate limiting | 04-ratelimit | 2 hours | @@ -358,18 +357,12 @@ await prisma.webhookLog.create({ // Continue with processing... ``` -### 5.4 Complete LemonSqueezy Implementation - -**File:** `app/api/webhooks/lemon-squeezy/route.ts:230-236` - -Replace TODO with actual appointment creation logic. ### 5.5 Verification Checklist - [ ] Refund calculation includes PENDING - [ ] Slot overlap detection covers all cases - [ ] Webhook idempotency prevents duplicates -- [ ] LemonSqueezy creates appointments - [ ] Concurrent refund test passes --- diff --git a/docs/roadmap/infrastructure/11-background-jobs-inngest.md b/docs/roadmap/infrastructure/11-background-jobs-inngest.md index 450a9022d..0ea38a3c5 100644 --- a/docs/roadmap/infrastructure/11-background-jobs-inngest.md +++ b/docs/roadmap/infrastructure/11-background-jobs-inngest.md @@ -55,8 +55,7 @@ Inngest provides serverless background job processing with TypeScript-first deve Inngest Jobs: ├── Payment Webhooks │ ├── Stripe webhook processing -│ ├── Razorpay webhook processing -│ └── LemonSqueezy webhook processing +│ └── Razorpay webhook processing ├── Email │ ├── Welcome emails │ ├── Booking confirmations @@ -113,7 +112,7 @@ type Events = { // Payment events "payment/webhook.received": { data: { - gateway: "stripe" | "razorpay" | "lemonsqueezy"; + gateway: "stripe" | "razorpay"; eventId: string; eventType: string; payload: Record; diff --git a/docs/stream/01-architecture.md b/docs/stream/01-architecture.md index 70f39a58d..fc35ee39e 100644 --- a/docs/stream/01-architecture.md +++ b/docs/stream/01-architecture.md @@ -312,7 +312,7 @@ await chatClient.upsertUser({ id: user.id, name: user.name, image: user.image, - role: mapRoleToStream(user.role), // ⚠️ Currently returns "admin" for all + role: mapRoleToStream(user.role), // "admin" only for staff/admins, "user" for everyone else }); ``` @@ -536,22 +536,28 @@ export async function tokenProvider(userId: string) { ## Security Considerations -### 🔴 CRITICAL: Universal Admin Role +### Least-Privilege Stream Roles (#899) -**Current:** All users get "admin" role in Stream +**Current:** Only platform staff and admins get Stream's global `admin` role. Everyone else, consultants included, is mapped to the plain `user` role. ```typescript -// File: lib/user.ts:98-115 -export function mapRoleToStream(role: string): string { - return "admin"; // ⚠️ Everyone is admin! +// File: lib/user.ts +export function mapRoleToStream(role: string | null | undefined): string { + switch (role?.toUpperCase()) { + case "ADMIN": + case "STAFF": + return "admin"; + default: + return "user"; + } } ``` -**Impact:** +**How hosts get moderation:** -- No permission enforcement -- All users can moderate channels -- Potential data access issues +- Channel creation is performed server-side +- Each host receives a channel-scoped `channel_moderator` grant on their own host channels at creation time +- No global admin grant, and no moderation rights over unrelated peer direct-message channels **See:** [Troubleshooting - Universal Admin Role](./troubleshooting.md#universal-admin-role-critical) diff --git a/docs/stream/03-provider-authentication.md b/docs/stream/03-provider-authentication.md index 9609d4e04..53d833832 100644 --- a/docs/stream/03-provider-authentication.md +++ b/docs/stream/03-provider-authentication.md @@ -378,7 +378,7 @@ const connectChat = useCallback(async () => { id: userDetails.id, name: userDetails.name ?? userDetails.id, image: userDetails.image ?? undefined, - role: streamRole, // ⚠️ Currently always "admin" + role: streamRole, // "admin" only for staff/admins, "user" for everyone else }, () => getCachedToken("chat"), ); @@ -423,7 +423,7 @@ const connectChat = useCallback(async () => { - Singleton pattern: `StreamChat.getInstance()` returns same instance - User upserted to Stream database before connection -- Role mapping via `mapRoleToStream()` (currently always returns "admin") +- Role mapping via `mapRoleToStream()` (returns "admin" only for staff/admins, "user" for everyone else) - Token provided as callback function - Channel sync only runs once per session - Errors thrown to trigger retry logic diff --git a/docs/stream/06-channel-management.md b/docs/stream/06-channel-management.md index c7e3bd764..67e5fb150 100644 --- a/docs/stream/06-channel-management.md +++ b/docs/stream/06-channel-management.md @@ -283,6 +283,16 @@ export const syncUserEventChannels = async (userId: string) => { ## Channel Membership Rules +### Who May Talk to Whom (Policy) + +The platform deliberately supports only three conversation shapes. First, a consultant and a consultee who transact together share exactly one direct-message channel: consultations, subscriptions, and ad-hoc DMs between the same pair all reuse the deterministic `dm--` channel id (the two user ids are sorted before joining, so the pair can never produce a duplicate channel regardless of who initiates). Second, group events — webinars and classes — put every booked attendee and the host into one shared event channel, and this is the sanctioned space where consultees can talk alongside other consultees. Third, consultants collaborating on a joint webinar or class get a plan-scoped `collab-{webinar|class}-{planId}` channel that is reconciled against the accepted collaborator list. + +Consultee↔consultee direct messages are intentionally not supported. This is a decision, not a gap: peer-to-peer DMs on a marketplace are only safe with mature moderation infrastructure, and the block stays until the moderation enforcement shipped for #693 and the #899 hardening have settled in production. The full rationale is recorded in `docs/decisions/2026-07-11-moderation-enforcement-and-peer-chat-block.md`. There is no consultee↔consultee code path to disable — reviewers should keep it that way. + +### Server-Side Authorization for Membership Changes + +Stream's server-side API bypasses its own permission system whenever a valid API secret is presented, so every membership mutation must be authorized in our application layer before the Stream call. The `addMemberToChannel` server action requires a signed-in session and allows only admins, staff, or the channel's creator to add members; non-privileged callers can no longer lazily create channels they do not own. The channel-creation route applies the same rule: event channels require the caller to be the event's creator (or privileged), and custom channels are admin/staff-only. + ### Waitlist Channel Membership Only waitlist users with status `BOOKED` are included in event channel membership. Users with WAITING or NOTIFIED status are not added to Stream channels. This prevents users who have not yet confirmed their booking from accessing event chat. diff --git a/docs/stream/07-user-management.md b/docs/stream/07-user-management.md index be78ea94d..6158d9159 100644 --- a/docs/stream/07-user-management.md +++ b/docs/stream/07-user-management.md @@ -121,15 +121,9 @@ Stream Chat uses a role-based permission system. The `mapRoleToStream` function **Location:** `/lib/user.ts` (lines 98-115) -### CRITICAL BUG +### Least-Privilege Mapping (#899) -> **SECURITY ISSUE:** Currently, ALL users are mapped to the "admin" role in Stream Chat, regardless of their actual role in the application. This grants every user full permissions including the ability to create, read, update, and delete channels. -> -> **Risk Level:** HIGH -> -> **Impact:** Users have more permissions than intended, potentially allowing unauthorized access to channels and administrative functions. -> -> **Recommendation:** Implement proper role mapping with custom roles configured in the Stream Chat dashboard, or use Stream's built-in roles more appropriately. +The mapping now follows least privilege. Only platform staff and admins receive Stream's global `admin` role; every other user, consultants included, is mapped to the plain `user` role. Consultants no longer get a blanket administrative grant. Instead, channel creation happens server-side and each host is given a channel-scoped `channel_moderator` grant on their own host channels at creation time, rather than a global moderation grant that would also cover peer direct-message channels. ### Current Implementation @@ -145,27 +139,18 @@ export function mapRoleToStream(role: string | null | undefined): string; **Returns:** -- `string`: The Stream Chat role (currently always returns "admin") +- `string`: The Stream Chat role — `admin` for staff and admins, and `user` for everyone else **Current Behavior:** ```typescript export function mapRoleToStream(role: string | null | undefined): string { - if (!role) return "admin"; // Default to admin for team channel access - - switch (role.toUpperCase()) { + switch (role?.toUpperCase()) { case "ADMIN": - return "admin"; - case "CONSULTANT": - // Consultants need to create and manage their event channels - return "admin"; - case "CONSULTEE": - // Consultees need to read and participate in team channels they join - return "admin"; - case "USER": + case "STAFF": return "admin"; default: - return "admin"; + return "user"; } } ``` @@ -181,29 +166,9 @@ Stream Chat provides the following standard roles: | `guest` | Limited permissions | | `anonymous` | Very limited permissions | -### Recommended Implementation - -```typescript -// RECOMMENDED: Fix the role mapping -export function mapRoleToStream(role: string | null | undefined): string { - if (!role) return "user"; // Default to user, not admin +### Channel-Scoped Moderation - switch (role.toUpperCase()) { - case "ADMIN": - return "admin"; - case "CONSULTANT": - // Use custom role configured in Stream dashboard - return "consultant"; // or "channel_moderator" - case "CONSULTEE": - // Regular user role with channel participation - return "user"; - case "USER": - return "user"; - default: - return "user"; - } -} -``` +Consultants are mapped to the plain `user` role globally and instead receive a channel-scoped `channel_moderator` grant on their own host channels at creation time. This gives a host moderation authority over the channels they own without granting global admin permissions or moderation rights over unrelated peer direct-message channels. --- diff --git a/docs/stream/08-token-management.md b/docs/stream/08-token-management.md index b0c9edfa5..e321a721e 100644 --- a/docs/stream/08-token-management.md +++ b/docs/stream/08-token-management.md @@ -92,6 +92,11 @@ export const tokenProvider = async (userId: string): Promise ```typescript export const tokenProvider = async (userId: string) => { try { + // 0. Session bind (#899): a token may only be minted for the authenticated + // session user; staff/admins may mint for anyone. Stream's server-side + // API skips permission checks, so this is the only guard against spoofing. + await assertCanMintToken(userId); + // 1. Verify user exists const userDetails = await fetchUserDetails(userId); if (!userDetails) throw new Error("User not found"); @@ -135,9 +140,10 @@ export const tokenProvider = async (userId: string) => { ```typescript import { tokenProvider } from "@/actions/stream/chat/stream.action"; -// Generate video token for user +// Generate video token for the authenticated session user. +// Passing another user's ID throws unless the caller is staff/admin (#899). try { - const token = await tokenProvider("user-123"); + const token = await tokenProvider(sessionUser.id); console.log("Video token generated successfully"); } catch (error) { console.error("Token generation failed:", error); @@ -165,6 +171,10 @@ export const chatTokenProvider = async (userId: string): Promise ```typescript export const chatTokenProvider = async (userId: string) => { try { + // 0. Session bind (#899): mint only for the authenticated session user + // (staff/admins may mint for anyone). + await assertCanMintToken(userId); + // 1. Validate API credentials if (!apiKey) throw new Error("Stream API key not configured"); if (!apiSecret) throw new Error("Stream API secret not configured"); @@ -192,9 +202,10 @@ export const chatTokenProvider = async (userId: string) => { ```typescript import { chatTokenProvider } from "@/actions/stream/chat/stream.action"; -// Generate chat token for user +// Generate chat token for the authenticated session user. +// Passing another user's ID throws unless the caller is staff/admin (#899). try { - const token = await chatTokenProvider("user-123"); + const token = await chatTokenProvider(sessionUser.id); console.log("Chat token generated successfully"); } catch (error) { console.error("Token generation failed:", error); @@ -628,6 +639,12 @@ if (requestedUserId !== session.user.id) { } ``` +This is implemented in the `tokenProvider` and `chatTokenProvider` server actions (`actions/stream/chat/stream.action.ts`): both require a session, refuse to mint a token for a different user unless the caller is admin or staff, and refuse banned users outright. The banned-user check matters because Stream token revocation is timestamp-based — a moderated user could otherwise immediately re-mint a fresh token dated after the revocation and reconnect. + +### Moderation: Revocation and Deactivation + +When staff suspend a user, the moderation pipeline (`lib/moderation/side-effects.ts`, #693) calls `revokeUserToken(userId, new Date())`, which expires every token issued before that moment. Suspension recovery is automatic: once `banExpires` passes, the sign-in gate lifts and the token provider mints a fresh token that post-dates the revocation timestamp, so no un-revoke call is needed. A permanent ban additionally calls `deactivateUser` (with `mark_messages_deleted: false`), which blocks the user from connecting to Stream at all while preserving their message history for other channel members. Reinstating a banned user requires a symmetric `reactivateUser` call — tracked as a follow-up in the moderation ADR. + ### Use Environment Variables Store API credentials securely: diff --git a/docs/stream/13-recording-webhooks.md b/docs/stream/13-recording-webhooks.md index 973240749..ebd315926 100644 --- a/docs/stream/13-recording-webhooks.md +++ b/docs/stream/13-recording-webhooks.md @@ -41,7 +41,7 @@ The recording system enables consultants to record webinars and classes for late - **Consultant-only recording control** - Only the session host can start/stop - **Automatic webhook processing** - Recording lifecycle managed via webhooks - **Idempotent operations** - Safe to receive duplicate webhook events -- **Automatic transfer** - Cron job transfers recordings before expiration +- **Automatic transfer** - The `recording_ready` webhook enqueues the permanent-storage transfer immediately (via Next.js `after()`), and a cron job runs as a backstop sweeper that picks up any recording the webhook missed before its Stream URL expires - **Role-based access** - Different permissions for consultants, consultees, and admins --- @@ -494,7 +494,7 @@ sequenceDiagram participant Stream as Stream S3 participant Supa as Supabase Storage - Cron->>DB: Get expiring recordings (3 days out) + Cron->>DB: Get READY permanent recordings (14-day window) DB-->>Cron: Recording list loop Each Recording @@ -502,7 +502,7 @@ sequenceDiagram Transfer->>DB: Update status = TRANSFERRING Transfer->>Stream: Download video file - Stream-->>Transfer: Video data (blob) + Stream-->>Transfer: Video data (streamed body, #899) alt File too large (>500MB) Transfer->>DB: Revert to READY @@ -524,7 +524,7 @@ sequenceDiagram | ------------------- | ------------ | ------------------------------------- | | `MAX_TRANSFER_SIZE` | 500MB | Maximum file size for direct transfer | | `RECORDINGS_BUCKET` | "recordings" | Supabase storage bucket name | -| `daysBeforeExpiry` | 3 | Days before expiry to start transfer | +| `daysBeforeExpiry` | 5 | Days before expiry to start transfer (default). The production jobs pass 14 — the full Stream URL lifetime — so every READY permanent recording is swept near-ready rather than near-expiry (#899). | | `batchSize` | 10 | Max recordings per cron run | ### Storage Path Format @@ -874,7 +874,7 @@ import { RecordingTransferService } from "@/lib/stream/recording-transfer-servic async function processExpiringRecordings() { const result = await RecordingTransferService.processExpiringRecordings( - 3, // daysBeforeExpiry + 14, // daysBeforeExpiry — full Stream URL lifetime, sweeps near-ready (#899) 10, // batchSize ); diff --git a/docs/stream/troubleshooting.md b/docs/stream/troubleshooting.md index c794d2f22..7e32047ec 100644 --- a/docs/stream/troubleshooting.md +++ b/docs/stream/troubleshooting.md @@ -32,86 +32,35 @@ Comprehensive troubleshooting guide for Stream Chat and Video integration issues This section documents known critical bugs and their workarounds. Review before deploying to production. -### Universal Admin Role (Critical) +### Stream Role Mapping (Resolved in #899) -**Severity:** CRITICAL | **Security Impact:** HIGH +**Severity:** RESOLVED | **Security Impact:** N/A #### Problem Description -All users receive "admin" role in Stream Chat regardless of their actual role in the system. This means there is no permission differentiation between user types. +Earlier builds mapped every user to the "admin" role in Stream Chat regardless of their actual role, which left no permission differentiation between user types. As of #899 the mapping follows least privilege, so this is no longer an issue. #### Location -**File:** `/Users/kaustavghosh/Desktop/familiarise_web/lib/user.ts` -**Lines:** 98-115 +**File:** `lib/user.ts` #### Current Code ```typescript export function mapRoleToStream(role: string | null | undefined): string { - if (!role) return "admin"; // Default to admin for team channel access - - switch (role.toUpperCase()) { + switch (role?.toUpperCase()) { case "ADMIN": - return "admin"; - case "CONSULTANT": - return "admin"; // Should be custom role or "channel_moderator" - case "CONSULTEE": - return "admin"; // Should be "user" or "channel_member" - case "USER": - return "admin"; - default: - return "admin"; - } -} -``` - -#### Impact - -1. **No Permission Enforcement:** - - Consultees can moderate channels they shouldn't - - All users can delete messages from anyone - - No role-based access control - -2. **Security Risks:** - - Unauthorized access to sensitive operations - - Potential data tampering - - No audit trail for privileged operations - -3. **Billing Impact:** - - Stream pricing may differ based on user roles - - All users counted as admin users - -#### Recommended Fix - -**Option 1: Custom Roles** (Recommended) - -```typescript -export function mapRoleToStream(role: string | null | undefined): string { - if (!role) return "user"; - - switch (role.toUpperCase()) { - case "ADMIN": - return "admin"; - case "CONSULTANT": - return "channel_moderator"; // Can moderate their own channels - case "CONSULTEE": - return "user"; // Regular user permissions case "STAFF": - return "admin"; // Full administrative access + return "admin"; default: return "user"; } } ``` -#### Current Workaround - -**Temporary Mitigation:** +#### How Hosts Get Moderation -- Application-level permission checks (don't rely on Stream roles) -- Audit logging for sensitive operations -- User education about not abusing permissions +Only platform staff and admins receive Stream's global `admin` role. Everyone else, consultants included, is mapped to the plain `user` role. Channel creation happens server-side, and each host is given a channel-scoped `channel_moderator` grant on their own host channels at creation time. Hosts therefore moderate the channels they own without receiving global admin permissions or moderation rights over unrelated peer direct-message channels. --- @@ -381,7 +330,7 @@ logger.error("stream.chat.connection_failed", { | Issue | Workaround | Effectiveness | Notes | | --------------- | ---------------------------- | ------------- | -------------------------------- | -| Admin role bug | Application-level checks | Partial | Doesn't prevent Stream API abuse | +| Admin role bug | Resolved in #899 | Fixed | Least-privilege role mapping now in place | | Token expiry | 50-min cache (10-min buffer) | Good | Still occasional drops | | Race conditions | Atomic creation | Moderate | Race window still exists | | User cleanup | Exclusion list | Good | Manual maintenance required | diff --git a/docs/team/platform-testing-playbook.md b/docs/team/platform-testing-playbook.md index 02f21c3f6..36d3dcb39 100644 --- a/docs/team/platform-testing-playbook.md +++ b/docs/team/platform-testing-playbook.md @@ -54,8 +54,6 @@ graph TB subgraph "Payments" A --> I[Razorpay - India UPI/Cards] A --> J[Stripe - International] - A --> K[Lemon Squeezy - SaaS Billing] - A --> L[Xflow - Regional] end subgraph "Communications" @@ -817,8 +815,6 @@ graph TD |---------|--------|----------------|-----------| | **Razorpay** | India | UPI, debit/credit cards, netbanking, wallets | Indian consultees paying in INR | | **Stripe** | International | Credit/debit cards, ACH, SEPA | Non-Indian consultees | -| **Lemon Squeezy** | Global | Cards, PayPal | SaaS-style billing (future) | -| **Xflow** | Regional | Regional methods | Region-specific payments (future) | ### Payment States diff --git a/jobs/appointments/detect-consultant-no-shows.ts b/jobs/appointments/detect-consultant-no-shows.ts new file mode 100644 index 000000000..05199246b --- /dev/null +++ b/jobs/appointments/detect-consultant-no-shows.ts @@ -0,0 +1,87 @@ +/** + * Consultant No-Show Detection Job (GitHub Actions Wrapper) — #471 + * + * Thin wrapper around scripts/appointments/detect-consultant-no-shows.ts. + * Adds GitHub Actions outputs + error handling. Runs hourly. + */ + +import { + detectConsultantNoShows, + disconnectDatabase, + type NoShowResult, +} from "../../scripts/appointments/detect-consultant-no-shows"; +import fs from "node:fs"; +import { abortIfMaintenance } from "../../lib/maintenance-cron"; +import { CronLockHeldError } from "../../lib/cron/with-cron-lock"; +import * as Sentry from "@sentry/nextjs"; + +function outputToGitHubActions(result: NoShowResult): void { + if (!process.env.GITHUB_ACTIONS) return; + + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + const outputs = [ + `detected=${result.detected}`, + `refunded=${result.refunded}`, + `success=${result.success}`, + ].join("\n"); + fs.appendFileSync(outputFile, outputs + "\n"); + } + + if (result.detected > 0) { + console.log( + `::notice::Consultant no-shows: ${result.detected} detected, ${result.refunded} refunded`, + ); + } + if (!result.success) { + console.log(`::warning::No-show job had errors: ${result.errors.join("; ")}`); + } +} + +async function main(): Promise { + await abortIfMaintenance("detect-consultant-no-shows"); + Sentry.logger.info("job:detect-consultant-no-shows started"); + console.log("⏰ Starting consultant no-show detection job..."); + console.log(`Timestamp: ${new Date().toISOString()}`); + + try { + const result = await detectConsultantNoShows(); + + console.log("\n📊 Job Results:"); + console.log(` Detected: ${result.detected}`); + console.log(` Refunded: ${result.refunded}`); + console.log(` Success: ${result.success}`); + + if (result.errors.length > 0) { + console.log("\n⚠️ Errors:"); + result.errors.forEach((e) => console.log(` - ${e}`)); + } + + outputToGitHubActions(result); + + Sentry.logger.info("job:detect-consultant-no-shows finished", { + detected: result.detected, + refunded: result.refunded, + }); + + if (!result.success) { + process.exit(1); + } + } catch (error) { + // #476 — lock held = another run is live; skip cleanly (exit 0). + if (error instanceof CronLockHeldError) { + Sentry.logger.info("job:detect-consultant-no-shows skipped — lock held"); + console.log(`⏭️ ${error.message}`); + return; + } + Sentry.captureException(error, { + tags: { subsystem: "jobs", job: "detect-consultant-no-shows" }, + }); + console.error("❌ Fatal error in consultant no-show detection:", error); + process.exit(1); + } finally { + await disconnectDatabase(); + } +} + +main(); diff --git a/jobs/cleanup/release-pending-trust-earnings.ts b/jobs/cleanup/release-pending-trust-earnings.ts index 855c6fce8..b36059362 100644 --- a/jobs/cleanup/release-pending-trust-earnings.ts +++ b/jobs/cleanup/release-pending-trust-earnings.ts @@ -1,8 +1,8 @@ /** * Release PENDING_TRUST earnings — invoice-fraud guard release valve (#687). * - * Promotes OrganizationEarnings rows from PENDING_TRUST → PENDING when - * the sponsoring org has either: + * Promotes both OrganizationEarnings AND ConsultantEarnings rows (#687 E-02) + * from PENDING_TRUST → PENDING when the sponsoring org has either: * 1. transitioned to status=ACTIVE (admin verification), or * 2. paid at least one OrganizationInvoice. * @@ -79,28 +79,55 @@ async function runReleasePendingTrustEarningsUnlocked(): Promise c.id) }, - status: EarningStatus.PENDING_TRUST, - }, - data: { status: EarningStatus.PENDING }, - }); - result.released = update.count; + if (orgCandidates.length > 0) { + const orgUpdate = await prisma.organizationEarnings.updateMany({ + where: { + id: { in: orgCandidates.map((c) => c.id) }, + status: EarningStatus.PENDING_TRUST, + }, + data: { status: EarningStatus.PENDING }, + }); + result.released += orgUpdate.count; + } + if (consultantCandidates.length > 0) { + const consultantUpdate = await prisma.consultantEarnings.updateMany({ + where: { + id: { in: consultantCandidates.map((c) => c.id) }, + status: EarningStatus.PENDING_TRUST, + }, + data: { status: EarningStatus.PENDING }, + }); + result.released += consultantUpdate.count; + } } catch (err) { const message = err instanceof Error ? err.message : String(err); result.errors.push(message); diff --git a/jobs/reconcile/reconcile-ledgers.ts b/jobs/reconcile/reconcile-ledgers.ts index 4392f7187..c5eaabd8b 100644 --- a/jobs/reconcile/reconcile-ledgers.ts +++ b/jobs/reconcile/reconcile-ledgers.ts @@ -24,6 +24,7 @@ import { recordSystemError, } from "../../lib/enterprise/system-events"; import { CronLockHeldError } from "../../lib/cron/with-cron-lock"; +import { freezeWalletSpend } from "../../lib/payments/wallet-freeze"; import * as Sentry from "@sentry/nextjs"; async function main(): Promise { @@ -102,6 +103,46 @@ async function main(): Promise { }, }, ); + + // #837 — a wallet cache/journal drift means the balance can't be trusted, + // so freeze spend on each drifted account (scoped, not platform-wide) and + // page P0. Only WALLET_BALANCE_DRIFT freezes; other finding kinds page via + // the captureException above but don't gate spend. + const walletDrift = report.findings.filter( + (f) => f.kind === "WALLET_BALANCE_DRIFT" && f.billingAccountId, + ); + for (const f of walletDrift) { + const froze = await freezeWalletSpend({ + billingAccountId: f.billingAccountId!, + organizationId: f.organizationId ?? null, + reason: `ledger reconcile ${report.id}: wallet cache ${f.actualPaise}p ≠ journal ${f.expectedPaise}p (Δ${f.deltaPaise}p)`, + }); + Sentry.captureException( + new Error( + `WALLET_BALANCE_DRIFT — wallet spend frozen for billing account ${f.billingAccountId}`, + ), + { + level: "fatal", + tags: { subsystem: "jobs", job: "reconcile-ledgers" }, + contexts: { + wallet: { + billingAccountId: f.billingAccountId, + organizationId: f.organizationId ?? null, + // JSON can't serialize BigInt; walletBalance is BigInt at runtime. + expectedPaise: Number(f.expectedPaise), + actualPaise: Number(f.actualPaise), + deltaPaise: Number(f.deltaPaise), + reportId: report.id, + newlyFrozen: froze, + }, + }, + }, + ); + } + // #837 — captureException queues asynchronously; process.exit would drop + // the discrepancy alert + wallet-freeze P0 pages before the transport + // flushes. Drain first so the pages actually reach Sentry. + await Sentry.flush(2000); process.exit(2); } } catch (error) { @@ -121,6 +162,7 @@ async function main(): Promise { summary: "Ledger reconciliation crashed", err: error, }); + await Sentry.flush(2000); process.exit(1); } finally { await prisma.$disconnect(); diff --git a/jobs/stream/transfer-expiring-recordings.ts b/jobs/stream/transfer-expiring-recordings.ts index 354c3b63c..b4b903ee6 100644 --- a/jobs/stream/transfer-expiring-recordings.ts +++ b/jobs/stream/transfer-expiring-recordings.ts @@ -67,10 +67,12 @@ async function main(): Promise { "transfer-expiring-recordings", { failMode: "open" }, async () => { - // Auto-transfer SUPABASE_PERMANENT recordings expiring in 5 days + // #899 — 14-day window = every READY permanent recording (Stream URLs + // live exactly 14d), so the sweep starts transfers near-ready and + // backstops ready-time webhook kicks that died, not just near-expiry. const result = await RecordingTransferService.processExpiringRecordings( - 5, + 14, 10, "SUPABASE_PERMANENT", ); @@ -87,6 +89,25 @@ async function main(): Promise { await notifyConsultantsOfExpiringRecordings(expiringStreamOnly); } + // #899 — backlog alert: permanent recordings <72h from Stream expiry that + // this sweep still left untransferred. Non-zero means the pipeline is + // falling behind or failing repeatedly; page before the bytes lapse. + const atRisk = + await RecordingTransferService.countAtRiskPermanentRecordings(72); + if (atRisk > 0) { + console.warn( + `⚠️ ${atRisk} permanent recording(s) <72h from Stream expiry, still untransferred`, + ); + Sentry.captureMessage( + "Permanent recordings at risk of Stream URL expiry", + { + level: "warning", + tags: { subsystem: "jobs", job: "transfer-expiring-recordings" }, + extra: { atRisk }, + }, + ); + } + const duration = (Date.now() - startTime) / 1000; console.log(`\n⏱️ Job completed in ${duration.toFixed(2)} seconds`); console.log(` Transferred: ${result.succeeded}`); diff --git a/lib/api/operators/stats.ts b/lib/api/operators/stats.ts index 2cf9f3fd9..48bc24e18 100644 --- a/lib/api/operators/stats.ts +++ b/lib/api/operators/stats.ts @@ -204,6 +204,7 @@ export async function getStaffDashboardStats(): Promise { prisma.consultantReview.count({ where: { createdAt: { gte: weekStart }, + deletedAt: null, }, }), prisma.supportTicket.count({ diff --git a/lib/api/organizations/membership-transitions.ts b/lib/api/organizations/membership-transitions.ts index dc657d3e7..01cca95d9 100644 --- a/lib/api/organizations/membership-transitions.ts +++ b/lib/api/organizations/membership-transitions.ts @@ -136,12 +136,17 @@ export async function applyMembershipRoleEffects( * Why we don't force logout * ------------------------- * The UX cost of "you've been signed out, please log in again" is high - * relative to the marginal security benefit. Role *downgrades* (the - * stale-OWNER-session risk) are typically followed by an explicit - * member-removal flow that calls BetterAuth's `revokeSession` — the - * harder kill. The bump pattern handles the middle case: role changed, - * membership still active, session payload must reflect the new role - * within a single round-trip. + * relative to the marginal security benefit, so removal uses this same + * generation bump rather than a hard session kill. There is no + * server-side revoke-by-userId available: the `admin` plugin (which + * exposes `auth.api.revokeUserSessions({ body: { userId } })`) is not + * installed, and core BetterAuth `revokeSession`/`revokeSessions` need + * the target user's own session token/headers — which an admin removing + * someone else does not hold. This code also runs inside a Prisma + * `$transaction`, where a BetterAuth API call (writing outside the tx) + * would be unsound. So membership removal, role downgrade, and + * soft-suspend all rely on the bump: the next request through + * `customSession` sees the stale generation and refetches. * * Failure mode if not called * -------------------------- diff --git a/lib/auth-helpers.ts b/lib/auth-helpers.ts index 87c89d035..c7d6e0cd7 100644 --- a/lib/auth-helpers.ts +++ b/lib/auth-helpers.ts @@ -22,6 +22,16 @@ export async function requireApiAuth(): Promise< error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), }; } + // #693 defense-in-depth — ban-time session deletion + the sign-in gate + // cover the normal paths; this catches a session minted in the race window. + if (session.user.banned === true) { + return { + error: NextResponse.json( + { error: "Account suspended" }, + { status: 403 }, + ), + }; + } return { session }; } diff --git a/lib/auth.ts b/lib/auth.ts index fa3dd0ee8..0c2b81801 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -3,7 +3,12 @@ import { betterAuth } from "better-auth"; import { APIError } from "better-auth/api"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { nextCookies } from "better-auth/next-js"; -import { customSession, organization } from "better-auth/plugins"; +import { admin, customSession, organization } from "better-auth/plugins"; +import { + adminAc, + userAc, + defaultAc, +} from "better-auth/plugins/admin/access"; import { sso } from "@better-auth/sso"; import bcrypt from "bcrypt"; import { Prisma } from "@prisma/client"; @@ -20,6 +25,13 @@ import { applyMembershipRoleEffects } from "@/lib/api/organizations/membership-t import { buildConsentArtifact } from "@/lib/compliance/dpdp"; import { PURPOSE_CODES } from "@/lib/compliance/purpose-codes"; +// STAFF = moderator: ban/list/get/set-role over users + session control +// (a subset of the full admin AC). Shares defaultAc so statements line up. +const staffAc = defaultAc.newRole({ + user: ["list", "ban", "get", "set-role"], + session: ["list", "revoke", "delete"], +}); + export const auth = betterAuth({ secret: process.env.BETTER_AUTH_SECRET, baseURL: process.env.BETTER_AUTH_URL, @@ -393,6 +405,22 @@ export const auth = betterAuth({ }, plugins: [ + // Moderation (#693, starts #725 Tier-1): provides User.banned/banReason/ + // banExpires, blocks sign-in for banned users, and auto-unbans at sign-in + // once banExpires passes (lazy suspension expiry — no cron). Ban writes + // happen directly via Prisma in lib/moderation, not auth.api.banUser. + // defaultRole must be a valid UserRole enum value — the plugin's + // user.create.before hook otherwise writes "user" and breaks signup. + admin({ + defaultRole: "CONSULTEE", + adminRoles: ["ADMIN", "STAFF"], + // adminRoles must map to keys in `roles` or the plugin throws at + // module load. STAFF = moderator: a subset of full admin capability. + roles: { ADMIN: adminAc, STAFF: staffAc, user: userAc }, + bannedUserMessage: + "Your account has been suspended. If you believe this is a mistake, please contact support.", + }), + // Enterprise: BetterAuth Organization plugin. // Arch 4-Modified: BetterAuth Member.role is a free-form string; the // source of truth is our Membership model (linked via @@ -453,6 +481,11 @@ export const auth = betterAuth({ sessionGeneration: true, consulteeProfileId: true, consultantProfileId: true, + // #693 defense-in-depth: sessions are deleted at ban time and + // sign-in is plugin-gated, but a session minted in the race window + // must still resolve as banned. + banned: true, + banExpires: true, // SSO membership sync: BetterAuth auto-provisioning creates a // BetterAuth Member row; we need a typed Membership sibling. Pull the // unrepaired ones (no Membership yet) so the loop below auto-creates @@ -475,6 +508,9 @@ export const auth = betterAuth({ }); const liveSessionGeneration = currentUserRow?.sessionGeneration ?? user.sessionGeneration ?? 0; + const effectivelyBanned = + (currentUserRow?.banned ?? false) && + (!currentUserRow?.banExpires || currentUserRow.banExpires > new Date()); const preloadedProfiles = currentUserRow ? { consulteeProfileId: currentUserRow.consulteeProfileId, @@ -646,6 +682,7 @@ export const auth = betterAuth({ // Always emit the live value so client code can detect a // stale session by comparing this against its cached payload. sessionGeneration: liveSessionGeneration, + banned: effectivelyBanned, organizationMemberships, ssoEnforcementFailed, }, diff --git a/lib/collaborators/service.ts b/lib/collaborators/service.ts index 827718936..b6c0c5619 100644 --- a/lib/collaborators/service.ts +++ b/lib/collaborators/service.ts @@ -69,6 +69,28 @@ function asPlanRole(planType: PlanType, role: string): CollaboratorRole | null { /** * Invite a collaborator to a webinar or class plan. */ +// #768 lockdown #12 — capability booleans, set from invite input. Default +// false so an unspecified permission is never silently granted. +// Enforced: canSeeAttendees (participant-roster GET). +// TODO #768 — enforce canApprovePayment / canViewAnalytics / canEditEvent +// once collaborator-facing payment-approval, analytics, and event-edit +// surfaces exist; today they have no endpoint to gate, so only the SET lands. +export interface CollaboratorPermissions { + canApprovePayment?: boolean; + canViewAnalytics?: boolean; + canEditEvent?: boolean; + canSeeAttendees?: boolean; +} + +function normalizePermissions(permissions?: CollaboratorPermissions) { + return { + canApprovePayment: permissions?.canApprovePayment ?? false, + canViewAnalytics: permissions?.canViewAnalytics ?? false, + canEditEvent: permissions?.canEditEvent ?? false, + canSeeAttendees: permissions?.canSeeAttendees ?? false, + }; +} + export async function inviteCollaborator( planType: PlanType, planId: string, @@ -76,6 +98,7 @@ export async function inviteCollaborator( role: string, revenueSharePercentage: number, invitedById: string, + permissions?: CollaboratorPermissions, ): Promise { // Validate percentage range if (revenueSharePercentage <= 0 || revenueSharePercentage > 90) { @@ -85,6 +108,8 @@ export async function inviteCollaborator( const planRole = asPlanRole(planType, role); if (!planRole) return null; + const perms = normalizePermissions(permissions); + // Verify the invited consultant profile exists before creating a collaborator record. // Without this check, a stale or fabricated consultantProfileId creates an orphaned row. const inviteeProfile = await prisma.consultantProfile.findUnique({ @@ -125,6 +150,7 @@ export async function inviteCollaborator( status: "PENDING", invitedById, respondedAt: null, + ...perms, }, }); } @@ -140,6 +166,7 @@ export async function inviteCollaborator( revenueShareBps: pctToBps(revenueSharePercentage), status: "PENDING", invitedById, + ...perms, }, }); }, diff --git a/lib/compliance/dpdp.ts b/lib/compliance/dpdp.ts index df181a60a..480d74a29 100644 --- a/lib/compliance/dpdp.ts +++ b/lib/compliance/dpdp.ts @@ -1,9 +1,12 @@ /** - * DPDP (Digital Personal Data Protection Act, 2023) — INDIA COMPLIANCE STUB. - * - * STATUS: stub. `recordConsent` creates a ConsentArtifact row with a mock - * hash; `checkConsent` returns `true` unconditionally. Live impl lands in - * a follow-up PR. + * DPDP (Digital Personal Data Protection Act, 2023) — INDIA COMPLIANCE. + * + * STATUS: consent primitives are LIVE. `recordConsent` writes a ConsentArtifact + * row with a real SHA-256 payload hash; `checkConsent` is fail-closed — it + * returns `true` only when a non-withdrawn, non-expired artifact exists for the + * (user, purpose) pair, else `false`. The substantive operator obligations + * below (Consent Manager registration, breach reporting, rights fulfilment) + * remain follow-up work. * * ───────────────────────────────────────────────────────────────────────── * LIVE IMPLEMENTATION REQUIREMENTS (follow-up PR) diff --git a/lib/data/consultant-dashboard.ts b/lib/data/consultant-dashboard.ts index 62cdb239e..9c01a19e2 100644 --- a/lib/data/consultant-dashboard.ts +++ b/lib/data/consultant-dashboard.ts @@ -476,7 +476,8 @@ export async function getConsultantDashboard( prisma.consultantReview.aggregate({ _avg: { rating: true }, _count: { rating: true }, - where: { consultantProfileId }, + // #693 — mirror the moderation recalc: removed reviews don't count + where: { consultantProfileId, deletedAt: null }, }), // 3. Session completion rate (last 30 days) prisma.slotOfAppointment.groupBy({ diff --git a/lib/data/consultant-detail.ts b/lib/data/consultant-detail.ts index 2776f410c..4cde1de3a 100644 --- a/lib/data/consultant-detail.ts +++ b/lib/data/consultant-detail.ts @@ -104,7 +104,8 @@ export const getConsultantDetail = cache(async (consultantId: string) => { export const getConsultantReviews = cache( async (consultantProfileId: string) => { const reviews = await prisma.consultantReview.findMany({ - where: { consultantProfileId }, + // #693 — moderation-removed reviews stay hidden from the public page + where: { consultantProfileId, deletedAt: null }, take: 20, include: { consultantProfile: { diff --git a/lib/data/explore-experts.ts b/lib/data/explore-experts.ts index ecfb02052..d8d971f65 100644 --- a/lib/data/explore-experts.ts +++ b/lib/data/explore-experts.ts @@ -37,7 +37,7 @@ export const consultantListInclude = { domain: { select: { id: true, name: true } }, subDomains: { select: { id: true, name: true } }, tags: { select: { id: true, name: true } }, - reviews: { select: { rating: true }, take: 10 }, + reviews: { where: { deletedAt: null }, select: { rating: true }, take: 10 }, subscriptionPlans: { select: { id: true, @@ -318,7 +318,12 @@ const getCachedRecentReviews = unstable_cache( async (limit: number) => { return prisma.consultantReview.findMany({ // #781 §B — soft-deleted profiles leave public surfaces - where: { rating: { gte: 4 }, consultantProfile: { deletedAt: null } }, + // #693 — moderation-removed reviews leave public surfaces too + where: { + rating: { gte: 4 }, + deletedAt: null, + consultantProfile: { deletedAt: null }, + }, orderBy: { createdAt: "desc" }, take: limit, include: { diff --git a/lib/data/home.ts b/lib/data/home.ts index c15aa289a..def5165e5 100644 --- a/lib/data/home.ts +++ b/lib/data/home.ts @@ -36,6 +36,7 @@ export const getHomeExperts = unstable_cache( subDomains: { select: { id: true, name: true } }, tags: { select: { id: true, name: true } }, reviews: { + where: { deletedAt: null }, select: { rating: true }, take: 10, }, @@ -67,7 +68,12 @@ export const getHomeReviews = unstable_cache( async () => { const reviews = await prisma.consultantReview.findMany({ // #781 §B — soft-deleted profiles leave public surfaces - where: { rating: { gte: 4 }, consultantProfile: { deletedAt: null } }, + // #693 — moderation-removed reviews leave public surfaces too + where: { + rating: { gte: 4 }, + deletedAt: null, + consultantProfile: { deletedAt: null }, + }, take: 20, include: { consultantProfile: { diff --git a/lib/data/org-analytics.ts b/lib/data/org-analytics.ts index 225d4ed64..ce42c4a8c 100644 --- a/lib/data/org-analytics.ts +++ b/lib/data/org-analytics.ts @@ -17,6 +17,7 @@ import prisma from "@/lib/prisma"; import { ledgerAccountId } from "@/lib/payments/ledger/post"; import { sumPaise } from "@/lib/payments/utils/money"; import { resolveActivationSignals } from "@/lib/enterprise/org-activation-signals"; +import { ENABLE_HOST_ORGS } from "@/lib/feature-flags"; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; @@ -204,7 +205,9 @@ export async function getOrgAnalytics( }, }) : Promise.resolve(0), - org.canHost + // Honesty gate (#687): with ENABLE_HOST_ORGS off no new splits accrue, so + // don't surface host earnings even if canHost is still set on the row. + ENABLE_HOST_ORGS && org.canHost ? prisma.organizationEarnings.groupBy({ by: ["status"], where: { organizationId: orgId }, @@ -314,7 +317,9 @@ export async function getOrgAnalytics( last30dPaise: sumPaise(reimbursementAgg._sum.amount), } : null, - earnings: org.canHost + // Honesty gate (#687): mirror the query gate above — flag off ⇒ null, not + // an empty array, so a still-canHost row doesn't imply zeroed host earnings. + earnings: ENABLE_HOST_ORGS && org.canHost ? earningsAggregate.map((e) => ({ status: e.status, count: e._count._all, diff --git a/lib/labels/session-labels.ts b/lib/labels/session-labels.ts index 520fbe73e..b0693486b 100644 --- a/lib/labels/session-labels.ts +++ b/lib/labels/session-labels.ts @@ -424,6 +424,11 @@ export const EARNING_STATUS_BADGE: Record = { label: "Ready for payout", className: "bg-emerald-100 text-emerald-900 border-emerald-200", }, + // #837 — in a payout batch but cash hasn't left yet; honestly distinct from Paid. + BATCHED: { + label: "Processing payout", + className: "bg-sky-100 text-sky-900 border-sky-200", + }, PAID: { label: "Paid", className: "bg-green-100 text-green-900 border-green-200", diff --git a/lib/meeting.ts b/lib/meeting.ts index 493400482..eef046ddd 100644 --- a/lib/meeting.ts +++ b/lib/meeting.ts @@ -50,69 +50,6 @@ export interface MeetingAppointment { } | null; } -/** - * Creates a new meeting (This function might need less usage now) - * @param client The Stream Video client - * @param options Meeting options. `organizationId` (optional) stamps the - * Stream Video call's `custom.organizationId` for #B2 enterprise tagging - * so org workspace operators can later list calls scoped to their org. Omit (or pass - * `null`) for personal meetings — the key is left out entirely so older - * calls don't accumulate stray null fields. - * @returns The meeting ID (Stream Call ID) - */ -export const createMeeting = async ( - client: StreamVideoClient, - options: { - title: string; - dateTime?: Date; - description?: string; - link?: string; - organizationId?: string | null; - }, -) => { - if (!client) { - throw new Error("Stream client not initialized"); - } - - try { - const id = crypto.randomUUID(); - const call: Call = client.call("default", id); - - if (!call) { - throw new Error("Failed to create call"); - } - - const startsAt = - options.dateTime?.toISOString() ?? new Date(Date.now()).toISOString(); - const description = options.description ?? "Instant Meeting"; - - // Build custom payload — only include `organizationId` when set so - // legacy calls (no org) remain shape-compatible. camelCase here mirrors - // the existing video-call custom data convention (appointmentId/slotId). - const custom: Record = { - title: options.title, - description: description, - link: options.link, - ...(options.organizationId - ? { organizationId: options.organizationId } - : {}), - }; - - await call.getOrCreate({ - data: { - starts_at: startsAt, - custom, - }, - }); - - return id; - } catch (error) { - console.error("Error creating meeting:", error); - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "stream" } }); - throw error; - } -}; - /** * Gets an existing meeting session ID from the DB or creates a new Stream call * and corresponding DB session if one doesn't exist for the appointment slot. diff --git a/lib/moderation/cancel-user-engagements.ts b/lib/moderation/cancel-user-engagements.ts new file mode 100644 index 000000000..d13330f37 --- /dev/null +++ b/lib/moderation/cancel-user-engagements.ts @@ -0,0 +1,554 @@ +/** + * Moderation bulk-cancel (#693): cancel every future engagement a suspended + * or banned user is part of, with 100% refunds to the innocent counterparty — + * moderation is platform-initiated, so booking-time policy tiers do not apply. + * + * Mirrors the CAS doctrine of app/api/appointments/[appointmentId]/cancel: + * status moves ride a guarded updateMany (double-cancel loses the CAS and is + * skipped), refunds run AFTER each cancel commits because refundPayment owns + * its own Serializable tx. Every step is idempotent, so a re-run after a + * partial failure (or budget exhaustion) is safe. + */ +import * as Sentry from "@sentry/nextjs"; +import prisma from "@/lib/prisma"; +import { notifyAppointmentCancelled } from "@/lib/novu"; +import { refundPayment } from "@/lib/payments/operations/refund"; +import { handleSlotOpening } from "@/lib/waitlist"; +import { + CANCELLABLE_FROM, + CLASS_EVENT_ALLOWED_FROM, + EVENT_ALLOWED_FROM, +} from "@/lib/booking/transitions"; + +export interface BulkCancelSummary { + engagementsCancelled: number; + attendeeRemovals: number; + refundsIssued: number; + refundedPaise: number; + failures: Array<{ kind: string; id: string; error: string }>; + /** Work items not reached inside the time budget — safe to re-run. */ + remaining: Array<{ kind: string; id: string }>; +} + +interface BulkCancelOptions { + initiatedByUserId: string; + notes?: string; + /** Netlify functions are wall-clock capped; leave headroom for the rest + * of the best-effort phase. */ + budgetMs?: number; +} + +type WorkItem = + | { kind: "consultation" | "subscription"; id: string } + | { kind: "webinar-event" | "class-event"; id: string } + | { kind: "webinar-attendance" | "class-attendance"; id: string }; + +type FutureSlotFilter = { + completionStatus: "SCHEDULED"; + startsAt: { gt: Date }; +}; + +const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +const captureModerationError = (error: unknown) => + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "moderation" } }, + ); + +export async function cancelFutureEngagementsForUser( + targetUserId: string, + { initiatedByUserId, notes, budgetMs = 15_000 }: BulkCancelOptions, +): Promise { + const deadline = Date.now() + budgetMs; + const summary: BulkCancelSummary = { + engagementsCancelled: 0, + attendeeRemovals: 0, + refundsIssued: 0, + refundedPaise: 0, + failures: [], + remaining: [], + }; + + const target = await prisma.user.findUnique({ + where: { id: targetUserId }, + select: { consulteeProfileId: true, consultantProfileId: true }, + }); + if (!target) return summary; + + const futureSlot: FutureSlotFilter = { + completionStatus: "SCHEDULED", + startsAt: { gt: new Date() }, + }; + + const work: WorkItem[] = []; + if (target.consulteeProfileId) { + work.push( + ...(await collectConsulteeWork( + target.consulteeProfileId, + targetUserId, + futureSlot, + )), + ); + } + if (target.consultantProfileId) { + work.push( + ...(await collectConsultantWork(target.consultantProfileId, futureSlot)), + ); + } + + for (let i = 0; i < work.length; i++) { + if (Date.now() > deadline) { + summary.remaining = work.slice(i); + Sentry.captureMessage( + `[moderation] bulk-cancel budget exhausted for user ${targetUserId}; ${summary.remaining.length} engagement(s) left — re-run the action to finish`, + { tags: { subsystem: "moderation" } }, + ); + break; + } + await runWorkItem(work[i], targetUserId, { + initiatedByUserId, + notes, + summary, + }); + } + + return summary; +} + +// Engagements where the target is the buyer (consultee): exclusive +// consultations/subscriptions they own, plus group events they merely attend. +async function collectConsulteeWork( + consulteeProfileId: string, + targetUserId: string, + futureSlot: FutureSlotFilter, +): Promise { + const [consultations, subscriptions, attendedSlots] = await Promise.all([ + prisma.consultation.findMany({ + where: { + requestedById: consulteeProfileId, + status: { in: [...CANCELLABLE_FROM] }, + appointment: { slotsOfAppointment: { some: futureSlot } }, + }, + select: { id: true }, + }), + prisma.subscription.findMany({ + where: { + requestedById: consulteeProfileId, + status: { in: [...CANCELLABLE_FROM] }, + appointments: { some: { slotsOfAppointment: { some: futureSlot } } }, + }, + select: { id: true }, + }), + // Group events the target merely attends — remove + refund just them. + prisma.slotOfAppointment.findMany({ + where: { + ...futureSlot, + user: { some: { id: targetUserId } }, + appointment: { + OR: [{ webinarId: { not: null } }, { classId: { not: null } }], + }, + }, + select: { + appointment: { select: { webinarId: true, classId: true } }, + }, + }), + ]); + + const work: WorkItem[] = [ + ...consultations.map((c) => ({ kind: "consultation" as const, id: c.id })), + ...subscriptions.map((s) => ({ kind: "subscription" as const, id: s.id })), + ]; + const webinarIds = new Set(); + const classIds = new Set(); + for (const slot of attendedSlots) { + if (slot.appointment?.webinarId) webinarIds.add(slot.appointment.webinarId); + if (slot.appointment?.classId) classIds.add(slot.appointment.classId); + } + work.push( + ...Array.from(webinarIds, (id) => ({ + kind: "webinar-attendance" as const, + id, + })), + ...Array.from(classIds, (id) => ({ + kind: "class-attendance" as const, + id, + })), + ); + return work; +} + +// Engagements the target hosts (consultant): exclusive engagements plus whole +// group events they run — every attendee is refunded when these cancel. +async function collectConsultantWork( + consultantProfileId: string, + futureSlot: FutureSlotFilter, +): Promise { + const [consultations, subscriptions, webinars, classes] = await Promise.all([ + prisma.consultation.findMany({ + where: { + consultationPlan: { consultantProfileId }, + status: { in: [...CANCELLABLE_FROM] }, + appointment: { slotsOfAppointment: { some: futureSlot } }, + }, + select: { id: true }, + }), + prisma.subscription.findMany({ + where: { + subscriptionPlan: { consultantProfileId }, + status: { in: [...CANCELLABLE_FROM] }, + appointments: { some: { slotsOfAppointment: { some: futureSlot } } }, + }, + select: { id: true }, + }), + prisma.webinar.findMany({ + where: { + webinarPlan: { consultantProfileId }, + status: { in: EVENT_ALLOWED_FROM.CANCELLED }, + appointment: { slotsOfAppointment: { some: futureSlot } }, + }, + select: { id: true }, + }), + prisma.class.findMany({ + where: { + classPlan: { consultantProfileId }, + status: { in: CLASS_EVENT_ALLOWED_FROM.CANCELLED }, + appointments: { some: { slotsOfAppointment: { some: futureSlot } } }, + }, + select: { id: true }, + }), + ]); + return [ + ...consultations.map((c) => ({ kind: "consultation" as const, id: c.id })), + ...subscriptions.map((s) => ({ kind: "subscription" as const, id: s.id })), + ...webinars.map((w) => ({ kind: "webinar-event" as const, id: w.id })), + ...classes.map((c) => ({ kind: "class-event" as const, id: c.id })), + ]; +} + +// Dispatch a single work item; every failure is recorded and swallowed so the +// budgeted loop continues to the next engagement. +async function runWorkItem( + item: WorkItem, + targetUserId: string, + ctx: { initiatedByUserId: string; notes?: string; summary: BulkCancelSummary }, +): Promise { + const { initiatedByUserId, notes, summary } = ctx; + try { + switch (item.kind) { + case "consultation": + case "subscription": + await cancelExclusiveEngagement(item.kind, item.id, { + initiatedByUserId, + notes, + summary, + }); + break; + case "webinar-event": + case "class-event": + await cancelGroupEvent(item.kind, item.id, { + initiatedByUserId, + summary, + }); + break; + case "webinar-attendance": + case "class-attendance": + await removeAttendee(item.kind, item.id, targetUserId, { + initiatedByUserId, + summary, + }); + break; + } + } catch (error) { + summary.failures.push({ kind: item.kind, id: item.id, error: errMsg(error) }); + captureModerationError(error); + } +} + +interface NormalizedEngagement { + planTitle?: string; + consultantUser?: { id: string; name: string | null } | null; + consulteeUser?: { id: string; name: string | null } | null; + appointments: Array<{ + id: string; + appointmentType: string; + // amount is number at runtime — the extended client converts BigInt on read + payment: Array<{ id: string; amount: number; paymentStatus: string }>; + }>; +} + +async function cancelExclusiveEngagement( + kind: "consultation" | "subscription", + engagementId: string, + ctx: { initiatedByUserId: string; notes?: string; summary: BulkCancelSummary }, +) { + const planSelect = { + select: { + title: true, + consultantProfile: { + select: { user: { select: { id: true, name: true } } }, + }, + }, + } as const; + const requestedBySelect = { + select: { user: { select: { id: true, name: true } } }, + } as const; + const appointmentSelect = { + select: { + id: true, + appointmentType: true, + payment: { select: { id: true, amount: true, paymentStatus: true } }, + }, + } as const; + + let engagement: NormalizedEngagement | null = null; + if (kind === "consultation") { + const row = await prisma.consultation.findUnique({ + where: { id: engagementId }, + select: { + consultationPlan: planSelect, + requestedBy: requestedBySelect, + appointment: appointmentSelect, + }, + }); + if (row) { + engagement = { + planTitle: row.consultationPlan?.title, + consultantUser: row.consultationPlan?.consultantProfile?.user, + consulteeUser: row.requestedBy?.user, + appointments: row.appointment ? [row.appointment] : [], + }; + } + } else { + const row = await prisma.subscription.findUnique({ + where: { id: engagementId }, + select: { + subscriptionPlan: planSelect, + requestedBy: requestedBySelect, + appointments: appointmentSelect, + }, + }); + if (row) { + engagement = { + planTitle: row.subscriptionPlan?.title, + consultantUser: row.subscriptionPlan?.consultantProfile?.user, + consulteeUser: row.requestedBy?.user, + appointments: row.appointments, + }; + } + } + if (!engagement) return; + + const cancellationData = { + status: "CANCELLED" as const, + cancellationReason: "MODERATION" as const, + cancellationNotes: ctx.notes ?? null, + cancelledAt: new Date(), + cancelledBy: ctx.initiatedByUserId, + }; + + const moved = await prisma.$transaction(async (tx) => { + const res = + kind === "consultation" + ? await tx.consultation.updateMany({ + where: { id: engagementId, status: { in: [...CANCELLABLE_FROM] } }, + data: cancellationData, + }) + : await tx.subscription.updateMany({ + where: { id: engagementId, status: { in: [...CANCELLABLE_FROM] } }, + data: cancellationData, + }); + if (res.count === 0) return 0; + await tx.slotOfAppointment.updateMany({ + where: + kind === "consultation" + ? { + appointment: { consultationId: engagementId }, + completionStatus: "SCHEDULED", + } + : { + appointment: { subscriptionId: engagementId }, + completionStatus: "SCHEDULED", + }, + data: { completionStatus: "CANCELLED" }, + }); + return res.count; + }); + if (moved === 0) return; // lost the CAS — already terminal, no refund + + ctx.summary.engagementsCancelled += 1; + + for (const appt of engagement.appointments) { + const paid = appt.payment.find( + (p) => p.paymentStatus === "SUCCEEDED" && p.amount > 0, + ); + if (paid) { + await issueFullRefund(paid.id, ctx.initiatedByUserId, ctx.summary); + } + } + + const userIds = [ + engagement.consultantUser?.id, + engagement.consulteeUser?.id, + ].filter((id): id is string => !!id); + if (userIds.length > 0) { + void notifyAppointmentCancelled(userIds, { + appointmentType: + engagement.appointments[0]?.appointmentType ?? kind.toUpperCase(), + consultantName: engagement.consultantUser?.name || "Consultant", + consulteeName: engagement.consulteeUser?.name || "Consultee", + planTitle: engagement.planTitle || "N/A", + dashboardUrl: "/dashboard", + reason: "MODERATION", + cancelledBy: "system", + }); + } +} + +async function cancelGroupEvent( + kind: "webinar-event" | "class-event", + eventId: string, + ctx: { initiatedByUserId: string; summary: BulkCancelSummary }, +) { + const isWebinar = kind === "webinar-event"; + + const moved = await prisma.$transaction(async (tx) => { + const res = isWebinar + ? await tx.webinar.updateMany({ + where: { id: eventId, status: { in: EVENT_ALLOWED_FROM.CANCELLED } }, + data: { status: "CANCELLED" }, + }) + : await tx.class.updateMany({ + where: { + id: eventId, + status: { in: CLASS_EVENT_ALLOWED_FROM.CANCELLED }, + }, + data: { status: "CANCELLED" }, + }); + if (res.count === 0) return 0; + await tx.slotOfAppointment.updateMany({ + where: { + appointment: isWebinar ? { webinarId: eventId } : { classId: eventId }, + completionStatus: "SCHEDULED", + }, + data: { completionStatus: "CANCELLED" }, + }); + return res.count; + }); + if (moved === 0) return; + + ctx.summary.engagementsCancelled += 1; + + // Whole-event moderation cancel refunds EVERY attendee in full — unlike the + // consultant-initiated cancel route, which defers buyer refunds to the + // participant-removal flow. + const payments = await prisma.payment.findMany({ + where: { + appointment: isWebinar ? { webinarId: eventId } : { classId: eventId }, + paymentStatus: "SUCCEEDED", + amount: { gt: 0 }, + }, + select: { id: true, userId: true }, + }); + for (const payment of payments) { + await issueFullRefund(payment.id, ctx.initiatedByUserId, ctx.summary); + } + + const attendeeIds = Array.from(new Set(payments.map((p) => p.userId))); + if (attendeeIds.length > 0) { + void notifyAppointmentCancelled(attendeeIds, { + appointmentType: isWebinar ? "WEBINAR" : "CLASS", + consultantName: "Consultant", + consulteeName: "Attendee", + planTitle: "N/A", + dashboardUrl: "/dashboard", + reason: "MODERATION", + cancelledBy: "system", + }); + } +} + +async function removeAttendee( + kind: "webinar-attendance" | "class-attendance", + eventId: string, + targetUserId: string, + ctx: { initiatedByUserId: string; summary: BulkCancelSummary }, +) { + const isWebinar = kind === "webinar-attendance"; + const eventFilter = isWebinar ? { webinarId: eventId } : { classId: eventId }; + + const slots = await prisma.slotOfAppointment.findMany({ + where: { + appointment: eventFilter, + completionStatus: "SCHEDULED", + startsAt: { gt: new Date() }, + user: { some: { id: targetUserId } }, + }, + select: { id: true }, + }); + if (slots.length === 0) return; + + await prisma.$transaction( + slots.map((slot) => + prisma.slotOfAppointment.update({ + where: { id: slot.id }, + data: { user: { disconnect: { id: targetUserId } } }, + }), + ), + ); + ctx.summary.attendeeRemovals += 1; + + const paid = await prisma.payment.findFirst({ + where: { + userId: targetUserId, + appointment: eventFilter, + paymentStatus: "SUCCEEDED", + amount: { gt: 0 }, + }, + select: { id: true }, + }); + if (paid) { + await issueFullRefund(paid.id, ctx.initiatedByUserId, ctx.summary); + } + + try { + await handleSlotOpening({ + ...(isWebinar ? { webinarId: eventId } : { classId: eventId }), + slotsAvailable: 1, + reason: "participant_removed", + }); + } catch (error) { + // Waitlist promotion is opportunistic — the removal itself stands. + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "moderation" } }, + ); + } +} + +async function issueFullRefund( + paymentId: string, + initiatedByUserId: string, + summary: BulkCancelSummary, +) { + try { + const r = await refundPayment({ + paymentId, + // amountPaise omitted → refundPayment refunds the full refundable balance + reason: "moderation (100% — platform-initiated cancellation)", + initiatedByUserId, + }); + summary.refundsIssued += 1; + summary.refundedPaise += r.amountRefundedPaise; + } catch (error) { + summary.failures.push({ + kind: "refund", + id: paymentId, + error: errMsg(error), + }); + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "moderation" } }, + ); + } +} diff --git a/lib/moderation/side-effects.ts b/lib/moderation/side-effects.ts new file mode 100644 index 000000000..5aea77b59 --- /dev/null +++ b/lib/moderation/side-effects.ts @@ -0,0 +1,331 @@ +/** + * Moderation action side-effects (#693) — two-phase execution. + * + * Phase 1 (applyTransactionalEffects) runs inside the action route's + * interactive transaction: user ban flags, session revocation, earnings hold, + * profile unverification, review soft-delete. All-or-nothing with the + * ModerationAction row, so a report can never read ACTION_TAKEN while the + * target's account state didn't move. + * + * Phase 2 (applyBestEffortEffects) runs after commit: bulk cancel + refunds + * (refundPayment owns its own Serializable tx), Stream revocation, Novu. + * Each step is individually try/caught — one failure never blocks the next — + * and the outcome lands in ModerationAction.sideEffects for staff visibility. + */ +import * as Sentry from "@sentry/nextjs"; +import type { ModerationActionType } from "@prisma/client"; +import { EarningStatus } from "@prisma/client"; +import type { Tx } from "@/lib/prisma"; +import { + getStreamChatClient, + withStreamCircuitBreaker, +} from "@/lib/stream-client"; +import { assertEarningStatusTransitionLegal } from "@/lib/payments/payouts/earning-status"; +import { + notifyModerationWarning, + notifyAccountSuspended, + notifyAccountBanned, + notifyVerificationStatusChanged, +} from "@/lib/novu"; +import { + cancelFutureEngagementsForUser, + type BulkCancelSummary, +} from "./cancel-user-engagements"; + +export interface ModerationSideEffectInput { + actionType: ModerationActionType; + report: { id: string; targetUserId: string; reviewId: string | null }; + staffUserId: string; + notes?: string; + /** Required for USER_SUSPENDED. */ + suspensionDays?: number; +} + +export interface TransactionalEffectResult { + sessionsRevoked?: number; + earningsHeld?: number; + profilesUnverified?: number; + reviewRemoved?: boolean; + banExpires?: string | null; +} + +type StepStatus = "ok" | "failed" | "skipped"; + +export interface SideEffectSummary extends TransactionalEffectResult { + cancellations?: BulkCancelSummary; + stream?: StepStatus; + notification?: StepStatus; + errors?: string[]; +} + +const HOLDABLE: EarningStatus[] = [ + EarningStatus.PENDING, + EarningStatus.PENDING_TRUST, + EarningStatus.READY, +]; + +const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +const captureModerationError = (error: unknown) => + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "moderation" } }, + ); + +export async function applyTransactionalEffects( + tx: Tx, + input: ModerationSideEffectInput, +): Promise { + const { actionType, report } = input; + + switch (actionType) { + case "USER_SUSPENDED": + case "USER_BANNED": + return banOrSuspendUser(tx, input); + case "PROFILE_UNVERIFIED": + return unverifyProfiles(tx, report.targetUserId); + case "CONTENT_REMOVED": + return softDeleteReview(tx, report.reviewId); + case "WARNING_ISSUED": + case "NO_ACTION": + return {}; + } +} + +async function banOrSuspendUser( + tx: Tx, + input: ModerationSideEffectInput, +): Promise { + const { actionType, report, notes, suspensionDays } = input; + const banExpires = + actionType === "USER_SUSPENDED" + ? new Date(Date.now() + (suspensionDays ?? 7) * 86_400_000) + : null; + await tx.user.update({ + where: { id: report.targetUserId }, + data: { + banned: true, + banReason: notes ?? `moderation: ${actionType}`, + banExpires, + }, + }); + const result: TransactionalEffectResult = { + banExpires: banExpires ? banExpires.toISOString() : null, + }; + + const revoked = await tx.session.deleteMany({ + where: { userId: report.targetUserId }, + }); + result.sessionsRevoked = revoked.count; + + if (actionType === "USER_BANNED") { + const earningsHeld = await holdBannedConsultantEarnings( + tx, + report.targetUserId, + ); + if (earningsHeld !== undefined) result.earningsHeld = earningsHeld; + } + return result; +} + +// Hold the banned consultant's unpaid earnings for admin disposition; HELD is +// skipped by the release-earnings cron. PAID/REFUNDED rows are untouchable by +// doctrine — the guard below enforces it per row. Returns undefined when the +// target has no consultant profile (earningsHeld stays unset, as before). +async function holdBannedConsultantEarnings( + tx: Tx, + targetUserId: string, +): Promise { + const target = await tx.user.findUnique({ + where: { id: targetUserId }, + select: { consultantProfileId: true }, + }); + if (!target?.consultantProfileId) return undefined; + + const holdable = await tx.consultantEarnings.findMany({ + where: { + consultantProfileId: target.consultantProfileId, + status: { in: HOLDABLE }, + }, + select: { id: true, status: true }, + }); + for (const row of holdable) { + assertEarningStatusTransitionLegal(row.id, row.status, EarningStatus.HELD); + } + const held = await tx.consultantEarnings.updateMany({ + where: { + id: { in: holdable.map((r) => r.id) }, + status: { in: HOLDABLE }, + }, + data: { status: EarningStatus.HELD }, + }); + return held.count; +} + +async function unverifyProfiles( + tx: Tx, + targetUserId: string, +): Promise { + // Both fields: explore + the booking gate filter on verificationStatus, + // while isVerified is the projected display flag. + const updated = await tx.consultantProfile.updateMany({ + where: { userId: targetUserId }, + data: { isVerified: false, verificationStatus: "REJECTED" }, + }); + return { profilesUnverified: updated.count }; +} + +async function softDeleteReview( + tx: Tx, + reviewId: string | null, +): Promise { + if (!reviewId) return {}; + const review = await tx.consultantReview.findUnique({ + where: { id: reviewId }, + select: { consultantProfileId: true, deletedAt: true }, + }); + if (!review || review.deletedAt) return {}; + await tx.consultantReview.update({ + where: { id: reviewId }, + data: { deletedAt: new Date() }, + }); + const remaining = await tx.consultantReview.aggregate({ + where: { + consultantProfileId: review.consultantProfileId, + deletedAt: null, + }, + _avg: { rating: true }, + }); + await tx.consultantProfile.update({ + where: { id: review.consultantProfileId }, + data: { rating: remaining._avg.rating || 0 }, + }); + return { reviewRemoved: true }; +} + +type TriggerOutcome = { success: boolean; error?: Error | string } | null; + +export async function applyBestEffortEffects( + input: ModerationSideEffectInput, + transactional: TransactionalEffectResult, +): Promise { + const { actionType } = input; + const summary: SideEffectSummary = { ...transactional }; + const errors: string[] = []; + + if (actionType === "USER_SUSPENDED" || actionType === "USER_BANNED") { + await runBulkCancellations(input, summary, errors); + await runStreamRevocation(input, summary, errors); + } + + await runNotification(input, transactional, summary, errors); + + if (errors.length > 0) summary.errors = errors; + return summary; +} + +async function runBulkCancellations( + input: ModerationSideEffectInput, + summary: SideEffectSummary, + errors: string[], +): Promise { + const { report, staffUserId, notes } = input; + try { + summary.cancellations = await cancelFutureEngagementsForUser( + report.targetUserId, + { initiatedByUserId: staffUserId, notes }, + ); + } catch (error) { + errors.push(`cancellations: ${errMsg(error)}`); + captureModerationError(error); + } +} + +async function runStreamRevocation( + input: ModerationSideEffectInput, + summary: SideEffectSummary, + errors: string[], +): Promise { + const { actionType, report } = input; + try { + // revokeUserToken expires every previously-issued Stream token; the + // token provider re-mints only for non-banned users, so suspension + // self-heals after banExpires without an un-revoke. + await withStreamCircuitBreaker(async () => { + const chat = getStreamChatClient(); + await chat.revokeUserToken(report.targetUserId, new Date()); + if (actionType === "USER_BANNED") { + // Deactivated users cannot connect at all; history is preserved. + await chat.deactivateUser(report.targetUserId, { + mark_messages_deleted: false, + }); + } + }); + summary.stream = "ok"; + } catch (error) { + summary.stream = "failed"; + errors.push(`stream: ${errMsg(error)}`); + captureModerationError(error); + } +} + +async function runNotification( + input: ModerationSideEffectInput, + transactional: TransactionalEffectResult, + summary: SideEffectSummary, + errors: string[], +): Promise { + try { + // The Novu wrappers are non-throwing (TriggerResult) — read the success + // flag; the catch only covers unexpected throws. + const trigger = await triggerModerationNotification( + input, + transactional, + summary, + ); + if (trigger === null) { + summary.notification = "skipped"; + } else if (trigger.success) { + summary.notification = "ok"; + } else { + summary.notification = "failed"; + errors.push(`notification: ${errMsg(trigger.error ?? "trigger failed")}`); + } + } catch (error) { + summary.notification = "failed"; + errors.push(`notification: ${errMsg(error)}`); + captureModerationError(error); + } +} + +function triggerModerationNotification( + input: ModerationSideEffectInput, + transactional: TransactionalEffectResult, + summary: SideEffectSummary, +): Promise { + const { actionType, report, notes } = input; + switch (actionType) { + case "WARNING_ISSUED": + case "CONTENT_REMOVED": + return notifyModerationWarning(report.targetUserId, { reason: notes }); + case "USER_SUSPENDED": + return notifyAccountSuspended(report.targetUserId, { + reason: notes, + suspendedUntil: transactional.banExpires ?? "", + appointmentsCancelled: summary.cancellations?.engagementsCancelled, + }); + case "USER_BANNED": + return notifyAccountBanned(report.targetUserId, { + reason: notes, + appointmentsCancelled: summary.cancellations?.engagementsCancelled, + }); + case "PROFILE_UNVERIFIED": + return notifyVerificationStatusChanged(report.targetUserId, { + status: "REJECTED", + reason: notes, + dashboardUrl: "/dashboard", + }); + case "NO_ACTION": + return Promise.resolve(null); + } +} diff --git a/lib/novu/index.ts b/lib/novu/index.ts index 6de32b40d..02211c532 100644 --- a/lib/novu/index.ts +++ b/lib/novu/index.ts @@ -38,6 +38,10 @@ export { notifyNewBookingRequest, notifyVerificationStatusChanged, notifyPayoutProcessed, + // Moderation (#693) + notifyModerationWarning, + notifyAccountSuspended, + notifyAccountBanned, // Admin notifyGeneralAnnouncement, notifyNewConsultantApplication, diff --git a/lib/novu/service.ts b/lib/novu/service.ts index 98877f6f1..5115a6925 100644 --- a/lib/novu/service.ts +++ b/lib/novu/service.ts @@ -4,6 +4,7 @@ * Non-throwing: logs errors and returns success/failure status. * Pattern follows lib/email.ts (graceful degradation). */ +import { createHash } from "node:crypto"; import * as Sentry from "@sentry/nextjs"; import { getNovuClient, isNovuConfigured } from "./client"; import { @@ -21,6 +22,9 @@ import { type SubscriptionPayload, type BookingRequestPayload, type VerificationPayload, + type ModerationWarningPayload, + type AccountSuspendedPayload, + type AccountBannedPayload, type PayoutPayload, type AnnouncementPayload, type WaitlistPayload, @@ -51,13 +55,51 @@ interface TriggerResult { error?: Error | string; } +// Unconfigured Novu in a deployed env means notifications silently vanish — +// a console.warn nobody reads is not enough. Local dev stays console-only. +function reportNotConfigured(workflowId: string): void { + console.warn(`[Novu] Not configured. Skipped workflow: ${workflowId}`); + if (process.env.NODE_ENV === "production") { + Sentry.captureMessage(`[Novu] Not configured — dropped ${workflowId}`, { + level: "warning", + tags: { subsystem: "novu" }, + }); + } +} + +// Deterministic transactionId so app-level retries can't double-notify: Novu +// rejects a repeated transactionId. Derived from recipient(s) + workflow + +// canonical payload (the payloads carry the entity ids). `dedupeKey` lets a +// caller that legitimately re-sends an identical payload (e.g. 24h vs 1h +// appointment reminders) disambiguate the sends. +function deriveTransactionId( + workflowId: string, + recipients: string | string[], + payload: NovuPayload, + dedupeKey?: string, +): string { + const canonicalPayload = JSON.stringify( + Object.fromEntries( + Object.entries(payload).sort(([a], [b]) => a.localeCompare(b)), + ), + ); + const recipientKey = Array.isArray(recipients) + ? recipients.toSorted((a, b) => a.localeCompare(b)).join(",") + : recipients; + const hash = createHash("sha256") + .update(`${workflowId}|${recipientKey}|${dedupeKey ?? canonicalPayload}`) + .digest("hex"); + return `${workflowId}:${hash.slice(0, 32)}`; +} + async function triggerWorkflow( workflowId: string, subscriberId: string, payload: T, + dedupeKey?: string, ): Promise { if (!isNovuConfigured()) { - console.warn(`[Novu] Not configured. Skipped workflow: ${workflowId}`); + reportNotConfigured(workflowId); return { success: false, error: "Novu not configured" }; } @@ -67,6 +109,12 @@ async function triggerWorkflow( workflowId, to: subscriberId, payload, + transactionId: deriveTransactionId( + workflowId, + subscriberId, + payload, + dedupeKey, + ), }); console.log(`[Novu] Triggered ${workflowId} for ${subscriberId}`); return { success: true }; @@ -91,9 +139,10 @@ async function triggerForMultiple( workflowId: string, userIds: string[], payload: T, + dedupeKey?: string, ): Promise { if (!isNovuConfigured()) { - console.warn(`[Novu] Not configured. Skipped workflow: ${workflowId}`); + reportNotConfigured(workflowId); return userIds.map(() => ({ success: false, error: "Novu not configured" as const, @@ -102,7 +151,7 @@ async function triggerForMultiple( if (userIds.length === 0) return []; if (userIds.length === 1) - return [await triggerWorkflow(workflowId, userIds[0], payload)]; + return [await triggerWorkflow(workflowId, userIds[0], payload, dedupeKey)]; const BATCH_SIZE = 100; const results: TriggerResult[] = []; @@ -115,6 +164,12 @@ async function triggerForMultiple( workflowId, to: batch, payload, + transactionId: deriveTransactionId( + workflowId, + batch, + payload, + dedupeKey, + ), }); console.log( `[Novu] Triggered ${workflowId} for ${batch.length} subscribers`, @@ -142,7 +197,7 @@ async function triggerBroadcastWorkflow( payload: T, ): Promise { if (!isNovuConfigured()) { - console.warn(`[Novu] Not configured. Skipped broadcast: ${workflowId}`); + reportNotConfigured(workflowId); return { success: false, error: "Novu not configured" }; } @@ -211,14 +266,18 @@ export async function notifyAppointmentCompleted( ); } +// `dedupeKey` (appointment + window) keeps the 1h reminder from being +// swallowed as a duplicate of the 24h one — their payloads are identical. export async function notifyAppointmentReminder( userIds: string[], payload: AppointmentPayload, + dedupeKey?: string, ) { return triggerForMultiple( NOVU_WORKFLOWS.APPOINTMENT_REMINDER, userIds, payload, + dedupeKey, ); } @@ -429,6 +488,37 @@ export async function notifyVerificationStatusChanged( ); } +// Moderation (#693) — fire-and-forget; callers run these in the best-effort +// phase, never inside the moderation transaction. +export async function notifyModerationWarning( + targetUserId: string, + payload: ModerationWarningPayload, +) { + return triggerWorkflow( + NOVU_WORKFLOWS.MODERATION_WARNING, + targetUserId, + payload, + ); +} + +export async function notifyAccountSuspended( + targetUserId: string, + payload: AccountSuspendedPayload, +) { + return triggerWorkflow( + NOVU_WORKFLOWS.ACCOUNT_SUSPENDED, + targetUserId, + payload, + ); +} + +export async function notifyAccountBanned( + targetUserId: string, + payload: AccountBannedPayload, +) { + return triggerWorkflow(NOVU_WORKFLOWS.ACCOUNT_BANNED, targetUserId, payload); +} + export async function notifyPayoutProcessed( consultantUserId: string, payload: PayoutPayload, diff --git a/lib/novu/workflows.ts b/lib/novu/workflows.ts index 4908d91f4..52e280abd 100644 --- a/lib/novu/workflows.ts +++ b/lib/novu/workflows.ts @@ -54,6 +54,12 @@ export const NOVU_WORKFLOWS = { GENERAL_ANNOUNCEMENT: "general-announcement", NEW_CONSULTANT_APPLICATION: "new-consultant-application", + // Moderation (#693) — staff actions against a reported user. Workflow + // definitions must exist in the Novu dashboard with these slugs. + MODERATION_WARNING: "moderation-warning", + ACCOUNT_SUSPENDED: "account-suspended", + ACCOUNT_BANNED: "account-banned", + // Waitlist WAITLIST_SPOT_AVAILABLE: "waitlist-spot-available", @@ -230,6 +236,23 @@ export type VerificationPayload = { dashboardUrl: string; }; +// Moderation (#693) +export type ModerationWarningPayload = { + reason?: string; +}; + +export type AccountSuspendedPayload = { + reason?: string; + /** ISO timestamp the suspension lapses (lazy expiry at sign-in). */ + suspendedUntil: string; + appointmentsCancelled?: number; +}; + +export type AccountBannedPayload = { + reason?: string; + appointmentsCancelled?: number; +}; + export type PayoutPayload = { amount: number; currency: string; diff --git a/lib/payments/core/types.ts b/lib/payments/core/types.ts index 1fa75f633..b034000b1 100644 --- a/lib/payments/core/types.ts +++ b/lib/payments/core/types.ts @@ -124,7 +124,7 @@ export const CURRENCY_MULTIPLIERS: Record = { CAD: 100, // cents SGD: 100, // cents AED: 100, // fils - NGN: 100, // kobo (for XFlow) + NGN: 100, // kobo }; // ============================================================================ diff --git a/lib/payments/gateway-router.ts b/lib/payments/gateway-router.ts index c1c1fbad2..90b3aab0e 100644 --- a/lib/payments/gateway-router.ts +++ b/lib/payments/gateway-router.ts @@ -11,11 +11,11 @@ * - Stripe international: ~6.3% (4.3% processing + 2% currency conversion) */ -import { PaymentGateway } from "@prisma/client"; +import type { SupportedCheckoutGateway } from "@/schemas/checkout"; export interface GatewayRoutingResult { - /** Selected payment gateway */ - gateway: PaymentGateway; + /** Selected payment gateway — always an implemented gateway, never a stub */ + gateway: SupportedCheckoutGateway; /** Human-readable reason for the selection (for audit logs) */ reason: string; /** Whether this is a Razorpay International Bank Transfer */ @@ -35,7 +35,7 @@ export interface GatewayRoutingResult { */ export function routeGateway(params: { buyerCountry: string; - requestedGateway?: PaymentGateway; + requestedGateway?: SupportedCheckoutGateway; }): GatewayRoutingResult { const { buyerCountry, requestedGateway } = params; diff --git a/lib/payments/index.ts b/lib/payments/index.ts index ae090b741..187bf11ee 100644 --- a/lib/payments/index.ts +++ b/lib/payments/index.ts @@ -1,6 +1,6 @@ /** * Payments Module - Main Exports - * Unified payment gateway abstraction for Stripe, Razorpay, LemonSqueezy, and XFlow + * Unified payment gateway abstraction for Stripe and Razorpay */ import { PaymentGateway } from "@prisma/client"; @@ -74,22 +74,6 @@ export async function createPaymentIntent( case "RAZORPAY": return createRazorpayOrder(params); - case "LEMON_SQUEEZY": - // TODO: Implement when LemonSqueezy KYC is complete - throw new PaymentError( - "LemonSqueezy integration not yet available - KYC pending", - "NOT_IMPLEMENTED", - "LEMON_SQUEEZY", - ); - - case "XFLOW": - // TODO: Implement when XFlow is ready for production - throw new PaymentError( - "XFlow integration not yet available", - "NOT_IMPLEMENTED", - "XFLOW", - ); - default: throw new PaymentError( `Unsupported payment gateway: ${paymentGateway}`, @@ -315,8 +299,6 @@ export function getPaymentGateway(paymentIntentId: string): PaymentGateway { // Extract gateway from mock ID if (paymentIntentId.includes("cs_mock")) return "STRIPE"; if (paymentIntentId.includes("order_mock")) return "RAZORPAY"; - if (paymentIntentId.includes("ls_mock")) return "LEMON_SQUEEZY"; - if (paymentIntentId.includes("xf_mock")) return "XFLOW"; } if (paymentIntentId.startsWith("cs_") || paymentIntentId.startsWith("pi_")) { diff --git a/lib/payments/operations/checkout.ts b/lib/payments/operations/checkout.ts index a5534b182..5e791cd8d 100644 --- a/lib/payments/operations/checkout.ts +++ b/lib/payments/operations/checkout.ts @@ -37,7 +37,10 @@ import { isUserEnrolled, countWebinarParticipants, } from "@/lib/payments/utils/participants"; -import { markWaitlistAsBooked } from "@/lib/waitlist/slot-handler"; +import { + markWaitlistAsBooked, + countWaitlistHolds, +} from "@/lib/waitlist/slot-handler"; import { getExchangeRates } from "@/lib/currency"; import { applyCreditsToPayment, @@ -51,6 +54,8 @@ import { type AppointmentType, } from "@/lib/payments/payouts"; import { walletDebit } from "@/lib/api/organizations/wallet"; +import { isWalletFrozen, WalletFrozenError } from "@/lib/payments/wallet-freeze"; +import { recordSystemError } from "@/lib/enterprise/system-events"; import { recordBookingUtilization, ProgramAssignmentLimitError, @@ -65,7 +70,10 @@ import { } from "@/lib/payments/validation/currency-guards"; import { checkPaymentLegsSumToAmount } from "@/lib/payments/payment-legs"; import { recordOverageAtCheckout } from "@/lib/payments/billing/overage-settlement"; -import { getInvoiceCreditLimitPaise } from "@/lib/enterprise/governance"; +import { + getInvoiceCreditLimitPaise, + assertVerifiedDomainOrThrow, +} from "@/lib/enterprise/governance"; import { notifyOrgProgramExhausted, notifyOrgProgramCapNear, @@ -1041,41 +1049,51 @@ async function verifyPlanExistsInsideLock( tx: Tx, appointmentType: string, planId: string, -): Promise { - let planExists = false; +): Promise<{ + consultantProfileId: string | null; + organizationId: string | null; +}> { + // ADR 18 — also surface the plan's consultant + org ownership so the + // allowlist/exclusivity checks below reuse this lookup. + const select = { consultantProfileId: true, organizationId: true } as const; + let plan: { + consultantProfileId: string | null; + organizationId: string | null; + } | null = null; switch (appointmentType) { case "CONSULTATION": - planExists = !!(await tx.consultationPlan.findUnique({ + plan = await tx.consultationPlan.findUnique({ where: { id: planId }, - select: { id: true }, - })); + select, + }); break; case "SUBSCRIPTION": - planExists = !!(await tx.subscriptionPlan.findUnique({ + plan = await tx.subscriptionPlan.findUnique({ where: { id: planId }, - select: { id: true }, - })); + select, + }); break; case "WEBINAR": - planExists = !!(await tx.webinarPlan.findUnique({ + plan = await tx.webinarPlan.findUnique({ where: { id: planId }, - select: { id: true }, - })); + select, + }); break; case "CLASS": - planExists = !!(await tx.classPlan.findUnique({ + plan = await tx.classPlan.findUnique({ where: { id: planId }, - select: { id: true }, - })); + select, + }); break; } - if (!planExists) { + if (!plan) { throw new Error( "This plan is no longer available. Please refresh and try again.", ); } + return plan; } /** @@ -1085,6 +1103,9 @@ async function verifyPlanExistsInsideLock( async function revalidateInsideLock( data: CheckoutInput, userId: string, + // ADR 18 — Program funding this org-sponsored booking; null for + // PERSONAL/marketplace checkouts. Drives the curated-panel check. + programId: string | null = null, ): Promise { // Re-run the same validation as calculateAmountAndValidate // but this time we're inside the lock, so it's safe @@ -1100,7 +1121,52 @@ async function revalidateInsideLock( } // BUG-E: Re-validate plan still exists (could be deleted between initial validation and lock) - await verifyPlanExistsInsideLock(tx, data.appointmentType, data.planId); + const plan = await verifyPlanExistsInsideLock( + tx, + data.appointmentType, + data.planId, + ); + + // ADR 18 — curated-panel enforcement (#971 shipped the stub). Rows on + // the funding Program restrict org-sponsored bookings to listed + // consultants; zero rows keep the sponsor network open. Checked under + // the distributed lock to close the check-then-book race. + if (programId) { + const panel = await tx.programConsultantAllowlist.findMany({ + where: { programId }, + select: { consultantProfileId: true }, + }); + if ( + panel.length > 0 && + !panel.some( + (row) => row.consultantProfileId === plan.consultantProfileId, + ) + ) { + throw new Error( + "This consultant is not on your organization's approved panel for this program. Choose a listed consultant or ask your organization admin.", + ); + } + } + + // ADR 18 — exclusiveEngagement blocks the consultant's independent + // plans (no org ownership) while an ACTIVE membership declares + // exclusivity. Org-owned plans stay bookable. The "hide" half + // (marketplace visibility filtering) remains future work per the ADR. + if (plan.consultantProfileId && !plan.organizationId) { + const exclusive = await tx.membership.findFirst({ + where: { + consultantProfileId: plan.consultantProfileId, + exclusiveEngagement: true, + status: "ACTIVE", + }, + select: { id: true }, + }); + if (exclusive) { + throw new Error( + "This consultant works exclusively through their organization; their independent plans cannot be booked.", + ); + } + } // FIX #548: Validate waitlist entry if this checkout originates from a waitlist offer. // Verify the entry belongs to this user, is in NOTIFIED status, and hasn't expired. @@ -1274,7 +1340,18 @@ async function revalidateInsideLock( [consultantUserId || ""], ); - if (currentParticipants >= webinar.webinarPlan.maxParticipants) { + // #837 — count NOTIFIED waitlist holds (seats offered to waitlisted + // users) so an FCFS buyer can't take one; exclude this buyer's own hold + // so a waitlisted user's fromWaitlist checkout still fits. + const webinarHolds = await countWaitlistHolds(tx, { + webinarId: data.eventId, + excludeUserId: userId, + }); + + if ( + currentParticipants + webinarHolds >= + webinar.webinarPlan.maxParticipants + ) { throw new Error("Webinar is full"); } break; @@ -1310,7 +1387,16 @@ async function revalidateInsideLock( ownerUserId ? [ownerUserId] : [], ); - if (currentParticipants >= classInstance.classPlan.maxParticipants) { + // #837 — count NOTIFIED waitlist holds; exclude this buyer's own. + const classHolds = await countWaitlistHolds(tx, { + classId: data.eventId, + excludeUserId: userId, + }); + + if ( + currentParticipants + classHolds >= + classInstance.classPlan.maxParticipants + ) { throw new Error("Class is full"); } break; @@ -1600,10 +1686,17 @@ export async function handleWebinarCheckout( consultantUserId || "", ]); + // #837 — NOTIFIED waitlist holds occupy seats; exclude this buyer's own hold + // so a waitlisted user's fromWaitlist checkout still fits. + const webinarHolds = await countWaitlistHolds(tx, { + webinarId: webinar.id, + excludeUserId: userId, + }); + // Check if max participants reached // NOTE: Waitlist creation happens OUTSIDE the transaction in handleCheckout's catch block, // because creating it here (inside the transaction) would be rolled back on throw. - if (currentParticipants >= plan.maxParticipants) { + if (currentParticipants + webinarHolds >= plan.maxParticipants) { throw new Error("Webinar is full"); } @@ -1725,10 +1818,16 @@ export async function handleClassCheckout( consultantUserId ? [consultantUserId] : [], ); + // #837 — NOTIFIED waitlist holds occupy seats; exclude this buyer's own. + const classHolds = await countWaitlistHolds(tx, { + classId: classInstance.id, + excludeUserId: userId, + }); + // Check if max participants reached // NOTE: Waitlist creation happens OUTSIDE the transaction in handleCheckout's catch block, // because creating it here (inside the transaction) would be rolled back on throw. - if (currentParticipants >= plan.maxParticipants) { + if (currentParticipants + classHolds >= plan.maxParticipants) { throw new Error("Class is full"); } @@ -1849,6 +1948,9 @@ export async function handleCheckout( let fundingSource: "PERSONAL" | "WALLET" | "INVOICE" | "LICENSE" | null = null; let programAssignmentId: string | null = null; + // ADR 18 — Program behind the assignment above; feeds the curated-panel + // check in revalidateInsideLock. + let fundingProgramId: string | null = null; let callerMembershipId: string | null = null; // #785 B6 — effective INVOICE credit limit; threaded to the Serializable // booking tx for a race-safe re-check (the pre-lock check below is fast-fail only). @@ -1938,6 +2040,12 @@ export async function handleCheckout( // book-everything-then-ghost abuse pattern. The cap auto-lifts // once the org is verified OR pays its first invoice. if (fundingSource === "INVOICE") { + // K-02 / #687 — an org may not accrue INVOICE debt without a verified + // domain. The credit-limit gate below caps unverified-STATUS orgs; this + // asserts the orthogonal domain-ownership proof (OrgDomainClaim), the + // gate governance.ts documents for INVOICE funding but had no caller. + await assertVerifiedDomainOrThrow(prisma, org.id, "INVOICE_FUNDING"); + const explicitLimit = org.billingAccount?.creditLimit ?? null; const isVerified = org.status === "ACTIVE"; const governanceLimit = isVerified ? null : getInvoiceCreditLimitPaise(); @@ -2009,7 +2117,7 @@ export async function handleCheckout( }, }, orderBy: { periodEnd: "desc" }, - select: { id: true }, + select: { id: true, programId: true }, }); if (!assignment) { @@ -2021,13 +2129,13 @@ export async function handleCheckout( } programAssignmentId = assignment.id; - // ADR 18 — future curated-panel enforcement. The Program is resolved - // HERE, but the authoritative check belongs inside - // revalidateInsideLock, where the plan's consultant is already loaded - // and the distributed lock closes the TOCTOU window: allowlist rows - // exist for the resolved Program ⇒ the booked plan's consultant must - // be listed. Absent rows keep the network open — sponsors fund any - // marketplace consultant by design. + // ADR 18 — the Program is resolved HERE, but the authoritative + // curated-panel check runs inside revalidateInsideLock, where the + // plan's consultant is loaded and the distributed lock closes the + // TOCTOU window: allowlist rows exist for the resolved Program ⇒ the + // booked plan's consultant must be listed. Absent rows keep the + // network open — sponsors fund any marketplace consultant by design. + fundingProgramId = assignment.programId; } } @@ -2091,7 +2199,7 @@ export async function handleCheckout( ); // STEP 3: RE-VALIDATE INSIDE LOCK (critical for preventing TOCTOU race conditions) - await revalidateInsideLock(validatedData, userId); + await revalidateInsideLock(validatedData, userId, fundingProgramId); console.log( JSON.stringify({ @@ -2366,6 +2474,13 @@ export async function handleCheckout( // Triggered only when we also have a resolved program assignment, // which guarantees the booking is actually sponsored. if (isOrgWalletPayment && billingAccountId) { + // #837 — refuse to spend a wallet whose cache drifted from the + // journal (frozen by the ledger-reconcile job): the balance can't + // be trusted until ops reconciles. Chargeback recovery is NOT gated + // (see wallet-freeze.ts). + if (await isWalletFrozen(tx, billingAccountId)) { + throw new WalletFrozenError(billingAccountId); + } await walletDebit(tx, { billingAccountId, amountPaise: amount, @@ -2883,15 +2998,29 @@ export async function handleCheckout( } } } catch (earningsError) { - // Log but don't fail — sync-payment-earnings job will pick up the gap + // C-01 #837 — payment + booking are committed but earnings + the + // BOOKING journal are not. Real money moved, so we don't roll back + // and we don't pretend success with a silent warning: page (ERROR) + // and durably record the ledger gap. The healer is the data-state + // sync-payment-earnings scan (SUCCEEDED payment + earnings:none), + // keyed on row state — not on this marker — so it's guaranteed and + // idempotent even if this alert is lost. + await recordSystemError({ + category: "PAYOUT", + summary: `Earnings + booking journal not written for committed payment ${paymentResponse!.id} (checkout mock/zero/sponsored path)`, + err: earningsError, + correlationId: paymentResponse!.id, + context: { + paymentIntent: paymentResponse!.id, + userId, + appointmentType: validatedData.appointmentType, + path: "checkout", + }, + }); console.error( `⚠️ Failed to create earnings for mock payment:`, earningsError, ); - Sentry.captureException(earningsError instanceof Error ? earningsError : new Error(String(earningsError)), { - tags: { subsystem: "payments" }, - level: "warning", - }); } // FIX #437: Consultant qualifying action (receiving first paid booking) @@ -3036,6 +3165,13 @@ export async function handleCheckout( } } + // #837 — WalletFrozenError carries httpStatus=409 + an actionable reason; + // don't let it collapse into the generic "Failed to record payment + // information" below. Rethrow so the route surfaces the 409. + if (dbError instanceof WalletFrozenError) { + throw dbError; + } + // Preserve specific error messages (duplicate registration, full capacity, etc.) if (dbError instanceof Error) { const preservedMessages = [ diff --git a/lib/payments/operations/mock.ts b/lib/payments/operations/mock.ts index 570f67eca..f30465fba 100644 --- a/lib/payments/operations/mock.ts +++ b/lib/payments/operations/mock.ts @@ -50,12 +50,6 @@ function generateMockPaymentId(gateway: PaymentGateway): string { case "RAZORPAY": return `order_mock_${random}${timestamp}`; - case "LEMON_SQUEEZY": - return `ls_mock_${random}_${timestamp}`; - - case "XFLOW": - return `xf_mock_${random}_${timestamp}`; - default: return `mock_${random}_${timestamp}`; } diff --git a/lib/payments/payouts/earnings-service.ts b/lib/payments/payouts/earnings-service.ts index 9f7e251ac..02a4875f8 100644 --- a/lib/payments/payouts/earnings-service.ts +++ b/lib/payments/payouts/earnings-service.ts @@ -40,6 +40,8 @@ export interface EarningsSummary { totalEarnings: number; pendingEarnings: number; readyEarnings: number; + /** #837 — in a payout batch, cash not yet disbursed (distinct from PAID). */ + batchedEarnings: number; paidEarnings: number; heldEarnings: number; /** @@ -70,6 +72,8 @@ interface OrgEarningsSummary { totalEarnings: number; pendingEarnings: number; readyEarnings: number; + /** #837 — in a payout batch, cash not yet disbursed (distinct from PAID). */ + batchedEarnings: number; paidEarnings: number; heldEarnings: number; } @@ -310,6 +314,41 @@ export async function createEarningsFromPayment({ payment.createdAt, ); + // #687 E-01/E-02 — the PENDING_TRUST park keys on the SPONSORING org + // (the org that OWES the invoice = payment.organizationId), NOT the + // expert's HOST org from resolveOrgSplit. An unverified sponsor that + // may never pay its invoice must not let consultant OR org earnings + // clear. Decide ONCE here and apply to every row this booking writes + // (consultant, primary-org, collaborator-org). + // Only INVOICE (NET-X postpaid) funding creates a receivable the + // sponsor might never settle — PERSONAL/WALLET/LICENSE are already + // paid, so an org id retained on those legs must NOT park. Gate on + // the charged billing account's fundingSource, not on organizationId + // alone. + const sponsorOrgId = payment.organizationId; + let parkForTrust = false; + if (sponsorOrgId && payment.billingAccountId) { + const billingAccount = await tx.billingAccount.findUnique({ + where: { id: payment.billingAccountId }, + select: { fundingSource: true }, + }); + if (billingAccount?.fundingSource === "INVOICE") { + const sponsorOrg = await tx.organization.findUnique({ + where: { id: sponsorOrgId }, + select: { status: true }, + }); + if (sponsorOrg?.status === "PENDING_VERIFICATION") { + const paidInvoiceCount = await tx.organizationInvoice.count({ + where: { organizationId: sponsorOrgId, status: "PAID" }, + }); + parkForTrust = paidInvoiceCount === 0; + } + } + } + const initialEarningStatus: EarningStatus = parkForTrust + ? EarningStatus.PENDING_TRUST + : EarningStatus.PENDING; + // Determine platform fee and consultant pool based on whether org split applies. // #778 §C-2 — floor the marketplace fee (was Math.round); the shaved paisa // stays in the consultant pool (gross − fee), the pool's residual party. @@ -427,7 +466,10 @@ export async function createEarningsFromPayment({ consultantSharePaise: creditedShare, role: isOwner ? EarningRole.OWNER : EarningRole.COLLABORATOR, shareBps, - status: EarningStatus.PENDING, + // #687 E-02 — park consultant payables too when the sponsor + // is an unverified INVOICE org; else the platform owes real + // money for a ghost sponsor's booking. + status: initialEarningStatus, holdUntil, currency: "INR", }, @@ -450,7 +492,8 @@ export async function createEarningsFromPayment({ grossAmount, platformFeePaise, consultantSharePaise: totalConsultantPool, - status: EarningStatus.PENDING, + // #687 E-02 — see multi-party branch above. + status: initialEarningStatus, holdUntil, currency: "INR", }, @@ -463,28 +506,12 @@ export async function createEarningsFromPayment({ // Skip when orgShare is 0 (Platform-only mode: platformCommissionRate = 1.0) // — creating 0-value rows adds noise without value. // - // PR-1d / #687: if the sponsoring org is still PENDING_VERIFICATION + // PR-1d / #687: if the SPONSORING org (payment.organizationId, resolved + // above into initialEarningStatus — E-01) is still PENDING_VERIFICATION // and has never paid an invoice, accruals start in PENDING_TRUST // instead of PENDING. The `release-pending-trust-earnings` cron // promotes them once the org is verified or first invoice clears. if (orgSplit && orgSplit.orgShare > 0) { - const sponsorOrg = await tx.organization.findUnique({ - where: { id: orgSplit.organizationId }, - select: { status: true }, - }); - let initialStatus: EarningStatus = EarningStatus.PENDING; - if (sponsorOrg?.status === "PENDING_VERIFICATION") { - const paidInvoiceCount = await tx.organizationInvoice.count({ - where: { - organizationId: orgSplit.organizationId, - status: "PAID", - }, - }); - if (paidInvoiceCount === 0) { - initialStatus = EarningStatus.PENDING_TRUST; - } - } - await tx.organizationEarnings.create({ data: { organizationId: orgSplit.organizationId, @@ -494,7 +521,7 @@ export async function createEarningsFromPayment({ orgSharePaise: orgSplit.orgShare, consultantSharePaise: orgSplit.consultantSharePaise, refundedAmountPaise: 0, - status: initialStatus, + status: initialEarningStatus, holdUntil, currency: "INR", // Rate-card snapshot: persist the exact split applied so @@ -538,24 +565,9 @@ export async function createEarningsFromPayment({ continue; } - // PR-1d / #687 — same PENDING_TRUST gate as the primary org above. - const collabSponsorOrg = await tx.organization.findUnique({ - where: { id: s.orgSplit.organizationId }, - select: { status: true }, - }); - let collabInitialStatus: EarningStatus = EarningStatus.PENDING; - if (collabSponsorOrg?.status === "PENDING_VERIFICATION") { - const paidInvoiceCount = await tx.organizationInvoice.count({ - where: { - organizationId: s.orgSplit.organizationId, - status: "PAID", - }, - }); - if (paidInvoiceCount === 0) { - collabInitialStatus = EarningStatus.PENDING_TRUST; - } - } - + // #687 E-01 — same sponsor-scoped PENDING_TRUST gate as the primary + // org above; the park keys on the SPONSOR (payment.organizationId, + // via initialEarningStatus), not this collaborator's host org. await tx.organizationEarnings.create({ data: { organizationId: s.orgSplit.organizationId, @@ -568,7 +580,7 @@ export async function createEarningsFromPayment({ orgSharePaise: s.orgSplit.orgShare, consultantSharePaise: s.orgSplit.consultantSharePaise, refundedAmountPaise: 0, - status: collabInitialStatus, + status: initialEarningStatus, holdUntil, currency: "INR", rateCardIdApplied: s.orgSplit.rateCardIdApplied, @@ -840,31 +852,37 @@ export async function releaseEarningsFromHold(): Promise { export async function getConsultantEarningsSummary( consultantProfileId: string, ): Promise { - const [pending, ready, paid, held, pendingTrust] = await Promise.all([ - prisma.consultantEarnings.aggregate({ - where: { consultantProfileId, status: EarningStatus.PENDING }, - _sum: { consultantSharePaise: true }, - }), - prisma.consultantEarnings.aggregate({ - where: { consultantProfileId, status: EarningStatus.READY }, - _sum: { consultantSharePaise: true }, - }), - prisma.consultantEarnings.aggregate({ - where: { consultantProfileId, status: EarningStatus.PAID }, - _sum: { consultantSharePaise: true }, - }), - prisma.consultantEarnings.aggregate({ - where: { consultantProfileId, status: EarningStatus.HELD }, - _sum: { consultantSharePaise: true }, - }), - prisma.consultantEarnings.aggregate({ - where: { consultantProfileId, status: EarningStatus.PENDING_TRUST }, - _sum: { consultantSharePaise: true }, - }), - ]); + const [pending, ready, batched, paid, held, pendingTrust] = + await Promise.all([ + prisma.consultantEarnings.aggregate({ + where: { consultantProfileId, status: EarningStatus.PENDING }, + _sum: { consultantSharePaise: true }, + }), + prisma.consultantEarnings.aggregate({ + where: { consultantProfileId, status: EarningStatus.READY }, + _sum: { consultantSharePaise: true }, + }), + prisma.consultantEarnings.aggregate({ + where: { consultantProfileId, status: EarningStatus.BATCHED }, + _sum: { consultantSharePaise: true }, + }), + prisma.consultantEarnings.aggregate({ + where: { consultantProfileId, status: EarningStatus.PAID }, + _sum: { consultantSharePaise: true }, + }), + prisma.consultantEarnings.aggregate({ + where: { consultantProfileId, status: EarningStatus.HELD }, + _sum: { consultantSharePaise: true }, + }), + prisma.consultantEarnings.aggregate({ + where: { consultantProfileId, status: EarningStatus.PENDING_TRUST }, + _sum: { consultantSharePaise: true }, + }), + ]); const pendingEarnings = sumPaise(pending._sum.consultantSharePaise); const readyEarnings = sumPaise(ready._sum.consultantSharePaise); + const batchedEarnings = sumPaise(batched._sum.consultantSharePaise); const paidEarnings = sumPaise(paid._sum.consultantSharePaise); const heldEarnings = sumPaise(held._sum.consultantSharePaise); const pendingTrustEarnings = sumPaise( @@ -873,10 +891,16 @@ export async function getConsultantEarningsSummary( return { consultantProfileId, + // #837 — batched money is real cleared earnings in transit; keep it in the total. totalEarnings: - pendingEarnings + readyEarnings + paidEarnings + heldEarnings, + pendingEarnings + + readyEarnings + + batchedEarnings + + paidEarnings + + heldEarnings, pendingEarnings, readyEarnings, + batchedEarnings, paidEarnings, heldEarnings, pendingTrustEarnings, @@ -1211,7 +1235,7 @@ export async function releaseHeldEarnings( * Get earnings statistics for admin dashboard */ export async function getEarningsStats() { - const [pending, ready, paid, held, refunded] = await Promise.all([ + const [pending, ready, batched, paid, held, refunded] = await Promise.all([ prisma.consultantEarnings.aggregate({ where: { status: EarningStatus.PENDING }, _sum: { consultantSharePaise: true, platformFeePaise: true }, @@ -1222,6 +1246,11 @@ export async function getEarningsStats() { _sum: { consultantSharePaise: true, platformFeePaise: true }, _count: true, }), + prisma.consultantEarnings.aggregate({ + where: { status: EarningStatus.BATCHED }, + _sum: { consultantSharePaise: true, platformFeePaise: true }, + _count: true, + }), prisma.consultantEarnings.aggregate({ where: { status: EarningStatus.PAID }, _sum: { consultantSharePaise: true, platformFeePaise: true }, @@ -1250,6 +1279,12 @@ export async function getEarningsStats() { consultantSharePaise: sumPaise(ready._sum.consultantSharePaise), platformFeePaise: sumPaise(ready._sum.platformFeePaise), }, + // #837 — batched, cash not yet disbursed (was previously counted under ready). + batched: { + count: batched._count, + consultantSharePaise: sumPaise(batched._sum.consultantSharePaise), + platformFeePaise: sumPaise(batched._sum.platformFeePaise), + }, paid: { count: paid._count, consultantSharePaise: sumPaise(paid._sum.consultantSharePaise), @@ -1265,8 +1300,11 @@ export async function getEarningsStats() { consultantSharePaise: sumPaise(refunded._sum.consultantSharePaise), platformFeePaise: sumPaise(refunded._sum.platformFeePaise), }, + // #837 — platform fee is earned once the sale settles (READY); batched/paid + // are downstream of READY, so include all three to keep recognized revenue whole. totalPlatformRevenue: sumPaise(paid._sum.platformFeePaise) + + sumPaise(batched._sum.platformFeePaise) + sumPaise(ready._sum.platformFeePaise), }; } @@ -1281,7 +1319,7 @@ export async function getEarningsStats() { export async function getOrgEarningsSummary( organizationId: string, ): Promise { - const [pending, ready, paid, held] = await Promise.all([ + const [pending, ready, batched, paid, held] = await Promise.all([ prisma.organizationEarnings.aggregate({ where: { organizationId, status: EarningStatus.PENDING }, _sum: { orgSharePaise: true }, @@ -1290,6 +1328,10 @@ export async function getOrgEarningsSummary( where: { organizationId, status: EarningStatus.READY }, _sum: { orgSharePaise: true }, }), + prisma.organizationEarnings.aggregate({ + where: { organizationId, status: EarningStatus.BATCHED }, + _sum: { orgSharePaise: true }, + }), prisma.organizationEarnings.aggregate({ where: { organizationId, status: EarningStatus.PAID }, _sum: { orgSharePaise: true }, @@ -1302,15 +1344,22 @@ export async function getOrgEarningsSummary( const pendingEarnings = sumPaise(pending._sum.orgSharePaise); const readyEarnings = sumPaise(ready._sum.orgSharePaise); + const batchedEarnings = sumPaise(batched._sum.orgSharePaise); const paidEarnings = sumPaise(paid._sum.orgSharePaise); const heldEarnings = sumPaise(held._sum.orgSharePaise); return { organizationId, + // #837 — batched money is real cleared earnings in transit; keep it in the total. totalEarnings: - pendingEarnings + readyEarnings + paidEarnings + heldEarnings, + pendingEarnings + + readyEarnings + + batchedEarnings + + paidEarnings + + heldEarnings, pendingEarnings, readyEarnings, + batchedEarnings, paidEarnings, heldEarnings, }; diff --git a/lib/payments/payouts/org-payout-service.ts b/lib/payments/payouts/org-payout-service.ts index a450b0c70..06fb23d10 100644 --- a/lib/payments/payouts/org-payout-service.ts +++ b/lib/payments/payouts/org-payout-service.ts @@ -13,9 +13,10 @@ * - createOrgPayoutBatch(orgId, periodStart, periodEnd, opts?) * Atomic batch creation: claim READY earnings, compute aggregated * totals, write the OrganizationPayout (status DRAFT/PENDING), - * flip the claimed earnings to PAID, write SettlementLedgerEntry - * + audit log. Optionally accepts an `idempotencyKey` so cron - * retries become no-ops via the unique constraint. + * flip the claimed earnings to BATCHED (#837 — NOT PAID; cash has + * not moved yet), write SettlementLedgerEntry + audit log. Optionally + * accepts an `idempotencyKey` so cron retries become no-ops via the + * unique constraint. * * - processOrgPayout(payoutId) * State machine progression: PENDING → PROCESSING → COMPLETED | @@ -413,9 +414,13 @@ export async function createOrgPayoutBatch( }, }); + // #837 E-03/E-04 — batch creation only STAGES the earnings; cash has + // not left. Mark BATCHED, not PAID. The PAID flip moves to + // markOrgPayoutCompleted (PROCESSING → COMPLETED), so a batch built + // with ENABLE_LIVE_PAYOUTS off never reads as paid to auditors. await tx.organizationEarnings.updateMany({ where: { orgPayoutId: created.id, status: "READY" }, - data: { status: "PAID" }, + data: { status: "BATCHED" }, }); await tx.orgAuditLog.create({ @@ -773,8 +778,10 @@ async function markPayoutFailedFromSubmission( if (claim.count === 0) return; // Release earnings back to READY so they're eligible for the next batch. + // #837 — a submission-rejected payout is pre-COMPLETED, so its earnings are + // BATCHED (never reached PAID). await tx.organizationEarnings.updateMany({ - where: { orgPayoutId: payoutId, status: "PAID" }, + where: { orgPayoutId: payoutId, status: "BATCHED" }, data: { status: "READY", orgPayoutId: null }, }); @@ -886,6 +893,14 @@ export async function markOrgPayoutCompleted(payoutId: string): Promise<{ }, }); + // #837 E-03/E-04 — cash has now actually moved (COMPLETED + UTR). This is + // the ONLY place org earnings become PAID; createOrgPayoutBatch staged them + // as BATCHED. + await tx.organizationEarnings.updateMany({ + where: { orgPayoutId: payoutId, status: "BATCHED" }, + data: { status: "PAID" }, + }); + await tx.orgAuditLog.create({ data: { organizationId: payout.organizationId, @@ -1000,8 +1015,9 @@ async function markOrgPayoutFailedInternal( // Release the underlying earnings back to READY so the next batch // sees them. This is the inverse of the createOrgPayoutBatch claim. + // #837 — a PROCESSING→FAILED payout's earnings are BATCHED (never PAID). await tx.organizationEarnings.updateMany({ - where: { orgPayoutId: payoutId, status: "PAID" }, + where: { orgPayoutId: payoutId, status: "BATCHED" }, data: { status: "READY", orgPayoutId: null }, }); diff --git a/lib/payments/payouts/payout-service.ts b/lib/payments/payouts/payout-service.ts index c92d554a8..de280e16a 100644 --- a/lib/payments/payouts/payout-service.ts +++ b/lib/payments/payouts/payout-service.ts @@ -332,7 +332,11 @@ export async function createPayoutBatch( }, }); - // Link the exact earnings we summed, with guards against concurrent state changes + // Link the exact earnings we summed, with guards against concurrent state changes. + // #837 E-03/E-04 — mark BATCHED (not left READY): the earning is now in a + // batch and must NOT be re-picked by the next batch. Cash hasn't moved yet; + // the PAID flip happens only at COMPLETED in handlePayoutWebhook. The CAS + // re-asserts the pre-batch state (READY + payoutId null). const linkResult = await tx.consultantEarnings.updateMany({ where: { id: { in: readyEarnings.map((e) => e.id) }, @@ -341,6 +345,7 @@ export async function createPayoutBatch( }, data: { payoutId: payout.id, + status: EarningStatus.BATCHED, }, }); @@ -420,11 +425,13 @@ export async function rejectPayout( ); } - // Unlink earnings and set them back to READY + // Unlink earnings and set them back to READY. #837 — a rejectable payout is + // PENDING, so its earnings are BATCHED (never PAID); release only those. await prisma.consultantEarnings.updateMany({ - where: { payoutId }, + where: { payoutId, status: EarningStatus.BATCHED }, data: { payoutId: null, + status: EarningStatus.READY, }, }); @@ -741,9 +748,11 @@ async function processSinglePayout(payout: { // picked up by the next batch. Without this, earnings linked to a // payout that failed before the gateway call (e.g., "No payout account") // would remain orphaned since no webhook fires to unlink them. + // #837 — pre-gateway failure means cash never moved; earnings are BATCHED + // (never PAID) so release them back to READY. await prisma.consultantEarnings.updateMany({ - where: { payoutId: payout.id }, - data: { payoutId: null }, + where: { payoutId: payout.id, status: EarningStatus.BATCHED }, + data: { payoutId: null, status: EarningStatus.READY }, }); return { @@ -945,9 +954,11 @@ export async function handlePayoutWebhook( const cumulativeCreditedPayments = sumPaise(previousCompletedPayouts._sum.amount) + payout.amount; - // Update earnings to PAID + // Update earnings to PAID. #837 E-03/E-04 — this COMPLETED webhook (with + // gatewayUtr above) is the ONLY place consultant earnings become PAID; + // createPayoutBatch staged them as BATCHED. await tx.consultantEarnings.updateMany({ - where: { payoutId: payout.id }, + where: { payoutId: payout.id, status: EarningStatus.BATCHED }, data: { status: EarningStatus.PAID, paidAt: new Date(), @@ -1014,10 +1025,13 @@ export async function handlePayoutWebhook( payoutStatus === PayoutStatus.FAILED || payoutStatus === PayoutStatus.CANCELLED ) { + // #837 — a FAILED/CANCELLED payout never disbursed; its earnings are + // BATCHED (never PAID) so release them back to READY for the next batch. await tx.consultantEarnings.updateMany({ - where: { payoutId: payout.id }, + where: { payoutId: payout.id, status: EarningStatus.BATCHED }, data: { payoutId: null, + status: EarningStatus.READY, }, }); diff --git a/lib/payments/wallet-freeze.ts b/lib/payments/wallet-freeze.ts new file mode 100644 index 000000000..3bb1d723f --- /dev/null +++ b/lib/payments/wallet-freeze.ts @@ -0,0 +1,74 @@ +/** + * #837 — wallet-spend freeze, scoped to a single drifted BillingAccount. + * + * When the ledger-reconcile job finds WALLET_BALANCE_DRIFT (cached + * `BillingAccount.walletBalance` ≠ the journal's WALLET account) the balance is + * no longer trustworthy, so we must stop spending it until ops reconciles. + * + * There is no schema column for a per-wallet freeze and the launch schema is + * frozen, so the freeze rides the existing append-only `SystemEvent` log rather + * than a new column/table: + * - the freeze KEY is the indexed `correlationId` = `wallet-freeze:`, + * - the current state is the category of the LATEST event under that key + * (WALLET_FREEZE → frozen, WALLET_UNFREEZE → cleared). + * FREEZE is set by the reconcile job; UNFREEZE is a manual ops action once the + * drift is fixed. The check is a single indexed DB read inside the caller's tx, + * so it fails CLOSED — an unreadable log blocks the spend rather than leaking it. + * + * Deliberately gates only discretionary SPEND (the checkout booking debit), not + * chargeback recovery (app/api/webhooks/utils.ts): a lost-dispute debit recovers + * money the bank already pulled and must not be stranded on a drift freeze. + */ + +import prisma, { type PrismaLike } from "@/lib/prisma"; +import { recordSystemEvent } from "@/lib/enterprise/system-events"; + +const FREEZE_CATEGORY = "WALLET_FREEZE"; +const UNFREEZE_CATEGORY = "WALLET_UNFREEZE"; +const freezeKey = (billingAccountId: string) => `wallet-freeze:${billingAccountId}`; + +export class WalletFrozenError extends Error { + public readonly httpStatus = 409; + constructor(public billingAccountId: string) { + super( + `Wallet spend frozen on billing account ${billingAccountId} pending ledger-drift resolution`, + ); + this.name = "WalletFrozenError"; + } +} + +/** True when the account's latest freeze event is a FREEZE. Reads inside the + * caller's tx/client so the check shares the spend's snapshot. */ +export async function isWalletFrozen( + db: PrismaLike, + billingAccountId: string, +): Promise { + const latest = await db.systemEvent.findFirst({ + where: { + correlationId: freezeKey(billingAccountId), + category: { in: [FREEZE_CATEGORY, UNFREEZE_CATEGORY] }, + }, + orderBy: { createdAt: "desc" }, + select: { category: true }, + }); + return latest?.category === FREEZE_CATEGORY; +} + +/** Freeze wallet spend for one account. Idempotent — no-op if already frozen. + * Returns true when it wrote a new freeze event. */ +export async function freezeWalletSpend(params: { + billingAccountId: string; + organizationId?: string | null; + reason: string; +}): Promise { + if (await isWalletFrozen(prisma, params.billingAccountId)) return false; + await recordSystemEvent({ + organizationId: params.organizationId ?? null, + category: FREEZE_CATEGORY, + severity: "ERROR", + message: `Wallet spend FROZEN for billing account ${params.billingAccountId}: ${params.reason}`, + context: { billingAccountId: params.billingAccountId, reason: params.reason }, + correlationId: freezeKey(params.billingAccountId), + }); + return true; +} diff --git a/lib/payments/webhooks/handlers.ts b/lib/payments/webhooks/handlers.ts index f48ef1665..6285e438d 100644 --- a/lib/payments/webhooks/handlers.ts +++ b/lib/payments/webhooks/handlers.ts @@ -131,6 +131,29 @@ interface EventData { * * Used by both webhook handlers and mock payment flows */ +// #837 — discriminated Phase-1 outcomes so Phase 2 can auto-refund the two +// captured-but-blocked cases (amount mismatch, double-booking loser) instead of +// parking the funds on manual ops. `null` = already-processed / metadata-fail. +type PaymentSuccessTxResult = + | { + outcome: "amount_mismatch"; + paymentId: string; + gatewayAmountPaise: number; + expectedAmount: number; + } + | { + outcome: "confirmed"; + paymentId: string; + appointmentId: string; + appointmentType: string; + userId: string; + userName: string | null; + amount: number; + currency: string; + capturedAfterTerminal: boolean; + doubleBookingBlocked: boolean; + }; + export async function handlePaymentSuccess( paymentIntentId: string, rawMetadata: Record, @@ -159,7 +182,7 @@ export async function handlePaymentSuccess( // winner confirmed and blocks. The SUCCEEDED early-return keeps the retry // idempotent. const txResult = await withSerializableRetry(() => - prisma.$transaction(async (tx) => { + prisma.$transaction(async (tx): Promise => { const payment = await tx.payment.findUnique({ where: { paymentIntent: paymentIntentId }, include: { user: { include: { consulteeProfile: true } } }, @@ -202,11 +225,14 @@ export async function handlePaymentSuccess( }, }, ); + // #837 — mark SUCCEEDED (gateway truth) + stamp REQUIRES_MANUAL_RECOVERY as + // the FALLBACK. Phase 2 auto-refunds the wrong-amount capture; the manual + // marker only survives if that refund call itself throws. await tx.payment.update({ where: { id: payment.id }, data: { paymentStatus: PaymentStatus.SUCCEEDED, - description: `REQUIRES_MANUAL_RECOVERY: capture amount ${gatewayAmountPaise}p ≠ expected ${payment.amount}p. Booking NOT confirmed.`, + description: `REQUIRES_MANUAL_RECOVERY: capture amount ${gatewayAmountPaise}p ≠ expected ${payment.amount}p. Booking NOT confirmed; auto-refund attempted.`, }, }); console.error( @@ -218,12 +244,18 @@ export async function handlePaymentSuccess( user_id: payment.userId, gateway_amount_paise: gatewayAmountPaise, expected_amount_paise: payment.amount, - action_required: - "IMMEDIATE: reconcile captured funds; confirm or refund manually", + action_required: "auto-refund attempted; reconcile only if it failed", timestamp: new Date().toISOString(), }), ); - return null; // Skip confirmation + Phase 2 — requires manual intervention + // Signal Phase 2 to auto-refund post-commit — the gateway refund call must + // not run inside this Serializable tx. + return { + outcome: "amount_mismatch", + paymentId: payment.id, + gatewayAmountPaise, + expectedAmount: payment.amount, + }; } // VALIDATION: Check metadata before processing @@ -350,6 +382,7 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! // Return data needed for Phase 2 return { + outcome: "confirmed", paymentId: payment.id, appointmentId: appointment.id, appointmentType: metadata.appointmentType, @@ -360,6 +393,9 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! // #855 — a capture that landed after the booking was cancelled; Phase 2 // auto-refunds it instead of treating it as a confirmed booking. capturedAfterTerminal: confirmResult.capturedAfterTerminal, + // #837 — the #827 first-confirmed-wins guard blocked this booking; Phase 2 + // auto-refunds the loser and releases its tentative hold. + doubleBookingBlocked: confirmResult.doubleBookingBlocked ?? false, }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, @@ -369,6 +405,50 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! // If transaction returned null, the payment was already processed or had a metadata error if (!txResult) return; + // #837 — the gateway captured a different amount than we ordered. Auto-refund + // the whole capture (never confirm a booking for the wrong money) and skip + // Phase 2. REQUIRES_MANUAL_RECOVERY stays stamped as the fallback if the + // refund throws. Idempotent: on webhook replay the payment is already + // SUCCEEDED so the SUCCEEDED early-return fires before this path is reached, + // and refundPayment's refundable-balance guard blocks any double-refund. + if (txResult.outcome === "amount_mismatch") { + try { + await refundPayment({ + paymentId: txResult.paymentId, + reason: "capture amount mismatch", + initiatedByUserId: null, + }); + // Refund succeeded — clear the Phase 1 REQUIRES_MANUAL_RECOVERY marker so + // ops dashboards don't flag a payment that no longer needs manual recovery. + await prisma.payment.update({ + where: { id: txResult.paymentId }, + data: { + description: `Auto-refunded: capture amount ${txResult.gatewayAmountPaise}p ≠ expected ${txResult.expectedAmount}p. Booking NOT confirmed.`, + }, + }); + } catch (refundError) { + Sentry.captureException( + refundError instanceof Error ? refundError : new Error(String(refundError)), + { + tags: { subsystem: "payments" }, + level: "error", + contexts: { + payment: { + paymentId: txResult.paymentId, + gatewayAmountPaise: txResult.gatewayAmountPaise, + expectedAmount: txResult.expectedAmount, + }, + }, + }, + ); + console.error( + "Failed to auto-refund amount-mismatch capture; REQUIRES_MANUAL_RECOVERY (Phase 2):", + refundError, + ); + } + return; + } + // #855 — the capture landed after the booking was cancelled. The payment is // SUCCEEDED (gateway truth) but the booking is dead, so auto-refund and skip // the rest of Phase 2 — no success email, earnings, invoice, or notifications @@ -393,6 +473,51 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! return; } + // #837 — the #827 first-confirmed-wins guard blocked this booking: the payment + // is SUCCEEDED but the slots lost to an overlapping confirmed booking, so + // auto-refund and release the tentative hold (otherwise a paid customer holds + // no booking and their slots block rebooking). Skip the rest of Phase 2 — no + // earnings/invoice/notifications for a booking that never confirmed. + // Idempotent: webhook replay hits the SUCCEEDED early-return before here; + // refundPayment's refundable-balance guard blocks a double-refund; the slot + // release runs after a successful refund so a refund failure leaves the hold + // for the #830 orphan sweep + manual recovery rather than freeing it unpaid. + if (txResult.doubleBookingBlocked) { + try { + await refundPayment({ + paymentId: txResult.paymentId, + reason: "double-booking blocked at confirmation", + initiatedByUserId: null, + }); + // Release the tentative hold only once the money is back. + await withSerializableRetry(() => + prisma.$transaction( + (tx) => cleanupFailedPaymentAppointment(tx, txResult.appointmentId), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + ); + } catch (refundError) { + Sentry.captureException( + refundError instanceof Error ? refundError : new Error(String(refundError)), + { + tags: { subsystem: "payments" }, + level: "error", + contexts: { + booking: { + paymentId: txResult.paymentId, + appointmentId: txResult.appointmentId, + }, + }, + }, + ); + console.error( + "Failed to auto-refund double-booking loser; slots left for #830 sweep (Phase 2):", + refundError, + ); + } + return; + } + // Phase 2: Non-critical post-transaction work (earnings, invoice, waitlist, notifications) // Failures here are logged but do NOT roll back the payment. // The `sync-payment-earnings` and related background jobs serve as safety nets. @@ -524,11 +649,19 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! } } } catch (earningsError) { - Sentry.captureException( - earningsError instanceof Error ? earningsError : new Error(String(earningsError)), - { tags: { subsystem: "payments" }, level: "warning" }, - ); - // Log but don't fail — sync-payment-earnings job will pick up the gap + // C-01 #837 — payment + booking are committed but earnings + the BOOKING + // journal are not. Real money moved, so we don't roll back and we don't + // pretend success with a silent warning: page (ERROR) and durably record + // the ledger gap. The healer is the data-state sync-payment-earnings scan + // (SUCCEEDED payment + earnings:none), keyed on row state — not on this + // marker — so it's guaranteed and idempotent even if this alert is lost. + await recordSystemError({ + category: "PAYOUT", + summary: `Earnings + booking journal not written for committed payment ${paymentId} (webhook path)`, + err: earningsError, + correlationId: paymentId, + context: { paymentId, appointmentId, userId, path: "webhook" }, + }); console.error( `⚠️ Failed to create earnings for payment ${paymentId}:`, earningsError, @@ -599,34 +732,39 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! // --- Novu notifications (M5 FIX: moved outside transaction) --- try { + // #734 — the notification only needs the consultant's id/name; the old + // 4-level include dragged full User + profile rows for all four shapes. + const consultantUserSelect = { + select: { user: { select: { id: true, name: true } } }, + } as const; const appointmentForNotif = await prisma.appointment.findUnique({ where: { id: appointmentId }, - include: { + select: { consultation: { - include: { + select: { consultationPlan: { - include: { consultantProfile: { include: { user: true } } }, + select: { consultantProfile: consultantUserSelect }, }, }, }, subscription: { - include: { + select: { subscriptionPlan: { - include: { consultantProfile: { include: { user: true } } }, + select: { consultantProfile: consultantUserSelect }, }, }, }, webinar: { - include: { + select: { webinarPlan: { - include: { consultantProfile: { include: { user: true } } }, + select: { consultantProfile: consultantUserSelect }, }, }, }, class: { - include: { + select: { classPlan: { - include: { consultantProfile: { include: { user: true } } }, + select: { consultantProfile: consultantUserSelect }, }, }, }, @@ -773,37 +911,35 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! */ export async function handlePaymentFailure(paymentIntentId: string) { return await prisma.$transaction(async (tx) => { + // #734 — narrowed from a 5-level include; the failure path only reads + // the payer's email/name and the consultant's name for notifications. + const consultantUserSelect = { + select: { + consultantProfile: { + select: { user: { select: { id: true, name: true } } }, + }, + }, + } as const; const payment = await tx.payment.findUnique({ where: { paymentIntent: paymentIntentId }, - include: { - user: true, + select: { + id: true, + paymentStatus: true, + userId: true, + appointmentId: true, + amount: true, + currency: true, + description: true, + user: { select: { email: true, name: true } }, appointment: { - include: { + select: { + id: true, + appointmentType: true, consultation: { - include: { - consultationPlan: { - include: { - consultantProfile: { - include: { - user: true, - }, - }, - }, - }, - }, + select: { consultationPlan: consultantUserSelect }, }, subscription: { - include: { - subscriptionPlan: { - include: { - consultantProfile: { - include: { - user: true, - }, - }, - }, - }, - }, + select: { subscriptionPlan: consultantUserSelect }, }, }, }, @@ -1277,7 +1413,7 @@ export async function confirmExistingAppointment( tx: Tx, appointmentId: string, userId?: string, -): Promise<{ capturedAfterTerminal: boolean }> { +): Promise<{ capturedAfterTerminal: boolean; doubleBookingBlocked?: boolean }> { // First fetch appointment to determine type const appointment = await tx.appointment.findUnique({ where: { id: appointmentId }, @@ -1355,8 +1491,11 @@ export async function confirmExistingAppointment( slotId: slot.id, }, }).catch(() => {}); - // slots stay tentative; do not confirm over the winner - return { capturedAfterTerminal: false }; + // #837 — slots stay tentative here; the webhook's Phase 2 auto-refunds + // the loser and releases the hold. The #830 sweep re-drives via this + // same guard and reports (doesn't refund), so signalling the block up is + // what routes the refund without fighting the guard. + return { capturedAfterTerminal: false, doubleBookingBlocked: true }; } } } @@ -1640,73 +1779,31 @@ async function sendPaymentSuccessNotification( */ async function sendPaymentFailureNotification( tx: Tx, - payment: MoneyAsNumber< - Prisma.PaymentGetPayload<{ - include: { - user: true; - appointment: { - include: { - consultation: { - include: { - consultationPlan: { - include: { - consultantProfile: { - include: { - user: true; - }; - }; - }; - }; - }; - }; - subscription: { - include: { - subscriptionPlan: { - include: { - consultantProfile: { - include: { - user: true; - }; - }; - }; - }; - }; - }; - }; - }; - }; - }> - >, + payment: { + id: string; + appointmentId: string | null; + amount: number; + currency: string; + description: string | null; + user: { email: string | null; name: string | null }; + }, ) { try { + const consultantUserSelect = { + select: { + consultantProfile: { + select: { user: { select: { name: true } } }, + }, + }, + } as const; const appointment = await tx.appointment.findUnique({ where: { id: payment.appointmentId || "" }, - include: { + select: { consultation: { - include: { - consultationPlan: { - include: { - consultantProfile: { - include: { - user: true, - }, - }, - }, - }, - }, + select: { id: true, consultationPlan: consultantUserSelect }, }, subscription: { - include: { - subscriptionPlan: { - include: { - consultantProfile: { - include: { - user: true, - }, - }, - }, - }, - }, + select: { id: true, subscriptionPlan: consultantUserSelect }, }, }, }); diff --git a/lib/reviews.ts b/lib/reviews.ts new file mode 100644 index 000000000..b63f4012a --- /dev/null +++ b/lib/reviews.ts @@ -0,0 +1,90 @@ +import prisma, { type Tx } from "@/lib/prisma"; + +/** + * Recomputes the denormalized ConsultantProfile.rating after a review + * mutation. Explore sort (orderByForSort) and the minRating filter read this + * column, so every create/update/delete must call it or ratings drift. + * Accepts a transaction client so the recompute stays atomic with the + * mutation that triggered it. + */ +export async function recomputeConsultantRating( + tx: Tx, + consultantProfileId: string, +): Promise { + const agg = await tx.consultantReview.aggregate({ + // #693 — soft-removed reviews (deletedAt set) must not count toward the + // denormalized rating. + where: { consultantProfileId, deletedAt: null }, + _avg: { rating: true }, + }); + + await tx.consultantProfile.update({ + where: { id: consultantProfileId }, + data: { rating: agg._avg.rating ?? 0 }, + }); +} + +/** + * Review eligibility: the consultee must have at least one completed booking + * with the consultant. "Completed" means a Consultation or Subscription whose + * status reached COMPLETED (or that has at least one held session — a slot + * with completionStatus COMPLETED, which covers in-flight subscriptions), or + * a trial that reached COMPLETED/CONVERTED. + */ +export async function hasCompletedBookingWith( + consulteeProfileId: string, + consultantProfileId: string, +): Promise { + const completedBooking = { + OR: [ + { status: "COMPLETED" as const }, + { + appointment: { + slotsOfAppointment: { + some: { completionStatus: "COMPLETED" as const }, + }, + }, + }, + ], + }; + + const [consultation, subscription, trial] = await Promise.all([ + prisma.consultation.findFirst({ + where: { + requestedById: consulteeProfileId, + consultationPlan: { consultantProfileId }, + ...completedBooking, + }, + select: { id: true }, + }), + prisma.subscription.findFirst({ + where: { + requestedById: consulteeProfileId, + subscriptionPlan: { consultantProfileId }, + OR: [ + { status: "COMPLETED" }, + { + appointments: { + some: { + slotsOfAppointment: { + some: { completionStatus: "COMPLETED" }, + }, + }, + }, + }, + ], + }, + select: { id: true }, + }), + prisma.trialSession.findFirst({ + where: { + consulteeProfileId, + consultantProfileId, + status: { in: ["COMPLETED", "CONVERTED"] }, + }, + select: { id: true }, + }), + ]); + + return Boolean(consultation || subscription || trial); +} diff --git a/lib/scim/operations.ts b/lib/scim/operations.ts index f6c9c4aae..8c0a1dc35 100644 --- a/lib/scim/operations.ts +++ b/lib/scim/operations.ts @@ -1,3 +1,4 @@ +import { Prisma } from "@prisma/client"; import type { PrismaLike } from "@/lib/prisma"; /** * SCIM 2.0 User operations — `createUser`, `patchUser`, `deprovisionUser`, @@ -26,6 +27,11 @@ import { bumpUserSessionGeneration, } from "@/lib/api/organizations/membership-transitions"; import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { + DomainVerificationRequiredError, + UNVERIFIED_ORG_SEAT_CAP, + hasVerifiedDomain, +} from "@/lib/enterprise/governance"; import { dispatchWebhookEvent } from "@/lib/enterprise/outbound-webhooks/dispatch"; import { resolveRoleFromGroupNames } from "./resource-user"; @@ -108,6 +114,12 @@ export async function createOrReprovisionScimUser( return { kind: "USER_ERASED", userId: existingUser.id }; } + // Serializable, matching the invite path (organizations/[orgId]/ + // invitations): the seat-cap gate below is a count-then-create TOCTOU + // that a parallel IdP provisioning burst could slip past at READ + // COMMITTED. Serializable makes concurrent transactions that both read + // the seat counts and insert fail one side at commit (P2034) instead of + // both overshooting UNVERIFIED_ORG_SEAT_CAP. return prisma.$transaction(async (tx) => { const user = existingUser ? await tx.user.update({ @@ -203,6 +215,25 @@ export async function createOrReprovisionScimUser( } satisfies ScimUserOpResult; } + // #675 parity with the invite path — an unverified org is hard-capped at + // UNVERIFIED_ORG_SEAT_CAP seats; SCIM auto-provisioning must honor the same + // gate or an IdP could bulk-provision straight past it. Only brand-new + // seats count (reprovisions returned above). Throw (not a CONFLICT return) + // so the User/profile writes above roll back instead of orphaning. + if (!(await hasVerifiedDomain(tx, organizationId))) { + const [activeMembers, pendingInvites] = await Promise.all([ + tx.membership.count({ + where: { organizationId, status: "ACTIVE" }, + }), + tx.invitation.count({ + where: { organizationId, status: "pending" }, + }), + ]); + if (activeMembers + pendingInvites >= UNVERIFIED_ORG_SEAT_CAP) { + throw new DomainVerificationRequiredError("BULK_SEATS"); + } + } + const created = await tx.membership.create({ data: { organizationId, @@ -252,7 +283,7 @@ export async function createOrReprovisionScimUser( role: created.role, status: created.status, } satisfies ScimUserOpResult; - }); + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); } /** diff --git a/lib/stream/recording-handlers.ts b/lib/stream/recording-handlers.ts index b956c3619..59bbc2112 100644 --- a/lib/stream/recording-handlers.ts +++ b/lib/stream/recording-handlers.ts @@ -3,6 +3,7 @@ * Handles webhook events for recording lifecycle */ +import { after } from "next/server"; import prisma from "@/lib/prisma"; import { RecordingStatus } from "@prisma/client"; import { streamLogger } from "@/lib/stream-logger"; @@ -15,6 +16,7 @@ import { generateRecordingTitle, getEventAttendeeIds, } from "@/lib/stream/recording-utils"; +import { RecordingTransferService } from "@/lib/stream/recording-transfer-service"; // Types for Stream webhook payloads export interface StreamRecordingStartedEvent { @@ -309,6 +311,27 @@ export async function handleRecordingReady( durationInMinutes, }); + // #899 — permanent-policy recordings start transferring at ready-time + // instead of waiting for the near-expiry window. The transfer is the heavy + // Stream-S3-download + Supabase-upload, so it runs via `after()` (not a bare + // `void`) — on serverless an unawaited promise is killed once the webhook + // response returns, which would drop the kick; `after()` keeps it alive past + // the response. The 6-hourly cron sweep still backstops any kick that dies + // with the function. + const storagePolicy = + appointment?.webinar?.webinarPlan?.recordingStoragePolicy ?? + appointment?.class?.classPlan?.recordingStoragePolicy; + if (storagePolicy === "SUPABASE_PERMANENT") { + after(() => + RecordingTransferService.queueRecordingTransfer(recording.id).catch( + (err) => + streamLogger.error("Ready-time transfer kick threw", err, { + recordingId: recording.id, + }), + ), + ); + } + // Build recipient list — for webinar/class, include all enrolled attendees // (the meeting session slot only has the consultant's allocation slot users) const slotUserIds = meetingSession.slotOfAppointment.user?.map( @@ -342,15 +365,19 @@ export async function handleRecordingReady( "Unknown Consultant"; } - void notifyRecordingAvailable(userIds, { - appointmentType, - consultantName, - recordingUrl: url, - dashboardUrl: `${getAppUrl()}/dashboard`, - }).catch((err) => - streamLogger.error("Failed to send recording notification", err, { - streamCallId, - }), + // Same serverless rationale as the transfer kick above: run the + // notification via `after()` so it survives the webhook response. + after(() => + notifyRecordingAvailable(userIds, { + appointmentType, + consultantName, + recordingUrl: url, + dashboardUrl: `${getAppUrl()}/dashboard`, + }).catch((err) => + streamLogger.error("Failed to send recording notification", err, { + streamCallId, + }), + ), ); } } catch (error) { diff --git a/lib/stream/recording-transfer-service.ts b/lib/stream/recording-transfer-service.ts index 9284ab214..a5163e3ab 100644 --- a/lib/stream/recording-transfer-service.ts +++ b/lib/stream/recording-transfer-service.ts @@ -21,7 +21,9 @@ const RECORDINGS_BUCKET = "recordings"; const storageClient = supabaseAdmin || supabase; // Maximum file size for direct transfer (500MB) -// Files larger than this should use resumable uploads (future enhancement) +// #899 — uploads now stream (no in-memory buffering), but the recordings +// bucket is provisioned with a 500MB fileSizeLimit, so keep the pre-flight +// reject to fail fast instead of burning a full upload into a server 413. const MAX_TRANSFER_SIZE = 500 * 1024 * 1024; // 500MB // STR-2/3 — page engineering once a recording has burned through this many @@ -79,27 +81,26 @@ function buildStoragePolicyFilter( export class RecordingTransferService { /** - * Queue a recording for transfer to Supabase + * Queue a recording for transfer to Supabase. + * + * #899 — no broker: "queueing" is an immediate best-effort transfer, + * fired from the recording_ready webhook so permanent recordings move + * near-ready instead of near-expiry. Every failure path in + * transferRecordingToSupabase reverts status to READY, and the stale- + * TRANSFERRING sweep in processExpiringRecordings recovers kicks that die + * mid-flight, so the 6-hourly cron always backstops this. * @param recordingId The recording ID to queue */ static async queueRecordingTransfer(recordingId: string): Promise { - try { - // Update status to TRANSFERRING - await prisma.recording.update({ - where: { id: recordingId }, - data: { - status: "TRANSFERRING" as RecordingStatus, - }, - }); - - streamLogger.info("Recording queued for transfer", { recordingId }); - return true; - } catch (error) { - streamLogger.error("Failed to queue recording for transfer", error, { + const { success, error } = + await this.transferRecordingToSupabase(recordingId); + if (!success) { + streamLogger.warn("Ready-time transfer kick failed; cron will retry", { recordingId, + error, }); - return false; } + return success; } /** @@ -270,13 +271,15 @@ export class RecordingTransferService { storagePath, }); - // Use blob() instead of arrayBuffer() for more efficient memory handling - // Blob is more memory-efficient in most JS runtimes for large files - const fileBlob = await response.blob(); + // #899 — pipe the download straight into the storage upload instead of + // materializing the file (response.blob() buffered up to 500MB in + // memory). storage-js accepts ReadableStream and sets duplex:"half" + // itself; blob() is only the fallback for a body-less response. + const uploadBody = response.body ?? (await response.blob()); const { error: uploadError } = await storageClient.storage .from(RECORDINGS_BUCKET) - .upload(storagePath, fileBlob, { + .upload(storagePath, uploadBody, { contentType, cacheControl: "31536000", // 1 year cache upsert: true, @@ -362,6 +365,23 @@ export class RecordingTransferService { }; try { + // #899 — recover transfers killed mid-flight (serverless webhook kick, + // crashed cron run): TRANSFERRING with no update for 2h is stuck, and + // nothing else ever revisits it. Revert to READY so this sweep retries. + const stale = await prisma.recording.updateMany({ + where: { + status: "TRANSFERRING", + storageType: "STREAM_S3", + updatedAt: { lt: new Date(Date.now() - 2 * 60 * 60 * 1000) }, + }, + data: { status: "READY" as RecordingStatus }, + }); + if (stale.count > 0) { + streamLogger.warn("Reset stale TRANSFERRING recordings to READY", { + count: stale.count, + }); + } + const expiringRecordings = await prisma.recording.findMany({ where: { storageType: "STREAM_S3", @@ -383,18 +403,29 @@ export class RecordingTransferService { policyFilter, }); - for (const recording of expiringRecordings) { - results.processed++; - - const result = await this.transferRecordingToSupabase(recording.id); - - if (result.success) { - results.succeeded++; - } else { - results.failed++; - results.errors.push( - `Recording ${recording.id}: ${result.error || "Unknown error"}`, - ); + // #899 — network-bound transfers in chunks of 3: cuts sweep latency + // without piling memory/connection pressure onto one invocation. + // transferRecordingToSupabase never throws, so Promise.all is safe. + const CONCURRENCY = 3; + for (let i = 0; i < expiringRecordings.length; i += CONCURRENCY) { + const chunk = expiringRecordings.slice(i, i + CONCURRENCY); + const outcomes = await Promise.all( + chunk.map(async (recording) => ({ + id: recording.id, + result: await this.transferRecordingToSupabase(recording.id), + })), + ); + + for (const { id, result } of outcomes) { + results.processed++; + if (result.success) { + results.succeeded++; + } else { + results.failed++; + results.errors.push( + `Recording ${id}: ${result.error || "Unknown error"}`, + ); + } } } @@ -407,6 +438,28 @@ export class RecordingTransferService { } } + /** + * #899 — count permanent-policy recordings still on Stream S3 with less + * than `hoursBeforeExpiry` of URL life left. Non-zero after a sweep means + * the pipeline is falling behind or failing repeatedly; the transfer job + * pages on it before the bytes lapse. + */ + static async countAtRiskPermanentRecordings( + hoursBeforeExpiry: number = 72, + ): Promise { + const threshold = new Date( + Date.now() + hoursBeforeExpiry * 60 * 60 * 1000, + ); + return prisma.recording.count({ + where: { + storageType: "STREAM_S3", + status: "READY", + streamUrlExpiresAt: { lte: threshold, gt: new Date() }, + ...buildStoragePolicyFilter("SUPABASE_PERMANENT"), + }, + }); + } + /** * Get STREAM_ONLY recordings that are expiring soon (for notification purposes). * These won't be auto-transferred but consultants should be warned. @@ -600,6 +653,31 @@ export class RecordingTransferService { } } + /** + * Delete ONLY the Supabase storage object — no DB side effects. + * + * Retention cleanup (#899) uses this instead of deleteRecordingFromSupabase + * so the row's status flip to EXPIRED and the OrgAuditLog write land + * atomically in the caller's own transaction. Flipping status here would + * tombstone the row before the audit write; the cleanup candidate query + * filters `status notIn [EXPIRED, FAILED]`, so a failed audit write would + * never be retried and the audit trail would be lost permanently. + */ + static async deleteSupabaseObject( + supabasePath: string, + ): Promise<{ success: boolean; error?: string }> { + const { error } = await storageClient.storage + .from(RECORDINGS_BUCKET) + .remove([supabasePath]); + if (error) { + streamLogger.error("Failed to delete Supabase object", error, { + path: supabasePath, + }); + return { success: false, error: error.message }; + } + return { success: true }; + } + /** * Get the best available URL for a recording * Returns Supabase URL if available, otherwise Stream URL diff --git a/lib/user.ts b/lib/user.ts index 0c7265716..fc7c27d74 100644 --- a/lib/user.ts +++ b/lib/user.ts @@ -69,35 +69,22 @@ export const fetchReviews = async ( }; /** - * Maps application user roles to Stream Chat roles + * Maps application user roles to Stream Chat roles. * - * Standard Stream Chat roles: - * - admin: Full permissions (create, read, update, delete channels) - * - user: Basic user permissions (but may not have team channel access by default) - * - guest: Limited permissions - * - anonymous: Very limited permissions - * - * For now, using admin for consultants and consultees to ensure team channel access. - * This can be refined later with custom roles configured in Stream Chat dashboard. + * Least privilege (#899): only platform staff get Stream's global `admin`. + * Everyone else — consultants included — is a plain `user`; channel creation + * is server-side, and hosts get channel-scoped `channel_moderator` on their + * own channels at creation time instead of a global grant. * * @param role The application user role * @returns The corresponding Stream Chat role */ export function mapRoleToStream(role: string | null | undefined): string { - if (!role) return "admin"; // Default to admin for team channel access - - switch (role.toUpperCase()) { + switch (role?.toUpperCase()) { case "ADMIN": - return "admin"; - case "CONSULTANT": - // Consultants need to create and manage their event channels - return "admin"; - case "CONSULTEE": - // Consultees need to read and participate in team channels they join - return "admin"; - case "USER": + case "STAFF": return "admin"; default: - return "admin"; + return "user"; } } diff --git a/lib/waitlist/slot-handler.ts b/lib/waitlist/slot-handler.ts index 620e4a91e..4eb057cc2 100644 --- a/lib/waitlist/slot-handler.ts +++ b/lib/waitlist/slot-handler.ts @@ -4,8 +4,8 @@ */ import * as Sentry from "@sentry/nextjs"; -import prisma from "@/lib/prisma"; -import { WaitlistStatus } from "@prisma/client"; +import prisma, { type PrismaLike } from "@/lib/prisma"; +import { Prisma, WaitlistStatus } from "@prisma/client"; import { getNextBatchInQueue, updatePositions, @@ -17,6 +17,40 @@ import { countWebinarParticipants } from "@/lib/payments/utils/participants"; // Notification window in hours (48 hours to respond) const NOTIFICATION_WINDOW_HOURS = 48; +/** + * #837 seat soft-hold. A NOTIFIED waitlist entry inside its response window IS + * the held seat — the notified user has an exclusive offer to take it, so + * capacity checks must count these holds or an FCFS buyer grabs a seat already + * promised to a waitlisted user. Release needs no explicit step: decline/skip/ + * leave/expire all move the row out of NOTIFIED (or past expiresAt), dropping + * the count. Webinar/class seats are shared-slot user-connections (isTentative + * is per-slot, not per-user), so the NOTIFIED status — not a tentative slot — + * is the only schema-free way to hold one seat for one user. + * + * `excludeUserId` drops the caller's OWN hold so a notified user's fromWaitlist + * checkout isn't rejected by the very seat being reserved for them. + */ +export async function countWaitlistHolds( + db: PrismaLike, + params: { + webinarId?: string | null; + classId?: string | null; + excludeUserId?: string; + }, +): Promise { + const { webinarId, classId, excludeUserId } = params; + if (!webinarId && !classId) return 0; + return db.waitlist.count({ + where: { + status: WaitlistStatus.NOTIFIED, + ...(webinarId ? { webinarId } : { classId }), + ...(excludeUserId ? { userId: { not: excludeUserId } } : {}), + // A NOTIFIED row past its window isn't a live hold (expiry cron can lag). + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + }); +} + // Type for slot opening params export interface SlotOpeningParams { webinarId?: string; @@ -224,9 +258,10 @@ export async function handleWaitlistResponse(params: { switch (action) { case "ACCEPT": { - // Mark as accepted (actual booking will happen through checkout) - // We don't mark as BOOKED yet - that happens after successful payment - // For now, redirect them to checkout with a reserved spot + // #837 — the seat is already held: the entry stays NOTIFIED through the + // response window and countWaitlistHolds() blocks FCFS buyers from taking + // it at checkout. So we can safely redirect to checkout without racing. + // Booking is stamped BOOKED only after successful payment. const eventType = entry.webinarId ? "webinar" : "class"; const planId = entry.webinar?.webinarPlan.id || entry.class?.classPlan.id; @@ -407,8 +442,12 @@ export async function checkEventAvailability(params: { ); const maxParticipants = webinar.webinarPlan.maxParticipants; + // #837 — held seats (NOTIFIED offers) count against availability so we + // don't tell a new user "register directly" for a seat already reserved. + const holds = await countWaitlistHolds(prisma, { webinarId }); + return { - available: currentParticipants < maxParticipants, + available: currentParticipants + holds < maxParticipants, currentParticipants, maxParticipants, waitlistCount: webinar.waitlist.length, @@ -460,8 +499,11 @@ export async function checkEventAvailability(params: { const currentParticipants = uniqueParticipantIds.size; const maxParticipants = classInstance.classPlan.maxParticipants; + // #837 — held seats (NOTIFIED offers) count against availability. + const holds = await countWaitlistHolds(prisma, { classId }); + return { - available: currentParticipants < maxParticipants, + available: currentParticipants + holds < maxParticipants, currentParticipants, maxParticipants, waitlistCount: classInstance.waitlist.length, @@ -483,6 +525,8 @@ export async function joinWaitlist(params: { success: boolean; waitlistId?: string; position?: number; + // Set on friendly conflicts so the route can map to HTTP 409, not a 500. + code?: "ALREADY_ON_WAITLIST"; message: string; }> { const { userId, webinarId, classId, preferences } = params; @@ -514,6 +558,7 @@ export async function joinWaitlist(params: { if (existingEntry) { return { success: false, + code: "ALREADY_ON_WAITLIST", message: "You are already on the waitlist for this event", }; } @@ -529,16 +574,34 @@ export async function joinWaitlist(params: { }; } - // Create waitlist entry - const entry = await prisma.waitlist.create({ - data: { - userId, - webinarId, - classId, - preferences: preferences as object | undefined, - status: WaitlistStatus.WAITING, - }, - }); + // Create waitlist entry. The WAITING/NOTIFIED pre-check above is a race + // window — two concurrent joins both pass it, then @@unique([userId, + // webinarId])/([userId, classId]) rejects the loser with P2002. Catch it and + // return the same friendly conflict instead of a generic 500. + let entry: Awaited>; + try { + entry = await prisma.waitlist.create({ + data: { + userId, + webinarId, + classId, + preferences: preferences as object | undefined, + status: WaitlistStatus.WAITING, + }, + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + return { + success: false, + code: "ALREADY_ON_WAITLIST", + message: "You are already on the waitlist for this event", + }; + } + throw error; + } // Calculate position using priority-based queue const position = await calculatePosition(entry.id); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f3a51f9e6..f9cbf6203 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -106,6 +106,14 @@ model User { // events back to a (deterministic) actor without exposing PII. pseudonymousId String? @unique + // BetterAuth admin-plugin fields (#693 moderation, starts #725 Tier-1). + // Suspension = banned:true + banExpires set (lazy expiry, plugin auto-unbans + // at sign-in); permanent ban = banned:true + banExpires:null. Who/when/why + // lives in ModerationAction, not here. + banned Boolean? @default(false) + banReason String? + banExpires DateTime? @db.Timestamptz + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -266,6 +274,9 @@ enum CancellationReason { CONSULTANT_ISSUE TECHNICAL_ISSUE + // Moderation-initiated (#693) — staff suspend/ban bulk-cancels + MODERATION + // Other OTHER } @@ -406,6 +417,9 @@ model Session { updatedAt DateTime @updatedAt activeOrganizationId String? + // Required by the BetterAuth admin plugin's generated queries; impersonation + // itself is not enabled (#693). + impersonatedBy String? @@index([userId]) @@map("sessions") @@ -2346,13 +2360,20 @@ model ConsultantReview { consulteeProfile ConsulteeProfile @relation(fields: [consulteeProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) consulteeProfileId String + // Moderation CONTENT_REMOVED soft-delete (#693); public reads filter on + // null, staff moderation surfaces keep seeing the row. + deletedAt DateTime? @db.Timestamptz + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // One review per consultee per consultant — POST maps P2002 here to 409. + @@unique([consultantProfileId, consulteeProfileId]) // #696 — explore "trending" sort orders ConsultantProfile by // reviews._count; without this the per-profile aggregate seq-scans the // whole review table. Also serves the FK join + rating>=4 social-proof reads. - @@index([consultantProfileId]) + // (The @@unique above already prefixes consultantProfileId, so no separate + // single-column index is needed for it.) @@index([consulteeProfileId]) } @@ -3781,8 +3802,7 @@ enum PaymentLegSource { enum PaymentGateway { STRIPE RAZORPAY - LEMON_SQUEEZY - XFLOW + DODO_PAYMENTS // post-MVP: evaluation pending CARD } @@ -3895,7 +3915,12 @@ enum EarningStatus { PENDING // In hold period HELD // Extended hold (dispute) READY // Ready for payout - PAID // Successfully paid + /// #837 E-03/E-04 — rolled into a payout batch but cash has NOT left yet + /// (batch exists / gateway not wired / ENABLE_LIVE_PAYOUTS off). Distinct + /// from PAID so finance exports + dashboards don't claim money moved before + /// the payout reaches COMPLETED with a UTR. Excluded from batch-eligibility. + BATCHED + PAID // Cash actually disbursed (payout COMPLETED + UTR) REFUNDED // Refunded to consultee /// Earnings accrued from a PENDING_VERIFICATION INVOICE-funded org /// before the org has been verified or paid its first invoice. The @@ -4746,6 +4771,11 @@ model ModerationAction { takenById String takenBy User @relation(fields: [takenById], references: [id], onUpdate: Cascade, onDelete: Cascade) + // Post-hoc record of which side-effects actually executed (sessions + // revoked, appointments cancelled, refund totals, per-step failures) — + // best-effort steps can partially fail, and staff needs to see what stuck. + sideEffects Json? + createdAt DateTime @default(now()) @@index([reportId]) diff --git a/prisma/seedFiles/12a-create-refunds.ts b/prisma/seedFiles/12a-create-refunds.ts index 6d7964d9b..9b6cdc3fc 100644 --- a/prisma/seedFiles/12a-create-refunds.ts +++ b/prisma/seedFiles/12a-create-refunds.ts @@ -49,10 +49,6 @@ function generateRefundId(gateway: PaymentGateway): string { return `re_${faker.string.alphanumeric(24)}`; case "RAZORPAY": return `rfnd_${faker.string.alphanumeric(14)}`; - case "LEMON_SQUEEZY": - return `rf_${faker.string.alphanumeric(16)}`; - case "XFLOW": - return `XF-RF-${faker.string.alphanumeric(10).toUpperCase()}`; case "CARD": return `card_rf_${faker.string.alphanumeric(12)}`; default: diff --git a/prisma/seedFiles/12b-create-disputes.ts b/prisma/seedFiles/12b-create-disputes.ts index 3c2ddcf1a..51390cbdf 100644 --- a/prisma/seedFiles/12b-create-disputes.ts +++ b/prisma/seedFiles/12b-create-disputes.ts @@ -52,10 +52,6 @@ function generateDisputeId(gateway: PaymentGateway): string { return `dp_${faker.string.alphanumeric(24)}`; case "RAZORPAY": return `disp_${faker.string.alphanumeric(14)}`; - case "LEMON_SQUEEZY": - return `disp_${faker.string.alphanumeric(16)}`; - case "XFLOW": - return `XF-DSP-${faker.string.alphanumeric(10).toUpperCase()}`; case "CARD": return `card_disp_${faker.string.alphanumeric(12)}`; default: diff --git a/prisma/sql/check-constraints.sql b/prisma/sql/check-constraints.sql index 7a38c1e68..ff445ba21 100644 --- a/prisma/sql/check-constraints.sql +++ b/prisma/sql/check-constraints.sql @@ -162,3 +162,13 @@ ALTER TABLE "ConsultantPayout" DROP CONSTRAINT IF EXISTS "consultant_payout_tds_ -- SPLIT ALTER TABLE "ConsultantPayout" ADD CONSTRAINT "consultant_payout_tds_fy_format" CHECK ("tdsFinancialYear" IS NULL OR "tdsFinancialYear" ~ '^[0-9]{4}-[0-9]{2}$'); + +-- SPLIT +-- #784 — a Collaborator references exactly one plan: a webinar XOR a class. +-- The app-level backstop is assertCollaboratorPlanXor in +-- lib/collaborators/service.ts; this DB CHECK is the last line. Exactly one of +-- the two FKs is non-NULL <=> exactly one IS NULL, which `<>` expresses. +ALTER TABLE "Collaborator" DROP CONSTRAINT IF EXISTS "collaborator_plan_xor"; +-- SPLIT +ALTER TABLE "Collaborator" ADD CONSTRAINT "collaborator_plan_xor" + CHECK (("webinarPlanId" IS NULL) <> ("classPlanId" IS NULL)); diff --git a/prompts/booking-algorithm-tests/e2e-booking-agent-001-comprehensive-booking-lifecycle.md b/prompts/booking-algorithm-tests/e2e-booking-agent-001-comprehensive-booking-lifecycle.md index 0c2f56c1f..8719811cd 100644 --- a/prompts/booking-algorithm-tests/e2e-booking-agent-001-comprehensive-booking-lifecycle.md +++ b/prompts/booking-algorithm-tests/e2e-booking-agent-001-comprehensive-booking-lifecycle.md @@ -1789,7 +1789,7 @@ DELETE FROM "Domain" WHERE id = 'test-domain-001'; RequestStatus: PENDING | APPROVED | APPROVED_PENDING_PAYMENT | SCHEDULED | COMPLETED | REJECTED | CANCELLED | EXPIRED AppointmentsType: CONSULTATION | SUBSCRIPTION | WEBINAR | CLASS | TRIAL PaymentStatus: PENDING | SUCCEEDED | FAILED -PaymentGateway: STRIPE | RAZORPAY | LEMON_SQUEEZY | XFLOW | CARD +PaymentGateway: STRIPE | RAZORPAY | DODO_PAYMENTS | CARD WebinarStatus: SCHEDULED | IN_PROGRESS | COMPLETED | CANCELLED ClassStatus: SCHEDULED | IN_PROGRESS | COMPLETED | CANCELLED TrialSessionStatus: PENDING | SCHEDULED | COMPLETED | CONVERTED | CANCELLED | REJECTED diff --git a/schemas/checkout.ts b/schemas/checkout.ts index 79bcf27af..0195159ab 100644 --- a/schemas/checkout.ts +++ b/schemas/checkout.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { AppointmentsType, PaymentGateway } from "@prisma/client"; +import { AppointmentsType } from "@prisma/client"; import { validateSlotTiming } from "@/lib/payments/utils/slot-validation"; // Base schemas for individual components @@ -14,11 +14,15 @@ export const appointmentTypeSchema = z.enum([ export const paymentGatewaySchema = z.enum([ "STRIPE", "RAZORPAY", - "LEMON_SQUEEZY", - "XFLOW", "CARD", ]); +// The implemented checkout gateways — a strict subset of the PaymentGateway +// Prisma enum. Post-MVP stubs (e.g. DODO_PAYMENTS, #984) are NOT valid at +// checkout, so everything flowing into CheckoutInput.paymentGateway uses this +// narrow type, never the full enum. +export type SupportedCheckoutGateway = z.infer; + // Search params validation (URL query parameters) export const searchParamsSchema = z.object({ slotOfAvailabilityWeeklyId: z.string().optional(), @@ -298,7 +302,7 @@ export const validateSearchParamsForAppointmentType = ( export const createCheckoutData = (params: { appointmentType: AppointmentsType; planId: string; - paymentGateway: PaymentGateway; + paymentGateway: SupportedCheckoutGateway; eventId?: string; startsAt?: string; endsAt?: string; diff --git a/schemas/collaborators.ts b/schemas/collaborators.ts index f910c58cb..ae7de62d8 100644 --- a/schemas/collaborators.ts +++ b/schemas/collaborators.ts @@ -20,14 +20,29 @@ export const CLASS_COLLABORATOR_ROLES = [ export const WebinarCollaboratorRoleEnum = z.enum(WEBINAR_COLLABORATOR_ROLES); export const ClassCollaboratorRoleEnum = z.enum(CLASS_COLLABORATOR_ROLES); -export const inviteCollaboratorSchema = z.object({ - consultantProfileId: z.string().min(1, "Consultant profile ID is required"), - revenueSharePercentage: z - .number({ required_error: "Revenue share percentage is required" }) - .gt(0, "Revenue share percentage must be greater than 0") - .lte(90, "Revenue share percentage cannot exceed 90"), +// #768 lockdown #12 — typed permission booleans set at invite time. Default +// false (least privilege); the owner opts each capability in per collaborator. +export const collaboratorPermissionsSchema = z.object({ + canApprovePayment: z.boolean().optional().default(false), + canViewAnalytics: z.boolean().optional().default(false), + canEditEvent: z.boolean().optional().default(false), + canSeeAttendees: z.boolean().optional().default(false), }); +export type CollaboratorPermissions = z.infer< + typeof collaboratorPermissionsSchema +>; + +export const inviteCollaboratorSchema = z + .object({ + consultantProfileId: z.string().min(1, "Consultant profile ID is required"), + revenueSharePercentage: z + .number({ required_error: "Revenue share percentage is required" }) + .gt(0, "Revenue share percentage must be greater than 0") + .lte(90, "Revenue share percentage cannot exceed 90"), + }) + .merge(collaboratorPermissionsSchema); + export const inviteWebinarCollaboratorSchema = inviteCollaboratorSchema.extend({ role: WebinarCollaboratorRoleEnum, }); diff --git a/schemas/feedbacks.ts b/schemas/feedbacks.ts index 3da1b128c..491ad2978 100644 --- a/schemas/feedbacks.ts +++ b/schemas/feedbacks.ts @@ -22,3 +22,9 @@ export const CreateReviewSchema = z.object({ consultantProfileId: z.string().min(1, "Consultant profile ID is required"), consulteeProfileId: z.string().min(1, "Consultee profile ID is required"), }); + +// PUT only mutates the two consultee-owned fields; a partial keeps either optional. +export const UpdateReviewSchema = CreateReviewSchema.pick({ + rating: true, + reviewDescription: true, +}).partial(); diff --git a/scripts/appointments/detect-consultant-no-shows.ts b/scripts/appointments/detect-consultant-no-shows.ts new file mode 100644 index 000000000..fd1fcda67 --- /dev/null +++ b/scripts/appointments/detect-consultant-no-shows.ts @@ -0,0 +1,347 @@ +/** + * Consultant No-Show Detection + Handling — Core Logic (#471) + * + * The platform promises a full refund when the CONSULTANT no-shows a paid + * session, but only the foundation data existed (MeetingAttendance stamped by + * lib/stream/session-handlers.ts). This job closes the loop: detect confirmed + * consultant no-shows, auto-refund (reusing B1's refundPayment path, #990), + * mark the booking cancelled, and notify both parties. + * + * Imported by: + * - jobs/appointments/detect-consultant-no-shows.ts (GitHub Actions) + * + * Schedule: hourly. + * + * Scope: CONSULTATION only — a single-session, single-consultant exclusive + * booking where a full refund of the one payment is the correct remedy. + * Subscriptions are multi-session (a per-session no-show is a partial refund of + * one session out of N, which needs its own design); see the TODO below. + */ + +import prisma from "../../lib/prisma"; +import { + AppointmentStatus, + CancellationReason, + PaymentStatus, + SlotCompletionStatus, +} from "@prisma/client"; +import { + notifyAppointmentCancelled, + notifyRefundProcessed, +} from "../../lib/novu/service"; +import { getAppUrl } from "../../lib/url"; +import { refundPayment } from "@/lib/payments/operations/refund"; +import { withCronLock } from "@/lib/cron/with-cron-lock"; +import { CANCELLABLE_FROM } from "@/lib/booking/transitions"; + +// Conservative grace window: a session must have ended at least this long ago +// before we treat a missing consultant as a no-show. Well past the slot end so +// a late join or a delayed Stream participant-webhook cannot cause a false +// positive (which would wrongly cancel + refund a session the consultant DID +// attend). Money movement is hard to reverse, so this is deliberately generous. +const NO_SHOW_GRACE_MINUTES = 120; + +export interface NoShowResult { + success: boolean; + detected: number; + refunded: number; + errors: string[]; + timestamp: string; +} + +export async function detectConsultantNoShows(): Promise { + // #476 — locked at the core so every entry shares one mutual exclusion. + // Fail-closed: this is a money job (auto-refund), so per with-cron-lock.ts it + // refuses to run without a real Redis lock rather than risk a silent unlocked + // double-run. The CAS claim + refundPayment's refundable-balance guard remain + // the correctness backstop; the lock is the mutual-exclusion layer on top. + return withCronLock("detect-consultant-no-shows", { failMode: "closed" }, () => + detectConsultantNoShowsUnlocked(), + ); +} + +// Candidate consultations: still active (not already cancelled/completed), +// paid, whose slots have all ended past the grace window and where a +// MeetingSession actually happened (the call took place — a precondition for +// "the consultee showed up but the consultant didn't"). +function findNoShowCandidates(graceCutoff: Date) { + return prisma.consultation.findMany({ + where: { + status: { in: [AppointmentStatus.APPROVED, AppointmentStatus.SCHEDULED] }, + appointment: { + payment: { + some: { + paymentStatus: PaymentStatus.SUCCEEDED, + amount: { gt: 0 }, + deletedAt: null, + }, + }, + slotsOfAppointment: { + every: { endsAt: { lt: graceCutoff } }, + some: { endsAt: { lt: graceCutoff }, meetingSession: { isNot: null } }, + }, + }, + }, + include: { + consultationPlan: { + select: { + title: true, + consultantProfile: { + select: { userId: true, user: { select: { name: true } } }, + }, + }, + }, + requestedBy: { + select: { userId: true, user: { select: { name: true } } }, + }, + appointment: { + include: { + payment: { + select: { id: true, amount: true, currency: true, paymentStatus: true }, + }, + slotsOfAppointment: { + include: { + meetingSession: { + include: { attendances: { select: { userId: true } } }, + }, + }, + }, + }, + }, + }, + }); +} + +type NoShowCandidate = Awaited>[number]; +type NoShowParty = { + consultantUserId: string; + consulteeUserId: string; + appointmentId: string; +}; +type PaidPayment = NonNullable< + NoShowCandidate["appointment"] +>["payment"][number]; + +// Returns the party ids when `consultation` is a confirmed CONSULTANT no-show, +// or null to skip. Conservative definition: the consultee has a recorded join +// (positive evidence they showed up) AND the consultant has no MeetingAttendance +// row at all (firstJoinedAt is only ever written on a join, so an absent row +// means they never arrived). Neither-showed and consultee-no-show cases are +// intentionally excluded — no consultant-fault refund there. +function evaluateConsultantNoShow( + consultation: NoShowCandidate, +): NoShowParty | null { + const consultantUserId = + consultation.consultationPlan?.consultantProfile?.userId; + const consulteeUserId = consultation.requestedBy?.userId; + const appointmentId = consultation.appointment?.id; + if (!consultantUserId || !consulteeUserId || !appointmentId) { + // Cannot attribute presence without both user ids, and an undefined + // appointmentId would drop the where-filter on the slot updateMany below + // (Prisma ignores undefined) — skip, don't guess. + return null; + } + + // Presence across every session tied to this booking's slots. + const sessions = (consultation.appointment?.slotsOfAppointment ?? []) + .map((s) => s.meetingSession) + .filter((m): m is NonNullable => !!m); + if (sessions.length === 0) return null; + + const consultantJoined = sessions.some((s) => + s.attendances.some((a) => a.userId === consultantUserId), + ); + const consulteeJoined = sessions.some((s) => + s.attendances.some((a) => a.userId === consulteeUserId), + ); + + if (!consulteeJoined || consultantJoined) return null; + + return { consultantUserId, consulteeUserId, appointmentId }; +} + +// Claim it: CAS active → CANCELLED. This is the idempotency gate — the detection +// query only reads APPROVED/SCHEDULED, so once flipped a later run (or the +// auto-complete cron) cannot re-process it. A concurrent cancel landing here wins +// and this returns false → skip. On a successful claim we also reflect the +// no-show on the slots (no NO_SHOW slot status exists — schema frozen, #471 — +// CANCELLED is the closest; only move slots left SCHEDULED/UNVERIFIED). +async function claimConsultantNoShow( + consultationId: string, + appointmentId: string, +): Promise { + const claimed = await prisma.consultation.updateMany({ + where: { id: consultationId, status: { in: CANCELLABLE_FROM } }, + data: { + status: AppointmentStatus.CANCELLED, + cancellationReason: CancellationReason.CONSULTANT_UNAVAILABLE, + cancellationNotes: "#471 consultant no-show — auto-cancelled + refunded", + cancelledAt: new Date(), + }, + }); + if (claimed.count === 0) return false; + + await prisma.slotOfAppointment.updateMany({ + where: { + appointmentId, + completionStatus: { + in: [SlotCompletionStatus.SCHEDULED, SlotCompletionStatus.UNVERIFIED], + }, + }, + data: { completionStatus: SlotCompletionStatus.CANCELLED }, + }); + return true; +} + +// Full refund, reusing B1's refundPayment path (#990). Idempotent: +// refundPayment's refundable-balance guard throws if already refunded, so even a +// stale re-entry cannot double-refund. On failure we surface for ops (the +// cancellation stands) rather than silently swallowing. Returns the refunded +// amount, whether refundPayment succeeded, and the payment (for notifications). +async function refundNoShowConsultation( + consultation: NoShowCandidate, + errors: string[], +): Promise<{ + refundedPaise: number; + succeeded: boolean; + paidPayment: PaidPayment | undefined; +}> { + const paidPayment = consultation.appointment?.payment?.find( + (p) => p.paymentStatus === PaymentStatus.SUCCEEDED && p.amount > 0, + ); + if (!paidPayment) { + const msg = `No refundable payment for no-show consultation ${consultation.id}`; + console.warn(` ⚠️ ${msg}`); + errors.push(msg); + return { refundedPaise: 0, succeeded: false, paidPayment: undefined }; + } + try { + const r = await refundPayment({ + paymentId: paidPayment.id, + reason: "consultant no-show (#471)", + initiatedByUserId: null, + }); + console.log(` 💸 Refunded ${r.amountRefundedPaise}p`); + return { + refundedPaise: r.amountRefundedPaise, + succeeded: true, + paidPayment, + }; + } catch (refundErr) { + const msg = `Failed to refund no-show consultation ${consultation.id} (payment ${paidPayment.id}): ${refundErr}`; + console.error(` ❌ ${msg}`); + errors.push(msg); + return { refundedPaise: 0, succeeded: false, paidPayment }; + } +} + +// Fire-and-forget notifications (non-blocking, reusing the Novu service). +function notifyNoShowParties( + consultation: NoShowCandidate, + party: NoShowParty, + refundedPaise: number, + paidPayment: PaidPayment | undefined, +): void { + const consultantName = + consultation.consultationPlan?.consultantProfile?.user?.name ?? + "Consultant"; + const consulteeName = consultation.requestedBy?.user?.name ?? "Consultee"; + const planTitle = consultation.consultationPlan?.title ?? "Consultation"; + const dashboardUrl = `${getAppUrl()}/dashboard`; + + void notifyAppointmentCancelled( + [party.consultantUserId, party.consulteeUserId], + { + appointmentId: party.appointmentId, + appointmentType: "consultation", + consultantName, + consulteeName, + planTitle, + dashboardUrl, + cancelledBy: "system", + reason: "Consultant did not attend the scheduled session.", + }, + ).catch((e) => console.error(`[no-show] cancellation notify failed:`, e)); + + if (refundedPaise > 0 && paidPayment) { + void notifyRefundProcessed(party.consulteeUserId, { + amount: refundedPaise, + currency: paidPayment.currency, + reason: "consultant no-show", + appointmentType: "consultation", + consultantName, + dashboardUrl, + }).catch((e) => console.error(`[no-show] refund notify failed:`, e)); + } +} + +async function detectConsultantNoShowsUnlocked(): Promise { + const errors: string[] = []; + let detected = 0; + let refunded = 0; + + const graceCutoff = new Date(Date.now() - NO_SHOW_GRACE_MINUTES * 60 * 1000); + + console.log("🔍 Scanning for consultant no-shows..."); + console.log(` Grace window: ${NO_SHOW_GRACE_MINUTES} min after session end`); + + const candidates = await findNoShowCandidates(graceCutoff); + + console.log(`Found ${candidates.length} paid, ended candidates to check`); + + for (const consultation of candidates) { + try { + const party = evaluateConsultantNoShow(consultation); + if (!party) continue; + + detected++; + console.log(`\n🚫 Consultant no-show: consultation ${consultation.id}`); + console.log(` Plan: ${consultation.consultationPlan?.title}`); + + const claimed = await claimConsultantNoShow( + consultation.id, + party.appointmentId, + ); + if (!claimed) { + console.log(` ⏭️ Skipped — status changed since scan`); + detected--; + continue; + } + + const { refundedPaise, succeeded, paidPayment } = + await refundNoShowConsultation(consultation, errors); + if (succeeded) refunded++; + + notifyNoShowParties(consultation, party, refundedPaise, paidPayment); + } catch (error) { + const msg = `Failed to handle candidate consultation ${consultation.id}: ${error}`; + console.error(` ❌ ${msg}`); + errors.push(msg); + } + } + + // TODO(#471): subscriptions are multi-session — a single-session consultant + // no-show is a partial refund of one session out of N, not a whole-booking + // cancel. Deferred pending per-session refund design; consultations (the + // single-session exclusive case) are handled above. + + console.log("\n📊 No-Show Summary:"); + console.log(` Detected: ${detected}`); + console.log(` Refunded: ${refunded}`); + if (errors.length > 0) { + console.log("\n⚠️ Errors:"); + errors.forEach((e) => console.log(` - ${e}`)); + } + + return { + success: errors.length === 0, + detected, + refunded, + errors, + timestamp: new Date().toISOString(), + }; +} + +export async function disconnectDatabase(): Promise { + await prisma.$disconnect(); +} diff --git a/scripts/appointments/reconcile-slot-availability.ts b/scripts/appointments/reconcile-slot-availability.ts index bd99f9103..edc361356 100644 --- a/scripts/appointments/reconcile-slot-availability.ts +++ b/scripts/appointments/reconcile-slot-availability.ts @@ -21,6 +21,7 @@ import prisma from "../../lib/prisma"; import { PaymentStatus } from "@prisma/client"; import { withCronLock, LONG_JOB_TTL_MS } from "@/lib/cron/with-cron-lock"; +import { buildOccupiedAppointmentFilter } from "@/utils/slotAllocation/occupancyPolicy"; export interface SlotReconciliationResult { success: boolean; @@ -143,21 +144,33 @@ async function detectDoubleBookings(): Promise<{ console.log("\n🔍 Detecting double-booked slots..."); try { - // Get all confirmed (non-tentative) future slots grouped by consultant. - // TODO: The canonical occupancy policy (buildOccupiedAppointmentFilter) also treats - // unpaid-but-active states (PENDING, APPROVED, APPROVED_PENDING_PAYMENT) as occupied. - // This detection is limited to SUCCEEDED payments, so overlaps involving those states - // will be missed. A future improvement could use buildOccupiedAppointmentFilter here. + // Get all future slots whose parent event is in an occupied state, grouped + // by consultant. Occupancy is defined by the canonical policy + // (buildOccupiedAppointmentFilter), not by a SUCCEEDED-payment filter, so + // overlaps involving unpaid/tentative holds (PENDING, APPROVED, + // APPROVED_PENDING_PAYMENT) are caught too — the old payment-only query + // missed them. const confirmedSlots = await prisma.slotOfAppointment.findMany({ where: { - isTentative: false, endsAt: { gt: new Date() }, // Only future slots appointment: { - payment: { - some: { - paymentStatus: PaymentStatus.SUCCEEDED, + AND: [ + { OR: buildOccupiedAppointmentFilter() }, + // Exclude legitimately in-flight tentative holds. A consultation/ + // subscription reset to PENDING is either awaiting first approval or + // mid-reschedule (#623) — its slots are transient and self-resolve, so + // flagging them is report noise, not a real double-booking. We still + // catch APPROVED_PENDING_PAYMENT (unpaid but committed) overlaps, which + // is the widening this detector was changed to cover. + { + NOT: { + OR: [ + { consultation: { status: "PENDING" } }, + { subscription: { status: "PENDING" } }, + ], + }, }, - }, + ], }, }, // FIX #625: Include all 5 appointment types (not just consultation/subscription) diff --git a/scripts/appointments/send-appointment-reminders.ts b/scripts/appointments/send-appointment-reminders.ts index dae19be6c..236913052 100644 --- a/scripts/appointments/send-appointment-reminders.ts +++ b/scripts/appointments/send-appointment-reminders.ts @@ -210,14 +210,20 @@ async function sendRemindersForWindow(window: { const baseUrl = getAppUrl(); - await notifyAppointmentReminder(uniqueUserIds, { - appointmentType, - consultantName, - consulteeName, - planTitle, - dateTime: slot.startsAt.toISOString(), - dashboardUrl: `${baseUrl}/dashboard`, - }); + await notifyAppointmentReminder( + uniqueUserIds, + { + appointmentType, + consultantName, + consulteeName, + planTitle, + dateTime: slot.startsAt.toISOString(), + dashboardUrl: `${baseUrl}/dashboard`, + }, + // 24h and 1h payloads are identical — key the Novu transactionId by + // window so the second reminder isn't deduped away. + `${apt.id}:${window.label}`, + ); sent++; } catch (error) { diff --git a/scripts/cleanup/cleanup-old-stream-recordings.ts b/scripts/cleanup/cleanup-old-stream-recordings.ts index 0a833a9da..c119df165 100644 --- a/scripts/cleanup/cleanup-old-stream-recordings.ts +++ b/scripts/cleanup/cleanup-old-stream-recordings.ts @@ -13,6 +13,10 @@ * per-recording. The local tombstone is what makes the dashboard * stop offering the URL; the underlying S3 object is Stream's problem. * + * Supabase objects ARE ours, though (#899): recordings already moved to + * the permanent bucket get their object deleted before the tombstone, + * otherwise the bytes outlive the retention window (DPDP gap). + * * Schedule: daily at 03:00 UTC (avoids the 02:00 abandoned-top-ups * slot + the 02:30 reconcile-ledgers slot — Prisma connection pool * contention). @@ -21,6 +25,7 @@ import prisma from "../../lib/prisma"; import { AUDIT_ACTIONS } from "../../lib/enterprise/audit-actions"; import { withCronLock } from "@/lib/cron/with-cron-lock"; +import { RecordingTransferService } from "@/lib/stream/recording-transfer-service"; export interface StreamRetentionResult { scanned: number; @@ -30,6 +35,104 @@ export interface StreamRetentionResult { errors: string[]; } +type OrgRetention = { id: string; streamRecordingRetentionDays: number }; +type RecordingCandidate = { id: string; supabasePath: string | null }; + +function recordOrgOutcome( + result: StreamRetentionResult, + org: OrgRetention, + expiredCount: number, +): void { + result.cutoffsByOrg.push({ + organizationId: org.id, + retentionDays: org.streamRecordingRetentionDays, + expiredCount, + }); +} + +// #899 — resolve which candidates are ready to tombstone. Rows without a +// Supabase object tombstone directly. Rows with one must have their storage +// object deleted first (DPDP) — an EXPIRED row with bytes still in the bucket is +// orphaned storage and a retention violation. The network-bound deletes run in +// bounded chunks (mirroring processExpiringRecordings' transfer sweep) so a large +// candidate set can't serialise into a timeout or exhaust the pool. A failed +// delete keeps its row un-tombstoned so tomorrow's run retries the pair together. +async function collectTombstoneIds( + org: OrgRetention, + candidates: RecordingCandidate[], + result: StreamRetentionResult, +): Promise { + const tombstoneIds: string[] = []; + + // Rows without a Supabase object need no storage call — tombstone directly. + for (const candidate of candidates) { + if (!candidate.supabasePath) { + tombstoneIds.push(candidate.id); + } + } + + const supabaseCandidates = candidates.filter((c) => c.supabasePath); + const CONCURRENCY = 5; + for (let i = 0; i < supabaseCandidates.length; i += CONCURRENCY) { + const chunk = supabaseCandidates.slice(i, i + CONCURRENCY); + await Promise.all( + chunk.map(async (candidate) => { + // #899 — delete only the storage object here; the row's status flip + // + audit log land together in the transaction below so a partial + // failure can't tombstone the row before the audit write (which the + // `notIn [EXPIRED]` candidate filter would then never retry). + const del = await RecordingTransferService.deleteSupabaseObject( + candidate.supabasePath!, + ); + if (del.success) { + tombstoneIds.push(candidate.id); + } else { + result.success = false; + result.errors.push( + `org=${org.id} recording=${candidate.id}: ${del.error}`, + ); + } + }), + ); + } + + return tombstoneIds; +} + +// Tombstone + clear the now-deleted Supabase pointers atomically with the audit +// log (the storage object was removed above). storageType reflects that only +// Stream's S3 copy — if any — remains. +async function tombstoneRecordings( + org: OrgRetention, + tombstoneIds: string[], + cutoff: Date, +): Promise { + await prisma.$transaction(async (tx) => { + await tx.recording.updateMany({ + where: { id: { in: tombstoneIds } }, + data: { + status: "EXPIRED", + supabaseUrl: null, + supabasePath: null, + storageType: "STREAM_S3", + }, + }); + await tx.orgAuditLog.create({ + data: { + organizationId: org.id, + category: "SYSTEM", + action: AUDIT_ACTIONS.SYSTEM.STREAM_RECORDING_DELETED, + description: `Tombstoned ${tombstoneIds.length} recording(s) past ${org.streamRecordingRetentionDays}d retention`, + details: { + cutoff: cutoff.toISOString(), + retentionDays: org.streamRecordingRetentionDays, + count: tombstoneIds.length, + }, + }, + }); + }); +} + // #476 — locked at the core so every entry (GH Actions / HTTP) shares one // mutual exclusion; fail-open: repeat-safe side effects, lock is belt-and-braces. export async function cleanupOldStreamRecordings(): Promise { @@ -72,44 +175,25 @@ async function cleanupOldStreamRecordingsUnlocked(): Promise { - await tx.recording.updateMany({ - where: { id: { in: candidates.map((c) => c.id) } }, - data: { status: "EXPIRED" }, - }); - await tx.orgAuditLog.create({ - data: { - organizationId: org.id, - category: "SYSTEM", - action: AUDIT_ACTIONS.SYSTEM.STREAM_RECORDING_DELETED, - description: `Tombstoned ${candidates.length} recording(s) past ${org.streamRecordingRetentionDays}d retention`, - details: { - cutoff: cutoff.toISOString(), - retentionDays: org.streamRecordingRetentionDays, - count: candidates.length, - }, - }, - }); - }); - result.expired += candidates.length; - result.cutoffsByOrg.push({ - organizationId: org.id, - retentionDays: org.streamRecordingRetentionDays, - expiredCount: candidates.length, - }); + await tombstoneRecordings(org, tombstoneIds, cutoff); + result.expired += tombstoneIds.length; + recordOrgOutcome(result, org, tombstoneIds.length); } catch (err) { result.success = false; const msg = err instanceof Error ? err.message : String(err); diff --git a/scripts/payments/cleanup-abandoned-payments.ts b/scripts/payments/cleanup-abandoned-payments.ts index 7cc5bf169..ac1a13f4c 100644 --- a/scripts/payments/cleanup-abandoned-payments.ts +++ b/scripts/payments/cleanup-abandoned-payments.ts @@ -57,37 +57,6 @@ export async function cancelPaymentIntent( await cancelRazorpayOrder(paymentIntent); break; - case PaymentGateway.LEMON_SQUEEZY: - if (process.env.LEMON_SQUEEZY_API_KEY) { - const response = await fetch( - `https://api.lemonsqueezy.com/v1/payments/${paymentIntent}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${process.env.LEMON_SQUEEZY_API_KEY}`, - "Content-Type": "application/json", - }, - }, - ); - if (response.ok) { - console.log(`✅ Cancelled Lemon Squeezy payment: ${paymentIntent}`); - } else { - throw new Error(`HTTP ${response.status}`); - } - } else { - console.warn("⚠️ LEMON_SQUEEZY_API_KEY not configured"); - } - break; - - case PaymentGateway.XFLOW: - if (process.env.XFLOW_SECRET_KEY) { - // Add Xflow cancellation logic here when available - console.log(`✅ Cancelled Xflow payment: ${paymentIntent}`); - } else { - console.warn("⚠️ XFLOW_SECRET_KEY not configured"); - } - break; - default: console.warn(`⚠️ Unknown payment gateway: ${gateway}`); } diff --git a/tasks/1.txt b/tasks/1.txt deleted file mode 100644 index 878415b89..000000000 --- a/tasks/1.txt +++ /dev/null @@ -1,566 +0,0 @@ -The plan is complete. Here's a summary of what we'll implement: - - Summary - - Key Findings - - - tiringly_web has a better payout implementation using RazorpayX Payouts + Stripe Connect - - familiarise_web has payment collection working but no payout system - - The existing familiarise_web docs planned for Razorpay Route, but RazorpayX is more flexible - - Implementation Plan - - 1. Database Schema: Add ConsultantEarnings, Payout, PayoutAccount, Invoice, WebhookEvent models - 2. RazorpayX Integration: Copy razorpay-payouts.ts from tiringly_web (contacts, fund accounts, payouts with idempotency keys) - 3. Stripe Connect: Copy stripe-connect.ts from tiringly_web (connected accounts, transfers) - 4. Payout Service: Admin approval workflow with auto-approve under ₹5000 - 5. Weekly Payouts: Cron job every Monday - 6. Invoice Generation: GST-compliant invoices with HSN codes - 7. Skip: LemonSqueezy/XFlow webhook completion (not actively used) - - Source Files - - All payout services will be copied from tiringly_web and adapted for familiarise_web's ConsultantProfile model (instead of MentorProfile). - - SDK Sources - - - https://razorpay.com/docs/api/x/payouts/ - - https://docs.stripe.com/connect/payouts-connected-accounts - - https://docs.stripe.com/api/transfers/create?lang=node - -● User approved Claude's plan - ⎿ Plan saved to: ~/.claude/plans/tingly-jumping-wirth.md · /plan to edit - Payout Implementation Plan for familiarise_web - - Executive Summary - - Implement a complete payout system for consultants in familiarise_web, using the proven - patterns from tiringly_web. This includes: - 1. Razorpay (India): RazorpayX Payouts API (contacts, fund accounts, payouts) - 2. Stripe (International): Stripe Connect (connected accounts, transfers) - 3. Fix payment inconsistencies in webhook handling - - --- - Analysis: tiringly_web vs familiarise_web - - tiringly_web (BETTER - Use as Reference) - - | Component | Implementation | - |------------------|---------------------------------------------------------| - | Razorpay Payouts | RazorpayX API (contacts, fund accounts, direct payouts) | - | Stripe Payouts | Stripe Connect (transfers to connected accounts) | - | Models | InstructorRevenue, Payout, PayoutAccount | - | Admin Workflow | PENDING → APPROVED → PROCESSING → COMPLETED | - | Idempotency | Keys required (March 2025 Razorpay mandate) | - | Revenue Hold | 7-day hold before payout eligible | - - familiarise_web (Current State) - - | Component | Status | - |--------------------|--------------------------------------------| - | Payment Collection | Working (Stripe, Razorpay) | - | Payout System | NOT IMPLEMENTED | - | Docs | Planned for Razorpay Route (less flexible) | - | Models | Missing earnings/payout tracking | - - Key Decision: RazorpayX Payouts > Razorpay Route - - - RazorpayX: More flexible, supports admin approval, batch processing, post-payment earnings - calculation - - Route: Requires split at checkout time, less control - - Recommendation: Use RazorpayX (like tiringly_web) - - --- - Files to Create/Modify - - Phase 1: Database Schema - - New Models in prisma/schema.prisma - - // Add these new models and enums - - enum EarningStatus { - PENDING // In hold period - HELD // Extended hold (dispute) - READY // Ready for payout - PAID // Successfully paid - REFUNDED // Refunded to consultee - } - - enum PayoutStatus { - PENDING // Awaiting approval - APPROVED // Admin approved - PROCESSING // Being processed - COMPLETED // Successfully sent - FAILED // Provider rejected - CANCELLED // Manually cancelled - } - - enum PayoutMethod { - BANK_TRANSFER - UPI - STRIPE_TRANSFER - } - - enum AccountType { - BANK_ACCOUNT - UPI - STRIPE_CONNECT - } - - model ConsultantEarnings { - id String @id @default(cuid()) - consultantProfileId String - paymentId String @unique - payoutId String? - - // Revenue breakdown - grossAmount Int // Total sale price - platformFee Int // Platform cut (20%) - consultantShare Int // Consultant cut (80%) - - // Status tracking - status EarningStatus @default(PENDING) - holdUntil DateTime // Release after hold period - paidAt DateTime? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: - [id]) - payment Payment @relation(fields: [paymentId], references: [id]) - payout Payout? @relation(fields: [payoutId], references: [id]) - - @@index([consultantProfileId, status]) - @@index([status, holdUntil]) - @@index([payoutId]) - } - - model Payout { - id String @id @default(cuid()) - consultantProfileId String - provider PaymentGateway - providerPayoutId String? @unique - amount Int - currency String @default("INR") - status PayoutStatus @default(PENDING) - method PayoutMethod - batchId String? - - // Processing - failureReason String? - retryCount Int @default(0) - processedAt DateTime? - approvedAt DateTime? - approvedBy String? - - // Idempotency (required by Razorpay from March 2025) - idempotencyKey String? @unique - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: - [id]) - earnings ConsultantEarnings[] - - @@index([consultantProfileId, status]) - @@index([batchId]) - } - - model PayoutAccount { - id String @id @default(cuid()) - consultantProfileId String - provider PaymentGateway - accountType AccountType @default(BANK_ACCOUNT) - - // Bank details (store only masked, full via gateway) - accountHolderName String? - bankName String? - accountNumberLast4 String? // Only last 4 digits - ifscCode String? - - // UPI - upiId String? - - // Gateway IDs - stripeAccountId String? @unique - stripeAccountStatus String? - razorpayContactId String? - razorpayFundAccId String? @unique - - // Verification - isVerified Boolean @default(false) - isDefault Boolean @default(false) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: - [id]) - - @@index([consultantProfileId]) - } - - Update Existing Models - - // Add to ConsultantProfile - model ConsultantProfile { - // ... existing fields ... - - // Payout relations - earnings ConsultantEarnings[] - payouts Payout[] - payoutAccounts PayoutAccount[] - - // Cached balances - totalRevenue Int @default(0) - pendingRevenue Int @default(0) - } - - // Add to Payment - model Payment { - // ... existing fields ... - - earnings ConsultantEarnings? - } - - --- - Phase 2: Payout Services - - Create: lib/payments/payouts/razorpay-payouts.ts - - Copy from: tiringly_web/lib/payments/providers/razorpay/razorpay-payouts.ts - - Key adaptations: - - Same RazorpayX API integration - - Environment variables: RAZORPAYX_KEY_ID, RAZORPAYX_KEY_SECRET, RAZORPAYX_ACCOUNT_NUMBER - - // Key functions from tiringly_web to copy: - - RazorpayPayoutsService class - - createContact() - - createFundAccount() - - createPayout() with idempotency key - - verifyWebhookSignature() - - mapPayoutStatus() - - determinePayoutMode() // IMPS/NEFT/UPI based on amount - - Create: lib/payments/payouts/stripe-connect.ts - - Copy from: tiringly_web/lib/payments/providers/stripe/stripe-connect.ts - - Key adaptations: - - Same Stripe Connect integration - - Environment variables: STRIPE_SECRET_KEY, STRIPE_CONNECT_CLIENT_ID - - // Key functions from tiringly_web to copy: - - StripeConnectService class - - createConnectedAccount() - - createAccountLink() // Onboarding URL - - createTransfer() // Platform → Connected account - - mapPayoutStatus() - - Create: lib/payments/payouts/payout-service.ts - - Copy from: tiringly_web/lib/payouts/payout-service.ts - - Key adaptations: - - Replace instructorId → consultantProfileId - - Replace MentorProfile → ConsultantProfile - - Replace InstructorRevenue → ConsultantEarnings - - // Key functions from tiringly_web to copy: - - getPendingPayouts() - - checkPayoutEligibility() - - createPayoutBatch() - - approvePayout() - - rejectPayout() - - processApprovedPayouts() - - handlePayoutWebhook() - - getPayoutStats() - - Create: lib/payments/payouts/earnings-service.ts - - New file for earnings management: - - // Functions to create: - - createEarningsFromPayment(payment, appointmentType) - - releaseEarningsFromHold() // Called by cron - - getConsultantEarningsSummary(consultantId) - - refundEarnings(paymentId) - - --- - Phase 3: API Routes - - Create: app/api/consultant/payout-accounts/route.ts - - - GET - List consultant's payout accounts - - POST - Create new payout account (triggers RazorpayX contact + fund account) - - PATCH - Set default account - - Create: app/api/consultant/earnings/route.ts - - - GET - List consultant's earnings with status filters - - Create: app/api/admin/payouts/route.ts - - - GET - List all payouts (with filters) - - POST - Create payout batch (trigger from admin dashboard) - - Create: app/api/admin/payouts/[payoutId]/route.ts - - - GET - Get payout details - - PATCH - Approve/reject payout - - Create: app/api/admin/payouts/process/route.ts - - - POST - Process all approved payouts - - Update: app/api/webhooks/razorpay/route.ts - - Add payout webhook handlers: - // Add to existing webhook handler: - case "payout.processed": - case "payout.reversed": - case "payout.rejected": - await handlePayoutWebhook(event); - break; - - Update: app/api/webhooks/stripe/route.ts - - Add payout webhook handlers: - // Add to existing webhook handler: - case "payout.created": - case "payout.paid": - case "payout.failed": - await handlePayoutWebhook(event); - break; - case "account.updated": - await handleAccountUpdated(event); - break; - - --- - Phase 4: Cron Jobs - - Create: app/api/cron/release-earnings/route.ts - - Runs hourly - releases earnings from hold: - // Move PENDING → READY when holdUntil < now - // Update consultant pendingRevenue - - Create: app/api/cron/process-payouts/route.ts - - Runs weekly - creates payout batches: - // Find consultants with READY earnings >= minimum - // Create payout records - // Optionally auto-approve or wait for admin - - Update: .github/workflows/scheduled-jobs.yml - - Add payout cron jobs: - - cron: '0 * * * *' # Hourly: release earnings - - cron: '30 17 * * 1' # Monday 11PM IST: process payouts - - --- - Phase 5: Fix Payment Inconsistencies - - 1. Add WebhookEvent Logging (from tiringly_web) - - Create: prisma/schema.prisma addition: - model WebhookEvent { - id String @id @default(cuid()) - provider String // razorpay, stripe, etc. - eventId String @unique - eventType String - payload Json - signature String? - processed Boolean @default(false) - processedAt DateTime? - error String? - receivedAt DateTime @default(now()) - - @@index([provider]) - @@index([processed]) - } - - Update all webhook handlers to log events before processing. - - 2. Complete LemonSqueezy Webhook (if needed) - - File: app/api/webhooks/lemon-squeezy/route.ts - - Add appointment creation logic (currently TODO) - - 3. Complete XFlow Webhook (if needed) - - File: app/api/webhooks/xflow/route.ts - - Add appointment creation logic (currently TODO) - - --- - Environment Variables Required - - # RazorpayX Payouts (new) - RAZORPAYX_KEY_ID= - RAZORPAYX_KEY_SECRET= - RAZORPAYX_ACCOUNT_NUMBER= - RAZORPAYX_WEBHOOK_SECRET= - - # Stripe Connect (new) - STRIPE_CONNECT_CLIENT_ID= - STRIPE_CONNECT_WEBHOOK_SECRET= - - # Existing (already have) - RAZORPAY_KEY_ID= - RAZORPAY_KEY_SECRET= - STRIPE_SECRET_KEY= - STRIPE_WEBHOOK_SECRET= - - --- - Implementation Order - - PR 1: Database Schema + Core Services - - 1. Add Prisma models (ConsultantEarnings, Payout, PayoutAccount, WebhookEvent) - 2. Run migration - 3. Create lib/payments/payouts/razorpay-payouts.ts - 4. Create lib/payments/payouts/stripe-connect.ts - 5. Create lib/payments/payouts/payout-service.ts - 6. Create lib/payments/payouts/earnings-service.ts - - PR 2: Webhook Integration + Earnings Tracking - - 1. Update payment success webhook to create earnings records - 2. Add payout webhook handlers (Razorpay + Stripe) - 3. Add WebhookEvent logging to all handlers - 4. Create earnings cron job (release from hold) - - PR 3: API Routes + Admin Dashboard - - 1. Create consultant payout account routes - 2. Create consultant earnings routes - 3. Create admin payout management routes - 4. Create payout cron job (weekly processing) - - PR 4: Payment Fixes (Optional) - - 1. Complete LemonSqueezy webhook (if using) - 2. Complete XFlow webhook (if using) - 3. Add invoice generation (if needed) - - --- - Source Files Reference - - From tiringly_web (Copy & Adapt) - - | Source File | Target File - | - |-----------------------------------------------------|---------------------------------------- - -----| - | lib/payments/providers/razorpay/razorpay-payouts.ts | - lib/payments/payouts/razorpay-payouts.ts | - | lib/payments/providers/stripe/stripe-connect.ts | lib/payments/payouts/stripe-connect.ts - | - | lib/payouts/payout-service.ts | lib/payments/payouts/payout-service.ts - | - | app/api/instructor/payout-accounts/route.ts | - app/api/consultant/payout-accounts/route.ts | - | app/api/cron/release-revenue/route.ts | app/api/cron/release-earnings/route.ts - | - | app/api/cron/process-payouts/route.ts | app/api/cron/process-payouts/route.ts - | - - From familiarise_web (Reference Only) - - | File | Purpose | - |------------------------------------------------|-------------------------------| - | docs/finances/02-payout-architecture.md | Business requirements | - | docs/finances/06-payout-implementation-plan.md | Original plan (superseded) | - | lib/payments/core/razorpay.ts | Existing Razorpay integration | - | lib/payments/core/stripe.ts | Existing Stripe integration | - | lib/payments/webhooks/handlers.ts | Add earnings creation here | - - --- - Key Constants - - // lib/payments/payouts/constants.ts - export const PAYOUT_CONSTANTS = { - PLATFORM_FEE_PERCENTAGE: 20, // 20% platform fee - CONSULTANT_SHARE_PERCENTAGE: 80, // 80% to consultant - MINIMUM_PAYOUT_AMOUNT: 50000, // ₹500 in paise - AUTO_APPROVE_THRESHOLD: 500000, // ₹5000 in paise - auto-approve below this - HOLD_PERIOD_HOURS: { - CONSULTATION: 24, - WEBINAR: 48, - SUBSCRIPTION: 168, // 7 days - CLASS: 24, - }, - }; - - --- - User Decisions - - | Question | Decision | - |--------------------|-------------------------------------------------------------| - | LemonSqueezy/XFlow | Skip for now - Focus on Razorpay/Stripe only | - | Payout Frequency | Weekly - Process every Monday | - | Admin Approval | Auto-approve under ₹5000, admin approval for larger amounts | - | Invoice Generation | Yes - Add GST-compliant invoice generation | - - --- - Additional Phase: Invoice Generation - - Add Invoice Model to prisma/schema.prisma - - model Invoice { - id String @id @default(cuid()) - paymentId String? - invoiceNumber String @unique // INV-YYYYMM-XXXXX - amount Int - currency String @default("INR") - status PaymentStatus @default(PENDING) - items Json // Line items with HSN codes - pdfUrl String? - dueDate DateTime? - paidAt DateTime? - - // Tax breakdown - taxAmount Int? // GST amount in paise - taxRate Float? // 18% for services - hsnCode String? // SAC code (999293 for consulting) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - payment Payment? @relation(fields: [paymentId], references: [id]) - - @@index([invoiceNumber]) - @@index([status]) - } - - Create: lib/tax/invoice-generator.ts - - Copy from: tiringly_web/lib/tax/invoice-generator.ts - - HSN/SAC codes for consulting services - - CGST/SGST calculation - - Invoice number sequencing - - PDF generation - - Updated Implementation Order - - | PR | Content | - |------|------------------------------------------------------------------------------------| - | PR 1 | Database Schema (Earnings, Payout, PayoutAccount, Invoice, WebhookEvent) | - | PR 2 | Core Services (razorpay-payouts, stripe-connect, payout-service, earnings-service) | - | PR 3 | Webhook Integration (earnings on payment success, payout webhooks, event logging) | - | PR 4 | API Routes (consultant accounts, admin payouts, cron jobs) | - | PR 5 | Invoice Generation (invoice model, generator, PDF) | - - - - - - - - - -● The plan is approved. Let me set up the implementation tasks: diff --git a/tasks/2.txt b/tasks/2.txt deleted file mode 100644 index 7c2aa425b..000000000 --- a/tasks/2.txt +++ /dev/null @@ -1,326 +0,0 @@ - -● The plan is complete. Here's a summary: - - Plan Summary - - Part 1: Scripts + GitHub Actions (Replace Vercel Cron) - - - 3 Core Scripts in scripts/ - Reusable payout logic - - 3 Jobs in jobs/ - GitHub Actions wrappers - - 3 Workflows in .github/workflows/ - Weekly Monday schedule - - Delete vercel.json and app/api/cron/ routes - - Part 2: Admin Dashboard Revamp - - - Sectioned navigation with Payments, Payouts, Invoices groups - - 6 new Payout pages - Pending, Processing, Completed, Earnings, Details - - 2 new pages - Subscriptions, Invoices - - Stats cards, bulk actions, filters, CSV export - - Part 3: Staff Dashboard - - - Full payout access (same as admin) - - 2 new pages - Payouts, Invoices - - Total: 20 new files, 4 modified files - -──────────────────────────────────────────────────────────────────────────────────────────────────────── - Ready to code? - - Here is Claude's plan: -╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - Payout Scripts + Admin/Staff Dashboard Revamp - - Overview - - 1. Replace Vercel cron jobs with scripts + GitHub Actions (following existing patterns) - 2. Full dashboard revamp for Admin and Staff with new payment/payout management pages - - --- - Part 1: Convert Vercel Cron to Scripts + GitHub Actions - - Current State (to be removed) - - - vercel.json - Cron configuration - - app/api/cron/release-earnings/route.ts - - app/api/cron/create-payouts/route.ts - - app/api/cron/process-payouts/route.ts - - New Structure (following existing patterns) - - 1.1 Core Scripts (scripts/) - - | File | Purpose | - |--------------------------------|-----------------------------------------------------| - | scripts/release-earnings.ts | Release earnings from hold period → READY status | - | scripts/create-payout-batch.ts | Create weekly payout batch for eligible consultants | - | scripts/process-payouts.ts | Process approved payouts via RazorpayX/Stripe | - - Pattern to follow: scripts/cleanup-abandoned-payments.ts - - Export reusable functions with typed results - - Self-executable via import.meta.url check - - Structured error handling with result object - - Console logging with emoji indicators - - Proper prisma.$disconnect() cleanup - - 1.2 GitHub Actions Wrappers (jobs/) - - | File | Purpose | - |-----------------------------|-----------------------------------| - | jobs/release-earnings.ts | Wrapper for GitHub Actions output | - | jobs/create-payout-batch.ts | Wrapper with GitHub annotations | - | jobs/process-payouts.ts | Wrapper with exit codes | - - Pattern to follow: jobs/cleanup-abandoned-payments.ts - - Import from scripts, add GITHUB_OUTPUT writing - - Use ::error:: annotations for failures - - Proper exit codes (0/1) - - 1.3 GitHub Workflows (.github/workflows/) - - | File | Schedule | Purpose - | - |-------------------------|------------------------------------|-------------------------------------- - ---| - | release-earnings.yml | Hourly (0 * * * *) | Move earnings past hold period to - READY | - | create-payout-batch.yml | Weekly Monday 8PM UTC (0 20 * * 0) | Create batch (1:30 AM IST) - | - | process-payouts.yml | Weekly Monday 9PM UTC (0 21 * * 0) | Process approved payouts - | - - Pattern to follow: .github/workflows/cleanup-abandoned-payments.yml - - on: schedule + workflow_dispatch (manual trigger) - - Environment secrets for DATABASE_URL, payment gateway keys - - npx tsx jobs/[job-name].ts execution - - Failure notification - - 1.4 NPM Scripts (package.json) - - { - "scripts:release-earnings": "npx tsx scripts/release-earnings.ts", - "scripts:create-payout-batch": "npx tsx scripts/create-payout-batch.ts", - "scripts:process-payouts": "npx tsx scripts/process-payouts.ts" - } - - --- - Part 2: Admin Dashboard Revamp - - Current Navigation - - Overview → Payments → Approval Payments → Refunds → Disputes → Analytics → Users → Settings - - New Navigation (Reorganized) - - Overview - ───────────────── - PAYMENTS - ├── All Payments - ├── Approval Payments - ├── Subscriptions (NEW) - ├── Refunds - └── Disputes - ───────────────── - PAYOUTS (NEW SECTION) - ├── Pending Approval - ├── Processing - ├── Completed - └── Consultant Earnings - ───────────────── - INVOICES (NEW) - ───────────────── - Analytics - Users - Settings - - New Pages to Create - - 2.1 Payouts Section (app/dashboard/admin/payouts/) - - | File | Purpose | - |---------------------|-------------------------------------------| - | page.tsx | Payout overview with stats + pending list | - | pending/page.tsx | Payouts awaiting admin approval | - | processing/page.tsx | Payouts in PROCESSING status | - | completed/page.tsx | Completed payout history | - | [payoutId]/page.tsx | Individual payout details + actions | - | earnings/page.tsx | Consultant earnings overview | - - Features: - - Stats cards: Pending count/amount, Processing, Completed this month - - Bulk approve/reject actions - - Filter by consultant, amount range, provider - - Export to CSV - - 2.2 Subscriptions Page (app/dashboard/admin/subscriptions/) - - | File | Purpose | - |---------------------------|---------------------------| - | page.tsx | Active subscriptions list | - | [subscriptionId]/page.tsx | Subscription details | - - Features: - - Active, Expiring Soon, Expired tabs - - Subscription status, plan, user info - - Manual cancellation/extension actions - - 2.3 Invoices Page (app/dashboard/admin/invoices/) - - | File | Purpose | - |----------------------|---------------------------------| - | page.tsx | Invoice list with search/filter | - | [invoiceId]/page.tsx | Invoice details + PDF download | - - Features: - - Filter by date range, status, amount - - Download PDF/CSV export - - GST details display - - 2.4 Updated Layout (app/dashboard/admin/layout.tsx) - - const NAV_SECTIONS = [ - { - title: null, // No header - items: [{ name: "Overview", path: "home", icon: "LayoutDashboard" }] - }, - { - title: "Payments", - items: [ - { name: "All Payments", path: "payments", icon: "CreditCard" }, - { name: "Approval Payments", path: "approval-payments", icon: "Clock" }, - { name: "Subscriptions", path: "subscriptions", icon: "RefreshCw" }, - { name: "Refunds", path: "refunds", icon: "RotateCcw" }, - { name: "Disputes", path: "disputes", icon: "AlertTriangle" }, - ] - }, - { - title: "Payouts", - items: [ - { name: "Pending Approval", path: "payouts/pending", icon: "ClipboardCheck" }, - { name: "Processing", path: "payouts/processing", icon: "Loader" }, - { name: "Completed", path: "payouts/completed", icon: "CheckCircle" }, - { name: "Consultant Earnings", path: "payouts/earnings", icon: "Wallet" }, - ] - }, - { - title: null, - items: [ - { name: "Invoices", path: "invoices", icon: "FileText" }, - { name: "Analytics", path: "analytics", icon: "BarChart3" }, - { name: "Users", path: "users", icon: "Users" }, - ] - } - ]; - - --- - Part 3: Staff Dashboard Update - - Add Payout Access (Full Access) - - Update app/dashboard/staff/[staffId]/layout.tsx navigation: - - const sidebarItems = [ - // ... existing items ... - { name: "Payments", icon: CreditCard, path: "payments" }, - { name: "Payouts", icon: Wallet, path: "payouts" }, // NEW - // ... rest ... - ]; - - New Staff Pages - - | File | Purpose | - |------------------------------------------------------------|-----------------------------------| - | app/dashboard/staff/[staffId]/(features)/payouts/page.tsx | Payout management (same as admin) | - | app/dashboard/staff/[staffId]/(features)/invoices/page.tsx | Invoice viewing | - - Note: Staff pages will reuse admin components but with staff-specific API routes. - - --- - Part 4: API Routes for Dashboard - - Existing Routes (already created) - - - app/api/admin/payouts/route.ts - List/create batches - - app/api/admin/payouts/[id]/route.ts - Approve/reject - - app/api/admin/payouts/process/route.ts - Process approved - - app/api/consultant/earnings/route.ts - Consultant earnings - - app/api/invoices/route.ts - List invoices - - app/api/invoices/[id]/route.ts - Invoice details - - New Routes Needed - - - app/api/admin/subscriptions/route.ts - List subscriptions - - app/api/admin/subscriptions/[id]/route.ts - Subscription details/actions - - app/api/staff/payouts/route.ts - Staff payout access (mirror admin) - - --- - Files to Create/Modify - - Create (20 files) - - Scripts (3): - - scripts/release-earnings.ts - - scripts/create-payout-batch.ts - - scripts/process-payouts.ts - - Jobs (3): - - jobs/release-earnings.ts - - jobs/create-payout-batch.ts - - jobs/process-payouts.ts - - Workflows (3): - - .github/workflows/release-earnings.yml - - .github/workflows/create-payout-batch.yml - - .github/workflows/process-payouts.yml - - Admin Pages (8): - - app/dashboard/admin/payouts/page.tsx - - app/dashboard/admin/payouts/pending/page.tsx - - app/dashboard/admin/payouts/processing/page.tsx - - app/dashboard/admin/payouts/completed/page.tsx - - app/dashboard/admin/payouts/earnings/page.tsx - - app/dashboard/admin/payouts/[payoutId]/page.tsx - - app/dashboard/admin/subscriptions/page.tsx - - app/dashboard/admin/invoices/page.tsx - - Staff Pages (2): - - app/dashboard/staff/[staffId]/(features)/payouts/page.tsx - - app/dashboard/staff/[staffId]/(features)/invoices/page.tsx - - API Routes (1): - - app/api/admin/subscriptions/route.ts - - Modify (4 files) - - - app/dashboard/admin/layout.tsx - Add sectioned navigation - - app/dashboard/staff/[staffId]/layout.tsx - Add payouts/invoices nav - - package.json - Add npm scripts - - Delete vercel.json - Remove Vercel cron config - - Delete app/api/cron/ - Remove API-based cron routes - - --- - Execution Order - - 1. Phase 1: Scripts - Create scripts + jobs + workflows - 2. Phase 2: Remove Vercel cron - Delete vercel.json and api/cron routes - 3. Phase 3: Admin Layout - Update navigation with sections - 4. Phase 4: Payout Pages - Create admin payout management pages - 5. Phase 5: Invoice/Subscription Pages - Create remaining admin pages - 6. Phase 6: Staff Dashboard - Add payout/invoice access to staff - 7. Phase 7: Test - Verify scripts run manually, pages load correctly - - --- - Dependencies - - - Existing: @tanstack/react-query, Prisma, NextAuth - - UI: shadcn components (Card, Table, Button, Badge, Dialog) - - No new dependencies needed -╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - - Would you like to proceed? - - ❯ 1. Yes, and auto-accept edits - 2. Yes, and manually approve edits - 3. Type here to tell Claude what to change - - ctrl-g to edit in Vim \ No newline at end of file diff --git a/tasks/b2c-compliance-issue-body.md b/tasks/b2c-compliance-issue-body.md deleted file mode 100644 index 9a9db9a07..000000000 --- a/tasks/b2c-compliance-issue-body.md +++ /dev/null @@ -1,401 +0,0 @@ -## Summary - -The 2026-05-02 enterprise-readiness audit (`ENTERPRISE_PRODUCTION_GRADE_CHECKLIST_2026_05_02.md`, `ENTERPRISE_READINESS.md`) covered the B2B / org-sponsored side comprehensively. **B2C compliance — the consumer marketplace side — was not audited at the same depth.** This issue is the equivalent audit for consumer-side payments, payouts, refunds, disputes, invoices, subscriptions, and DPDP for the Consultee-pays-Consultant flow. - -It surfaces **two production bugs** (wrong TDS section + stale TDS rate), several **structural compliance gaps** (no Section 52 GST TCS, no GSTR-8, no Form 26Q automation, no Grievance Officer disclosure, no DPDP consent at signup, no DSAR/erasure), and the **2025–2027 DPDP Rules phased deadlines** that bind us by **13 May 2027**. - -> Sister issues: #677 (payments-subsystem master tracker), #716 (refunds/payouts/pricing epic), #715 (overage charging), #703 (Enterprise Phase 2 deferred work). This issue scopes the **B2C** layer that those don't cover. - ---- - -## Risk Matrix - -| # | Area | Severity | Type | Notes | -|---|------|----------|------|-------| -| 1 | **TDS section + rate are wrong** | 🔴 CRITICAL | Bug | Live code uses Sec 194J at 10%; correct is Sec 194O at **0.10%** (Finance Act 2024, w.e.f. 1 Oct 2024). Two files disagree. | -| 2 | **No GST TCS Section 52** | 🔴 CRITICAL | Missing | E-commerce operator MUST collect 1% TCS (0.5% CGST + 0.5% SGST or 1% IGST) on net taxable supplies of registered consultants and file GSTR-8 monthly. Not implemented. | -| 3 | **No DPDP consent at signup** | 🔴 CRITICAL | Missing | DPDP Rules 2025 (notified 13 Nov 2025) — granular purpose-limited consent required by **13 May 2027**. Currently nothing at consumer signup. | -| 4 | **No DSAR / data export / erasure for consumers** | 🔴 CRITICAL | Missing | DPDP Section 11–13 rights — access, correction, erasure, grievance, nomination. No endpoints exist. | -| 5 | **No Grievance Officer / Nodal Officer disclosure** | 🟠 HIGH | Missing | Consumer Protection (E-Commerce) Rules 2020 Rule 4(5) — name, designation, contact prominently displayed. Not present. | -| 6 | **No 48-hour acknowledgement / 30-day resolution SLA on grievances** | 🟠 HIGH | Missing | E-Commerce Rules Rule 4(5) — both timelines mandatory. No SLA tracking. | -| 7 | **No place-of-supply state capture at B2C checkout** | 🟠 HIGH | Bug | CBIC Notification 02/2023-IT + Circular 209/3/2024-GST mandates recording recipient's State for B2C inter-state services to determine IGST vs CGST/SGST. Currently relies on org context which is null for B2C. | -| 8 | **Form 26Q automation missing** | 🟠 HIGH | Missing | Schema has `TDSRecord.reportedInForm26Q` flag but no e-filing / NSDL FVU export. Quarterly returns are mandatory; Q4 FY25-26 due **31 May 2026**. Penalty ₹200/day under Sec 234E. | -| 9 | **No subscription cancellation flow + UPI AutoPay mandate consent** | 🟠 HIGH | Missing | Auto-renewal disclosure required; UPI AutoPay AFA-exempt cap is ₹15,000 for non-exempt categories (education NOT exempt); 24h pre-debit notice required (RBI 5 May 2021). | -| 10 | **RBI PA Master Direction Sep 2025: pass-through prohibition** | 🟠 HIGH | Architectural | New Master Direction removes the merchant-directed split-settlement carve-out. Must onboard each consultant as a Razorpay Route sub-merchant **OR** maintain a nodal/escrow account. Current architecture assumes the old model. | -| 11 | **No refund SLA enforcement** | 🟡 MEDIUM | Missing | Refunds work but no 7–14 day SLA tracker; E-Commerce Rules 4(11) requires "reasonable period" per RBI norms. | -| 12 | **Chargeback evidence-submission UI** | 🟡 MEDIUM | Missing | Razorpay 7-day evidence window; admin dashboard shows disputes but no upload/evidence form. | -| 13 | **HSN code: 999293 vs 999299 selection logic** | 🟡 MEDIUM | Bug | Code defaults to 999293 (consulting) but should pick 999299 for educational webinars/classes. Currently invoice renderer picks one or the other; logic appears static. | -| 14 | **Consultant GST registration enforcement** | 🟡 MEDIUM | Policy | Per Sec 24(ix), services suppliers via ECO need GST registration regardless of turnover (no goods-style waiver under Notif 34/2023-CT). Currently no GSTIN gate at consultant onboarding. | -| 15 | **Stale comment in `lib/compliance/tds.ts`** | 🟢 LOW | Doc | Says `"194O": 0.01` — comment looks like 1% but with the rate change should be 0.001 (0.10%). | -| 16 | **Equalisation Levy / 206AB / 206C(1H) cleanup** | 🟢 LOW | Hygiene | All three abolished by Finance Act 2024/2025. Verify no residual code paths reference them. | - ---- - -## Detailed Findings - -### 1. TDS — Section + Rate Are Wrong (🔴 CRITICAL BUG) - -**File:** `lib/payments/tax/tds-service.ts:1–28` - -```typescript -/** - * TDS (Tax Deducted at Source) Service — Section 194J - * - * Rules: - * - Threshold: ₹50,000/financial year (April–March) - * - Rate: 10% with verified PAN, 20% without PAN - * - Applies to professional/technical services (Section 194J) - */ -export const TDS_THRESHOLD_PAISE = 5_000_000; // ₹50,000 -export const TDS_RATE_WITH_PAN = 10; -export const TDS_RATE_WITHOUT_PAN = 20; -``` - -**What's wrong:** -- **Section 194J** applies to direct B2B professional-services contracts (e.g., a CA invoicing a corporate client). The platform is an **e-commerce operator** under Sec 194O Explanation, and consultants are **e-commerce participants**. The correct section is **194O**. -- **Rate**: 194O dropped from 1% to **0.10%** w.e.f. 1 Oct 2024 (Finance (No. 2) Act 2024). Live code uses 10% which over-withholds **100×**. -- **Threshold**: For 194O, the threshold of ₹5,00,000/FY applies only to resident **individuals/HUF** with valid PAN. Live code uses ₹50,000 (the 194J threshold). -- **No-PAN fallback**: 194O has a **special 5% override** under 206AA (not the usual 20%). Live code uses 20%. - -Sources: -- [TDSMan — Section 194O TDS on E-Commerce Participants (Sep 2025)](https://blog.tdsman.com/2025/09/section-194o-tds-on-payments-by-e-commerce-operators-to-participants/) -- [ClearTax — TDS Rate Chart FY 2025-26](https://cleartax.in/s/tds-rate-chart) -- [ClearTax — TDS/TCS Changes from 1 Apr 2025](https://cleartax.in/s/tds-and-tcs-changes-from-april-2025) - -**Second TDS file disagrees:** `lib/compliance/tds.ts:45` correctly identifies 194O as the default but uses the **stale 1% rate** (`"194O": 0.01`). Should be `0.001`. - -```typescript -// lib/compliance/tds.ts (current) -const TDS_RATES = { - "194O": 0.01, // ← STALE: should be 0.001 since Oct 2024 - "194J": 0.1, - ... -}; -export const DEFAULT_SECTION = "194O"; -``` - -**Fix:** -1. Reconcile the two TDS files. The B2C path (`lib/payments/tax/tds-service.ts`) should pivot to 194O. -2. Update rate to 0.10% (`0.001`). -3. Update threshold to ₹5,00,000 for resident individuals/HUF; flag others as 5% (no-PAN) or DTAA-rate (non-resident). -4. Migrate existing `TDSRecord` rows: their applied section was wrong. Decide whether to back-correct (refund excess withholding) or grandfather pre-fix records. - ---- - -### 2. GST TCS Section 52 — Completely Missing (🔴 CRITICAL) - -**Status:** No code references `Section 52`, `TCS`, `GSTR-8`, or `tcsCollected`. - -**What's required (CGST Sec 52):** Every e-commerce operator must collect **1% TCS** on the net taxable value of consultant supplies (0.5% CGST + 0.5% SGST for intra-state, or 1% IGST for inter-state) and file **GSTR-8** by the 10th of the following month. Net = gross supplies through ECO − returns. - -**Distinction from 194O:** 194O is income-tax TDS on the consultant; Section 52 is GST TCS that the platform deposits to government. **Both apply concurrently.** - -**Why it matters:** Non-collection is a breach of Sec 52 + Rule 67(1) CGST Rules. Penalty: equal to the TCS not collected (Sec 122(1)(viii) CGST). Plus interest at 18% p.a. under Sec 50(3). Plus the consultant can't claim the TCS credit in their GST return — direct revenue impact for the consultant. - -**Implementation:** -- Add `gstTcsCollected` field to `Payment` and `ConsultantEarnings`. -- Calculate at payment-success time: 1% of net taxable supply. -- Aggregate monthly into a `GstTcsBatch` model. -- Add GSTR-8 export (CSV in GSTN-required format). -- File via GSTN portal or auto-submit via GSP partner (ClearTax/IRIS). - -Sources: -- [CBIC Rule 67 + Sec 52 CGST Act](https://taxinformation.cbic.gov.in/content/html/tax_repository/gst/rules/cgst_rules/active/chapter11/rule67_v1.00.html) -- [ClearTax — GSTR-8 Filing](https://cleartax.in/s/gstr-8) - ---- - -### 3. DPDP Consent + DSAR — Nothing at B2C Layer (🔴 CRITICAL) - -**File checked:** `app/auth/signup/page.tsx` — no consent checkbox, no granular purpose disclosure. - -**Required by 13 May 2027** (DPDP Rules 2025, MeitY G.S.R. 846(E) of 13 Nov 2025): - -| Sub-requirement | What we need | -|---|---| -| Granular consent at signup | Itemised purposes (booking, payment, recordings, marketing, analytics) — each toggleable | -| Withdrawal | One-click revocation; cascade to revoke downstream processing | -| Data principal rights | Endpoints for access, correction, erasure, grievance, nomination | -| Parental consent for minors | Age gate at signup; verified parental consent for users < 18 | -| Notice in 22 scheduled languages | Beyond English — at minimum Hindi, Bengali, Tamil for our key markets | -| Retention engine | Auto-erasure per Rule 8 (3-yr inactivity for e-commerce > 2 cr users) | -| Breach notice | "Without delay" to DPB + affected users; we already have a partial 72h alert cron from Round 2 (#701 still open) | -| DPO if Significant Data Fiduciary | Designate when criteria are met | - -**Phased rollout:** -- Phase 1 (immediate, done): Data Protection Board operational -- Phase 2 (by 13 Nov 2026): Consent Manager framework live -- Phase 3 (by **13 May 2027**): Substantive obligations enforceable - -**Current state:** Privacy policy page exists at `app/(pages)/privacy/page.tsx` but no granular consent or rights endpoints. Org-side has `ConsentArtifact` schema (#701) but no consumer equivalent. - -Sources: -- [PIB — DPDP Rules 2025 Notification](https://static.pib.gov.in/WriteReadData/specificdocs/documents/2025/nov/doc20251117695301.pdf) -- [MeitY — DPDP Rules 2025](https://www.meity.gov.in/documents/act-and-policies/digital-personal-data-protection-rules-2025-gDOxUjMtQWa) -- [Deloitte — DPDP Rules 2025 Implementation](https://www.deloitte.com/in/en/services/consulting/about/indias-dpdp-rules-2025-leading-digital-privacy-compliance.html) - ---- - -### 4. Consumer Protection — Grievance Officer + SLAs (🟠 HIGH) - -**Status:** Mentioned in `docs/payments/international-research/04-international-refunds-disputes.md:112` and `docs/hiring/contractor-research-report.md:160` but **not implemented anywhere in `app/`**. - -**Required by Consumer Protection (E-Commerce) Rules 2020:** -- **Rule 4(5)**: Appoint a Grievance Officer; display name, designation, contact details prominently. Acknowledge complaints **within 48 hours**, resolve **within 1 month**. -- **Rule 4(11)**: Refunds in "a reasonable period" (RBI norms: 7–14 days for cards, 5–7 for UPI). -- **Rule 5**: As a marketplace ECO, display seller (consultant) details — legal name, principal address, GSTIN, customer-care. - -**Implementation:** -- Add `/grievance` public page with officer name + email + 48hr/30-day SLA disclosure. -- Add `app/api/grievances/route.ts` — POST creates a `Grievance` record; sends acknowledgement email within 48h via Novu. -- SLA dashboard at `/dashboard/admin/grievances` with breach alerting. -- Consultant detail page must show: legal name (or trading name), address summary, GSTIN (when registered), customer-care channel. - -Source: [Consumer Protection (E-Commerce) Rules 2020 — full text](https://thc.nic.in/Central%20Governmental%20Rules/Consumer%20Protection%20(E-Commerce)%20Rules,%202020.pdf) - ---- - -### 5. Place-of-Supply State Capture at B2C Checkout (🟠 HIGH) - -**File:** `lib/payments/operations/checkout.ts` non-org path — Payment.organizationId is null and we have no consumer-state field. - -**Required:** CBIC Notification 02/2023-IT + Circular 209/3/2024-GST require recording the recipient's State on every B2C invoice for inter-state online services. That State becomes the deemed place of supply, determining IGST vs CGST+SGST split. - -**Implementation:** -- Add `Payment.consumerStateCode` (2-char) — captured from billing address at checkout. -- Update `lib/compliance/gst.ts:deriveGstBreakdown` call site for B2C invoices to use `consumerStateCode` instead of org's GST state. -- Update `Invoice` model + PDF renderer to display place of supply. -- Block checkout in production if state code is missing on India-resident purchases. - -Source: [VJM Global — Place of Supply for Online Services to Unregistered Recipients](https://www.vjmglobal.com/blog/clarification-on-place-supply-online-services-supplied-by-suppliers-services-to-unregistered-recipients) - ---- - -### 6. Form 26Q Quarterly Filing Automation (🟠 HIGH) - -**File:** `lib/payments/tax/tds-service.ts:268, 298–312` has a `markAsReported` function that flips `reportedInForm26Q` but no FVU/NSDL export. - -**Required:** -- Quarterly Form 26Q covers all non-salary TDS (194O, 194C, 194J, etc.). -- Q4 FY25-26 due **31 May 2026** (i.e. ~4 weeks from now per the conversation date). -- Penalties: ₹200/day (Sec 234E) capped at TDS amount; ₹10,000–₹1,00,000 (Sec 271H) for non-filing > 1 year. -- Form 16A within 15 days of return due date. - -**Implementation:** -- Generate FVU file from `TDSRecord` rows for the quarter. -- Either submit via NSDL TIN-FC or auto-submit via GSP partner. -- Auto-generate Form 16A PDFs and email each consultant. -- Add `/dashboard/admin/tds/quarterly-returns` dashboard. - -Sources: [ClearTax — Form 26Q TDS Return Filing](https://cleartax.in/s/tds-return-non-salary), [SAG Infotech — TDS Return Due Dates FY 2025-26](https://blog.saginfotech.com/due-date-filing-tds-tcs-return) - ---- - -### 7. Subscription Cancellation + UPI AutoPay Consent (🟠 HIGH) - -**Status:** `Subscription` model exists; `UpdateSubscriptionSchema` allows status updates; no cancellation flow code found, no pro-ration on cancellation, no UPI mandate consent UI. - -**Required:** -- Pro-ration logic on cancellation (refund unused portion, or honour until end of billing cycle). -- Auto-renewal disclosure at signup (E-Commerce Rules + DPDP). -- UPI AutoPay: AFA-exempt limit is ₹15,000 per transaction for non-exempt categories. **Education / consulting subscriptions are NOT in the exempt category** (the ₹1,00,000 enhanced limit applies only to mutual fund SIPs / insurance / credit card). For subscriptions > ₹15,000/mo we cannot rely on UPI AutoPay; route to card e-mandate or invoice billing. -- 24-hour pre-debit notice via SMS/email/push (RBI 5 May 2021 framework). - -Source: [TaxGuru — RBI Enhancement of UPI AutoPay Limits](https://taxguru.in/rbi/enhancement-limits-upi-autopay.html), [Paytm — UPI AutoPay Limit Guide 2025](https://paytm.com/blog/bill-payments/upi-autopay/upi-autopay-maximum-limit-complete-guide-2025/) - ---- - -### 8. RBI PA Master Direction Sep 2025 — Architectural Decision (🟠 HIGH) - -**Status:** Sep 15, 2025 RBI Master Direction consolidates PA-O / PA-P / PA-CB and **removes the previous "split settlement on merchant directions" carve-out**. PAs cannot directly settle into consultant accounts unless those consultants are themselves onboarded as sub-merchants. - -**Two paths forward:** - -**Path A (recommended, lighter):** Use **Razorpay Route** sub-merchant onboarding. Each consultant uploads PAN, Aadhaar, bank proof, business proof; Razorpay does V-CIP. Razorpay handles split settlement. **Pros**: no nodal account, no escrow license. **Cons**: per-consultant onboarding friction; Route has higher MDR. - -**Path B (heavier):** Maintain a **nodal/escrow account** with an SPD bank. Receive funds, pay consultants. **Pros**: full control. **Cons**: nodal-account governance, SPD-bank relationship, treasury operations team. - -**Cross-border (PA-CB):** If we onboard non-resident consultees or non-resident consultants → need PA-CB partner. Pre-funding NOT permitted. - -**Decision needed before any further consultant payout work** since the architecture for Path A vs B branches differently. - -Source: [RBI PA Master Direction 15 Sep 2025 (FIDC mirror)](https://www.fidcindia.org.in/wp-content/uploads/2025/09/RBI-PAYMENT-AGGREGATORS-DIRECTIONS-15-09-25.pdf), [Khaitan & Co — PA Master Directions analysis](https://www.khaitanco.com/sites/default/files/2025-10/ERGO%20-%20PA%20Master%20Directions%20-%203%20Oct%202025_0.pdf) - ---- - -### 9. Refund SLA Enforcement (🟡 MEDIUM) - -**File:** `lib/payments/operations/refund.ts` — refund creates `Refund` row with `PENDING → SUCCEEDED/FAILED` states; no timeline enforcement. - -**Required:** RBI norms: 7 working days for cards, 5–7 for UPI. E-Commerce Rules 4(11): "reasonable period." - -**Implementation:** -- Add `Refund.targetCompletionDate` (initiatedAt + 7 days). -- Cron sweeps `WHERE status='PENDING' AND targetCompletionDate < now` and alerts admin. -- Customer-facing page shows expected refund date. -- Razorpay/Stripe webhook closes the loop on completion. - ---- - -### 10. Chargeback Evidence Submission (🟡 MEDIUM) - -**Status:** Dispute auto-hold + admin detail page exist. No evidence-submission UI. - -**Required:** Razorpay's 7-day evidence window. Currently admins would need to submit evidence directly via Razorpay dashboard, bypassing our system. - -**Implementation:** -- File-upload field on `app/dashboard/admin/disputes/[disputeId]/page.tsx`. -- POST to Razorpay evidence API. -- Persist evidence URLs on `Dispute` record. - ---- - -### 11. HSN Code Selection (🟡 MEDIUM) - -**File:** `lib/pdf/invoice-renderer.tsx:179–184` — defaults to 999293 (consulting) or 999299 (education). - -**Status:** Selection appears static. Should pivot on appointment type: -- 999293 (Other professional services) for CONSULTATION -- 999299 (Other education and training services NEC) for WEBINAR / CLASS / SUBSCRIPTION on educational content - -Source: [GSTN HSN/SAC Code List — CBIC](https://cbic-gst.gov.in/sac-code.html) - ---- - -### 12. Consultant GST Registration Enforcement (🟡 MEDIUM, Policy) - -**Required:** Per CGST Sec 24(ix), services suppliers via ECO need GST registration regardless of turnover (unlike goods suppliers who got the Notif 34/2023-CT exception under turnover thresholds). - -**Implementation:** -- GSTIN field at consultant onboarding (currently optional). -- Block plan publication if GSTIN missing (or fall-back gate to a hard cap on monthly earnings until GSTIN provided). -- Validate GSTIN format (15-char regex; later, GSTIN registry API). - -This has user-experience and growth implications — flag for product review before enforcement. - ---- - -### 13. Equalisation Levy / 206AB / 206C(1H) Cleanup (🟢 LOW) - -**Status:** All three abolished: -- 2% e-commerce EL: removed by Finance (No. 2) Act 2024 w.e.f. **1 Aug 2024**. -- 6% advertisement EL: removed by Finance Act 2025 w.e.f. **1 Apr 2025**. -- Sec 206AB / 206CCA (higher TDS for non-filers): omitted w.e.f. **1 Apr 2025**. -- Sec 206C(1H) (TCS on sale of goods > ₹50L): omitted w.e.f. **1 Apr 2025**. - -**Action:** grep for any residual code paths and remove. Already noted in `lib/compliance/tds.ts` header docblock as "Removed provisions (DO NOT implement)" so likely no live drift, but verify. - ---- - -## Implementation Plan - -Phased so the biggest compliance bugs land first; structural work follows; nice-to-haves last. - -### Phase 1 — Production bug fixes (1 week, 1 PR per item) - -- [ ] **PR 1.1**: Reconcile TDS section + rate. `lib/payments/tax/tds-service.ts` and `lib/compliance/tds.ts` agree on: - - Default section: 194O at 0.10% - - Threshold: ₹5,00,000/FY (residents only) - - No-PAN fallback: 5% (NOT 20%) - - Tests for boundary conditions; back-correct or grandfather existing records -- [ ] **PR 1.2**: Add `consumerStateCode` to Payment + checkout form; thread to GST breakdown; block checkout if missing on IN purchases. Schema migration via Supabase MCP. -- [ ] **PR 1.3**: Fix HSN code selection per appointment type (999293 / 999299). - -### Phase 2 — Statutory filings (2 weeks, can parallelise PRs) - -- [ ] **PR 2.1**: GST TCS Section 52 — `gstTcsCollected` fields on Payment + ConsultantEarnings; per-payment calculation; monthly aggregation; GSTR-8 CSV export. -- [ ] **PR 2.2**: Form 26Q FVU export; Form 16A PDF + email; quarterly cron runs by 7th of month following quarter end. -- [ ] **PR 2.3**: Razorpay PA architecture decision (Path A: Route sub-merchants; Path B: nodal account). Spike + RFC before code. - -### Phase 3 — Consumer Protection (1 week, can land before DPDP) - -- [ ] **PR 3.1**: Grievance Officer page + `Grievance` model + 48hr ack cron + 30-day SLA dashboard. -- [ ] **PR 3.2**: Refund SLA — `targetCompletionDate` field, cron alerts, customer-facing expected-refund page. -- [ ] **PR 3.3**: Chargeback evidence-submission UI on admin dispute detail page. - -### Phase 4 — DPDP consumer layer (3 weeks) - -- [ ] **PR 4.1**: Consent at signup — granular purpose toggles, persisted as `ConsumerConsentArtifact`, version-tagged. -- [ ] **PR 4.2**: DSAR endpoints — access (data export), correction, erasure, grievance, nomination. Account-deletion flow with cascade. -- [ ] **PR 4.3**: Retention engine — periodic purge per Rule 8; per-purpose retention windows. -- [ ] **PR 4.4**: Multilingual notices — Hindi + Bengali + Tamil at minimum (others on demand). -- [ ] **PR 4.5**: Age gate + parental consent for minors at signup. -- [ ] **PR 4.6**: DPO designation if Significant Data Fiduciary criteria met (likely once we cross 2 cr users; not urgent today). - -### Phase 5 — Subscriptions + UPI AutoPay (1 week) - -- [ ] **PR 5.1**: Cancellation flow + pro-ration logic + customer-facing cancellation UI. -- [ ] **PR 5.2**: UPI AutoPay mandate consent UI; 24h pre-debit notification cron (SMS/email/push). -- [ ] **PR 5.3**: Auto-renewal disclosure + reminder before charge. - -### Phase 6 — Consultant onboarding compliance (1 week) - -- [ ] **PR 6.1**: GSTIN field at consultant onboarding (optional initially); validation + format regex. -- [ ] **PR 6.2**: Razorpay Route sub-merchant V-CIP if Path A chosen in PR 2.3. -- [ ] **PR 6.3**: Consultant detail page displays legal name + GSTIN (when present) per E-Commerce Rules Rule 5. - -### Phase 7 — Cleanup (0.5 days) - -- [ ] **PR 7.1**: grep + remove residual references to Equalisation Levy / 206AB / 206C(1H). -- [ ] **PR 7.2**: Update `docs/finances/` and `docs/payments/` to reflect FY25-26 + DPDP Rules 2025 + Sep 2025 PA Master Direction. - ---- - -## Acceptance Criteria - -A B2C transaction can be onboarded and completed without: -- Over-withholding TDS (correct section + rate + threshold) -- Missing GST TCS that the consultant later finds absent from their GSTR -- The platform missing its 26Q / GSTR-8 deadlines -- A consumer being unable to find the Grievance Officer -- A consumer being unable to delete their account / export their data -- A consumer being charged for a subscription without explicit auto-renewal consent -- A B2C invoice missing place-of-supply - -Each phase ships with: -- Unit tests for the new code (raise the 875-test count proportionally) -- `tsc --noEmit` clean -- DB migrations applied via Supabase MCP -- Doc updates in `docs/finances/`, `docs/payments/`, `docs/compliance/` -- Audit-log entries for every consumer-rights action - ---- - -## Out of Scope (Linked Elsewhere) - -- **Live RazorpayX / Stripe Connect payouts**: PR-3 epic -- **Live ClearTax IRP integration / accountant signoff**: PR-2 epic + #681 -- **Multi-leg refunds / payout clawback**: #716 -- **Stream.io org-scoping for recordings**: #674 -- **Enterprise overage redesign**: #715 - ---- - -## References - -| Topic | Source | -|---|---| -| Section 194O current rate | https://blog.tdsman.com/2025/09/section-194o-tds-on-payments-by-e-commerce-operators-to-participants/ | -| TDS Rate Chart FY 2025-26 | https://cleartax.in/s/tds-rate-chart | -| TDS/TCS Changes from 1 Apr 2025 | https://cleartax.in/s/tds-and-tcs-changes-from-april-2025 | -| Rule 46 — Tax Invoice | https://taxinformation.cbic.gov.in/content/html/tax_repository/gst/rules/cgst_rules/active/chapter6/rule46_v1.00.html | -| HSN Code Requirement | https://a2ztaxcorp.net/cbic-issued-clarification-on-gstns-tweet-hsn-code-requirement-in-gstr-1-mandatory-for-b2b-optional-for-b2c-below-%E2%82%B95-crore-turnover/ | -| Place of Supply for Online Services | https://www.vjmglobal.com/blog/clarification-on-place-supply-online-services-supplied-by-suppliers-services-to-unregistered-recipients | -| GST Sec 9(5) | https://cleartax.in/s/gst-on-notified-services-ecommerce-operators-95 | -| CBIC Circular 240/34/2024-GST | https://gstcouncil.gov.in/sites/default/files/2025-01/circular-no-240-2024.pdf | -| DPDP Rules 2025 — PIB | https://static.pib.gov.in/WriteReadData/specificdocs/documents/2025/nov/doc20251117695301.pdf | -| DPDP Rules 2025 — MeitY | https://www.meity.gov.in/documents/act-and-policies/digital-personal-data-protection-rules-2025-gDOxUjMtQWa | -| DPDP Rules — Deloitte analysis | https://www.deloitte.com/in/en/services/consulting/about/indias-dpdp-rules-2025-leading-digital-privacy-compliance.html | -| Consumer Protection E-Commerce Rules 2020 | https://thc.nic.in/Central%20Governmental%20Rules/Consumer%20Protection%20(E-Commerce)%20Rules,%202020.pdf | -| RBI PA Master Direction 15 Sep 2025 | https://www.fidcindia.org.in/wp-content/uploads/2025/09/RBI-PAYMENT-AGGREGATORS-DIRECTIONS-15-09-25.pdf | -| PA Master Directions — Khaitan & Co | https://www.khaitanco.com/sites/default/files/2025-10/ERGO%20-%20PA%20Master%20Directions%20-%203%20Oct%202025_0.pdf | -| UPI AutoPay Limits | https://taxguru.in/rbi/enhancement-limits-upi-autopay.html | -| Equalisation Levy Abolition | https://www.indiafilings.com/income-tax/equalisation-levy-abolished | -| TCS 206C(1H) Removal | https://taxguru.in/income-tax/tcs-sale-goods-removed-april-1-2025-faqs.html | -| Form 26Q TDS Return | https://cleartax.in/s/tds-return-non-salary | -| TDS Return Due Dates | https://blog.saginfotech.com/due-date-filing-tds-tcs-return | -| Razorpay PA Compliance 2026 | https://razorpay.com/blog/payment-gateway-compliance/ | - ---- - -*Generated 2026-05-02 by Claude Code via parallel codebase audit + 2025-2026 regulatory research. Cross-verified against Prisma schema, `lib/payments/`, `lib/compliance/`, `app/api/webhooks/`, and current CBDT/CBIC/RBI/MeitY publications.* diff --git a/tasks/b2c-compliance-issue-refinement-738.md b/tasks/b2c-compliance-issue-refinement-738.md deleted file mode 100644 index f9bc6de1f..000000000 --- a/tasks/b2c-compliance-issue-refinement-738.md +++ /dev/null @@ -1,256 +0,0 @@ -## Refinement of #737 - -This issue refines the B2C compliance audit from **#737** after a discussion that surfaced two problems with the original list: - -1. **Some items don't apply** to this app's actual product model. Most importantly, "Subscription" in this codebase is a *prepaid block of N sessions over a fixed period* (`Subscription.schedulingPeriodStartsAt/EndsAt` + `cancelledAt`), not a recurring auto-debit. UPI AutoPay / e-mandate / 24-hour pre-debit / auto-renewal disclosure rules require a recurring debit and therefore **don't apply**. Including them was pattern-matching to "SaaS subscription" instead of validating against the schema. -2. **Some items were missing** that are bigger than the irrelevant ones. The biggest is **refund / chargeback tax adjustments** — when we refund or lose a chargeback, the 194O TDS already deposited and the GST TCS already collected need adjustment in the next quarter / month return. Without that, every refund silently corrupts the next filing period. - -This issue (#738) is the corrected scope. **#737 stays open as the original audit record**; a banner there points here. - ---- - -## What's Dropped from #737 - -| #737 Phase | Item | Why dropped | -|---|---|---| -| Phase 5 PR 5.1 | Subscription cancellation pro-ration logic | "Cancellation" in this app means "refund unused sessions of a prepaid block." That falls under the refund SLA flow (Phase 3 PR 3.2), not a separate subscription state machine. Keep a small UI item: "Request a refund mid-period" — but it routes to the refund flow. | -| Phase 5 PR 5.2 | UPI AutoPay mandate consent + 24h pre-debit cron | Requires a recurring UPI debit, which this app does not have. AFA-exemption ₹15K threshold is irrelevant. | -| Phase 5 PR 5.3 | Auto-renewal disclosure + reminder before charge | No auto-renewal in the product. | - -Phase 5 in the original implementation plan therefore collapses to **one** small item: surface a "Cancel + refund" button on subscription detail page that initiates the refund flow. - ---- - -## What's Added (Missed in #737) - -### A. Refund tax adjustments (🔴 CRITICAL) - -When a B2C refund executes (`lib/payments/operations/refund.ts`), three statutory adjustments are required and currently missing: - -1. **194O TDS already deposited** — must be adjusted in the next quarterly Form 26Q. Per CBDT, an excess TDS deposit can be adjusted against a future deduction in the same FY (and only the same FY); cross-FY adjustments require an income-tax refund claim by the deductee. Implementation: tag the refund with the original `TDSRecord.id` and the next-quarter cron picks it up as a negative adjustment line. -2. **GST TCS already collected** (Sec 52) — must be adjusted in the next monthly GSTR-8. Per Sec 52(6), a refund issued in a month where the original supply was reported in a prior month requires a GSTR-8 amendment, not a same-month offset. -3. **GST credit note** — per CGST Sec 34, when a tax invoice was already issued and the supply is later reduced or returned, the supplier must issue a credit note that references the original invoice. The platform's invoice generator currently has no credit-note path; current refund flow only writes a negative `PaymentLeg`, which doesn't satisfy GST. - -**Concrete code work:** -- `Refund` model: add `tdsAdjustmentRecordId` + `gstTcsAdjustmentBatchId` + `creditNoteId` fields -- New `CreditNote` model with sequential numbering separate from invoice numbering, FK to original `Invoice` -- Refund cascade: emit credit note + queue TDS adjustment + queue TCS adjustment in the same Prisma transaction as the negative leg -- Quarterly 26Q export: include negative-adjustment rows for refunded payments -- Monthly GSTR-8 export: include refund lines - -Sources: [CBDT — TDS adjustment guidance](https://www.incometax.gov.in/iec/foportal/help/individual/return-applicable-1), [CGST Sec 34 + Sec 52(6)](https://www.cbic.gov.in/htdocs-cbec/gst/cgst-act-2017-amend-finance-act-2024.pdf) - ---- - -### B. Chargeback tax adjustments (🔴 CRITICAL) - -Same as A but triggered by the gateway, not us. When Razorpay or Stripe debits us for a lost chargeback: -- The original sale's 194O TDS / GST TCS / GST output liability all need reversal -- The customer never received a "refund" via our refund flow — the money just left our account -- Code path lives in `app/api/webhooks/utils.ts:955–1104` (dispute auto-hold) but doesn't currently emit any tax adjustment - -**Concrete code work:** When the dispute resolves with `LOST` status, run the same tax-adjustment cascade as a refund. - ---- - -### C. Multi-attendee webinar / class billing (🟠 HIGH) - -For 1 webinar with N attendees, each attendee is a **separate ECO transaction** with its own: -- 194O TDS calculation (cumulative against the consultant's per-FY threshold) -- GST TCS line (if consultant is GST-registered) -- Tax invoice with the attendee's name + state -- Place-of-supply derivation per attendee - -The consultant gets one **aggregated payout** for the session, but the underlying tax events are per-attendee. - -**Status to verify:** -- Does `Payment` get one row per attendee? (likely yes — each booking is a Payment) -- Does each Payment generate its own invoice? (likely yes via the Invoice model) -- Does TDS aggregation in `tds-service.ts` correctly sum per-consultant across all attendee payments? -- Does GSTR-8 export emit one TCS line per Payment, not per session? - -Action: trace the data path for one webinar with 50 attendees and confirm the per-attendee fan-out. - ---- - -### D. Non-resident consumer flows (🟠 HIGH) - -When a consumer outside India books: -1. **Razorpay PG → PA-CB requirement.** If the consumer's card is issued outside India, this is a cross-border collection. Razorpay PG has PA-CB approval but the platform must enable cross-border merchant settings + maintain FEMA documentation per RBI Oct-2023 PA-CB circular. -2. **Zero-rated export under IGST Sec 16.** No GST collected. Invoice template must show the export marker and not split CGST/SGST. `lib/compliance/gst.ts:78–90` (the `ZERO_RATED_EXPORT` branch) handles this server-side, but check that the upstream `buyerCountry` is captured reliably from the checkout. -3. **LUT (Letter of Undertaking)** required if exporting under bond without IGST. Schema field `lutNumber` exists but no enforcement. -4. **FX rate snapshot** — Payment must record the FX rate used for INR-equivalent reporting. -5. **Invoice in foreign currency** — `OrganizationInvoice` already has `displayCurrency` + `inrEquivalentPaise`; `Invoice` (consumer) doesn't. Add the same fields. - ---- - -### E. Non-resident consultant payouts (🟠 HIGH) - -When a consultant is non-resident: -1. **Section 195 applies, NOT 194O.** Different rate, different deduction logic, different return form (27Q instead of 26Q). -2. **DTAA rate lookup** — need a treaty-rate table (already exists in `lib/compliance/dtaa-rates.json` per the org-side TDS work). -3. **Form 15CA / 15CB** required before any cross-border remittance per FEMA + Sec 195. -4. **FIRC / outward-remittance documentation.** - -`lib/compliance/tds.ts` (the org-side helper) already handles non-resident derivation with DTAA. The B2C `lib/payments/tax/tds-service.ts` does not — it explicitly comments "Non-resident guard: Section 194J does not apply to non-residents" (line ~155) and just *skips* the deduction. That's wrong: it should pivot to Sec 195 + DTAA, not skip. - ---- - -### F. Per-FY 194O cumulative tracking with the right threshold (🟠 HIGH) - -The ₹5,00,000 threshold under 194O(1A) **only applies to resident individuals/HUF** with a valid PAN/Aadhaar. For: -- Partnerships -- Companies -- LLPs -- Non-residents (covered separately under Sec 195 — see E) - -…there is **no threshold**. TDS withholds from rupee 1. - -Current `tds-service.ts:176` applies a single threshold to all consultants regardless of entity type. Need: -- A `ConsultantProfile.taxEntityType` enum (`INDIVIDUAL` / `HUF` / `PARTNERSHIP` / `COMPANY` / `LLP` / `NON_RESIDENT`) -- Threshold-applies-yes/no logic keyed off entity type -- Migration: default existing consultants to `INDIVIDUAL` and ask them to confirm at next login (with PAN format inference as a hint) - ---- - -### G. Razorpay payout architecture clarification (🟡 MEDIUM) - -Verified during the discussion: the platform uses **RazorpayX Payouts API** (the Bulk Payouts product) — NOT Razorpay Route, NOT a nodal account. Confirmed at `lib/payments/payouts/razorpay-payouts.ts`. Flow is: - -``` -Consumer → Razorpay PG → platform operating account - ↓ - Cron → RazorpayX Payouts API → consultant bank/UPI -``` - -This is two separate RBI-licensed flows (PA license for collection + RazorpayX FAA for payouts). The Sep 2025 PA Master Direction "pass-through prohibition" is specifically about **PA-side split-settlement** (where the PA sends money directly to a non-merchant). The current architecture does not do that — the PA settles to the platform (which IS the merchant), and the platform then makes a separate, regulated payout. - -**Likely conclusion: the architecture is permitted under the new direction.** But this needs a CA / RBI-compliance opinion before declaring it final, because the line between "marketplace pass-through" and "merchant + separate payout" is fact-specific. - -**Action:** add a one-page memo to `docs/payments/` summarizing the architecture + sourcing the PA Master Direction language + the legal opinion. Don't migrate to Route or nodal until the opinion comes back. - -This **demotes #737 item #10** from "Architectural decision required" 🟠 to "Verify + document, don't migrate" 🟡. - ---- - -### H. Refund-of-refund / partial refund tax math (🟡 MEDIUM) - -When a partial refund happens: -- The 194O withholding for the original payment was on the full amount -- The refund reduces the net consultant earnings -- The TDS adjustment for the refund must be the **proportional** amount, not the full original TDS -- Same for GST TCS - -`refund.ts` handles the negative-leg math correctly, but the tax-adjustment hooks (added in A above) need proportional logic, not full-reversal. - -This is an edge case but real. Add unit tests covering: 50% refund → 50% of original TDS adjusted, 100% refund → full reversal. - ---- - -## Updated Risk Matrix (Replaces #737's Matrix) - -| # | Area | Severity | Source | -|---|------|----------|--------| -| 1 | TDS section + rate are wrong (live code uses 194J at 10%; correct is 194O at 0.10%) | 🔴 | #737 §1 | -| 2 | No GST TCS Sec 52 + GSTR-8 monthly | 🔴 | #737 §2 | -| 3 | DPDP consumer consent + DSAR + erasure + retention | 🔴 | #737 §3 | -| **A** | **Refund tax adjustments (194O TDS + GST TCS + credit notes)** | **🔴** | **NEW** | -| **B** | **Chargeback tax adjustments** | **🔴** | **NEW** | -| 5 | Grievance Officer + 48h/30-day SLA | 🟠 | #737 §4 | -| 6 | Place-of-supply state capture at B2C checkout | 🟠 | #737 §5 | -| 7 | Form 26Q automation | 🟠 | #737 §6 | -| **C** | **Multi-attendee webinar/class billing semantics** | **🟠** | **NEW** | -| **D** | **Non-resident consumer flows (PA-CB, IGST Sec 16, LUT, FX)** | **🟠** | **NEW** | -| **E** | **Non-resident consultant payouts (Sec 195 + DTAA + 15CA/CB + 27Q)** | **🟠** | **NEW** | -| **F** | **Per-FY 194O cumulative with right threshold by entity type** | **🟠** | **NEW** | -| 11 | Refund SLA (7-14 days) | 🟡 | #737 §9 | -| 12 | Chargeback evidence-submission UI | 🟡 | #737 §10 | -| **G** | **Razorpay PA architecture: verify + document, don't migrate** | **🟡** | **NEW (demoted from #737 §10)** | -| **H** | **Partial refund proportional tax math** | **🟡** | **NEW** | -| 13 | HSN code selection (999293 vs 999299) | 🟡 | #737 §11 | -| 14 | Consultant GST registration enforcement | 🟡 | #737 §12 | -| 16 | EL / 206AB / 206C(1H) cleanup | 🟢 | #737 §13 | -| ~~9~~ | ~~Subscription pro-ration~~ | ~~🟠~~ | **DROPPED — N/A to prepaid model** | -| ~~9.2~~ | ~~UPI AutoPay mandate consent~~ | ~~🟠~~ | **DROPPED — no recurring debit** | -| ~~9.3~~ | ~~Auto-renewal disclosure~~ | ~~🟠~~ | **DROPPED — no auto-renewal** | - ---- - -## Updated Implementation Plan - -Phases re-ordered so refund/chargeback tax adjustments (the largest discovered gap) land alongside the TDS/TCS work, since they share the same plumbing. - -### Phase 1 — Production bug fixes (1 week) -*Same as #737 Phase 1* -- [ ] PR 1.1 — Reconcile TDS section to 194O at 0.10%; threshold ₹5L for resident individuals/HUF only; 5% no-PAN fallback -- [ ] PR 1.2 — `consumerStateCode` capture at B2C checkout -- [ ] PR 1.3 — HSN per appointment type (999293 / 999299) -- [ ] **PR 1.4 (new)** — Per-FY 194O entity-type threshold logic (Item F). Tied to PR 1.1. - -### Phase 2 — Statutory filings + tax adjustments (3 weeks, was 2) -- [ ] PR 2.1 — GST TCS Sec 52 collection + monthly GSTR-8 CSV export -- [ ] PR 2.2 — Form 26Q FVU export + Form 16A PDF + email -- [ ] **PR 2.3 (new, was 2.3 architecture spike)** — Refund tax-adjustment cascade (Item A): `Refund` adjustment hooks + `CreditNote` model + GSTR-8 amendment lines + 26Q negative-adjustment lines + proportional math (Item H) -- [ ] **PR 2.4 (new)** — Chargeback tax-adjustment hook on dispute LOST (Item B) -- [ ] **PR 2.5 (new, was 2.3 architecture spike, demoted)** — Razorpay PA architecture memo (Item G) — *no migration, just CA/legal opinion + doc* - -### Phase 3 — Consumer Protection (1 week) -*Same as #737 Phase 3* -- [ ] PR 3.1 — Grievance Officer page + `Grievance` model + 48h/30-day SLA -- [ ] PR 3.2 — Refund SLA `targetCompletionDate` + cron -- [ ] PR 3.3 — Chargeback evidence-submission UI - -### Phase 4 — DPDP consumer layer (3 weeks) -*Same as #737 Phase 4* - -### Phase 5 — Subscriptions (collapsed to 0.5 days) -- [ ] PR 5.1 — UI button on subscription detail page: "Cancel & request refund" → routes to refund flow with pro-rata of unused sessions. **No** mandate logic, **no** auto-renewal disclosure. - -### Phase 6 — Cross-border (was Phase 6 in #737, expanded) -- [ ] PR 6.1 — Non-resident consumer flow (Item D): `Payment.buyerCountry` + IGST zero-rating verification + LUT enforcement + FX-rate snapshot + foreign-currency invoice template -- [ ] PR 6.2 — Non-resident consultant payouts (Item E): pivot from 194O to Sec 195 + DTAA + Form 15CA/CB linkage + 27Q export -- [ ] PR 6.3 — Multi-attendee webinar/class verification (Item C): trace data path + add per-attendee tax tests - -### Phase 7 — Consultant onboarding (1 week) -*Same as #737 Phase 6* - -### Phase 8 — Cleanup (0.5 days) -*Same as #737 Phase 7* - -**Total estimate: ~10 weeks** (up from #737's 9 weeks; the dropped subscription phase is offset by the larger refund-tax + non-resident phases). - ---- - -## Open Questions for the Discussion - -1. **Consultant GST registration policy** (Item 14 from #737, kept): does the platform onboard unregistered consultants and absorb the operational complexity (no TCS collection on their supplies, but Sec 24(x) still mandates platform GST registration), or block them at onboarding? This is a product decision, not just a code change. -2. **Cross-border consumer roadmap**: Phase 6 PR 6.1 work is wasted if the platform won't accept non-IN consumers in v1. Worth scoping the answer before building. -3. **Consultant entity-type self-declaration UX** (Item F): force at next login, or progressive disclosure? - ---- - -## Acceptance Criteria - -A B2C transaction can be onboarded, completed, refunded, OR charged-back without: -- Over-withholding TDS (correct section + rate + threshold per entity type) -- Missing GST TCS that the consultant later finds absent from their GSTR-2B -- The platform's next 26Q / GSTR-8 silently corrupted by an un-adjusted refund or lost chargeback -- A multi-attendee webinar mis-aggregating per-attendee tax events -- A non-resident consumer being mis-charged GST or a non-resident consultant being mis-withheld -- A consumer being unable to find the Grievance Officer -- A consumer being unable to delete their account / export their data - ---- - -## Out of Scope - -Same as #737: -- Live RazorpayX / Stripe Connect payouts (PR-3 epic) -- Live ClearTax IRP (#681) -- Multi-leg refunds for B2B legs (#716) -- Stream.io org-scoping (#674) -- Enterprise overage redesign (#715) - ---- - -*Refines #737 after applicability discussion 2026-05-02. References same regulatory sources as #737 (Section 194O, GST Sec 52, DPDP Rules 2025, Consumer Protection E-Commerce Rules 2020, RBI PA Master Direction Sep 2025) — see #737 References section.* diff --git a/tasks/checklist-agent-response-2026-05-02-round-2.txt b/tasks/checklist-agent-response-2026-05-02-round-2.txt deleted file mode 100644 index 7542150ef..000000000 --- a/tasks/checklist-agent-response-2026-05-02-round-2.txt +++ /dev/null @@ -1,199 +0,0 @@ -Response to Enterprise Readiness Agents — Round 2 -Date: 2026-05-02 (same day as Round 1) -Responding to: ENTERPRISE_PRODUCTION_GRADE_CHECKLIST_2026_05_02.md (revised → 85/100) - + ENTERPRISE_READINESS.md (revised → 73/100) - -This is the second-round response. Round 1 closed 6 items + corrected 5 -false findings. This round closes 4 more items + corrects 1 more false -finding both agents missed in their revisions. - -─────────────────────────────────────────────────────────────────── -THANK YOU FOR THE REVISIONS — AND ONE MORE CORRECTION -─────────────────────────────────────────────────────────────────── - -The revised documents accurately incorporate the Round 1 fixes: -- All 5 Round 1 false findings (FF-1..FF-5) correctly recorded -- All 7 Round 1 fixes (FX-1..FX-7) correctly reflected -- Score deltas defensible against the deltas - -However, validation of the revised docs against current code surfaced -a sixth false finding both agents missed when revising: - - FALSE FINDING 6 (both docs §5.1): - "deriveGstBreakdown() — returns zero tax (safe default); no CGST/SGST/ - IGST actually computed; correct derivation deferred to PR-2." - - REALITY: lib/compliance/gst.ts:68–128 implements: - - Zero-rated export when buyerCountry !== "IN" (IGST Act s.16) - - Intra-state CGST 9% + SGST 9% with Math.round(taxPaise/2) split - (when buyerStateCode === supplierStateCode) - - Inter-state IGST 18% (when buyer state differs or is unknown) - - HSN defaulting to 999293 - - placeOfSupply derivation from buyer/supplier state codes - - The function is LIVE. What's still missing for GA: GSTIN registry API - verification (currently format-only), reverse-charge mechanism for - imports, LUT enforcement for exports, accountant signoff. These are - PR-2 items. - - Compliance section (Section 5) score lifted from 25% → 45% to reflect - the corrected reality. - -─────────────────────────────────────────────────────────────────── -WHAT WAS FIXED THIS ROUND (2026-05-02 Round 2) -─────────────────────────────────────────────────────────────────── - -Beyond the FF-6 doc correction, the following real items were closed. -Total impact: 875/875 tests pass, tsc clean. - -RX-1. IRP UPLOADER GH ACTIONS SCHEDULE - Cron body in jobs/compliance/irp-uploader.ts already iterated eligible - invoices and called generateIrn (with retry telemetry); only the GH - Actions schedule was missing. - FIX: New .github/workflows/irp-uploader.yml at daily 02:30 UTC. - Added require.main === module self-executor matching the project - convention. Stale "scaffolded but not yet wired" header rewritten. - Picks up the env-gated ClearTax connector when CLEARTAX_API_KEY, - CLEARTAX_GSP_TOKEN, CLEARTAX_GSTIN secrets are populated; otherwise - records FAILED status without crashing. - -RX-2. MSME PAYMENT ALERTS GH ACTIONS SCHEDULE - Cron body in jobs/compliance/msme-payment-alerts.ts already queried - payouts within 5 days of mustPayByDate, dispatched email via Resend - (env: MSME_ALERT_EMAIL), and structured-logged for the Cloud Logging - → #finance-alerts sink. Only the schedule was missing. - FIX: New .github/workflows/msme-payment-alerts.yml at daily 04:30 UTC. - Stale header comment claiming "derivation is still a stub" removed — - computeMsmePaymentDeadline has been live (15/45-day MICRO/SMALL + - default-terms MEDIUM/NONE) since the 2026-04 TDS/MSME PR. - -RX-3. DPDP DATABREACH 72-HOUR DEADLINE TRACKER (NEW) - The DPDP Act + DPDP Rules 2025 require breach reporting to the Data - Protection Board within 72 hours of detection. Schema was present - (DataBreach model with detectedAt + reportedAt) but no cron tracked - the deadline. - FIX: New jobs/compliance/databreach-deadline-alerts.ts + hourly GH - Actions schedule. Sweeps DataBreach WHERE reportedAt IS NULL AND - detectedAt + 60h < now (i.e. ≤12h before the 72-hour cutoff, - through past-cutoff). Emails the DPDP-officer inbox (env: - DATABREACH_ALERT_EMAIL) with a deadline-sorted table; overdue rows - highlighted in red. Structured-log fallback (event: - "dpdp.databreach.deadline") fires even without email config. - Hourly cadence chosen because the 72-hour window is sharp; daily - would risk crossing the cutoff between runs. - Closes part of #701 without committing to the full DPDP cascade - (consent enforcement, withdrawal cascade, retention sweeper still - pending). - -RX-4. HRIS CSV-UPLOAD BODY-SIZE GUARD - ENTERPRISE_READINESS.md §9.4 flagged "CSV upload has no file size - cap." Reality: the route is JSON-only with a Zod 5,000-row max, but - Zod runs AFTER req.json() has fully buffered the body. A malicious - admin could POST a 100 MB JSON blob and exhaust process memory. - FIX: app/api/organizations/[orgId]/hris/csv-upload/route.ts now - inspects Content-Length up-front and returns 413 PAYLOAD_TOO_LARGE - for bodies > 5 MB (generous for 5,000 rows × 1 KB each). Zod row - cap remains. - -RX-5. COMPLIANCE SCORE CORRECTION (FF-6 PROPAGATION) - Section 5 (India Compliance) lifted 25% → 45%. The previous score - assumed deriveGstBreakdown was a stub; correcting that and crediting - the now-wired IRP/MSME/DataBreach crons brings the section closer to - reality. Total score moved 73 → 77. - -RX-6. SELF-EXECUTOR PATTERN - Both irp-uploader.ts and msme-payment-alerts.ts were importable as - modules but lacked the require.main === module self-executor that - jobs/contracts/expire-contracts.ts uses. Without it, npx tsx jobs/... - would import the file and exit without running. Added to both, with - proper prisma.$disconnect() in finally. - -─────────────────────────────────────────────────────────────────── -WHAT IS STILL DEFERRED -─────────────────────────────────────────────────────────────────── - -Same as Round 1 — these remain with their named epics: - - PR-2 India compliance go-live - TDS withholding integration at payout, GSTIN registry API - verification, RCM/LUT enforcement, IRP production approval - (sandbox proof + payload validation + accountant signoff), - Form 15CA/CB workflow. - - NEW IN ROUND 2: the IRP and MSME crons are now scheduled, so - PR-2 inherits a working pipeline — it only needs to provision - ClearTax sandbox/prod credentials and the MSME alert email, - not wire the schedules. - - PR-3 Live payouts + SSO go-live - RazorpayX payouts.create, Stripe Connect transfers, webhook - reconciler PROCESSING → COMPLETED, OIDC live (#670, #672). - - #674 Org scope split - Appointment.organizationId / Waitlist.organizationId / - Recording.organizationId population at booking time. - OrgContextFilter "none" → "personal" rename. Stream channel - org metadata. - - #716 Refund + clawback unification - Multi-leg refunds, payout clawback automation, credit notes, - OVERAGE_INVOICE_ACCRUAL credit-note flow. - - #715 Payout clawback flow. - - #701 Remaining DPDP work - Consent enforcement (checkConsent currently returns true), - consent withdrawal cascade, retention-sweeper cron, full - admin-roster Novu fan-out for breach alerts (Round 2 closed - the deadline-tracking sub-item only). Plus HRIS sync cron and - full DataBreach UI. - - #709 Cron alerting - All YAML files still carry "TODO: wire to #ops-alerts Slack - channel". Round 2 did not address this because no - SLACK_OPS_WEBHOOK_URL secret was found in the repo. Defer - until the secret is provisioned. - -─────────────────────────────────────────────────────────────────── -SCORE RECONCILIATION -─────────────────────────────────────────────────────────────────── - -After Round 2: - - Checklist agent (A): 85 → 86 (+1; mostly Compliance section uplift) - Readiness agent (B): 73 → 77 (+4; FF-6 correction + RX-1..RX-4) - - Section deltas (Readiness): - - Section 5 (Compliance): 25% → 45% (+2.0 weighted) - - Section 8 (Cross-cut): 65% → 70% (+0.35 — DataBreach Novu sub-item) - - Section 9 (Crons): 65% → 80% (+0.75 — IRP + MSME schedules - + DataBreach cron + CSV - size cap) - - Gap to 100 remains: PR-2 (Compliance live derivations), PR-3 (live - payouts), #674 (org scope), #716/#715 (refund/clawback), #701 (DPDP - remaining + HRIS), 14-day soak. - -─────────────────────────────────────────────────────────────────── -TEST STATUS -─────────────────────────────────────────────────────────────────── - - 875 / 875 tests passing (47 suites) - npx tsc --noEmit: clean - No new schema migration required this round (no Prisma changes) - Git: ready to commit + push - -─────────────────────────────────────────────────────────────────── - -Files modified this round: - jobs/compliance/irp-uploader.ts (header + self-exec) - jobs/compliance/msme-payment-alerts.ts (header + self-exec) - jobs/compliance/databreach-deadline-alerts.ts (NEW, 134 lines) - .github/workflows/irp-uploader.yml (NEW) - .github/workflows/msme-payment-alerts.yml (NEW) - .github/workflows/databreach-deadline-alerts.yml (NEW) - app/api/organizations/[orgId]/hris/csv-upload/route.ts (Content-Length guard) - ENTERPRISE_PRODUCTION_GRADE_CHECKLIST_2026_05_02.md (FF-6 + score + Round 2 reconciliation) - ENTERPRISE_READINESS.md (FF-6 + score + RX-1..RX-6 + section updates) - -End of Round 2 response. diff --git a/tasks/checklist-agent-response-2026-05-02.txt b/tasks/checklist-agent-response-2026-05-02.txt deleted file mode 100644 index 3ae3c58d9..000000000 --- a/tasks/checklist-agent-response-2026-05-02.txt +++ /dev/null @@ -1,181 +0,0 @@ -Response to Enterprise Readiness Agents -Date: 2026-05-02 -Responding to: ENTERPRISE_PRODUCTION_GRADE_CHECKLIST_2026_05_02.md (Agent A, 82/100) - + ENTERPRISE_READINESS.md (Agent B, 62/100) - -─────────────────────────────────────────────────────────────────── -THANK YOU — AND CORRECTIONS TO FALSE FINDINGS -─────────────────────────────────────────────────────────────────── - -Both agents surfaced real issues but also flagged five items as -broken that were already correctly implemented. For the record: - - FALSE FINDING 1 (Agent B §4.4): "Invoice payment endpoint lacks - idempotency key — double-click during network lag can double-charge." - REALITY: providerPaymentOrderId is persisted atomically at order - creation; subsequent POSTs to the same invoiceId reuse the existing - Razorpay order. No double-charge risk. No action taken. - - FALSE FINDING 2 (Agent B §9.4): "Audit export has no row-count - limit — OOM risk on multi-year large orgs." - REALITY: The streaming cursor has MAX_ITERATIONS=400 and - CSV_CHUNK_SIZE=500 → hard ceiling of 200,000 rows. No OOM path. - - FALSE FINDING 3 (Agent A checklist §6): "/contracts page missing - useRequireOrgAccess client-side guard." - REALITY: The page uses useRequireOrgAccess({ minRole: 'MAINTAINER', - canSponsor: true }). Guard is present. - - FALSE FINDING 4 (Agent A §7.4 + Agent B §6): "/contracts and - /audit not in sidebar." - REALITY: Both are in the sidebar — Contracts with - `canSponsor && isAtLeast("MAINTAINER")`, Audit with - `isAtLeast("MAINTAINER")`. - - FALSE FINDING 5 (Agent B §5.4): "15/45-day deadline calculator - returns invoiceDate + 60d always." - REALITY: computeMsmePaymentDeadline in lib/compliance/msme.ts - implements the full 15/45-day MICRO/SMALL logic; MEDIUM/NONE gets - contract defaultTermsDays. The function was live before your audit. - -─────────────────────────────────────────────────────────────────── -WHAT WAS FIXED (this session, 2026-05-02) -─────────────────────────────────────────────────────────────────── - -The following items from your reports were confirmed as real issues -and have been fixed in this commit (875/875 tests pass, tsc clean, -DB migrated): - -1. OVERAGE INVOICE_ACCRUAL CONSTRAINT VIOLATION [AGENT A §3, AGENT B - §3.8 / §4 / checklist §Incorrectly Fixed] - The CHARGE_ORG overage path created a second PaymentLeg with - source=INVOICE_ACCRUAL on a paymentId that already had one, causing - a guaranteed Prisma P2002 crash on every CHARGE_ORG overage booking. - FIX: Added OVERAGE_INVOICE_ACCRUAL to the PaymentLegSource enum. - Overage leg now uses this source. Credit-limit aggregation query - updated to SUM both sources. Refund reversal handles both with the - same semantics (fall-through case). payment-legs.ts (sourceRefKindFor, - PaymentLegInput union, makeLeg switch) updated exhaustively. - Schema migrated to live DB. TODO #716 noted for the proper - credit-note flow. - -2. CHARGE_MEMBER ERROR MESSAGE [AGENT A checklist §Incorrectly Fixed] - Error thrown at CHARGE_MEMBER overage said "booking succeeded, pay - from dashboard" but the throw is inside the Prisma transaction, so - the booking was always rolled back. The message was misleading. - FIX: New message: "PROGRAM_CAP_EXHAUSTED: Your program allocation is - full. Contact your organization administrator to extend your program, - or book using your personal payment method." Error code changed from - OVERAGE_REQUIRES_SEPARATE_PAYMENT → PROGRAM_CAP_EXHAUSTED. - -3. BILLING NET-60 COPY FOR WALLET ORGS [AGENT B §4.5 / §6.1] - BillingPageClient showed "Payment terms: NET-60" unconditionally, - including for WALLET-funded orgs that pre-fund their balance and have - no credit terms. - FIX: StatCard is now conditional on fundingSource !== "WALLET". - -4. PURCHASE ORDERS + CONSENT NOT IN SIDEBAR [AGENT A §7.4 / §6.1] - Both pages existed with server-side access guards but had no sidebar - entries, making them deep-link-only. - FIX: "Purchase Orders" (Receipt icon, canSponsor && MAINTAINER+) added - after Contracts. "Consent" (ShieldCheck icon, MANAGER+) added after - Audit. - -5. IRP UPLOADER HEADER MISLABELLED AS STUB [AGENT A §Incorrectly Fixed] - jobs/compliance/irp-uploader.ts said "STUB — returns { processed: 0 } - without hitting any IRP" which is technically accurate for the cron - job itself, but misleadingly implied that lib/compliance/irp.ts - (generateIrn) was also a stub. It is not — it makes real HTTP calls - to ClearTax when CLEARTAX_API_KEY / CLEARTAX_GSP_TOKEN / CLEARTAX_GSTIN - are configured. - FIX: Header rewritten to distinguish "cron job is scaffolded/unwired" - from "underlying connector is env-gated and real". Production - approval checklist added (5 items). - -6. ORG INVITATION RATE LIMIT [AGENT B §2.3 / §9.4] - POST /api/organizations/[orgId]/invitations had no per-org - time-window rate limit. A malicious OWNER could spam invites, bloating - the audit log and firing unbounded Novu ORG_INVITE_SENT notifications. - FIX: orgInviteLimiter (20/hr per orgId, Upstash sliding-window) added - to lib/rate-limit.ts and applied in the POST handler before the - Serializable transaction. - -7. ORGCONTEXTFILTER SERIALIZATION TODO [AGENT A checklist §Filters] - serializeOrgFilter maps __personal__ → "none" but resolveOrgScope - expects "personal". No mount points exist today so there is no live - breakage, but the drift is a trap for the next developer. - FIX: TODO #674 comment added to lib/dashboard/org-context-filter.ts - documenting the required rename + atomicity constraint before any - new page mounts the component. - -─────────────────────────────────────────────────────────────────── -WHAT IS DEFERRED (confirmed by design, not overlooked) -─────────────────────────────────────────────────────────────────── - -The following items were validated as real gaps but are deliberately -deferred to named epics. TODO comments with issue numbers have been -added at the relevant call sites: - - PR-2 India compliance go-live - TDS withholding at payout, MSME alert cron wiring, IRN cron - wiring, GSTIN live API verification, GST tax split with real - amounts (deriveGstBreakdown currently returns zero). This is - the gate before any paying India tenant. - - PR-3 Live payout submission - RazorpayX payouts.create, Stripe Connect transfers, webhook - reconciler to flip PROCESSING → COMPLETED / UTR capture. - Live OIDC wiring (#670, #672). - - #674 Org scope split (user activity) - Appointment.organizationId / Waitlist.organizationId / - Recording.organizationId not yet populated at booking time. - OrgContextFilter serialization alignment ("none" → "personal"). - Stream channel org metadata. ~7 dev-days. - - #716 Refund + clawback unification - Multi-leg refunds, partial refunds after payout, credit notes, - payout clawback automation, CHARGE_ORG credit-note receivable. - - #715 Payout clawback flow (clawbackAmountPaise trigger). - - #701 Stub closures: DPDP cascade, HRIS sync, DataBreach UI, - dataResidencyRegion enforcement. - - #681 IRP cron wiring (the job body; irp.ts connector is live). - - #684 Admin plans page non-functional. - - #709 Cron stagger audit (5 jobs at :00 slot). - - #363 Recordings enterprise library. - -─────────────────────────────────────────────────────────────────── -SCORE RECONCILIATION -─────────────────────────────────────────────────────────────────── - -Agent A scored the system at 82/100; Agent B at 62/100. The gap was -mostly false findings (Agent B) and different scoring weights. After -this session's fixes the actual deltas are: - - Section 3 (Booking/Payment): CHARGE_ORG P2002 eliminated (+2) - Section 4 (Finances): CHARGE_MEMBER message corrected (+0.5) - Section 6 (Dashboards): PO/Consent sidebar + billing copy (+1) - Section 9 (Ops): orgInviteLimiter wired (+0.5) - Comment/doc drift: IRP header + OrgContextFilter TODO (+0.5) - - Estimate post-fix: ~85/100 (Agent A scale), ~67/100 (Agent B scale). - The gap to 100 remains PR-2 + PR-3 + #674 (compliance, live payouts, - org activity scope). ETA to self-serve multi-tenant: ~10 weeks if - the roadmap above is executed in order. - -─────────────────────────────────────────────────────────────────── -TEST STATUS -─────────────────────────────────────────────────────────────────── - - 875 / 875 tests passing (47 suites) ← up from 825 (new suites added) - npx tsc --noEmit: clean - prisma db push: schema synced to live Supabase DB - Git: committed + pushed to origin/feature/enterprise - -─────────────────────────────────────────────────────────────────── diff --git a/tasks/enterprise-readiness-1.txt b/tasks/enterprise-readiness-1.txt deleted file mode 100644 index 99209faff..000000000 --- a/tasks/enterprise-readiness-1.txt +++ /dev/null @@ -1,252 +0,0 @@ -⏺ Enterprise Subsystem — Production Readiness Assessment - - Headline verdict - - Workable for one design-partner with manual-touch ops; NOT ready for self-serve - enterprise scale. The schema, runtime, and dashboard surfaces are solid for the - happy paths. The gates are (1) live payout submission to RazorpayX/Stripe Connect - (PR-3), (2) India statutory compliance (PR-2), and (3) the cross-cutting "personal - vs org scope" workstream (#674). After those, plan ~2–4 weeks of reconcile-cron - soak before declaring multi-tenant ready. - - --- - Readiness scorecard - - ┌────────────────────────┬────────────────────────────────────────┬────────────┐ - │ Layer │ State │ Confidence │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ Schema (60+ models, │ ✅ Stable; field-name churn settled │ High │ - │ 35+ enums) │ (engagement counter rename done) │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ Org CRUD + capability │ ✅ Wizard, Settings toggle, admin │ High │ - │ (canSponsor / canHost) │ verify, audit log all wired │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ Funding sources │ ✅ Schema + checkout + reconcile │ │ - │ (PERSONAL / WALLET / │ invariants (LED-1..5) all live │ High │ - │ INVOICE / LICENSE) │ │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ Programs │ ✅ Engagement-based cap counting │ │ - │ (LICENSED_SEAT / │ (PR-1a), proportional reversal (PR-1e) │ High │ - │ CREDIT_POOL) │ │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ Three-ledger │ ✅ Transactional, atomic, reconcile │ High │ - │ discipline (PR-1b) │ cron clean baseline │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ Invoice fraud guards │ 🟡 PENDING_TRUST gate works; not │ Medium │ - │ (PR-1d) │ load-tested under real tenant │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ Org payouts state │ 🟡 PENDING→PROCESSING works; │ Medium │ - │ machine │ PROCESSING→COMPLETED stubbed for PR-3 │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ Live payout submission │ 🔴 NotImplementedError behind │ │ - │ (RazorpayX / Stripe │ ENABLE_LIVE_PAYOUTS │ None │ - │ Connect) │ │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ India statutory (TDS / │ 🔴 All return safe defaults; no live │ │ - │ MSME / Form15CA-CB / │ derivation cron │ None │ - │ FIRC / IRP-IRN) │ │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ SSO live wiring │ 🟡 Schema solid; live OIDC provider + │ Medium │ - │ (BetterAuth) │ signin still partial (#670, #672) │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ │ ✅ 9 workflows wired; │ │ - │ Novu org notifications │ markOrgPayoutCompleted helper added │ High │ - │ │ this session │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ │ ⚠️ Org-blind — no │ │ - │ Stream chat / video │ custom.organizationProfileId on │ None │ - │ │ channels or calls (#674 workstream) │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ Recordings │ 🔴 No enterprise library (#367) │ None │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ Document review │ 🟡 Inherits via Appointment; no │ Medium │ - │ │ org-scoped surface (#674) │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ Analytics dashboard │ 🟡 Stat cards live; charts deferred │ Medium │ - │ │ (#663) │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ HRIS (CSV / API) │ 🔴 Schema only — no sync cron, no UI │ None │ - │ │ (#701) │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ DPDP / DataBreach UI │ 🔴 Schema only (#701) │ None │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ Programs v2 (PROJECT, │ 🔴 Enums reserved, no runtime │ None │ - │ RETAINER) │ │ │ - ├────────────────────────┼────────────────────────────────────────┼────────────┤ - │ │ 🟡 Buyer-country detection works; │ │ - │ Multi-currency routing │ auto-routing in │ Medium │ - │ │ docs/payments/multi-currency/ plan │ │ - └────────────────────────┴────────────────────────────────────────┴────────────┘ - - --- - What's solid (✅ ship it) - - - Org runtime: create wizard → admin verify → settings (capability + slug + - branding) → contracts CRUD → programs → checkout → wallet/invoice/license - PaymentLeg → reconcile invariant. End-to-end tested in the 2026-04-25 grand tour. - - Reconcile cron (scripts/reconcile/reconcile-ledgers.ts): 8 invariants (LED-1..5 + - cap drift + payout total + leg sum). Baseline on seed data is 11 findings, all - traced to seed gaps — zero real drift in production-shaped data. Drift injection - round-tripped clean. - - Concurrency primitives: distributed Redis lock on createOrgPayoutBatch, atomic - conditional updates on seat/cap/wallet, idempotency keys on cron-driven payouts, - P2002 → 409 mapping. Concurrent booking + auto-allocate tested across 6 e2e agents - (Mar 2026). - - Auth + authz: 4 user roles × 6 org roles, requireOrgAccess / requireOrgOwner - enforced server-side, sidebar gating client-side, cross-tenant IDOR audit done in - PR-1d round 4. - - 825 unit tests passing across 40 suites, npx tsc --noEmit clean post this - session's revert+fix work. - - What works but needs soak (🟡) - - - Invoice fraud guards (PR-1d) — credit-limit gate, GSTIN format, PENDING_TRUST - gate, verifiedAt SSO/seat-cap gates wired but not exercised by a real tenant under - load. Will probably catch edge cases first time a real Wipro-shape org rolls in. - - Org payouts — state machine works in dev; the PROCESSING→COMPLETED branch hasn't - run because the gateway submission is stubbed (PR-3). The markOrgPayoutCompleted - helper added this session is the call site PR-3 will use. - - Refunds — cascade reverses earnings + org earnings, PAID-status guard added - (PR-1b), proportional cap reversal (PR-1e). Multi-leg refunds + partial refunds + - payout clawback all rolled into the #716 epic — six phases, three follow-up PRs. - - Cron schedules — 35 GH Actions workflows, :00 slot has 5 simultaneous jobs, :15 - has 3. #709 tracks the stagger work; this session wired the missing - release-pending-trust at :30 to avoid making it worse. - - What's stubbed (🔴 blocks production) - - - Live payouts — RazorpayX payouts.create + Stripe Connect transfer.create not - called yet. The webhook reconciler that flips PROCESSING→COMPLETED is part of the - same PR-3 epic. - - India compliance (PR-2 epic) — lib/compliance/{tds,msme,form15,gst,irp}.ts all - return safe defaults. An India tenant cannot legally be onboarded today — TDS isn't - withheld, MSME deadlines aren't tracked, e-invoice IRNs aren't generated. Gating - item before any paying India tenant. - - Personal vs org scope split (#674) — Appointment, Waitlist, TrialSession have no - direct organizationProfileId column. Org admin has no - /dashboard/organization/[orgId]/{appointments,waitlist,trials,documents,recordings} - pages. Members can book under their org but org operators can't see the activity - from their dashboard. ~7 dev-days estimated. My BUG-016 attempt this session was - reverted because it tried to ship a filter without the underlying schema migration. - - Stream.io enterprise gating — chat channels and video calls have no org tag. An - Acme admin can't see "all my org's bookings". Direction documented in #674 - workstream. - - HRIS / DataBreach / DPDP cascade / OrgDomainClaim live flow — schema-only. #701 - tracks. - - Invoice PDF renderer — lib/pdf/invoice-renderer.tsx returns 500 in next dev and - Netlify preview because of React-version mismatch with @react-pdf/reconciler. Real - production bug, not a stub. Deferred to a standalone PR (#707) since the rewrite is - ~1000 lines of JSX→createElement. - - --- - Cross-cutting integrations - - ┌───────────────────┬───────────────────────────────────┬──────────────────────┐ - │ Subsystem │ Enterprise integration │ State │ - ├───────────────────┼───────────────────────────────────┼──────────────────────┤ - │ Booking / │ Org-funded checkout, lazy │ │ - │ appointments │ SUBSCRIPTION cap debit, │ ✅ Solid │ - │ │ credit-limit gate │ │ - ├───────────────────┼───────────────────────────────────┼──────────────────────┤ - │ Payments (Stripe │ Org webhook handlers, │ ✅ Solid │ - │ + Razorpay) │ INVOICE_PAID transactional │ │ - ├───────────────────┼───────────────────────────────────┼──────────────────────┤ - │ Refunds │ Cascade reverses earnings + org │ 🟡 #716 epic for │ - │ │ earnings │ unification │ - ├───────────────────┼───────────────────────────────────┼──────────────────────┤ - │ Stream.io │ No org tag on channels/calls │ 🔴 #674 workstream │ - │ video/chat │ │ │ - ├───────────────────┼───────────────────────────────────┼──────────────────────┤ - │ BetterAuth │ Member↔Membership bridge, SSO │ 🟡 Live SSO partial │ - │ │ providers schema │ (#670, #672) │ - ├───────────────────┼───────────────────────────────────┼──────────────────────┤ - │ Novu │ 9 org-lifecycle workflows; │ │ - │ notifications │ payout-completed helper just │ ✅ Solid │ - │ │ landed │ │ - ├───────────────────┼───────────────────────────────────┼──────────────────────┤ - │ Upstash Redis │ Rate limiters + payout batch lock │ ✅ Solid │ - ├───────────────────┼───────────────────────────────────┼──────────────────────┤ - │ Supabase / │ All FKs, indexes, partial uniques │ ✅ Solid │ - │ Postgres │ accounted for │ │ - ├───────────────────┼───────────────────────────────────┼──────────────────────┤ - │ Cron / GH Actions │ 35 workflows; │ 🟡 #709 stagger │ - │ │ release-pending-trust just wired │ pending │ - ├───────────────────┼───────────────────────────────────┼──────────────────────┤ - │ Admin dashboard │ Audit log viewer ✅; org │ 🟡 Plans page #684 │ - │ │ verification just shipped │ still non-functional │ - ├───────────────────┼───────────────────────────────────┼──────────────────────┤ - │ Recordings │ No enterprise library │ 🔴 #367 deferred │ - ├───────────────────┼───────────────────────────────────┼──────────────────────┤ - │ Document review │ Not org-scoped end-to-end │ 🟡 #674 deferred │ - ├───────────────────┼───────────────────────────────────┼──────────────────────┤ - │ Analytics │ Stat cards ✅; charts deferred │ 🟡 #663 │ - │ dashboard │ │ │ - ├───────────────────┼────────────────────────────────────┼───────────────────────┤ - │ Search / explore │ Marketplace org visibility toggle, │ ✅ Solid │ - │ │ public discovery URL │ │ - └───────────────────┴────────────────────────────────────┴───────────────────────┘ - - --- - Filtering / scope switchers state - - - OrganizationSwitcher (sidebar dropdown) — ✅ live in all 4 dashboards (consultant, - consultee, admin, staff). Self-hides for B2C-only users. Hard-redirects to - /dashboard/organization/[orgId]/home. - - OrgPayerSelector (checkout) — ✅ live; "Pay with my card" vs "Bill to Acme" works - end-to-end; referral credits correctly blocked on org-funded. - - OrgContextFilter (dashboard list filter) — ⚫ dead code. Component + helper exist - (components/dashboard/OrgContextFilter.tsx, lib/dashboard/org-context-filter.ts) but - no mount points. The fix is part of #674's user-activity-split workstream — needs - Appointment.organizationProfileId + backfill SQL + new org dashboard pages first. - Direction A (filter only on existing fields) was ruled out because Appointment - doesn't have the column today. - - Admin status filter on org list (/dashboard/admin/organizations) — ✅ shipped this - session; filterable by PENDING_VERIFICATION / ACTIVE / SUSPENDED / DEACTIVATED + - searchable by name/email/slug. - - --- - Tests & verification - - - Unit: 825 / 825 passing (40 suites). Coverage thin on org-payout-service.ts (25%, - mostly the deferred PR-3 paths) and org-workflows.ts (53%). slot-allocation + - payment-legs + tax + audit-actions all 80%+. - - E2E booking algorithm: 6 agents covered comprehensive booking lifecycle, - CRUD/limits/concurrency, auto-allocate, auth/ownership, overnight/scheduling, and - locking/validation. All green via Chrome DevTools MCP + Supabase MCP (Mar 2026). - - Enterprise grand tour: T.1 → T.29 walked end-to-end on 2026-04-25/26. 19 bugs filed - in bugs.txt; 11 fixed in tour, 7 + tracked in GH issues #717-#724. Reconcile drift - detection round-tripped clean. - - Type-check: npx tsc --noEmit clean across the project as of this session. - - Test gaps you should care about: no tests for the new capability toggle UI, slug - uniqueness 409 path, markOrgPayoutCompleted helper, tour-owner seed idempotency. - Recommend adding them before declaring v1 done — the next grand tour is the de-facto - smoke test. - - --- - Path to first paying B2B tenant - - Rough order — each line is roughly one PR: - - 1. PR-2 (India compliance go-live) — TDS withholding cron, MSME deadline tracking, - Form 15CA-CB workflow, IRP IRN upload, GSTIN live API verification. Gating item - before any paying India tenant. - 2. PR-3 (live payout submission) — wire RazorpayX payouts.create + Stripe Connect - transfers; webhook reconciler that calls markOrgPayoutCompleted. SSO live wiring - (#670, #672). - 3. #674 workstream — user activity split — adds Appointment.organizationProfileId + - backfill + new org dashboard pages + useOrgScope hook. Unlocks OrgContextFilter, - mounts the org-side appointments/waitlist/trials/documents/recordings views, and lets - Acme actually operate the product. - 4. #716 + #715 epics — refund unification, payout clawback, credit notes, overage - charging. Ship as 2-3 follow-up PRs. - 5. #701 stub closures — DPDP cascade, dataResidencyRegion enforcement, HRIS dedup, - DataBreach UI, OrgDomainClaim live flow. - 6. Operational readiness — #709 cron stagger, runbooks, oncall paging on reconcile - findings, Sentry alerts on payment_leg_sum_mismatch warns, BetterStack log retention - per org. - 7. Soak window — daily reconcile cron for 2–4 weeks against design-partner traffic - with zero discrepancies before declaring multi-tenant ready. - - Bottom line: today's stack supports one design partner with finance reviewing each - invoice + payout by hand. Self-serve enterprise onboarding at scale is roughly two - PRs and a soak window away. diff --git a/tasks/issue-triage-roadmap.md b/tasks/issue-triage-roadmap.md deleted file mode 100644 index 5c30adc25..000000000 --- a/tasks/issue-triage-roadmap.md +++ /dev/null @@ -1,405 +0,0 @@ -# Issue Triage & PR Roadmap - -> Generated: 2026-03-16 | Open issues: 83 → 61 after triage (22 closed) | Deployment: Netlify (primary) - ---- - -## 1. Issues to Close (19 issues) - -These are already resolved, stale, duplicate, or have no actionable code work. - -| # | Title | Reason | -|---|-------|--------| -| 451 | Booking algorithm bug (Codex pt1) | Fixed by PR #441 (17 booking fixes) + PR #404 (334 tests) + 6 E2E agent rounds | -| 452 | Booking algorithm bug (Codex pt2) | Same as above | -| 453 | Booking algorithm bug (Codex pt3) | Same as above | -| 465 | Weekly availability UTC alignment | Fixed by PR #462 — DateTime→Int migration + distributed lock | -| 393 | TrialScheduleCalendar moved | Component deprecated during dashboard redesign | -| 331 | MVP Launch Roadmap tracker | Meta tracker — no code action. Superseded by this document | -| 328 | MVP Launch Pending Items | Meta tracker — superseded | -| 340 | Scale readiness assessment | Planning-only. Docs exist at `docs/competition/` and `docs/deployment/` | -| 338 | Feature gap analysis | Docs exist at `docs/competition/` with threat matrix, battlecard, and per-competitor deep dives | -| 336 | `![security-critical]` (malformed title) | Placeholder/accidental creation | -| 358 | Recordings API perf from PR #357 | PR #357 was reverted (branch `revert-357`). Issue is moot. Recording perf tracked in #360 | -| 410 | AWS Migration Plan | Already labeled "wontfix". Staying on Netlify/Vercel | -| 294 | Race condition web/mobile backends | No mobile app exists or is planned near-term | -| 380 | Referral system & affiliate | Duplicate of #437 (which has full spec + assigned to @shubham79a) | -| 29 | Out of schedule workflow | Superseded by booking algorithm overhaul (PR #404, #441, #462) | -| 31 | Replace CUIDs with UUIDs | Wontfix — mixed cuid/uuid works fine with Prisma 7. New models use uuid() by convention | -| 299 | Schema architecture suggestions | Stale — many schema migrations since Dec 2025, 60+ models now | -| 267 | Payment analytics dashboard | Admin payments page exists. Remaining payment work tracked in #456 | -| 268 | Payment reconciliation | 7+ reconciliation cron jobs exist in `.github/workflows/`. Remaining work tracked in #456 | -| 373 | Research: Scheduling Alternatives | Research doc, not code. Preserved at `docs/research/scheduling-infrastructure-alternatives.md` | -| 446 | Architecture Review | Review doc, not single issue. Preserved at `docs/architecture/architecture-review-2026-02.md` | -| 484 | Production Scaling Roadmap | Planning doc. Preserved at `docs/infrastructure/production-scaling-roadmap.md` | - -**Verify before closing** (check if remaining items exist): -| # | Title | Verification | -|---|-------|-------------| -| 269 | Payment gateway improvements | Check against PR #423 (payment audit fixes) and PR #430 (referral + collab revenue) | -| 270 | Service Factory Pattern for Payments | Check if payment abstraction was done in PR #423 or #454 (utils consolidation) | - ---- - -## 2. Triage Matrix - -### Legend -- **Verdict**: QUICK-WIN / FOUNDER-REVIEW / INFRA / EXTERNAL / UX-AUDIT / DEFERRED -- **Effort**: S (<4h) / M (4h-2d) / L (2-5d) / XL (5d+) -- **Phase**: P0 (week 1) / P1 (weeks 2-3) / P2 (weeks 4-6) / P3 (launch prep) / P4 (post-launch) -- **Reviewer**: INTERN / FOUNDER / BOTH - -### P0 — Critical (7 issues) - -| # | Title | Verdict | Effort | Reviewer | Branch Name | Dependencies | -|---|-------|---------|--------|----------|-------------|-------------| -| 480 | Production readiness audit (3 Netlify showstoppers) | FOUNDER-REVIEW | L | FOUNDER | `fix/480-netlify-prod-readiness` | None — read audit first, then decide blockers | -| 488 | Subscription cancellation flow | FOUNDER-REVIEW | L | FOUNDER | `fix/488-subscription-cancellation` | None — launch blocker for money flow | -| 425 | Unsafe deletion of events/plans | FOUNDER-REVIEW | M | FOUNDER | `fix/425-safe-event-deletion` | None — data loss risk | -| 456 | Payment marketplace audit | FOUNDER-REVIEW | L | FOUNDER | `fix/456-payment-audit-fixes` | After #488 (touches same payment files) | -| 401 | Set global Prisma transaction timeout | QUICK-WIN | S | INTERN | `infra/401-prisma-timeout` | None — single config in `lib/prisma.ts` | -| 433 | Currency unit issue | QUICK-WIN | S | INTERN | `fix/433-currency-unit-display` | None — `utils/formatting.ts` | -| 485 | Price on cards + chronological grouping | QUICK-WIN | S | INTERN | `ui/485-price-on-cards` | None — explore page UI only | - -### P1 — Lock Down (15 issues) - -| # | Title | Verdict | Effort | Reviewer | Branch Name | Dependencies | -|---|-------|---------|--------|----------|-------------|-------------| -| 449 | Reschedule flow bugs + security | FOUNDER-REVIEW | M | BOTH | `fix/449-reschedule-security` | None | -| 448 | Reschedule notifications + audit trail | FOUNDER-REVIEW | M | BOTH | `fix/448-reschedule-notifications` | After #449 (same file area) | -| 400 | Stream Chat security vulnerabilities | FOUNDER-REVIEW | M | FOUNDER | `security/400-stream-chat-hardening` | None | -| 407 | Rate limiting strategy for Netlify | FOUNDER-REVIEW | M | BOTH | `security/407-rate-limiting` | None (enhances existing edge rate limiting) | -| 405 | User lifecycle (deletion, recreation, spam) | FOUNDER-REVIEW | L | FOUNDER | `fix/405-user-lifecycle` | None | -| 300 | In-app notification system | QUICK-WIN | M | INTERN | `feat/300-novu-in-app-notifications` | None (Novu already integrated) | -| 337 | Email notifications for reschedule | QUICK-WIN | M | INTERN | `feat/337-reschedule-emails` | None (Resend already integrated) | -| 274 | Extract shared dashboard components | QUICK-WIN | M | INTERN | `refactor/274-shared-dashboard-components` | None | -| 251 | Standardize checkout route params | QUICK-WIN | M | INTERN | `refactor/251-checkout-route-params` | None (rename only) | -| 468 | Cookie & notification preferences persistence | QUICK-WIN | M | INTERN | `feat/468-cookie-notification-prefs` | None | -| 381 | Advanced cookie consent features | QUICK-WIN | S | INTERN | `feat/381-advanced-cookie-consent` | After #468 | -| 476 | Distributed locking for cron jobs | INFRA | M | BOTH | `infra/476-cron-distributed-lock` | None | -| 481 | Billing guardrails (prevent surprise bills) | INFRA | S | FOUNDER | `infra/481-billing-guardrails` | None | -| 378 | PostHog analytics + Sentry error tracking | EXTERNAL | M | INTERN | `feat/378-posthog-sentry` | Sentry + PostHog accounts | -| 475 | Sentry error tracking (merge with #378) | EXTERNAL | - | - | Merged into #378 PR | — | - -### P2 — Polish (18 issues) - -| # | Title | Verdict | Effort | Reviewer | Branch Name | Dependencies | -|---|-------|---------|--------|----------|-------------|-------------| -| 440 | DB exclusion constraint for slot overlap | FOUNDER-REVIEW | M | FOUNDER | `fix/440-db-exclusion-constraint` | None (raw SQL migration) | -| 469 | Google One Tap sign-in | EXTERNAL | M | BOTH | `feat/469-google-one-tap` | Existing Google OAuth in BetterAuth | -| 334 | ConvertKit newsletter | EXTERNAL | S | INTERN | `feat/334-convertkit-newsletter` | ConvertKit account. Route has `TODO: Issue #334` | -| 487 | Consultant dashboard audit (14 pages) | UX-AUDIT | XL | BOTH | Decompose into per-page PRs: `ui/487-{page-name}` | After #274 | -| 486 | Consultee dashboard audit (8 tabs) | UX-AUDIT | L | BOTH | Decompose into per-tab PRs: `ui/486-{tab-name}` | After #274 | -| 445 | Consultee appointments tab UX | UX-AUDIT | M | INTERN | `ui/445-consultee-appointments-ux` | Subset of #486 | -| 450 | Performance: landing, explore, detail pages | UX-AUDIT | L | BOTH | `perf/450-page-load-optimization` | None | -| 309 | Slot availability API optimization | QUICK-WIN | M | INTERN | `perf/309-slot-api-caching` | None | -| 383 | Database query performance optimization | QUICK-WIN | M | BOTH | `perf/383-db-query-optimization` | Profile after deploy | -| 474 | Critical email retry with dead-letter queue | INFRA | M | BOTH | `infra/474-email-retry-dlq` | None | -| 473 | Stream.io circuit breaker | INFRA | M | INTERN | `infra/473-stream-circuit-breaker` | None | -| 472 | Session overrun detection | INFRA | M | INTERN | `infra/472-session-overrun-detection` | None | -| 471 | No-show detection and handling | INFRA | M | INTERN | `infra/471-no-show-detection` | None | -| 368 | Prisma connection pool exhaustion | INFRA | M | BOTH | `fix/368-prisma-pool-config` | None (already using PrismaPg adapter) | -| 308 | Seed data consistency | QUICK-WIN | M | INTERN | `fix/308-seed-data-consistency` | None | -| 279 | Subscription reschedule support | QUICK-WIN | M | BOTH | `feat/279-subscription-reschedule` | After #488 | -| 386 | LinkedIn URL redundancy fix | QUICK-WIN | S | INTERN | `fix/386-linkedin-url-redundancy` | Schema migration | -| ~~484~~ | ~~Production scaling roadmap~~ | CLOSED | - | - | Moved to `docs/infrastructure/production-scaling-roadmap.md` | — | - -### P3 — Launch Prep (8 issues) - -| # | Title | Verdict | Effort | Reviewer | Branch Name | Dependencies | -|---|-------|---------|--------|----------|-------------|-------------| -| 437 | Referral system UI completion | DEFERRED | L | BOTH | `feat/437-referral-ui` | Schema + API already exist | -| 438 | Invoice system (PDF + email) | DEFERRED | L | BOTH | `feat/438-invoice-pdf-generation` | Invoice model + API exist | -| 341 | Send inquiry feature | DEFERRED | M | INTERN | `feat/341-send-inquiry` | None | -| 379 | Consultant verification gate | DEFERRED | M | FOUNDER | `feat/379-consultant-verification` | After #405 (user lifecycle) | -| 377 | Intercom chat widget | EXTERNAL | S | INTERN | `feat/377-intercom-widget` | Intercom account ($74/mo or Crisp $25) | -| 409 | Aikido Security scanning | EXTERNAL | S | INTERN | `infra/409-aikido-security` | GitHub App install (free) | -| 387 | Staff onboarding validation workflow | DEFERRED | M | BOTH | `feat/387-staff-onboarding` | None | -| 399 | Novu webhook receiver for delivery tracking | DEFERRED | M | INTERN | `feat/399-novu-webhook-receiver` | After #300 | - -### P4 — Post-Launch (14 issues) - -| # | Title | Verdict | Effort | Reviewer | -|---|-------|---------|--------|----------| -| 367 | Enterprise recording library | DEFERRED | XL | FOUNDER | -| 366 | Recording monetization | DEFERRED | L | FOUNDER | -| 360 | Recording storage strategy | DEFERRED | M | BOTH | -| 342 | Stream Chat SDK feature roadmap | DEFERRED | L | BOTH | -| 326 | Multiple admin levels | DEFERRED | M | FOUNDER | -| 312 | Directus CMS for blog | EXTERNAL | L | BOTH | -| 371 | AI recommendation system | EXTERNAL | XL | FOUNDER | -| 373 | Scheduling infrastructure alternatives | DEFERRED | - | FOUNDER | -| 347 | Bulk document review | DEFERRED | M | INTERN | -| 348 | Real-time document updates | DEFERRED | L | BOTH | -| ~~446~~ | ~~Architecture review (full)~~ | CLOSED | - | Moved to `docs/architecture/architecture-review-2026-02.md` | -| 470 | Supabase storage UUID naming convention | DEFERRED | M | BOTH | -| 248 | Stream Chat sync on dashboard load | DEFERRED | M | INTERN | -| 409 | Aikido Security (if not done in P3) | EXTERNAL | S | INTERN | - ---- - -## 3. Phase Plan - -### Phase 0 — "Stop the Bleeding" (Week 1) - -**Goal**: Close dead issues, fix money-touching bugs, prevent data loss. - -``` -Day 1: Close 19 dead issues (30 min batch operation) - Ship #401 (prisma timeout — one config line) - Ship #433 (currency unit — utils/formatting.ts) - Ship #485 (price on cards — UI only) - -Day 2-3: #480 — Read production readiness audit, decide which of the 3 - Netlify showstoppers need code. Key findings: - - Permissions-Policy blocks camera/mic (Stream.io calls break) - - Netlify function timeout risks for payment webhooks - - Missing Netlify Next.js plugin - -Day 3-5: #488 — Subscription cancellation flow - Schema has CancellationReason enum + cancelledAt/cancelledBy fields - Missing: cancellation API endpoint, refund initiation, audit logging - Files: app/api/events/subscriptions/, lib/payments/ - -Day 5-7: #425 — Unsafe deletion guard - Planner services have deleteEvent/deleteWebinar with no soft-delete - or payment refund checks - Files: planner/services/ (webinar, class, consultation, subscription) -``` - -### Phase 1 — "Lock Down" (Weeks 2-3) - -**Goal**: Security hardening, notification completeness, quick refactors. - -``` -LANE A (Founder): #456 → #449+#448 → #405 -LANE B (Intern 1): #274 → #433 → #251 → #445 -LANE C (Intern 2/AI): #476 → #378 → #468+#381 -LANE D (Intern): #300 → #337 -Standalone: #400, #407, #481 (founder reviews when ready) -``` - -### Phase 2 — "Polish" (Weeks 4-6) - -**Goal**: Performance, UX, remaining infrastructure. - -``` -UX Audits: Decompose #487 (14 pages) and #486 (8 tabs) into - individual PRs. #445 is a subset of #486. -Performance: #450 → #309 → #383 (profile, then optimize) -Infra: #474, #473, #471, #472, #368 -Features: #469 (Google One Tap), #334 (ConvertKit) -Data: #440 (DB constraint), #308 (seeds), #386 (LinkedIn fix) -Depends: #279 (subscription reschedule) — only after #488 lands -``` - -### Phase 3 — "Launch Prep" (Week 7) - -**Goal**: Final features, external services, soft launch. - -``` -Features: #437 (referral UI), #438 (invoice PDF), #341 (inquiry), #379 (verification) -External: #377 (Intercom/Crisp), #409 (Aikido scan) -Staffing: #387 (staff onboarding validation) -``` - -### Phase 4 — "Post-Launch" (Month 2+) - -Everything deferred. Recordings, enterprise, AI, admin levels, CMS. - ---- - -## 4. Parallel PR Lanes - -### Lane Map - -``` -LANE A — Payments/Booking (FOUNDER) LANE B — UI/UX (INTERN) -────────────────────────────────── ───────────────────────── -#488 subscription cancel #433 currency unit - ↓ #485 price on cards -#425 unsafe deletion #274 shared components - ↓ ↓ -#456 payment audit #445 appointments UX - ↓ ↓ -#449 reschedule bugs #487 consultant audit (per-page) - ↓ #486 consultee audit (per-tab) -#448 reschedule notifications - -LANE C — Infrastructure (INTERN/AI) LANE D — Notifications (INTERN) -─────────────────────────────────── ────────────────────────────── -#401 prisma timeout #300 in-app notifications -#476 cron distributed lock #337 reschedule emails -#378+#475 Sentry + PostHog #334 ConvertKit -#468 cookie preferences - ↓ -#381 advanced cookies -``` - -### Conflict Zones - -| File/Directory | Issues That Touch It | Rule | -|---------------|---------------------|------| -| `prisma/schema.prisma` | #386, #440, #405 | ONE migration PR at a time. Rebase others after merge | -| `middleware.ts` | #407, #469 | Sequential — #407 first | -| `lib/payments/operations/checkout.ts` (1984 lines) | #488, #456 | #488 first, then #456 audits the result | -| `app/api/appointments/` | #449, #448 | Same PR or strictly sequential | -| Dashboard components | #274, #487, #486 | #274 (extract shared) merges BEFORE audit PRs | - ---- - -## 5. PR Strategy - -### Branch Naming Convention - -``` -{type}/{issue#}-{short-description} - -Types: - fix/ Bug fixes - feat/ New features - refactor/ Code reorganization - infra/ Infrastructure - security/ Security fixes - ui/ UI-only changes - perf/ Performance - docs/ Documentation only - chore/ Maintenance, deps - -Examples: - fix/488-subscription-cancellation - security/400-stream-chat-token-hardening - feat/300-novu-in-app-notifications - refactor/251-checkout-route-params -``` - -### PR Review Matrix - -| PR Type | First Reviewer | Final Approver | Auto-merge? | -|---------|---------------|---------------|-------------| -| UI-only (no API/DB) | Intern | Intern | Yes (after green CI) | -| Refactor (no logic change) | Intern | Intern | Yes | -| API logic change | Intern | Founder | No | -| Payment/money | Founder | Founder | No | -| Auth/security | Founder | Founder | No | -| Schema migration | Founder | Founder | No | -| Infrastructure/cron | Intern first pass | Founder | No | -| External integration | Intern | Founder | No | - -### PR Size Guidelines - -- **Target**: 100-300 lines changed -- **Maximum**: 500 lines (decompose if larger) -- **UX audits** (#487, #486): MUST decompose into per-page/per-tab PRs -- **Payment audit** (#456): May exceed 500 lines — that's OK for cross-cutting audits - ---- - -## 6. Dependency Graph - -``` - ┌─────────────────────┐ - │ Phase 0 (no deps) │ - └──┬──┬──┬──┬──┬──┬──┘ - │ │ │ │ │ │ - ┌────────┘ │ │ │ │ └────────┐ - ▼ ▼ ▼ ▼ ▼ ▼ - #401 #433 #485 #425 #480 - (prisma (currency) (deletion) (audit) - timeout) │ │ - │ │ - ┌───────────┘ │ - ▼ ▼ - #488 (subscription cancel) ──→ #456 (payment audit) - │ │ - ▼ ▼ - #279 (sub reschedule) #449 (reschedule bugs) - │ - ▼ - #448 (reschedule notifs) - - #468 (cookie prefs) ──→ #381 (advanced cookies) - #378 + #475 (merge into single Sentry+PostHog PR) - #274 (shared components) ──→ #487, #486, #445 (UX audits) - #405 (user lifecycle) ──→ #379 (verification gate) - #300 (in-app notifs) ──→ #399 (Novu webhook) - #450 (performance audit) ──→ #309, #383 (optimization) -``` - -**Critical path** (founder must do in this order): -1. #480 — Read audit, decide Netlify blockers (2h) -2. #488 — Subscription cancellation (2-3d) -3. #456 — Payment audit sweep (2-3d) -4. #425 — Unsafe deletion guard (1d) -5. #405 — User lifecycle (2-3d) - ---- - -## 7. External Integration Checklist - -| Service | Issue(s) | Free Tier | Paid Tier | Env Vars | When | -|---------|----------|-----------|-----------|----------|------| -| Sentry | #378, #475 | 5K errors/mo | $26/mo | `SENTRY_DSN` (already in `.env.sample`) | P1 — create account now | -| PostHog | #378 | 1M events/mo | Usage-based | `NEXT_PUBLIC_POSTHOG_KEY`, `POSTHOG_HOST` | P1 — create account now | -| Google One Tap | #469 | Free | Free | Existing Google OAuth client ID | P2 — just add One Tap script | -| ConvertKit | #334 | 1K subscribers | $29/mo | `CONVERTKIT_API_KEY`, `CONVERTKIT_FORM_ID` | P2 | -| Intercom | #377 | None | $74/mo Starter | `NEXT_PUBLIC_INTERCOM_APP_ID` | P3 (consider Crisp at $25/mo) | -| Aikido | #409 | Free <10 repos | Free for OSS | GitHub App install only | P3 | -| Directus | #312 | Self-host free | Cloud $15/mo | `DIRECTUS_URL`, `DIRECTUS_TOKEN` | P4 | -| AI/ML | #371 | TBD | TBD | TBD | P4 — needs product design first | - -**Incremental monthly cost**: P1 adds $0-26, P2 adds $0-29, P3 adds $25-74. Total: $25-129/mo. - -**Action now**: Create Sentry + PostHog accounts (5 min each). Env vars for Sentry already exist in `.env.sample`. - ---- - -## 8. Dependabot PR Triage (12 PRs) - -### Safe to Merge (low risk — dev deps, types, patches) - -| PR | Description | Action | -|----|------------|--------| -| #478 | Build tools bump (3 dev deps) | Merge | -| #443 | TypeScript types bump (2 updates) | Merge | -| #427 | Dev utilities bump (4 updates) | Merge | -| #417 | Axios patch v1.13.3→v1.13.4 | Merge | -| #408 | actions/github-script v7→v8 | Merge | -| #460 | actions/upload-artifact v4→v7 | Merge | - -### Review Carefully (production dependencies) - -| PR | Description | Risk | Action | -|----|------------|------|--------| -| #491 | Database/Storage group (16 updates!) | HIGH — Prisma/Supabase SDK changes | Review changelogs, test locally | -| #429 | Stream communication (7 updates) | MEDIUM — video/chat SDK | Test video calls in dev | -| #411 | Core framework (5 updates) | MEDIUM — Next.js/React | Test build + key pages | -| #413 | Payment group (2 updates) | MEDIUM — Stripe/Razorpay SDK | Test checkout flow | - -### Low Priority (merge when convenient) - -| PR | Description | Action | -|----|------------|--------| -| #490 | Email group (4 updates) — Resend/Novu | Merge after #300 (notifications) | -| #426 | Performance (2 updates) | Merge anytime | - ---- - -## 9. What to Do Right Now - -### Today (30 minutes) -1. Close the 19 dead issues (automated with comments) -2. Create Sentry + PostHog accounts -3. Assign Lane B + C + D issues to interns in GitHub - -### This Week (Phase 0) -1. Intern ships #401 (prisma timeout), #433 (currency), #485 (price on cards) -2. Founder reads #480 audit, decides Netlify blockers -3. Founder starts #488 (subscription cancellation) — single most important gap -4. Merge 6 safe dependabot PRs (#478, #443, #427, #417, #408, #460) - -### Next 2 Weeks (Phase 1) -1. Founder: #456 (payment audit) → #449+#448 (reschedule) -2. Intern: #274 (shared components) → #251 (checkout params) → #445 (appointments UX) -3. Intern/AI: #476 (cron locking) → #378 (Sentry+PostHog) → #468 (cookie prefs) -4. Intern: #300 (in-app notifications) → #337 (reschedule emails) diff --git a/tasks/notifications.txt b/tasks/notifications.txt deleted file mode 100644 index 8ef5ef692..000000000 --- a/tasks/notifications.txt +++ /dev/null @@ -1,88 +0,0 @@ -What's now correct but operationally untested (🟡 ship behind a flag, watch closely) - - - OrgPayoutService rewrite (PR-1c) — eligibility / batch / process state machine works end-to-end against - the DB, with Redis lock + idempotency key. But no live RazorpayX or Stripe Connect submission. Today it - gets a payout to PROCESSING and stops. The actual fund movement is a NotImplementedError behind - ENABLE_LIVE_PAYOUTS=true. - - PR-1d invoice-fraud guards — credit-limit gate + GSTIN/PAN format check + PENDING_TRUST earnings + - verified-domain SSO/seat-cap gates all wired. Has not been exercised against a real tenant under load. - - What's stubbed (🔴 not production-ready — defers compliance launch) - - - TDS / MSME / Form 15CA-CB / FIRC / IRP-IRN — lib/compliance/{tds,msme,form15,gst,irp}.ts all return safe - defaults. No live derivation cron. An India tenant cannot legally be onboarded today — TDS isn't withheld, - MSME deadlines aren't tracked, e-invoice IRNs aren't generated. This is the PR-2 epic. - - Live payout submission — see above, PR-3. - - HRIS integration (HrisConfig, HrisSyncJob, HrisEmployeeMap) — schema only. - - DataBreach notification — schema only, no UI/cron. - - DPDP consent cascade on withdrawal — schema only. - - Programs v2 (PROJECT, RETAINER program types) — enum reserved, no runtime. - - Cross-cutting integration readiness - - ┌───────────────────┬─────────────────────────────────────────────────────┬──────────────────────────────┐ - │ Subsystem │ Enterprise integration │ State │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ Booking / │ ✅ org-funded checkout, lazy SUBSCRIPTION cap │ Solid │ - │ appointments │ debit, credit-limit gate │ │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ Payments (Stripe │ ✅ org webhook handlers, INVOICE_PAID transactional │ Solid │ - │ + Razorpay) │ │ │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ Refunds │ 🟡 cascade reverses earnings + org earnings, PAID │ Works; needs more tests on │ - │ │ guard added │ multi-leg refunds │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ Stream.io │ ⚠️ unaffected by enterprise; orgs don't gate video │ Out of scope but a future │ - │ video/chat │ access today │ need (enterprise wants │ - │ │ │ room-level access policy) │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ BetterAuth │ ✅ Member ↔ Membership bridge, SSO providers schema │ Solid; live SSO wiring │ - │ │ │ (#670, #672) still partial │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ Novu │ ✅ 9 org-lifecycle workflows wired │ Solid │ - │ notifications │ │ │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ Upstash Redis │ ✅ rate limiters + payout batch lock │ Solid │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ Supabase / │ ✅ all FKs, indexes, partial uniques accounted for │ Solid │ - │ Postgres │ │ │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ Cron / GitHub │ 🟡 #709 collision audit pending; new │ Functional, ops cleanup │ - │ Actions │ release-pending-trust cron not yet wired into a │ pending │ - │ │ workflow.yml │ │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ Admin / staff │ 🟡 audit log viewer ✅; Plans page #684 still │ Partial │ - │ dashboards │ non-functional; verify-org admin endpoint exists │ │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ Search / explore │ ✅ marketplace org visibility toggle; │ Solid │ - │ │ /explore/enterprise/organisations shipping │ │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ Recordings │ 🔴 no enterprise library (#367) │ Deferred │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ Document review / │ 🟡 not org-scoped end-to-end (#674) │ Deferred │ - │ chat │ │ │ - ├───────────────────┼─────────────────────────────────────────────────────┼──────────────────────────────┤ - │ Analytics │ 🟡 #663 charts deferred │ Deferred │ - │ dashboard │ │ │ - └───────────────────┴─────────────────────────────────────────────────────┴──────────────────────────────┘ - - What it would take to call it "fully production-grade" - - In rough order: - - 1. PR-2 — India compliance go-live: TDS withholding cron, MSME deadlines, Form 15CA-CB workflow, IRP IRN - upload, GSTIN live API verification. This is the gating item before any paying India tenant. - 2. PR-3 — Live payout submission: RazorpayX payouts.create + Stripe Connect transfer, webhook reconciler - that flips PROCESSING → COMPLETED. SSO go-live (OIDC label fix, userId on org-scoped providers). - 3. The 5 stub areas in #701 — DPDP cascade, dataResidencyRegion enforcement, HRIS dedup, DataBreach UI, - OrgDomainClaim live-flow gaps. - 4. Operational readiness: cron schedule audit (#709), runbooks, oncall paging on reconcile findings, Sentry - alerts on payment_leg_sum_mismatch warns. - 5. Soak time: run the reconcile cron daily for 2–4 weeks against design-partner traffic with zero - discrepancies before declaring multi-tenant ready. - - Bottom line - - If you onboard one design partner with a manual-touch operational mode (finance reviews each invoice + - payout by hand, no INR scale yet) — the current stack is workable. If you want self-serve enterprise - onboarding at scale, you're 2 PRs away (PR-2 + PR-3) and a few weeks of soak. \ No newline at end of file diff --git a/tasks/payment-system-additional-issues.md b/tasks/payment-system-additional-issues.md deleted file mode 100644 index c4d38498b..000000000 --- a/tasks/payment-system-additional-issues.md +++ /dev/null @@ -1,845 +0,0 @@ -# Payment System - Additional Issues & Improvements - -**Date**: 2025-12-06 -**Status**: Identified - Awaiting Fix -**Priority**: Mixed (P1-P3) -**Related**: `tasks/payment-workflow-critical-bugs.md` (Issues 1-8 now fixed) - ---- - -## Executive Summary - -After fixing the 8 critical bugs documented in `payment-workflow-critical-bugs.md`, a comprehensive audit revealed **5 additional bugs**, **3 performance optimizations**, **2 type safety issues**, and **3 code cleanup items**. - -| Category | Count | Highest Severity | -| ------------- | ----- | ---------------- | -| Bugs | 5 | High | -| Optimizations | 3 | High Impact | -| Type Safety | 2 | High | -| Code Cleanup | 3 | Low | - ---- - -## BUG-A: Missing Past Webinar Validation - -### Severity: HIGH - -### Situation - -The webinar checkout validates that a webinar is scheduled (has appointment with slots), but does NOT validate that the scheduled time is in the future. Users can book webinars that have already occurred. - -**Code Location**: `lib/payments/operations/checkout.ts:936-940` - -```typescript -// Current code - only checks if scheduled, not if in future -if (!webinar.appointment?.slotsOfAppointment?.[0]) { - throw new Error( - "This webinar has not been scheduled yet. Please wait for the consultant to set a date and time.", - ); -} -// ❌ Missing: Check if webinar.appointment.slotsOfAppointment[0].startsAt > now -``` - -### How It Happens - -1. Consultant creates webinar scheduled for Jan 1, 2025 -2. Webinar occurs on Jan 1, 2025 -3. User visits webinar page on Jan 5, 2025 -4. User clicks "Book Now" -5. Checkout succeeds - user pays for past webinar! - -### Impact - -| Impact | Description | -| ------------------- | ------------------------------------------ | -| **Revenue Issues** | Refund requests for past events | -| **User Confusion** | Paying for something that already happened | -| **Support Tickets** | Users complaining about missed webinars | -| **Data Quality** | Bookings for past events pollute analytics | - -### Fix Options - -**Option A: Add time validation in checkout (Recommended)** - -```typescript -// After existing schedule validation -const scheduledStart = webinar.appointment.slotsOfAppointment[0].startsAt; -if (new Date(scheduledStart) < new Date()) { - throw new Error( - "This webinar has already occurred and is no longer available for booking.", - ); -} -``` - -**Option B: Add buffer time (15 minutes before start)** - -```typescript -const scheduledStart = webinar.appointment.slotsOfAppointment[0].startsAt; -const bufferMs = 15 * 60 * 1000; // 15 minutes -if (new Date(scheduledStart).getTime() - bufferMs < Date.now()) { - throw new Error("This webinar is starting soon or has already occurred."); -} -``` - -**Recommended**: Option B (allows late joiners but prevents past bookings) - -### Testing Checklist - -- [ ] Booking webinar in past throws error -- [ ] Booking webinar starting in 10 minutes throws error (with buffer) -- [ ] Booking webinar in future succeeds -- [ ] Error message is user-friendly - ---- - -## BUG-B: Race Condition in Refund Calculation - -### Severity: HIGH - -### Situation - -When processing refunds, the available refund amount is calculated by summing existing refunds. This calculation is not protected by a transaction, allowing concurrent refund requests to exceed the original payment amount. - -**Code Location**: `app/api/payments/refunds/route.ts:84-103` - -```typescript -// Current code - no transaction protection -const existingRefunds = await prisma.refund.findMany({ - where: { paymentId: payment.id }, -}); - -const totalRefunded = existingRefunds.reduce((sum, r) => sum + r.amount, 0); -const availableForRefund = payment.amount - totalRefunded; - -// ⚠️ Between this check and creating the refund, another request could succeed -if (amount > availableForRefund) { - throw new Error("Refund amount exceeds available balance"); -} - -// Create refund - race condition window! -await prisma.refund.create({ ... }); -``` - -### How It Happens - -``` -T=0:00 Payment of $100 exists, no refunds yet -T=0:01 Request A: Calculate available = $100 - $0 = $100 -T=0:02 Request B: Calculate available = $100 - $0 = $100 -T=0:03 Request A: Create $50 refund (succeeds) -T=0:04 Request B: Create $75 refund (succeeds!) - Total refunded: $125 > $100 original payment! -``` - -### Impact - -| Impact | Description | -| ------------------- | ---------------------------------------- | -| **Financial Loss** | Refunding more than paid | -| **Gateway Errors** | Stripe/Razorpay will reject over-refunds | -| **Data Corruption** | Refund totals don't match payment | -| **Audit Issues** | Financial records inconsistent | - -### Fix Options - -**Option A: Wrap in transaction with row lock (Recommended)** - -```typescript -await prisma.$transaction(async (tx) => { - // Lock the payment row for update - const payment = await tx.payment.findUnique({ - where: { id: paymentId }, - // Prisma doesn't support SELECT FOR UPDATE directly - // Use raw query or optimistic locking - }); - - const existingRefunds = await tx.refund.findMany({ - where: { paymentId: payment.id }, - }); - - const totalRefunded = existingRefunds.reduce((sum, r) => sum + r.amount, 0); - const availableForRefund = payment.amount - totalRefunded; - - if (amount > availableForRefund) { - throw new Error("Refund amount exceeds available balance"); - } - - await tx.refund.create({ ... }); -}); -``` - -**Option B: Use distributed lock (Redis)** - -```typescript -const lock = await lockPaymentRefund(paymentId); -try { - // ... refund logic -} finally { - await unlockPaymentRefund(lock); -} -``` - -**Option C: Optimistic locking with version field** - -Add `refundVersion` field to Payment model, increment on each refund. - -**Recommended**: Option A (simplest, uses existing Prisma transaction) - -### Testing Checklist - -- [ ] Concurrent refund requests don't exceed payment amount -- [ ] Sequential refunds work correctly -- [ ] Error message clear when exceeding balance -- [ ] Transaction rolls back on failure - ---- - -## BUG-C: Zero/Negative Amount Payments - -### Severity: MEDIUM - -### Situation - -The payment intent creation doesn't validate that the amount is positive. Zero-cost items (100% discount) or calculation errors could create invalid payment intents. - -**Code Location**: `lib/payments/core/stripe.ts:76-122` - -```typescript -export async function createStripePaymentIntent(params: { - amount: number; - currency: string; - // ... -}) { - // ❌ No validation of amount > 0 - const session = await stripe.checkout.sessions.create({ - line_items: [ - { - price_data: { - unit_amount: params.amount, // Could be 0 or negative! - // ... - }, - }, - ], - }); -} -``` - -### How It Happens - -1. Plan costs $100 -2. User applies 100% discount code -3. Discounted amount = $0 -4. Payment intent created with amount = 0 -5. Stripe may reject or create invalid session - -### Impact - -| Impact | Description | -| ------------------ | ----------------------------------- | -| **Gateway Errors** | Stripe rejects zero-amount payments | -| **UX Issues** | Confusing error messages | -| **Free Access** | If bypassed, users get free access | -| **Reporting** | $0 payments skew revenue metrics | - -### Fix Options - -**Option A: Validate in payment creation (Recommended)** - -```typescript -export async function createStripePaymentIntent(params: { amount: number; ... }) { - if (params.amount <= 0) { - throw new Error("Payment amount must be greater than zero"); - } - // ... rest of function -} -``` - -**Option B: Handle zero-amount as free checkout** - -```typescript -if (params.amount === 0) { - // Skip payment gateway, directly confirm appointment - return { - id: `free_${Date.now()}`, - client_secret: null, - isFreeCheckout: true, - }; -} -``` - -**Option C: Validate discount doesn't exceed plan price** - -```typescript -// In checkout.ts discount calculation -const discountedAmount = - discount.discountType === "PERCENTAGE" - ? amount * (1 - discount.discountValue / 100) - : Math.max(1, amount - discount.discountValue); // Minimum $0.01 - -if (discountedAmount < 100) { - // Minimum 1 INR/USD in cents - throw new Error("Discount cannot reduce price below minimum"); -} -``` - -**Recommended**: Option A + Option C (validate at both points) - -### Testing Checklist - -- [ ] 100% discount code throws appropriate error -- [ ] Negative amount throws error -- [ ] Minimum payment amount enforced -- [ ] Error messages guide user to contact support - ---- - -## BUG-D: Approval Flow Missing User Profile Validation - -### Severity: MEDIUM - -### Situation - -The approval payment creation doesn't verify that the user has a consultee profile before creating the payment intent. This could cause issues during webhook processing. - -**Code Location**: `lib/payments/operations/approval-payment.ts:55-113` - -```typescript -export async function createApprovalPaymentIntent( - params: CreateApprovalPaymentParams, -): Promise { - // Validates consultationId/subscriptionId exist - if (params.appointmentType === "CONSULTATION" && !params.consultationId) { - throw new Error("consultationId required..."); - } - - // ❌ Missing: Validate user has consultee profile - // The webhook handler (handlers.ts:335) will fail if profile missing - - const paymentResponse = await createPaymentIntent({ ... }); - // ... -} -``` - -### How It Happens - -1. Admin/consultant creates user without consultee profile -2. Consultant approves a request for this user -3. Payment link generated successfully -4. User pays -5. Webhook fails: "User profile not found for payment" -6. User charged but no appointment created! - -### Impact - -| Impact | Description | -| --------------------------- | -------------------------------------- | -| **Payment Without Service** | User pays but gets nothing | -| **Manual Recovery** | Admin must create appointment manually | -| **Support Burden** | Confusing situation for all parties | -| **Trust Issues** | User loses trust in platform | - -### Fix Options - -**Option A: Validate profile before payment creation (Recommended)** - -```typescript -export async function createApprovalPaymentIntent(params) { - // ... existing validation - - // Validate user has consultee profile - const user = await prisma.user.findUnique({ - where: { id: params.userId }, - include: { consulteeProfile: true }, - }); - - if (!user) { - throw new Error("User not found"); - } - - if (!user.consulteeProfile) { - throw new Error( - "User does not have a consultee profile. Please complete profile setup first.", - ); - } - - // ... rest of function -} -``` - -**Option B: Auto-create consultee profile if missing** - -```typescript -if (!user.consulteeProfile) { - await prisma.consulteeProfile.create({ - data: { userId: user.id }, - }); -} -``` - -**Recommended**: Option A (fail fast, don't auto-create incomplete profiles) - -### Testing Checklist - -- [ ] User without profile gets clear error -- [ ] User with profile can create payment -- [ ] Error prevents payment intent creation (no charge) -- [ ] Consultant sees helpful error message - ---- - -## BUG-E: Plan Deletion Race Condition - -### Severity: MEDIUM - -### Situation - -Plan validation occurs before acquiring the distributed lock. Between validation and checkout completion, the plan could be deleted or disabled. - -**Code Location**: `lib/payments/operations/checkout.ts:178-293` (validation) vs `1152-1165` (lock) - -```typescript -// Step 1: Validate plan (OUTSIDE LOCK) -const { amount, currency } = await calculateAmountAndValidate(data, userId); -// Plan confirmed to exist here - -// Step 2: Acquire lock -lock = await acquireCheckoutLock(data, planData); - -// ⚠️ Between steps 1 and 2, plan could be deleted! - -// Step 3: Inside lock - plan assumed to still exist -const result = await prisma.$transaction(async (tx) => { - // Uses planId from step 1, but plan might be gone -}); -``` - -### How It Happens - -``` -T=0:00 User A: Validate plan P1 exists ✓ -T=0:01 Admin: Delete plan P1 -T=0:02 User A: Acquire lock for P1 checkout -T=0:03 User A: Create payment for deleted plan → ERROR -``` - -### Impact - -| Impact | Description | -| ------------------ | ------------------------------------------ | -| **Cryptic Errors** | "Plan not found" during checkout | -| **Payment Issues** | Payment intent created but DB insert fails | -| **UX Degradation** | User sees error after entering payment | -| **Cleanup Needed** | Orphaned payment intents | - -### Fix Options - -**Option A: Re-validate plan inside lock (Recommended)** - -```typescript -// After acquiring lock, before transaction -await revalidateInsideLock(validatedData, userId); - -// Add to revalidateInsideLock: -async function revalidateInsideLock(data, userId) { - // Existing slot validation... - - // Add: Re-verify plan exists - const plan = await getPlanById(data.appointmentType, data.planId); - if (!plan) { - throw new Error("Plan is no longer available"); - } -} -``` - -**Option B: Use database-level constraints** - -Rely on foreign key constraints to fail the insert if plan deleted. - -**Option C: Soft-delete plans with availability window** - -Plans are never hard-deleted, just marked inactive. Checkout checks active status. - -**Recommended**: Option A (explicit validation is clearest) - -### Testing Checklist - -- [ ] Deleting plan during checkout causes clear error -- [ ] Payment intent cancelled if plan deleted -- [ ] Lock released on plan deletion error -- [ ] User sees friendly "plan unavailable" message - ---- - -## OPT-1: Heavy Include Chains in Notifications - -### Category: Performance - -### Impact: HIGH - -### Situation - -Payment notification functions fetch entire entity graphs with 4+ levels of nested includes, when only consultant name and email are needed. - -**Code Location**: `lib/payments/webhooks/handlers.ts:246-278, 804-841` - -```typescript -// Current: Fetches EVERYTHING -const payment = await tx.payment.findUnique({ - where: { paymentIntent: paymentIntentId }, - include: { - user: true, - appointment: { - include: { - consultation: { - include: { - consultationPlan: { - include: { - consultantProfile: { - include: { - user: true, // Just need name! - }, - }, - }, - }, - }, - }, - subscription: { - /* same deep nesting */ - }, - }, - }, - }, -}); -``` - -### Impact - -- Fetches 10+ tables when only 2-3 fields needed -- Slower response times under load -- Higher database connection usage -- Memory pressure from large result sets - -### Fix - -```typescript -// Optimized: Fetch only what's needed -const notificationData = await tx.payment.findUnique({ - where: { paymentIntent: paymentIntentId }, - select: { - id: true, - amount: true, - currency: true, - user: { - select: { email: true, name: true }, - }, - appointment: { - select: { - consultation: { - select: { - consultationPlan: { - select: { - consultantProfile: { - select: { - user: { select: { name: true } }, - }, - }, - }, - }, - }, - }, - }, - }, - }, -}); -``` - -### Estimated Improvement - -- Query size: ~80% reduction -- Response time: 20-50ms improvement -- Memory: ~70% reduction per request - ---- - -## OPT-2: Duplicate Participant Counting Logic - -### Category: Code Duplication - -### Impact: MEDIUM - -### Situation - -Participant counting for webinars and classes is implemented differently in 3 locations with subtle differences. - -**Locations**: - -1. `checkout.ts:245-250` (webinar validation) -2. `checkout.ts:277-281` (class validation) -3. `checkout.ts:1005-1012` (class checkout) - -```typescript -// Location 1: Simple optional chaining -const currentParticipants = - webinar.appointment?.slotsOfAppointment?.length || 0; - -// Location 2: Reduce pattern -const currentParticipants = classInstance.appointments.reduce( - (total, apt) => total + apt.slotsOfAppointment.length, - 0, -); - -// Location 3: Set-based unique counting -const uniqueUserIds = new Set(); -for (const apt of classInstance.appointments) { - for (const slot of apt.slotsOfAppointment) { - if (slot.user && Array.isArray(slot.user)) { - slot.user.forEach((u) => uniqueUserIds.add(u.id)); - } - } -} -``` - -### Issues - -- Location 1 counts slots, Location 3 counts unique users -- For multi-slot-per-user scenarios, counts differ -- Bug potential: Inconsistent capacity enforcement - -### Fix - -```typescript -// utils/eventParticipants.ts -export function countUniqueParticipants( - appointments: Array<{ - slotsOfAppointment: Array<{ user?: Array<{ id: string }> }>; - }>, -): number { - const uniqueUserIds = new Set(); - - for (const apt of appointments) { - for (const slot of apt.slotsOfAppointment) { - if (Array.isArray(slot.user)) { - slot.user.forEach((u) => uniqueUserIds.add(u.id)); - } - } - } - - return uniqueUserIds.size; -} - -// For single-appointment events (webinars) -export function countWebinarParticipants( - appointment: { - slotsOfAppointment: Array<{ user?: Array<{ id: string }> }>; - } | null, -): number { - if (!appointment) return 0; - return countUniqueParticipants([appointment]); -} -``` - ---- - -## OPT-3: Missing Database Indexes - -### Category: Performance - -### Impact: HIGH (at scale) - -### Situation - -The `validateSlotAvailability` function performs complex range queries without optimal indexes. - -**Query Pattern** (checkout.ts:356-378): - -```sql -SELECT * FROM "SlotOfAppointment" -WHERE ( - (startsAt <= $1 AND endsAt > $1) - OR (startsAt < $2 AND endsAt >= $2) -) -AND isTentative = false -``` - -### Current Indexes - -```prisma -model SlotOfAppointment { - // ... - @@index([appointmentId]) - @@index([isTentative, appointmentId]) -} -``` - -### Recommended Additional Indexes - -```prisma -model SlotOfAppointment { - // Existing - @@index([appointmentId]) - @@index([isTentative, appointmentId]) - - // New: For time range queries - @@index([startsAt, endsAt]) - @@index([isTentative, startsAt, endsAt]) -} -``` - -### Migration - -```sql -CREATE INDEX idx_slot_time_range ON "SlotOfAppointment" ("startsAt", "endsAt"); -CREATE INDEX idx_slot_tentative_time ON "SlotOfAppointment" ("isTentative", "startsAt", "endsAt"); -``` - ---- - -## TYPE-1: Unsafe `any` Type in Payment Response - -### Category: Type Safety - -### Severity: HIGH - -### Situation - -**Code Location**: `checkout.ts:1149` - -```typescript -let paymentResponse: any = null; // ❌ Loses all type safety -``` - -### Fix - -```typescript -interface PaymentIntentResponse { - id: string; - client_secret: string | null; - status?: string; -} - -let paymentResponse: PaymentIntentResponse | null = null; -``` - ---- - -## TYPE-2: Unsafe Type Casting - -### Category: Type Safety - -### Severity: MEDIUM - -### Situation - -**Code Location**: `checkout.ts:1008` - -```typescript -slot.user.forEach((u: { id: string }) => uniqueUserIds.add(u.id)); -// Casting without validation -``` - -### Fix - -```typescript -// Add type guard -function isUserWithId(value: unknown): value is { id: string } { - return typeof value === "object" && value !== null && "id" in value; -} - -// Use safely -if (Array.isArray(slot.user)) { - slot.user.filter(isUserWithId).forEach((u) => uniqueUserIds.add(u.id)); -} -``` - ---- - -## CLEAN-1: Dead Code - Unused `confirmAppointment` - -### Category: Code Cleanup - -### Severity: LOW - -**Location**: `checkout.ts:1078-1127` - -This function duplicates `confirmExistingAppointment` in handlers.ts but is never called. The handlers.ts version is more complete (handles multi-user events). - -**Action**: Remove function entirely. - ---- - -## CLEAN-2: Unused Import - -### Category: Code Cleanup - -### Severity: LOW - -**Location**: `checkout.ts:19` - -```typescript -import { handlePaymentSuccess } from "@/lib/payments/webhooks/handlers"; -// ❌ Never used in file -``` - -**Action**: Remove import. - ---- - -## CLEAN-3: Unused Parameter - -### Category: Code Cleanup - -### Severity: LOW - -**Location**: `checkout.ts:1081` - -```typescript -export async function confirmAppointment( - tx: Prisma.TransactionClient, - appointmentId: string, - _appointmentType: string, // ❌ Never used -) { -``` - -**Action**: Remove parameter (or remove entire function per CLEAN-1). - ---- - -## Implementation Priority Matrix - -| ID | Severity | Effort | Risk if Unfixed | Priority | -| ------- | -------- | ------- | --------------- | -------- | -| BUG-A | High | Low | Medium | P1 | -| BUG-B | High | Medium | High | P1 | -| BUG-C | Medium | Low | Low | P2 | -| BUG-D | Medium | Low | Medium | P2 | -| BUG-E | Medium | Low | Low | P3 | -| OPT-1 | High | Medium | N/A (perf) | P2 | -| OPT-2 | Medium | Low | N/A (maint) | P3 | -| OPT-3 | High | Low | N/A (perf) | P2 | -| TYPE-1 | High | Low | Medium | P2 | -| TYPE-2 | Medium | Low | Low | P3 | -| CLEAN-1 | Low | Low | N/A | P3 | -| CLEAN-2 | Low | Trivial | N/A | P3 | -| CLEAN-3 | Low | Trivial | N/A | P3 | - ---- - -## Recommended Fix Order - -**Phase 1 - Quick Wins (30 min)** - -1. BUG-A: Past webinar validation -2. CLEAN-2: Remove unused import -3. CLEAN-3: Remove unused parameter -4. TYPE-1: Add PaymentIntentResponse type - -**Phase 2 - Critical Bugs (1-2 hours)** 5. BUG-B: Refund race condition 6. BUG-C: Zero amount validation 7. BUG-D: Profile validation - -**Phase 3 - Optimization (2-3 hours)** 8. OPT-1: Optimize notification queries 9. OPT-2: Extract participant counting 10. OPT-3: Add database indexes - -**Phase 4 - Cleanup** 11. CLEAN-1: Remove dead code 12. TYPE-2: Add type guards 13. BUG-E: Plan deletion race - ---- - -_Created by: Payment System Audit_ -_Last Updated: 2025-12-06_ diff --git a/tasks/payment-workflow-critical-bugs.md b/tasks/payment-workflow-critical-bugs.md deleted file mode 100644 index 8275bd6b5..000000000 --- a/tasks/payment-workflow-critical-bugs.md +++ /dev/null @@ -1,1057 +0,0 @@ -# Payment Workflow Critical Bugs - -**Date**: 2025-12-06 -**Status**: Identified - Awaiting Fix -**Priority**: P0 (Critical) -**Affected Files**: - -- `lib/payments/operations/checkout.ts` -- `lib/payments/webhooks/handlers.ts` - ---- - -## Executive Summary - -During a comprehensive validation of all payment workflows for the 4 appointment types (Consultation, Subscription, Webinar, Class), **3 critical bugs**, **3 high-priority issues**, and **2 medium-priority issues** were identified. These bugs can result in: - -- Users receiving paid services without paying -- Duplicate database records causing data corruption -- Partial service delivery (only 1 of N sessions accessible) -- Race conditions under high load -- Incorrect slot times for webinars -- Development/testing data inconsistencies - -| # | Severity | Issue | Affected Types | -| --- | ----------- | ----------------------------------- | -------------------------- | -| 1 | 🔴 Critical | Wrong user's slots confirmed | Webinar, Class | -| 2 | 🔴 Critical | Duplicate subscription creation | Subscription | -| 3 | 🔴 Critical | Only first session confirmed | Class | -| 4 | 🟠 High | Lock TTL mismatch (30s vs 60s) | All types | -| 5 | 🟠 High | Webinar slot timing defaults to now | Webinar | -| 6 | 🟠 High | Mock payment status never updated | All (dev) | -| 7 | 🟡 Medium | Approval flow no duplicate check | Consultation, Subscription | -| 8 | 🟡 Medium | Failure handler lacks idempotency | All types | - ---- - -## Issue #1: Webinar/Class Confirms Wrong User's Slots - -### Situation - -When a user's payment succeeds for a webinar or class, the webhook handler confirms **all tentative slots** for that shared appointment, not just the paying user's slot. - -**Code Location**: `lib/payments/webhooks/handlers.ts:624-631` - -```typescript -async function confirmExistingAppointment( - tx: Prisma.TransactionClient, - appointmentId: string, -) { - // BUG: Updates ALL slots for this appointment, regardless of user - await tx.slotOfAppointment.updateMany({ - where: { appointmentId }, // ⚠️ No user filter! - data: { isTentative: false }, - }); - // ... -} -``` - -### How It Happens - -**Webinar Database Model**: - -``` -Webinar "React Basics" (max 50 participants) - └── Appointment (shared by all participants) - ├── SlotOfAppointment { userId: "user_A", isTentative: true } - ├── SlotOfAppointment { userId: "user_B", isTentative: true } - └── SlotOfAppointment { userId: "user_C", isTentative: true } -``` - -**Timeline**: - -``` -T=0:00 User A starts checkout → creates tentative slot for User A -T=0:05 User B starts checkout → creates tentative slot for User B -T=0:10 User C starts checkout → creates tentative slot for User C -T=0:30 User A completes payment - → Webhook calls confirmExistingAppointment(webinarAppointmentId) - → SQL: UPDATE slots SET isTentative=false WHERE appointmentId='xyz' - → ALL 3 slots become confirmed! -T=1:00 User B abandons checkout (never pays) -T=2:00 User C's payment fails - -RESULT: Users B and C have confirmed slots without paying! -``` - -### Impact - -| Impact | Description | -| --------------------- | --------------------------------------------------- | -| **Revenue Loss** | Users access paid content without payment | -| **Capacity Issues** | Confirmed non-paying users consume available slots | -| **Audit Trail** | Payment records don't match slot confirmations | -| **Refund Complexity** | No payment to refund for non-paying confirmed users | - -**Estimated Financial Impact**: If 10% of webinar/class checkouts are concurrent, approximately 5% of confirmed slots may be unpaid. - -### Root Cause - -The `confirmExistingAppointment` function was designed for 1:1 appointments (consultations) where each appointment has exactly one slot. For multi-user events (webinars/classes), the shared appointment model means the function confirms slots for ALL users. - -### Fix - -**Option A: Add userId parameter to confirmation function** - -```typescript -async function confirmExistingAppointment( - tx: Prisma.TransactionClient, - appointmentId: string, - userId?: string, // NEW: Optional user filter -) { - const whereClause: Prisma.SlotOfAppointmentWhereInput = { appointmentId }; - - // For multi-user events, only confirm the specific user's slot - if (userId) { - whereClause.user = { some: { id: userId } }; - } - - await tx.slotOfAppointment.updateMany({ - where: whereClause, - data: { isTentative: false }, - }); - // ... rest of function -} -``` - -**Option B: Store slotId in payment record** - -```typescript -// In checkout.ts, store the specific slot ID -const slot = await tx.slotOfAppointment.create({...}); - -await tx.payment.create({ - data: { - // ...existing fields - slotOfAppointmentId: slot.id, // NEW: Link to specific slot - }, -}); - -// In webhook handler, confirm only that slot -await tx.slotOfAppointment.update({ - where: { id: payment.slotOfAppointmentId }, - data: { isTentative: false }, -}); -``` - -**Recommended**: Option B (more precise, no ambiguity) - -### Testing Checklist - -- [ ] Concurrent webinar checkout: only paying user's slot confirmed -- [ ] Concurrent class checkout: only paying user's slots confirmed -- [ ] Payment failure: only failing user's slot remains tentative -- [ ] Existing consultation flow unchanged (regression test) - ---- - -## Issue #2: Subscription Duplicate Creation - -### Situation - -When a user checks out a subscription, the system creates the subscription record during checkout. When payment succeeds, the webhook handler creates **another subscription record** from metadata, resulting in duplicate subscriptions. - -**Code Locations**: - -- Checkout: `lib/payments/operations/checkout.ts:825-836` -- Webhook: `lib/payments/webhooks/handlers.ts:347-356, 412-474` - -### How It Happens - -**Step 1: Checkout creates Subscription A** - -```typescript -// checkout.ts:825-836 -const subscription = await tx.subscription.create({ - data: { - subscriptionPlanId: plan.id, - requestStatus: RequestStatus.PENDING, - // ... - }, -}); - -// BUT: createdAppointment is set to null for subscriptions -createdAppointment = null; // Line 1174 -``` - -**Step 2: Payment record has no appointmentId** - -```typescript -// checkout.ts:1217 -await tx.payment.create({ - data: { - // ... - appointmentId: createdAppointment?.id || null, // NULL for subscriptions! - }, -}); -``` - -**Step 3: Webhook uses legacy flow and creates Subscription B** - -```typescript -// handlers.ts:186-204 -if (payment.appointmentId) { - // NEW FLOW: Confirm existing -} else { - // LEGACY FLOW: Creates NEW subscription! - appointment = await createAppointmentFromWebhook(tx, metadata, payment); -} - -// handlers.ts:347-356 -case AppointmentsType.SUBSCRIPTION: - appointment = await createSubscription(tx, {...}); // Creates SECOND subscription! - break; -``` - -### Database State After Bug - -``` -Subscription A (created during checkout): - id: "sub_abc123" - status: PENDING ← Never updated, orphaned forever - planId: "plan_xyz" - userId: "user_001" - -Subscription B (created by webhook): - id: "sub_def456" - status: APPROVED - planId: "plan_xyz" - userId: "user_001" - -Payment: - id: "pay_789" - appointmentId: null → points to Subscription B's appointment -``` - -### Impact - -| Impact | Description | -| ---------------------- | ----------------------------------------- | -| **Data Corruption** | Two subscription records for one purchase | -| **Orphaned Records** | Subscription A remains PENDING forever | -| **Reporting Errors** | Analytics show double the subscriptions | -| **User Confusion** | Dashboard may show duplicate entries | -| **Cleanup Complexity** | No automated way to identify orphans | - -### Root Cause - -The subscription checkout flow was modified to not create appointments during checkout (consultant allocates slots later via Requests tab), but the webhook handler was not updated to handle this case. The payment record has no way to link back to the subscription created during checkout. - -### Fix - -**Option A: Store subscriptionId in payment record (Recommended)** - -```typescript -// 1. Add subscriptionId to Payment model in schema.prisma -model Payment { - // ...existing fields - subscriptionId String? - subscription Subscription? @relation(fields: [subscriptionId], references: [id]) -} - -// 2. In checkout.ts, link payment to subscription -case "SUBSCRIPTION": { - const subscriptionResult = await handleSubscriptionCheckout(...); - - await tx.payment.create({ - data: { - // ... - subscriptionId: subscriptionResult.subscription.id, // NEW - }, - }); - break; -} - -// 3. In webhook handler, check for existing subscription -if (payment.subscriptionId) { - // Subscription already exists, just confirm it - await tx.subscription.update({ - where: { id: payment.subscriptionId }, - data: { requestStatus: RequestStatus.APPROVED }, - }); -} else { - // Legacy flow for old payments - appointment = await createAppointmentFromWebhook(tx, metadata, payment); -} -``` - -**Option B: Store subscriptionId in metadata** - -```typescript -// In buildPaymentMetadata -function buildPaymentMetadata(data, userId, subscriptionId?: string) { - return { - // ...existing - subscriptionId: subscriptionId || "", - }; -} - -// In webhook, check metadata first -if (metadata.subscriptionId) { - await confirmExistingSubscription(tx, metadata.subscriptionId); -} else { - // Legacy creation -} -``` - -**Recommended**: Option A (database-level integrity, queryable) - -### Testing Checklist - -- [ ] New subscription checkout: only one subscription created -- [ ] Payment success: existing subscription updated to APPROVED -- [ ] Payment failure: subscription cleaned up or remains PENDING -- [ ] Legacy payments (no subscriptionId): still create subscription -- [ ] No orphaned PENDING subscriptions after 24h - ---- - -## Issue #3: Class Only First Session Confirmed - -### Situation - -When a user enrolls in a multi-session class (e.g., 10-week course), the checkout creates slots across ALL appointments (sessions). However, the payment record only stores the first appointment's ID. When payment succeeds, only the first session's slots are confirmed. - -**Code Locations**: - -- Slot creation: `lib/payments/operations/checkout.ts:986-1004` -- First appointment return: `lib/payments/operations/checkout.ts:1007-1017` -- Webhook confirmation: `lib/payments/webhooks/handlers.ts:628-631` - -### How It Happens - -**Step 1: Checkout creates slots for ALL sessions** - -```typescript -// checkout.ts:986-1004 -for (const appointment of classInstance.appointments) { - const slot = await tx.slotOfAppointment.create({ - data: { - appointmentId: appointment.id, - isTentative: !skipPayment, - user: { connect: { id: userId } }, - }, - }); - createdSlots.push(slot); -} -// createdSlots = [slot_week1, slot_week2, ..., slot_week10] -``` - -**Step 2: Only FIRST appointment returned** - -```typescript -// checkout.ts:1007-1014 -const firstAppointment = classInstance.appointments[0]; -return { - appointment: firstAppointment, // Only week 1! - plan, - amount: plan.price, - slotsCreated: createdSlots.length, -}; -``` - -**Step 3: Payment links to first appointment only** - -```typescript -// checkout.ts:1217 -appointmentId: createdAppointment?.id || null, // Only week 1's appointment ID -``` - -**Step 4: Webhook confirms only first session** - -```typescript -// handlers.ts:628-631 -await tx.slotOfAppointment.updateMany({ - where: { appointmentId }, // Only matches week 1 - data: { isTentative: false }, -}); -``` - -### Database State After Bug - -``` -Class "Python Bootcamp" (10 weeks) -├── Appointment Week 1 (ID: appt_001) -│ └── User's Slot: isTentative = false ✅ CONFIRMED -├── Appointment Week 2 (ID: appt_002) -│ └── User's Slot: isTentative = true ❌ STILL TENTATIVE -├── Appointment Week 3 (ID: appt_003) -│ └── User's Slot: isTentative = true ❌ STILL TENTATIVE -... -└── Appointment Week 10 (ID: appt_010) - └── User's Slot: isTentative = true ❌ STILL TENTATIVE - -Payment: - appointmentId: "appt_001" ← Only links to week 1 -``` - -### Impact - -| Impact | Description | -| ---------------------------------- | ------------------------------------------- | -| **Partial Access** | User can only access 1 of 10 sessions | -| **Cleanup Job Deletes Paid Slots** | Weeks 2-10 slots deleted as "abandoned" | -| **Support Tickets** | Users report missing sessions | -| **Refund Requests** | Perceived as service not delivered | -| **Manual Fix Required** | Admin must manually confirm remaining slots | - -### Root Cause - -The class checkout was designed to create all slots at once for efficiency, but the payment linking was designed for single-appointment types. The return value only includes the first appointment, and there's no mechanism to confirm slots across multiple appointments. - -### Fix - -**Option A: Store all slot IDs in payment metadata** - -```typescript -// In checkout.ts, store slot IDs -const createdSlotIds = createdSlots.map(s => s.id); - -return { - appointment: firstAppointment, - plan, - amount: plan.price, - slotsCreated: createdSlots.length, - slotIds: createdSlotIds, // NEW -}; - -// In metadata -metadata: { - ...buildPaymentMetadata(validatedData, userId), - classSlotIds: createdSlotIds.join(','), // NEW -} - -// In webhook handler -if (metadata.classSlotIds) { - const slotIds = metadata.classSlotIds.split(','); - await tx.slotOfAppointment.updateMany({ - where: { id: { in: slotIds } }, - data: { isTentative: false }, - }); -} -``` - -**Option B: Confirm by userId + classId** - -```typescript -// In webhook handler, for CLASS type -if (appointment?.class) { - // Confirm ALL user's slots for this class - await tx.slotOfAppointment.updateMany({ - where: { - appointment: { classId: appointment.class.id }, - user: { some: { id: payment.userId } }, - }, - data: { isTentative: false }, - }); -} -``` - -**Option C: Store all appointment IDs in junction table** - -```typescript -// Create PaymentAppointment junction table -model PaymentAppointment { - paymentId String - appointmentId String - payment Payment @relation(...) - appointment Appointment @relation(...) - @@id([paymentId, appointmentId]) -} - -// In checkout, link all appointments -for (const appointment of classInstance.appointments) { - await tx.paymentAppointment.create({ - data: { paymentId: payment.id, appointmentId: appointment.id }, - }); -} - -// In webhook, confirm all linked appointments -const linkedAppointments = await tx.paymentAppointment.findMany({ - where: { paymentId: payment.id }, -}); -for (const link of linkedAppointments) { - await confirmExistingAppointment(tx, link.appointmentId, payment.userId); -} -``` - -**Recommended**: Option B (simplest, no schema changes) - -### Testing Checklist - -- [ ] Class enrollment: all 10 sessions confirmed after payment -- [ ] Payment failure: all 10 sessions cleaned up -- [ ] Partial cleanup: if week 1 confirmed manually, others still work -- [ ] Cleanup job: doesn't delete paid user's slots -- [ ] User dashboard: shows all sessions as confirmed - ---- - -## Issue #4: Lock TTL Mismatch - -### Situation - -The checkout flow explicitly uses 30-second lock TTL, but the documentation and `appointmentlock.ts` specify 60 seconds as the default. This inconsistency can cause race conditions under high load. - -**Code Locations**: - -- Checkout locks: `lib/payments/operations/checkout.ts:576, 595, 610` -- Default TTL: `utils/appointmentlock.ts:47` - -### How It Happens - -```typescript -// checkout.ts uses explicit 30s -return await lockSlotBooking(consultantUserId, data.slotStartTimeInUTC, 30000); // 30s -return await lockEventCheckout(appointmentType, data.eventId, 30000); // 30s - -// appointmentlock.ts default is 60s -const DEFAULT_LOCK_TTL = 60000; // 60 seconds -``` - -### When This Causes Problems - -**Scenario: Slow Database Under Load** - -``` -T=0:00 User A acquires lock (TTL=30s) -T=0:05 User A starts database transaction -T=0:25 Database slow due to load (P99 latency spike) -T=0:30 Lock expires! ⚠️ -T=0:31 User B acquires same lock -T=0:32 User B starts competing transaction -T=0:35 User A's transaction completes → creates slot -T=0:40 User B's transaction completes → creates DUPLICATE slot! -``` - -### Impact - -| Impact | Description | -| ------------------------ | ----------------------------------- | -| **Race Conditions** | Lock expires during slow operations | -| **Double Bookings** | Two users book same slot | -| **Data Integrity** | Overlapping appointments | -| **Production Incidents** | Under high load, issue manifests | - -### Root Cause - -When the lock TTL was updated from 30s to 60s in `appointmentlock.ts`, the explicit overrides in `checkout.ts` were not updated. - -### Fix - -**Option A: Remove explicit TTL (use defaults)** - -```typescript -// Before -return await lockSlotBooking(consultantUserId, data.slotStartTimeInUTC, 30000); - -// After - use default 60s from appointmentlock.ts -return await lockSlotBooking(consultantUserId, data.slotStartTimeInUTC); -``` - -**Option B: Update to 60s explicitly** - -```typescript -// Update all three locations -return await lockSlotBooking(consultantUserId, data.slotStartTimeInUTC, 60000); -return await lockEventCheckout(appointmentType, data.eventId, 60000); -return await lockEventCheckout(appointmentType, data.planId, 60000); -``` - -**Recommended**: Option A (DRY principle, single source of truth) - -### Testing Checklist - -- [ ] Lock TTL is 60s in production logs -- [ ] Slow database (simulate with pg_sleep) doesn't cause race -- [ ] Documentation matches implementation - ---- - -## Implementation Priority - -| Priority | Issue | Effort | Risk if Unfixed | -| -------- | ------------------------------ | -------- | ------------------------- | -| P0 | #1 Wrong user slots confirmed | Medium | Revenue loss, free access | -| P0 | #2 Duplicate subscriptions | Medium | Data corruption | -| P0 | #3 Class partial confirmation | Low | Service not delivered | -| P1 | #4 Lock TTL mismatch | Low | Race conditions | -| P1 | #5 Webinar slot timing | Low | Incorrect slot times | -| P1 | #6 Mock payment status | Low | Data inconsistency | -| P2 | #7 Approval flow no dedup | Low | Duplicate payments | -| P2 | #8 Failure handler idempotency | Very Low | Minor cleanup issues | - -### Recommended Implementation Order - -1. **Issue #4** (5 min) - Quick fix, reduces risk for other fixes -2. **Issue #3** (30 min) - Simple fix with Option B -3. **Issue #1** (1 hour) - Requires careful testing with concurrent users -4. **Issue #2** (2 hours) - Requires schema change and migration -5. **Issue #5** (30 min) - Add scheduled time to Webinar model -6. **Issue #6** (15 min) - Update mock payment status in checkout -7. **Issue #7** (15 min) - Add check before creating approval payment -8. **Issue #8** (5 min) - Add idempotency check to failure handler - ---- - -## Issue #5: Webinar Slot Timing Defaults to Current Time - -### Situation - -When the first user books a webinar, the slot creation code falls back to `new Date()` for start/end times because there are no existing slots to copy from. The Webinar model lacks scheduled time fields. - -**Code Locations**: - -- Checkout: `lib/payments/operations/checkout.ts:910-912` -- Webhook: `lib/payments/webhooks/handlers.ts:502-504` - -### How It Happens - -**Step 1: First webinar booking** - -```typescript -// checkout.ts:910-912 -await tx.slotOfAppointment.create({ - data: { - startsAt: - webinar.appointment?.slotsOfAppointment[0]?.startsAt || new Date(), // ⚠️ No existing slots! - endsAt: webinar.appointment?.slotsOfAppointment[0]?.endsAt || new Date(), // ⚠️ Defaults to NOW - // ... - }, -}); -``` - -**Database Schema Issue**: - -```prisma -model Webinar { - id String @id @default(cuid()) - status WebinarStatus @default(SCHEDULED) - // ❌ NO scheduledStartAt field! - // ❌ NO scheduledEndAt field! - webinarPlanId String - appointment Appointment? -} - -// Compare to Class which HAS these fields: -model Class { - schedulingPeriodStartsAt DateTime? // ✅ Has time fields - schedulingPeriodEndsAt DateTime? // ✅ Has time fields -} -``` - -### Impact - -| Impact | Description | -| -------------------- | --------------------------------------------------- | -| **Wrong Slot Times** | First booking has start/end = checkout timestamp | -| **Calendar Issues** | Webinar appears at wrong time in user calendar | -| **Notifications** | Reminders sent for incorrect times | -| **Analytics** | Duration calculations will be wrong (0 or negative) | - -### Root Cause - -The Webinar model was designed assuming an appointment with slots would be created BEFORE users book. But the checkout flow creates the appointment on first booking, with no scheduled time to reference. - -### Fix - -**Option A: Add scheduled time fields to Webinar model (Recommended)** - -```prisma -model Webinar { - id String @id @default(cuid()) - scheduledStartAt DateTime @db.Timestamptz() // NEW - scheduledEndAt DateTime @db.Timestamptz() // NEW - status WebinarStatus @default(SCHEDULED) - // ... -} -``` - -```typescript -// In checkout.ts -await tx.slotOfAppointment.create({ - data: { - startsAt: webinar.scheduledStartAt, // Use webinar's scheduled time - endsAt: webinar.scheduledEndAt, - // ... - }, -}); -``` - -**Option B: Require webinar appointment creation before bookings** - -Consultants must create the webinar with scheduled time before users can book. - -### Testing Checklist - -- [ ] First webinar booking uses correct scheduled time -- [ ] Subsequent bookings use same time as first -- [ ] Calendar invites show correct time -- [ ] Migration updates existing webinars with placeholder times - ---- - -## Issue #6: Mock Payment Status Never Updated - -### Situation - -Mock payments (used in development) create payment records with `PENDING` status. Unlike real payments, no webhook is called to update the status to `SUCCEEDED`, leaving mock payments in PENDING state forever. - -**Code Location**: `lib/payments/operations/checkout.ts:1214` - -### How It Happens - -**Real Payment Flow**: - -``` -1. Checkout creates payment with status = PENDING -2. User pays via Stripe/Razorpay -3. Webhook calls handlePaymentSuccess() -4. Status updated to SUCCEEDED -``` - -**Mock Payment Flow**: - -``` -1. Checkout creates payment with status = PENDING -2. Mock payment intent returns immediately with status = "succeeded" -3. NO webhook is called ❌ -4. Database status stays PENDING forever ❌ -``` - -```typescript -// checkout.ts:1207-1221 -await tx.payment.create({ - data: { - // ... - paymentStatus: PaymentStatus.PENDING, // Same for mock AND real payments - isMockPayment, - // ... - }, -}); -// No code to update status for mock payments! -``` - -### Impact - -| Impact | Description | -| ---------------------- | ------------------------------------------------------ | -| **Data Inconsistency** | Appointment confirmed but payment shows PENDING | -| **Cleanup Job Issues** | May try to clean up "abandoned" mock payments | -| **Reporting** | Revenue reports don't count mock payments | -| **Testing Confusion** | Developers see PENDING status, think something's wrong | - -### Root Cause - -The mock payment system was designed to skip the payment gateway, but forgot to also skip the webhook step that updates payment status. - -### Fix - -```typescript -// In checkout.ts, after creating payment for mock flow -if (isMockPayment) { - // Update payment status directly for mock payments - await tx.payment.update({ - where: { paymentIntent: paymentResponse.id }, - data: { paymentStatus: PaymentStatus.SUCCEEDED }, - }); -} -``` - -**Alternative**: Call `handlePaymentSuccess` for mock payments after checkout: - -```typescript -if (isMockPayment) { - await handlePaymentSuccess( - paymentResponse.id, - buildPaymentMetadata(validatedData, userId), - ); -} -``` - -### Testing Checklist - -- [ ] Mock payment has SUCCEEDED status after checkout -- [ ] Mock payment not picked up by cleanup job -- [ ] Email notification sent for mock payments (if desired) - ---- - -## Issue #7: Approval Flow Missing Duplicate Payment Prevention - -### Situation - -The `createApprovalPaymentIntent` function in the approval flow doesn't check for existing payments before creating a new payment link. A `checkExistingPayment` function exists but is never called. - -**Code Location**: `lib/payments/operations/approval-payment.ts:55-103` - -### How It Happens - -```typescript -// approval-payment.ts:55-103 -export async function createApprovalPaymentIntent( - params: CreateApprovalPaymentParams, -): Promise { - // Validate params... - - // ❌ NO check for existing payment! - // checkExistingPayment() function exists (line 214) but is never called - - const paymentResponse = await createPaymentIntent({...}); - await prisma.payment.create({...}); // Creates new payment every time - - return {...}; -} - -// This function exists but is NEVER USED: -export async function checkExistingPayment(params: {...}): Promise { - // Checks for existing PENDING or SUCCEEDED payments -} -``` - -**Scenario**: - -1. Consultant clicks "Approve" on consultation -2. System creates Payment A with payment link -3. Consultant clicks "Approve" again (double-click, page reload, etc.) -4. System creates Payment B with different payment link -5. User receives two payment links -6. If user pays both, they're charged twice! - -### Impact - -| Impact | Description | -| --------------------- | --------------------------------------------------- | -| **Double Charges** | User could pay twice if they receive multiple links | -| **Orphaned Payments** | One payment succeeds, others become orphaned | -| **User Confusion** | Multiple payment emails for same consultation | - -### Root Cause - -The `checkExistingPayment` function was written but never integrated into `createApprovalPaymentIntent`. - -### Fix - -```typescript -export async function createApprovalPaymentIntent( - params: CreateApprovalPaymentParams, -): Promise { - // Check for existing payment first - const hasExistingPayment = await checkExistingPayment({ - consultationId: params.consultationId, - subscriptionId: params.subscriptionId, - }); - - if (hasExistingPayment) { - throw new Error( - "A payment link has already been generated for this request", - ); - } - - // Rest of function... -} -``` - -### Testing Checklist - -- [ ] Double-clicking "Approve" doesn't create duplicate payments -- [ ] Page refresh after approval doesn't create duplicate -- [ ] Error message shown when duplicate attempted - ---- - -## Issue #8: Payment Failure Handler Lacks Idempotency Check - -### Situation - -The `handlePaymentFailure` function doesn't check if a payment has already been marked as failed before processing. While mostly harmless, this could cause duplicate cleanup attempts. - -**Code Location**: `lib/payments/webhooks/handlers.ts:241-303` - -### How It Happens - -```typescript -// handlers.ts:241-303 -export async function handlePaymentFailure(paymentIntentId: string) { - return await prisma.$transaction(async (tx) => { - const payment = await tx.payment.findUnique({...}); - - if (!payment) { - console.warn(`Payment record not found...`); - return; // Early return for missing payment - } - - // ❌ NO check for already-failed payment! - // Unlike handlePaymentSuccess which checks: - // if (payment.paymentStatus === PaymentStatus.SUCCEEDED) return; - - await tx.payment.update({ - where: { id: payment.id }, - data: { paymentStatus: PaymentStatus.FAILED }, // Updates even if already FAILED - }); - - if (payment.appointment) { - await cleanupFailedPaymentAppointment(tx, payment.appointment.id); // Cleanup runs again - } - }); -} -``` - -**Comparison with Success Handler**: - -```typescript -// handlers.ts:106-108 - Success handler HAS idempotency check -if (payment.paymentStatus === PaymentStatus.SUCCEEDED) { - console.log(`Payment ${paymentIntentId} has already been processed.`); - return; // ✅ Early return -} -``` - -### Impact - -| Impact | Description | -| --------------- | --------------------------------------------------- | -| **Minor** | Duplicate cleanup attempts (mostly no-op) | -| **Logs** | Unnecessary log entries for already-failed payments | -| **Performance** | Extra database queries on duplicate webhooks | - -### Root Cause - -Oversight - success handler was given idempotency check but failure handler wasn't. - -### Fix - -```typescript -export async function handlePaymentFailure(paymentIntentId: string) { - return await prisma.$transaction(async (tx) => { - const payment = await tx.payment.findUnique({...}); - - if (!payment) { - console.warn(`Payment record not found...`); - return; - } - - // ADD: Idempotency check - if (payment.paymentStatus === PaymentStatus.FAILED) { - console.log(`Payment ${paymentIntentId} has already been marked as failed.`); - return; - } - - // Rest of function... - }); -} -``` - -### Testing Checklist - -- [ ] Duplicate failure webhooks don't cause errors -- [ ] Second failure webhook logs "already failed" message -- [ ] No duplicate cleanup operations - ---- - -## Verification Queries - -### Find Orphaned Subscriptions (Issue #2) - -```sql -SELECT s.id, s.request_status, s.created_at, p.id as payment_id -FROM "Subscription" s -LEFT JOIN "Appointment" a ON a.subscription_id = s.id -LEFT JOIN "Payment" p ON p.appointment_id = a.id -WHERE s.request_status = 'PENDING' -AND s.created_at < NOW() - INTERVAL '1 hour' -AND p.id IS NULL; -``` - -### Find Partially Confirmed Class Enrollments (Issue #3) - -```sql -SELECT c.id as class_id, u.id as user_id, u.email, - COUNT(*) as total_slots, - SUM(CASE WHEN s.is_tentative = false THEN 1 ELSE 0 END) as confirmed_slots -FROM "Class" c -JOIN "Appointment" a ON a.class_id = c.id -JOIN "SlotOfAppointment" s ON s.appointment_id = a.id -JOIN "_SlotOfAppointmentToUser" su ON su."A" = s.id -JOIN "User" u ON u.id = su."B" -GROUP BY c.id, u.id, u.email -HAVING COUNT(*) > SUM(CASE WHEN s.is_tentative = false THEN 1 ELSE 0 END) -AND SUM(CASE WHEN s.is_tentative = false THEN 1 ELSE 0 END) > 0; -``` - -### Find Unpaid Confirmed Webinar Slots (Issue #1) - -```sql -SELECT w.id as webinar_id, s.id as slot_id, u.email, - p.payment_status, s.is_tentative -FROM "Webinar" w -JOIN "Appointment" a ON a.webinar_id = w.id -JOIN "SlotOfAppointment" s ON s.appointment_id = a.id -JOIN "_SlotOfAppointmentToUser" su ON su."A" = s.id -JOIN "User" u ON u.id = su."B" -LEFT JOIN "Payment" p ON p.user_id = u.id AND p.appointment_id = a.id -WHERE s.is_tentative = false -AND (p.payment_status IS NULL OR p.payment_status != 'SUCCEEDED'); -``` - -### Find Webinars with Wrong Slot Times (Issue #5) - -```sql --- Find slots where start time is suspiciously close to creation time (likely defaulted to new Date()) -SELECT w.id as webinar_id, s.id as slot_id, - s.starts_at, s.ends_at, s.created_at, - EXTRACT(EPOCH FROM (s.starts_at - s.created_at)) as seconds_diff -FROM "Webinar" w -JOIN "Appointment" a ON a.webinar_id = w.id -JOIN "SlotOfAppointment" s ON s.appointment_id = a.id -WHERE ABS(EXTRACT(EPOCH FROM (s.starts_at - s.created_at))) < 60; -- Within 60 seconds -``` - -### Find Mock Payments Still Pending (Issue #6) - -```sql -SELECT p.id, p.payment_intent, p.payment_status, p.is_mock_payment, - p.created_at, a.id as appointment_id -FROM "Payment" p -LEFT JOIN "Appointment" a ON a.id = p.appointment_id -WHERE p.is_mock_payment = true -AND p.payment_status = 'PENDING'; -``` - -### Find Duplicate Approval Payments (Issue #7) - -```sql --- Consultations with multiple pending/succeeded payments -SELECT c.id as consultation_id, COUNT(p.id) as payment_count, - array_agg(p.payment_status) as statuses -FROM "Consultation" c -JOIN "Appointment" a ON a.consultation_id = c.id -JOIN "Payment" p ON p.appointment_id = a.id -WHERE p.payment_status IN ('PENDING', 'SUCCEEDED') -GROUP BY c.id -HAVING COUNT(p.id) > 1; -``` - ---- - -## References - -- **Related PR**: fix/payment-algorithm-2b (merged) -- **Documentation**: docs/payments/checkout-flow/KNOWN_ISSUES_AND_FIXES.md -- **Locking Docs**: docs/upstash/redis/locking/ - ---- - -_Created by: Payment Workflow Validation_ -_Last Updated: 2025-12-06_ diff --git a/tasks/stream-comms-issues.md b/tasks/stream-comms-issues.md deleted file mode 100644 index 620e5eed1..000000000 --- a/tasks/stream-comms-issues.md +++ /dev/null @@ -1,4 +0,0 @@ -1. channel name duplication. -2. communication between fellow consultees. -3. consultants can talk to other consultants because they want to collaborate for joint sessions -4. double check permissions for add members diff --git a/tests/typescript/race-conditions/test-checkout-race-condition-fix.ts b/tests/typescript/race-conditions/test-checkout-race-condition-fix.ts index 36922f4fc..088bec8c8 100644 --- a/tests/typescript/race-conditions/test-checkout-race-condition-fix.ts +++ b/tests/typescript/race-conditions/test-checkout-race-condition-fix.ts @@ -26,7 +26,7 @@ import { handleCheckout } from "@/lib/payments/operations/checkout"; import prisma from "@/lib/prisma"; import { CheckoutInput } from "@/schemas/checkout"; -import { PaymentGateway, PaymentStatus } from "@prisma/client"; +import { PaymentStatus } from "@prisma/client"; // ============================================================================ // Test Configuration @@ -56,7 +56,7 @@ const createCheckoutInput = (userId: string): CheckoutInput => ({ startsAt: TEST_CONFIG.SLOT_START, endsAt: TEST_CONFIG.SLOT_END, notes: `Test checkout for user ${userId}`, - paymentGateway: "STRIPE" as PaymentGateway, + paymentGateway: "STRIPE", }); // ============================================================================ diff --git a/utils/appointmentlock.ts b/utils/appointmentlock.ts index a77c41d2c..e4f3c2797 100644 --- a/utils/appointmentlock.ts +++ b/utils/appointmentlock.ts @@ -575,9 +575,18 @@ export async function unlockTrialSlot(lock: ApprovalLock): Promise { */ export async function lockAutoAllocate( consultantProfileId: string, + // #860 — optional day/slot-range scope. When the target day is known upfront + // (manual allocation), sharding the key lets non-overlapping-day allocations + // for one consultant run in parallel instead of all serializing. autoAllocate + // omits it (slots are discovered dynamically UNDER the lock) and stays + // consultant-wide. #440's GiST exclusion constraint is the correctness + // backstop for any residual cross-day overlap. + scope?: string, ttl: number = 150000, // 150s — 30s buffer over 120s transaction timeout (after 1% drift: ~148.5s) ): Promise { - const key = `auto-allocate:${consultantProfileId}`; + const key = scope + ? `auto-allocate:${consultantProfileId}:${scope}` + : `auto-allocate:${consultantProfileId}`; try { return await acquireLockWithRetry(key, ttl); } catch (error) { diff --git a/utils/onboarding-server.ts b/utils/onboarding-server.ts index 369a81db6..f357e7300 100644 --- a/utils/onboarding-server.ts +++ b/utils/onboarding-server.ts @@ -546,19 +546,139 @@ async function submitVerificationRequest( // MAIN ENTRY POINT // ============================================================================ -export async function processOnboardingData( - userId: string, - body: unknown, - // Return type: `user` is a Prisma User with deeply-included relations - // (consultantProfile, consulteeProfile, slots, domain, etc.). Typing it - // precisely would require a shared Prisma payload type across server/action/client - // layers — not worth the coupling. Callers only read a few string IDs from it. -): Promise<{ +const onboardingUserInclude = { + consultantProfile: { + include: { + slotsOfAvailabilityWeekly: true, + slotsOfAvailabilityCustom: true, + domain: true, + subDomains: true, + tags: true, + }, + }, + consulteeProfile: true, + workExperiences: true, + education: true, + certifications: true, + staffProfile: true, + adminProfile: true, +} satisfies Prisma.UserInclude; + +type OnboardingUser = Prisma.UserGetPayload<{ + include: typeof onboardingUserInclude; +}>; + +type OnboardingResult = { success: boolean; + // `user` is a Prisma User with deeply-included relations (consultantProfile, + // consulteeProfile, slots, domain, etc.). Typing it precisely would require a + // shared Prisma payload type across server/action/client layers — not worth + // the coupling. Callers only read a few string IDs from it. user?: Record; error?: string; verificationWarning?: string; -}> { +}; + +async function runOnboardingTransaction( + userId: string, + validatedBody: OnboardingData, + body: unknown, +): Promise { + return prisma.$transaction( + async (tx) => { + const baseUserData: Prisma.UserUpdateInput = { + ...buildUserUpdateData(validatedBody), + // Reset profile IDs (will be set by profileFkData) + consultantProfileId: null, + consulteeProfileId: null, + staffProfileId: null, + adminProfileId: null, + }; + + const profileFkData = await upsertProfileByRole( + userId, + validatedBody, + tx, + ); + + await persistProfessionalBackground( + userId, + profileFkData.consultantProfileId, + body as Record, + tx, + ); + + const user = await tx.user.update({ + // #724, #840: CAS guard — only apply the role/profile transition + // while the user is still un-onboarded, so two devices onboarding + // the same email can't last-write-wins each other. A no-match + // throws P2025 and rolls back the whole tx (incl. profile upserts). + where: { id: userId, onboardingCompleted: { not: true } }, + data: { ...baseUserData, ...profileFkData }, + include: onboardingUserInclude, + }); + + return user; + }, + { maxWait: 15000, timeout: 45000 }, + ); +} + +// #724, #840: another device already completed onboarding for this user; treat +// as idempotent success rather than clobbering their transition. Returns the +// success result on a P2025-after-completion, or null to signal a rethrow. +async function recoverIdempotentOnboarding( + userId: string, + error: unknown, +): Promise { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2025" + ) { + const existing = await prisma.user.findUnique({ + where: { id: userId }, + include: onboardingUserInclude, + }); + if (existing?.onboardingCompleted) { + return { success: true, user: existing }; + } + } + return null; +} + +// Post-transaction consultant verification. Returns a warning string when the +// profile saved but the verification submission failed, else undefined. +async function maybeSubmitConsultantVerification( + userId: string, + updatedUser: OnboardingUser, + body: unknown, + role: OnboardingData["role"], +): Promise { + if (role !== UserRole.CONSULTANT || !updatedUser.consultantProfileId) { + return undefined; + } + try { + await submitVerificationRequest( + userId, + updatedUser.consultantProfileId, + body as VerificationBody, + updatedUser.name || "", + updatedUser.email || "", + ); + return undefined; + } catch (verificationError) { + console.error( + "Failed to create verification request:", + verificationError, + ); + return "Your profile was saved but verification submission failed. Please contact support."; + } +} + +export async function processOnboardingData( + userId: string, + body: unknown, +): Promise { const { validateOnboardingData } = await import("./onboarding"); try { @@ -591,81 +711,22 @@ export async function processOnboardingData( // onboardingCompleted flag at launch. This path now only handles // CONSULTANT / CONSULTEE / STAFF / ADMIN profiles. - const updatedUser = await prisma.$transaction( - async (tx) => { - const baseUserData: Prisma.UserUpdateInput = { - ...buildUserUpdateData(validatedBody), - // Reset profile IDs (will be set by profileFkData) - consultantProfileId: null, - consulteeProfileId: null, - staffProfileId: null, - adminProfileId: null, - }; - - const profileFkData = await upsertProfileByRole( - userId, - validatedBody, - tx, - ); - - await persistProfessionalBackground( - userId, - profileFkData.consultantProfileId, - body as Record, - tx, - ); - - const user = await tx.user.update({ - where: { id: userId }, - data: { ...baseUserData, ...profileFkData }, - include: { - consultantProfile: { - include: { - slotsOfAvailabilityWeekly: true, - slotsOfAvailabilityCustom: true, - domain: true, - subDomains: true, - tags: true, - }, - }, - consulteeProfile: true, - workExperiences: true, - education: true, - certifications: true, - staffProfile: true, - adminProfile: true, - }, - }); + let updatedUser: OnboardingUser; + try { + updatedUser = await runOnboardingTransaction(userId, validatedBody, body); + } catch (error: unknown) { + const recovered = await recoverIdempotentOnboarding(userId, error); + if (recovered) return recovered; + throw error; + } - return user; - }, - { maxWait: 15000, timeout: 45000 }, + const verificationWarning = await maybeSubmitConsultantVerification( + userId, + updatedUser, + body, + validatedBody.role, ); - // Post-transaction: consultant verification - let verificationWarning: string | undefined; - if ( - validatedBody.role === UserRole.CONSULTANT && - updatedUser.consultantProfileId - ) { - try { - await submitVerificationRequest( - userId, - updatedUser.consultantProfileId, - body as VerificationBody, - updatedUser.name || "", - updatedUser.email || "", - ); - } catch (verificationError) { - console.error( - "Failed to create verification request:", - verificationError, - ); - verificationWarning = - "Your profile was saved but verification submission failed. Please contact support."; - } - } - return { success: true, user: updatedUser, verificationWarning }; } catch (error: unknown) { console.error("Error in processOnboardingData:", error); diff --git a/utils/slotAllocation/SlotAllocationService.ts b/utils/slotAllocation/SlotAllocationService.ts index 1859a2fbe..47a33a1a6 100644 --- a/utils/slotAllocation/SlotAllocationService.ts +++ b/utils/slotAllocation/SlotAllocationService.ts @@ -81,7 +81,11 @@ export class SlotAllocationService { try { switch (request.mode) { case "auto": - return await this.autoAllocate(request.eventType, request.eventId); + return await this.autoAllocate( + request.eventType, + request.eventId, + request.idempotencyKey, + ); case "manual": if (!request.slots || request.slots.length === 0) { @@ -96,6 +100,7 @@ export class SlotAllocationService { request.eventType, request.eventId, request.slots, + request.idempotencyKey, ); case "requested": @@ -263,10 +268,66 @@ export class SlotAllocationService { * double-booking. The lock is acquired BEFORE the Prisma transaction * and released in a finally block to guarantee cleanup. */ + /** + * #837 — idempotent-replay guard for double-submitted allocations. If this + * batch's key already stamped an appointment, return that batch instead of + * allocating again. The @unique on Appointment.allocationIdempotencyKey plus + * the P2002 catch in createAppointments backstop the concurrent (not-yet- + * committed) race, where two submits both pass this pre-check. + */ + private static async findIdempotentAllocation( + eventType: EventType, + eventId: string, + idempotencyKey?: string, + ): Promise { + if (!idempotencyKey) return null; + + const stamped = await prisma.appointment.findUnique({ + where: { allocationIdempotencyKey: idempotencyKey }, + select: { + consultationId: true, + subscriptionId: true, + webinarId: true, + classId: true, + }, + }); + if (!stamped) return null; + + const relationField = this.getEventRelationField(eventType); + const stampedEventId = (stamped as Record)[ + `${relationField}Id` + ]; + // Key is globally unique; a mismatch means the client reused it across + // bookings — refuse rather than hand back another event's appointments. + if (stampedEventId !== eventId) { + throw new AllocationConflictError( + "This idempotency key was already used for a different allocation.", + ); + } + + // The key only stamps the FIRST appointment; return the whole batch. + const appointments = await prisma.appointment.findMany({ + where: { + [`${relationField}Id`]: eventId, + } as Prisma.AppointmentWhereInput, + include: { slotsOfAppointment: true }, + }); + return { success: true, appointments }; + } + private static async autoAllocate( eventType: EventType, eventId: string, + idempotencyKey?: string, ): Promise { + // #837 — return the prior batch on a double-submit before doing any work. + const replay = await this.findIdempotentAllocation( + eventType, + eventId, + idempotencyKey, + ); + if (replay) return replay; + // Pre-fetch consultantProfileId for lock key (lightweight, outside transaction) const consultantProfileId = await this.getConsultantProfileId( eventType, @@ -296,6 +357,17 @@ export class SlotAllocationService { consulteeLock = await lockConsulteeBooking(consulteeLockUserId); } + // #837 TOCTOU — the pre-lock replay check can miss a concurrent first + // submit that stamped its key while we waited on the lock. Re-check now + // that we hold the locks so the loser replays the winner's batch instead + // of racing into the unique-constraint 409. + const lockedReplay = await this.findIdempotentAllocation( + eventType, + eventId, + idempotencyKey, + ); + if (lockedReplay) return lockedReplay; + // #908 — read/search/validate run OUTSIDE the write transaction, but still // UNDER the locks acquired above. An interactive txn pins its pooled // connection for its whole duration (incl. the JS between queries); doing @@ -516,6 +588,7 @@ export class SlotAllocationService { config, organizationId, reusableAppointmentId, // #898 — REUSE preserved 1:1 appointment + idempotencyKey, // #837 ); // Reconnect enrolled users to new slots (for group events like classes) @@ -581,7 +654,16 @@ export class SlotAllocationService { eventType: EventType, eventId: string, slotStrings: string[], + idempotencyKey?: string, ): Promise { + // #837 — return the prior batch on a double-submit before doing any work. + const replay = await this.findIdempotentAllocation( + eventType, + eventId, + idempotencyKey, + ); + if (replay) return replay; + // Pre-fetch consultantProfileId for lock key (lightweight, outside transaction) const consultantProfileId = await this.getConsultantProfileId( eventType, @@ -599,7 +681,16 @@ export class SlotAllocationService { // Acquire consultant-level distributed lock before the transaction. // Without this, concurrent manual allocations for the same subscription/class // can both pass validateNoConflicts() and create duplicate appointments. - const lock = await lockAutoAllocate(consultantProfileId); + // #860 — shard the lock by the earliest target day so allocations for + // different days don't serialize; same-day (the actual duplicate risk) + // still shares the key. #440's GiST constraint backstops cross-day overlap. + const lockScope = slotStrings + .map((s) => new Date(s)) + .filter((d) => !Number.isNaN(d.getTime())) + .sort((a, b) => a.getTime() - b.getTime())[0] + ?.toISOString() + .slice(0, 10); + const lock = await lockAutoAllocate(consultantProfileId, lockScope); // #898 follow-up — serialize on the consultee too (consultant → consultee // lock order) so one person can't be booked with two consultants at once. let consulteeLock: ApprovalLock | null = null; @@ -612,6 +703,17 @@ export class SlotAllocationService { consulteeLock = await lockConsulteeBooking(consulteeLockUserId); } + // #837 TOCTOU — the pre-lock replay check can miss a concurrent first + // submit that stamped its key while we waited on the lock. Re-check now + // that we hold the locks so the loser replays the winner's batch instead + // of racing into the unique-constraint 409. + const lockedReplay = await this.findIdempotentAllocation( + eventType, + eventId, + idempotencyKey, + ); + if (lockedReplay) return lockedReplay; + // #908 — slot parsing, count checks and validation run OUTSIDE the write // transaction (but under the locks above), so the heavy conflict read no // longer pins a pooled connection while the txn waits to start. @@ -827,6 +929,7 @@ export class SlotAllocationService { config, organizationId, reusableAppointmentId, // #898 — REUSE preserved 1:1 appointment + idempotencyKey, // #837 ); // Reconnect enrolled users to new slots (for group events like classes) @@ -1562,6 +1665,10 @@ export class SlotAllocationService { // instead of creating a second row on the @unique event FK (P2002). Only // ever set for single-call event types. reuseAppointmentId?: string, + // #837 — stamp this batch's dedupe key on the FIRST appointment only, so a + // replay trips the @unique (P2002 → typed 409 below) if it slips past the + // pre-check. Nullable by design: only real keys dedupe. + idempotencyKey?: string, ): Promise { const slotsPerCall = SlotCalculationService.getSlotsPerCall( config?.sessionDurationInHours || config?.durationInHours || 1, @@ -1618,7 +1725,12 @@ export class SlotAllocationService { let appointments: any[]; try { appointments = await Promise.all( - calls.map((callSlots) => { + calls.map((callSlots, callIndex) => { + // #837 — key belongs on the first appointment of the batch only. + const idempotencyData = + idempotencyKey && callIndex === 0 + ? { allocationIdempotencyKey: idempotencyKey } + : {}; const slotsToCreate = callSlots.map((slotStart) => { const endTime = new Date(slotStart.getTime() + 30 * 60 * 1000); return { @@ -1642,6 +1754,7 @@ export class SlotAllocationService { return tx.appointment.update({ where: { id: reuseAppointmentId }, data: { + ...idempotencyData, slotsOfAppointment: { create: slotsToCreate, }, @@ -1658,6 +1771,7 @@ export class SlotAllocationService { [this.getEventRelationField(eventType)]: { connect: { id: eventId }, }, + ...idempotencyData, ...(organizationId ? { organizationId } : {}), // B1 — freeze the refund terms at booking (see cancellation-policy.ts). cancellationPolicySnapshot: JSON.parse( diff --git a/utils/slotAllocation/types.ts b/utils/slotAllocation/types.ts index 18a01893e..d9b1584d9 100644 --- a/utils/slotAllocation/types.ts +++ b/utils/slotAllocation/types.ts @@ -38,6 +38,9 @@ export interface AllocationRequest { eventId: string; mode: AllocationMode; slots?: string[]; // ISO date strings for manual allocation + // #837 — client-supplied dedupe key (Idempotency-Key header). A double-submit + // carrying the same key returns the first batch instead of allocating twice. + idempotencyKey?: string; } /**